Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-egui

src/app.rs

//! egui presentation for the holger UI, built from [facett] components.
//!
//! One [`UiData`] drives everything; each tab issues at most one blocking core
//! call per click (the nornir-viz idiom). The repo list is rendered with a
//! `facett::table::Table` facet; the rest is plain themed egui. Selection +
//! every visible value live in [`UiData`]'s views, which expose `state_json()`
//! — so the same screen can be asserted headlessly.
//!
//! ═══════════════════════════════════════════════════════════════════════════
//! **RULE: DO NOT ADD ANOTHER `#[cfg(feature = "mini")]` AT A USE SITE.**
//! ═══════════════════════════════════════════════════════════════════════════
//!
//! There are already 76 of them across this file (47), `app/scene.rs` (23) and
//! `app/command.rs` (6), and they went 44 → 76 in a single commit. Every new
//! pane forces a decision at every one of those sites, and the compiler says
//! NOTHING when you miss one — you find out because the pane appears in
//! `holger-server-ui-mikro`.
//!
//! **The two axes are separate, and the mini tier used to have only one of
//! them.** `server/ui/Cargo.toml` said `mini = ["gui"]` — it enabled `gui` and
//! removed NOTHING. Same `eframe`, `egui`, `server-lib`, `modgunn` (→ `skade`),
//! `world-connector`, `nornir-auth-client/online`. The binaries measured on
//! 2026-07-22 (debug), under the bin names of the day: `holger-ui`
//! 1 048 139 472 B, `holger-ui-mikro`
//! 1 050 137 592 B — the "lean" tier was 2 MB **bigger**. It trimmed the surface
//! and linked the identical closure.
//!
//! ✅ **Fixed 2026-07-23.** Every heavy dependency is optional now and hangs off
//! the ONE pane that needs it (`about-card` · `signin` · `helix` · `tools` ·
//! `bench` · `warehouse-scan` · `demo`), and each tier feature selects only what
//! it can drive. Measured, release:
//!
//! ```text
//! gui-embedded  holger-server-ui-embedded  64 732 640 B  608 crates   (the opt-in fat one)
//! gui           holger-server-ui           31 603 160 B  534 crates
//! gui-mini      holger-server-ui-mini      20 685 888 B  493 crates
//! gui-mikro     holger-server-ui-mikro     20 488 072 B  492 crates
//! ```
//!
//! Two tests keep it that way, and neither reads a manifest:
//! `tests/tier_ladder.rs` fails if a tier is not strictly leaner than the one
//! above it, and `tests/thin_client.rs` fails if a shipped tier links a server
//! entry point or the storage engine.
//!
//! **What actually removes code:** `#[cfg]` on the leaf PLUS the dependency
//! behind an optional feature. Rust compiles whole functions; no runtime filter
//! ever drops a byte.
//!
//! **So when a pane must not exist in a tier, do all four — not just the cfg:**
//!
//! 1. Give it its OWN feature in `server/ui/Cargo.toml` (`signin`, `tools`,
//!    `helix`, …) and hang its deps off that feature, not off `gui`.
//! 2. Gate the leaf — its render fn and its module — with
//!    `#[cfg(feature = "<that feature>")]`. This is the only step that shrinks
//!    the binary. Pane bodies that the read-only tiers drop gate on the SURFACE
//!    marker (`#[cfg(not(feature = "mini"))]`); the tier features are what make
//!    that sound, and `lib.rs` carries a `compile_error!` if no tier is picked.
//! 3. Name that feature on the pane's declaration in `app/scene.rs`, so the
//!    scene, the View menu and the Ctrl-K palette drop it from ONE place —
//!    the way [`TAB_ORDER`] already does for the tab strip.
//! 4. Add the feature to that variant's `features` list in
//!    `.nornir/build-variants.toml`. The build-thing variant is the tier's
//!    single declaration; if it and this file disagree, the build-thing is
//!    lying about what it ships.
//!
//! **And keep it THIN.** Every shipped UI tier links no server entry point and no
//! storage engine — a client that embeds its own server shows its own in-process
//! world instead of the server's and becomes a second writer on the warehouse
//! lock. If a pane needs `LocalHolger`, an Iceberg catalog or an in-process
//! gateway, it belongs behind `gui-embedded`, the build that is asked for by name.
//!
//! [`TAB_ORDER`] is the one place that already does this right: a single
//! constant swaps the full tab strip for the tier's, and the scene / View menu /
//! Ctrl-K palette all derive from it. Copy THAT pattern.

use eframe::egui;
use egui::{RichText, Ui};
use facett::effects::IceDrip; // Skade-Vinter ice-drip winter decor (icicles + melt-droplets + frost)
// The 3D graph primitives. `Graph3D` + `RabbitLogo3D` are shared (the splash rabbit,
// the Lineage fill); the layout/decor policy knobs belong to the full tier's Graph tab.
#[cfg(not(feature = "mini"))]
use facett::graph3d::{DecorPolicy, Layout3D};
#[cfg(not(feature = "mini"))]
use facett::graph3d::Graph3D;
use facett::graph3d::RabbitLogo3D;
use facett::look::Theme as LookTheme; // the unified facett look & feel (one preset/frame)
use facett::menu::CommandPalette; // the facett overlay menu system — the Ctrl-K command palette
use facett::table::Table;
use facett::Facet;
// The SHARED declarative pane/scene host (facett_app::scene) — holger's tabbed shell is a
// `Scene` (Tabs root, one foldable `Slot` per tab, plus a grouped menubar) rendered through
// facett's OWN TabView + pane engine; `Device::from_width` drives the responsive canvas-first
// control fold. The builder / `SceneHost` / render bridge live in the `scene` child module
// below, so only the types the app struct itself names are imported here.
use facett_app::scene::{Device, NavState, Scene};
// The SHARED device-code sign-in pane (facett_auth_ui) mounted on the Sign-in tab, driven
// by holger's own `HolgerLoginSeam` over nornir-auth-client (LAW #5 — one pane, no twin).
// FULL TIER ONLY: the Sign-in tab is the one pane that links an outbound HTTP client
// (`nornir-auth-client/online` → reqwest), so the read-only tiers do not compile it.
#[cfg(not(feature = "mini"))]
use crate::login_seam::HolgerLoginSeam;
// `LoginPane`'s `ui`/`state_json` are `facett_core::Facet` methods; that trait is already
// in scope in this module (the scene/table facets use it), so no extra trait import here.
#[cfg(not(feature = "mini"))]
use facett_auth_ui::LoginPane;
// The TIME-HELIX DAG view — FULL TIER ONLY (the `helix` feature owns `facett-helix`).
#[cfg(not(feature = "mini"))]
use facett_helix::{EventKind, HelixView, Kind, PlanDag, Status};
use serde_json::{json, Value};
#[cfg(not(feature = "mini"))]
use std::collections::HashMap;
#[cfg(not(feature = "mini"))]
use std::collections::VecDeque;
use traits::ArtifactId;

#[cfg(not(feature = "mini"))]
use crate::data::{LineageEvent, LineageNode, NodeKind};
use crate::data::UiData;

/// Command palette + menu dispatch (build_commands / dispatch_command / run_command /
/// apply_repo_row_nav + the id→enum maps) — a child module so it can reach
/// `HolgerUiApp`'s private state while keeping `app` focused on rendering.
mod command;

/// The dependency-graph view types (GraphNodeKind palette, repo→kind classifier,
/// the Móðguðr verdict-graph renderer) — a child module; the `Graph3D`-building
/// methods (`build_graph` / `graph_tab`) stay here and use these.
/// FULL TIER ONLY — the Graph tab, the Móðguðr verdict graph and the Lineage
/// palette are all full-tier surfaces, so the whole module goes with them.
#[cfg(not(feature = "mini"))]
mod graph;
#[cfg(not(feature = "mini"))]
use graph::{render_security_graph, repo_node_kind, GraphNodeKind};

/// The declarative UI-scene layer (`holger_scene` builder + `SceneHost` impl +
/// `render_scene` / `scene_state_json`) — the whole shell (menubar + tabbed pane
/// tree) expressed as a `facett_app::scene::Scene` and rendered through facett-app's
/// own primitives, DRIVING the existing tab renderers. A child module so it reaches
/// `HolgerUiApp`'s private tab methods while keeping `app` focused on the widgets.
mod scene;

/// The holger-server-ui version, shown on the startup splash + in `state_json` (so the
/// robot can assert a version string is presented). Sourced from the crate's
/// `Cargo.toml` at compile time.
pub const HOLGER_UI_VERSION: &str = env!("CARGO_PKG_VERSION");

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep in a release build). Each Tools pane's run button emits one so
/// `nornir test --features testmatrix` SEES each operator surface fire. Mirrors
/// the reference emitter in `korp-collectors/src/lib.rs`.
#[inline]
#[cfg_attr(feature = "mini", allow(dead_code))]
pub(crate) fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

/// The recovered holger+znippy splash logo (low-poly steel Norse warrior resting
/// on a sword + a round shield bearing the znippy lightning-rabbit). Embedded at
/// compile time via `include_bytes!` so it ships INSIDE the binary — no runtime
/// file dependency (this is an airgap product). Decoded once on the first splash
/// frame into an egui texture (see [`HolgerUiApp::splash_logo_texture`]).
#[cfg(feature = "about-card")]
const HOLGER_LOGO_PNG: &[u8] = include_bytes!("../assets/holger-znippy-logo.png");

/// holger-server-ui's look roster. facett ships OS-specific presets (Windows + macOS,
/// each light+dark) but they are visually interchangeable here, so holger
/// collapses them to just **dark** and **light**, plus facett's non-OS `device`
/// look. Each entry is `(switcher label, facett Theme constructor)`; the index
/// into this slice is the persisted `look_preset`.
pub const LOOKS: &[(&str, fn() -> LookTheme)] = &[
    ("dark", LookTheme::windows_dark),
    ("light", LookTheme::windows_light),
    ("device", LookTheme::device),
];

/// The switcher roster as plain labels (`dark`, `light`, `device`).
/// The ⬇ save suggestion — the user's download dir (Hämtningar/Downloads,
/// falling back to $HOME) + `name-version`. A plain suggestion; the box is editable.
/// Present in EVERY tier: the download / SBOM / property read-tier extras are part of
/// the operator (full `gui`) UI too, not just the mini read-only client.
fn default_save_path(a: &crate::data::ArtifactView) -> String {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    let dir = [format!("{home}/Hämtningar"), format!("{home}/Downloads")]
        .into_iter()
        .find(|d| std::path::Path::new(d).is_dir())
        .unwrap_or(home);
    let file = if a.version.is_empty() {
        a.name.clone()
    } else {
        format!("{}-{}", a.name, a.version)
    };
    format!("{dir}/{file}")
}

pub fn look_names() -> Vec<String> {
    LOOKS.iter().map(|(n, _)| n.to_string()).collect()
}

/// The default look for holger-server-ui: `dark`. facett's `from_os` resolves every OS
/// to its dark variant, so the collapsed roster's first-launch default is dark.
/// Pure → unit-testable with no window.
pub fn default_look_preset() -> usize {
    LOOKS.iter().position(|(n, _)| *n == "dark").unwrap_or(0)
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Tab {
    Status,
    Repos,
    Browse,
    Archive,
    Artifact,
    Graph,
    Helix,
    Lineage,
    Tools,
    Upload,
    /// **Search** — cross-repo artifact discovery (`SearchService.Search`), the
    /// read-only search the mini tier exposes as its sixth view.
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    Search,
    /// **Sign in** — the operator OIDC device-code SSO surface. Mounts the ONE shared
    /// `facett_auth_ui::LoginPane` driven by holger's [`crate::login_seam::HolgerLoginSeam`]
    /// (LAW #5 — the same sign-in korp/dwarves mount, never a per-app twin).
    SignIn,
    /// **Settings** — the preferences pane, and the ONE home of holger's LOOK & FEEL.
    ///
    /// The look-preset switcher used to sit on the root chrome bar AND in a root
    /// menu-bar "Look" submenu; appearance is a preference, not navigation, so it
    /// moved here. This mirrors dwarves-demo's `settings` tab (the 🎛 glyph and the
    /// same split of responsibilities: root menu = navigation + actions, Settings =
    /// appearance + preferences), so an operator moving between the suite's apps
    /// finds the look control in the same place in each.
    ///
    /// Present on EVERY tier — the read-only mini/mikro artifacts carry no chrome
    /// control group, so before this pane existed their only look control was the
    /// root "Look" menu. Removing that menu without giving them Settings would have
    /// deleted the control outright.
    Settings,
}

/// The unique name of the shared-scene ROOT pane — a **draggable horizontal
/// master/detail [`Split`](facett_app::scene::Layout::Split)**: the registry rail
/// (master) beside the tabbed workspace (detail). The `Router` reports this id as the
/// scene's route, and a robot drags its divider via the render's `separators` rects.
///
/// It used to be the `Tabs` pane itself — a flat shell with no divider to grab. The
/// tabs moved down to [`WORKSPACE_PANE`]; this name stays put so every existing
/// route/deep-link that addressed `holger-shell` still resolves to the shell.
pub(crate) const SHELL_PANE: &str = "holger-shell";

/// The **registry rail** — the master side of holger's master/detail shell, and a
/// foldable DRAWER (it auto-folds crossing into a phone-class viewport and restores
/// above it). Itself a draggable VERTICAL split of the repository roster over the
/// selected repository's cross-tab context.
pub(crate) const RAIL_PANE: &str = "holger-rail";

/// The rail's top half: the repository ROSTER (filter + selectable list). Picking a
/// row here is the SAME `select_repo` every detail tab reads — this is the one master
/// selection, not a rail-local copy.
pub(crate) const RAIL_ROSTER_PANE: &str = "rail_roster_pane";

/// The rail's bottom half: what the shell knows about the SELECTED repository across
/// the whole workspace (loaded listing, archive, upload gate) — the detail the Repos
/// table (a row per repo) cannot show.
pub(crate) const RAIL_SELECTION_PANE: &str = "rail_selection_pane";

/// The unique name of the tabbed WORKSPACE pane (the detail side): the `Tabs` layout
/// hosting holger's operator tabs. The [`NavState`] addresses the active tab by this id.
pub(crate) const WORKSPACE_PANE: &str = "holger-workspace";

/// The tab strip order — the single source of truth mapping the [`Tab`] enum to its
/// scene-slot index, slot id, and header label. The shared [`Scene`] builds one foldable
/// `Slot` per entry in this order; [`NavState::active_tab`] indexes into it.
///
/// [`Tab::Settings`] is LAST on every tier: appending it leaves every existing tab's
/// strip index (and therefore every persisted `NavState`, deep-link and robot
/// `active_tab` assertion) exactly where it was.
#[cfg(not(feature = "mini"))]
const TAB_ORDER: [Tab; 12] = [
    Tab::Status,
    Tab::Repos,
    Tab::Browse,
    Tab::Archive,
    Tab::Artifact,
    Tab::Graph,
    Tab::Helix,
    Tab::Lineage,
    Tab::Tools,
    Tab::Upload,
    Tab::SignIn,
    Tab::Settings,
];
/// The **mini** tier's tab strip. `holger-server-ui-mikro` (bin `required-features =
/// ["mini"]`) is the read-only artifact for the mini SERVER (`server-lib/mini`:
/// no federation, no `online`), whose gRPC surface is exactly five read ops —
/// list_repositories / list_artifacts / list_archive_files / archive_info /
/// health. So the mini UI carries ONLY the tabs those back: no Upload (write),
/// no Tools (holger-ds/migrate/scan/deploy/airgap — federation & host surface),
/// no Sign in (OIDC needs `online`), no Graph/Helix/Lineage (scan- / proxy-cache-
/// fed). Since the scene, View menu, and Ctrl-K palette all derive from
/// `TAB_ORDER`, shrinking it here strips all three at once.
/// Settings rides along on the read-only tiers too: it is a LOCAL preference pane
/// (the look & feel), it calls no gRPC op, and without it the mini artifact would
/// have no look control at all once the root "Look" menu was retired.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
const TAB_ORDER: [Tab; 7] = [
    Tab::Status,
    Tab::Repos,
    Tab::Browse,
    Tab::Archive,
    Tab::Artifact,
    Tab::Search,
    Tab::Settings,
];
/// The **mikro** tier's tab strip — a STRICT SUBSET of the mini one, and the bottom
/// rung of the ladder. `holger-server-ui-mikro` (bin `required-features = ["gui-mikro"]`)
/// keeps the three ops that answer the only three questions an operator has against
/// an airgapped registry they cannot write to:
///
/// * **Status** — is it up, what version, how long (`AdminService.Health`).
/// * **Repos** — what does it carry (`RepositoryService.ListRepositories`).
/// * **Artifact** — is THIS exact coordinate on it, and how big
///   (`RepositoryService.GetArtifact` — a point lookup, no paging).
///
/// It drops the three PAGING/SCANNING surfaces the mini tier keeps — Browse
/// (`ListArtifacts`), Archive (`ListArchiveFiles` + `ArchiveInfo`) and Search
/// (`SearchService.Search`) — because each is a list-and-filter view whose whole
/// job is discovery, and the mikro tier's premise is that the caller already knows
/// the coordinate. That is a surface decision made from the gRPC surface, not from
/// taste: every op the mikro tier keeps is one round trip with no cursor.
///
/// Plus **Settings** — a local preference pane over the look & feel, no gRPC op
/// behind it. It is the only home the look control has once the root "Look" menu is
/// gone, and dropping it would have deleted the control from this tier rather than
/// moving it.
#[cfg(feature = "mikro")]
const TAB_ORDER: [Tab; 4] = [Tab::Status, Tab::Repos, Tab::Artifact, Tab::Settings];

impl Tab {
    /// The stable scene-slot id (also the `state_json.tab` string) for this tab.
    pub(crate) fn slot_id(self) -> &'static str {
        match self {
            Tab::Status => "status",
            Tab::Repos => "repos",
            Tab::Browse => "browse",
            Tab::Archive => "archive",
            Tab::Artifact => "artifact",
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            Tab::Search => "search",
            Tab::Graph => "graph",
            Tab::Helix => "helix",
            Tab::Lineage => "lineage",
            Tab::Tools => "tools",
            Tab::Upload => "upload",
            Tab::SignIn => "signin",
            Tab::Settings => "settings",
        }
    }
    /// The human header label shown on the scene's TabView button (the AccessKit label
    /// a robot clicks). Stable — asserted by the robot suite.
    pub(crate) fn label(self) -> &'static str {
        match self {
            Tab::Status => "Status",
            Tab::Repos => "Repos",
            Tab::Browse => "Browse",
            Tab::Archive => "Archive",
            Tab::Artifact => "Artifact",
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            Tab::Search => "Search",
            Tab::Graph => "Graph",
            Tab::Helix => "Helix",
            Tab::Lineage => "Lineage",
            Tab::Tools => "Tools",
            Tab::Upload => "Upload",
            Tab::SignIn => "Sign in",
            Tab::Settings => "Settings",
        }
    }
    /// This tab's index into [`TAB_ORDER`] (the scene's active-tab child index).
    pub(crate) fn index(self) -> usize {
        TAB_ORDER.iter().position(|t| *t == self).unwrap_or(0)
    }
    /// The tab at scene-slot index `i` (clamped to [`Tab::Status`]).
    pub(crate) fn from_index(i: usize) -> Tab {
        TAB_ORDER.get(i).copied().unwrap_or(Tab::Status)
    }
    /// A one-paragraph plain-language explanation of what this tab is for — shown
    /// behind the small ⓘ button at the top of each tab body. Mini-only (the affordance
    /// that reads it, `scene::tab_info`, is gated to the mini tier).
    #[cfg(feature = "mini")]
    pub(crate) fn help_text(self) -> &'static str {
        match self {
            Tab::Status => "The registry's health at a glance: is it reachable, what version \
                is it, how long has it been up. Read live over gRPC (AdminService.Health); \
                nothing here changes the server.",
            Tab::Repos => "Every repository the server exposes, one row each, with its \
                ecosystem (cargo · pip · maven · npm · …) and whether it is writable. Pick a \
                row here; it is the selection the Browse, Archive and Artifact tabs read.",
            Tab::Browse => "The artifacts inside the selected repository: name, version and \
                size, paged. A read-only listing (RepositoryService.ListArtifacts); this is \
                where you see what the registry is actually serving.",
            Tab::Archive => "The files packed inside the selected repository's immutable, \
                content-addressed znippy archive, plus its totals (file count · uncompressed \
                bytes · archive path). The on-disk truth behind the listing.",
            Tab::Artifact => "Look one artifact up by its coordinate (namespace · name · \
                version): whether it exists on the server and how big it is. A read-only \
                fetch; the quickest 'is this exact version here?' check.",
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            Tab::Search => "Find artifacts across every repository at once by name \
                substring (SearchService.Search): each hit shows which repo carries it, \
                the version and the size. A read-only aggregate over the same listings \
                Browse pages through.",
            // Full-tier tabs (never shown in the mini artifact, but the match is exhaustive):
            Tab::Graph => "The dependency/link graph across scanned artifacts.",
            Tab::Helix => "The time-helix DAG view of registry history.",
            Tab::Lineage => "The live proxy-cache-fill graph (a federation view).",
            Tab::Tools => "Operator functions (delivery bench, migrate, scan, deploy, airgap, demo).",
            Tab::Upload => "Push an artifact into a writable repository.",
            Tab::SignIn => "The OIDC device-code operator login.",
            Tab::Settings => "Preferences — the LOOK & FEEL of this window. Pick a theme \
                preset and every view re-themes at once (tables, graphs, chrome). A local \
                choice: it changes nothing on the server and sends no request.",
        }
    }
}

/// How the Lineage tab renders the live cache-fill graph: the rotating spatial
/// FILL (facett-graph3d) or its temporal companion (facett-helix).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg(not(feature = "mini"))]
pub(crate) enum LineageMode {
    /// The primary rotating 3D fill — nodes pop in and the graph grows as the
    /// cache fills (facett-graph3d).
    Graph3d,
    /// The time companion — each arrival wound onto a per-ecosystem coil
    /// (facett-helix). FULL TIER ONLY — the read-only tiers link no facett-helix.
    #[cfg(not(feature = "mini"))]
    Helix,
}

/// The four Tools-menu functions (a mirror of the holger CLI tools).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ToolsFn {
    HolgerDs,
    Migrate,
    Security,
    Deploy,
    Airgap,
    /// Tool #6 — the shared-framework **demo appliance**: start a demo registry
    /// container THROUGH the shared job manager `jera` (a durable `BOOT_CONTAINER`
    /// job), mirroring korp's Tools-menu container demos. Read-path-safe to declare +
    /// dispatch; the LIVE boot needs the `containers` feature.
    Demo,
}

/// The **structured outcome of the last upload attempt** — the write's success or
/// failure as READABLE DATA, not just the transient `notice` label. LAW #6: the
/// real-write outcome (`put_artifact`) is the ONLY human-facing result of the Upload
/// tab, so it is emitted field-by-field (`ok` / `bytes` / `stage` / `error`) and a
/// robot asserts the VALUE, never the pixels or a substring of a free-text string.
///
/// `attempted == false` is the clean-slate state (no upload run yet); `ok` is `None`
/// until an attempt lands, then `Some(true)` on a stored write or `Some(false)` on a
/// failure whose `stage` (`"read"` = the local file read, `"put"` = the server write)
/// and `error` are both populated (LAW #2 — the failure path is an asserted value).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UploadOutcome {
    /// `true` once the operator has clicked upload at least once.
    pub attempted: bool,
    /// `Some(true)` = the artifact was written; `Some(false)` = it failed; `None`
    /// before any attempt.
    pub ok: Option<bool>,
    /// Bytes written on success (the length of the file the server stored).
    pub bytes: Option<u64>,
    /// The repository the write targeted.
    pub repo: Option<String>,
    /// The failing stage on `ok == Some(false)`: `"read"` (the local file read) or
    /// `"put"` (the server write). `None` on success.
    pub stage: Option<&'static str>,
    /// The error string on failure (the same text folded into `notice`), `None` on
    /// success — so an operator (and a robot) reads WHY without parsing the notice.
    pub error: Option<String>,
}

impl UploadOutcome {
    /// The outcome as observable data (LAW #6): every field a robot asserts.
    pub fn state_json(&self) -> Value {
        json!({
            "attempted": self.attempted,
            "ok": self.ok,
            "bytes": self.bytes,
            "repo": self.repo,
            "stage": self.stage,
            "error": self.error,
        })
    }
}

/// The holger UI application: a [`UiData`] handle + the tab's input fields.
pub struct HolgerUiApp {
    data: UiData,
    tab: Tab,
    repo: String,
    namespace: String,
    name: String,
    version: String,
    #[cfg(not(feature = "mini"))]
    upload_path: String,
    #[cfg(not(feature = "mini"))]
    notice: Option<String>,
    /// The structured outcome of the last upload (the write's success/failure as
    /// DATA — see [`UploadOutcome`]). Paired with `notice` (the human label).
    #[cfg(not(feature = "mini"))]
    upload_outcome: UploadOutcome,
    /// Demo: static **Skade Vinter** showcase mode (the icy winter scene — replaces
    /// the old red "silent running" theme). Read-only; the whole UI re-themes to
    /// `Theme::skade_vinter()` and the ice-drip decor overlays. Manual toggle until
    /// the server reports its profile over the gRPC Admin service.
    static_mode: bool,
    /// The Skade-Vinter ice-drip decor (icicles + falling melt-droplets + frost
    /// shimmer) overlaid while in static mode. Time-driven; off otherwise.
    ice: IceDrip,
    /// Splash start time (egui seconds); shows the rabbit logo briefly then
    /// auto-dismisses. Paired with `splash_done`.
    splash_since: Option<f64>,
    splash_done: bool,
    /// The spinning znippy rabbit shown on the startup splash (a small animated
    /// accent beneath the recovered hero logo).
    logo: RabbitLogo3D,
    /// The decoded recovered holger+znippy hero logo, lazily loaded as an egui
    /// texture on the first splash frame from the compiled-in [`HOLGER_LOGO_PNG`]
    /// (decode-once, then cached for the rest of the splash). `None` until decoded.
    #[cfg(feature = "about-card")]
    logo_texture: Option<egui::TextureHandle>,
    /// Selected Tools function.
    tool: ToolsFn,
    /// Móðguðr (Tools#3) view mode: `false` = the verdict table, `true` = the
    /// dependency-graph view (nodes coloured by verdict).
    #[cfg(not(feature = "mini"))]
    security_graph: bool,
    /// The 3D repo graph (rebuilt when the underlying data signature changes).
    #[cfg(not(feature = "mini"))]
    graph: Option<Graph3D>,
    /// Node-kind labels parallel to the graph's nodes — the HOLGER NEON PALETTE
    /// mapping, surfaced in `state_json` so the robot asserts the graph as DATA.
    #[cfg(not(feature = "mini"))]
    graph_kinds: Vec<&'static str>,
    /// Signature of the data the graph was built from — `(repo_count, selected
    /// repo, artifact-file count)`. The graph rebuilds when this changes (a new
    /// selection lights a different subgraph; a loaded archive adds artifact nodes).
    #[cfg(not(feature = "mini"))]
    graph_sig: (usize, Option<usize>, usize),
    /// The TIME-HELIX DAG view (facett-helix) shown on the Helix tab — a plan/dep
    /// DAG wound along a synthesized time axis. Rebuilt when [`Self::helix_sig`]
    /// changes; `HelixView::demo()` when there is no real dep-graph data (or in the
    /// hands-free demo tour) so the tab is never blank.
    #[cfg(not(feature = "mini"))]
    helix: Option<HelixView>,
    /// Signature of the data the helix was built from — `(demo?, dep-graph
    /// repository, node count, edge count)`. The helix rebuilds when this changes
    /// (e.g. a Móðguðr scan populates a new repo's dependency graph).
    #[cfg(not(feature = "mini"))]
    helix_sig: (bool, String, usize, usize),
    /// Lineage tab render mode — the rotating 3D fill or the time-helix companion.
    #[cfg(not(feature = "mini"))]
    lineage_mode: LineageMode,
    /// The rotating 3D cache-fill graph (facett-graph3d), rebuilt as the lineage
    /// view grows (`lineage_sig` changes); `None` until first built.
    #[cfg(not(feature = "mini"))]
    lineage_graph: Option<Graph3D>,
    /// The time-helix companion (facett-helix) for the lineage view; `None` until built.
    #[cfg(not(feature = "mini"))]
    lineage_helix: Option<HelixView>,
    /// Signature of the lineage data the widgets were built from — `(node, edge,
    /// event count)`. When it changes (an arrival landed) the widgets rebuild so the
    /// new node pops in; the injected clock keeps rotation/traverse continuous.
    #[cfg(not(feature = "mini"))]
    lineage_sig: (usize, usize, usize),
    /// The LIVE feed sender the Lineage tab drips demo arrivals through (the same
    /// `mpsc` seam a real [`ChannelAuditLog`](crate::data::ChannelAuditLog) tee
    /// uses); `self.data.lineage` holds the paired receiver.
    #[cfg(not(feature = "mini"))]
    lineage_tx: std::sync::mpsc::Sender<LineageEvent>,
    /// Remaining demo arrivals to drip one-per-frame so the graph visibly fills and
    /// rotates when no real audit feed is wired.
    #[cfg(not(feature = "mini"))]
    lineage_demo: VecDeque<LineageEvent>,
    /// When set, drip one demo arrival per frame while the Lineage tab is open.
    #[cfg(not(feature = "mini"))]
    lineage_autofill: bool,
    /// Active look — an index into [`LOOKS`] (`dark` / `light` / `device`). The app applies
    /// this preset every frame; `apply` installs the full egui Style AND publishes
    /// the derived legacy palette so the facett Table facets re-theme coherently
    /// (COH-1). Replaces the old `facett::Theme::sci_fi()` call.
    look_preset: usize,
    /// The session's mTLS client identity (the cert+key the UI connected with).
    /// Tool #5 Airgap feeds these into the populate args when `auth=mtls`, so a
    /// real push reuses the operator's connect-time identity instead of failing
    /// with "--auth mtls needs --client-cert and --client-key".
    session_client_cert: Option<std::path::PathBuf>,
    session_client_key: Option<std::path::PathBuf>,
    /// 🎬 **Demo mode** (hands-free guided tour). `Some` when launched with
    /// `holger-server-ui --demo`: each frame the app applies the next due
    /// [`DemoStep`](crate::demo::DemoStep), driving the same `pub` hooks a human
    /// click runs (tab/tool switch, repo + graph-node selection, look re-theme),
    /// with the egui frame clock so the animations between beats render.
    #[cfg(feature = "demo")]
    demo: Option<DemoRun>,
    /// The facett **command palette** (Ctrl-K / Ctrl-Shift-P): a fuzzy launcher over
    /// every tab, tool, and *repository* (owned-String command ids let the per-repo
    /// commands be built at runtime). Pure data between frames; drawn last so it
    /// overlays everything. Its `invoked` id is dispatched by [`dispatch_command`].
    palette: CommandPalette,
    /// **About** popup open flag (shared common-framework identity surface). Toggled
    /// by the `Help · About holger` menu / palette command; the modal renders holger's
    /// logo + wordmark + version + the shared framework it is built on.
    about_open: bool,
    /// The shared About card (`facett_about::AboutCard`), built lazily on first open —
    /// it decodes holger's hero logo once (LFS-tolerant: an un-pulled blob yields no
    /// texture and the card shows its text identity) and remembers the 360px width.
    /// `None` until the About popup is first opened. This is the ADOPTION of the shared
    /// widget: holger no longer hand-rolls the About egui body (LAW #5).
    #[cfg(feature = "about-card")]
    about_card: Option<facett_about::AboutCard>,
    /// **The shared declarative pane/scene host (LAW #5 — ADOPTION).** holger's tabbed shell
    /// is a `facett_app::scene::Scene`: a `Tabs` root pane with one foldable `Slot` per tab.
    /// It is rendered through facett's OWN TabView + pane engine by [`scene::render`], driven
    /// by a thin [`SceneHost`] that fills each slot from holger's existing `*_tab` bodies —
    /// replacing the hand-rolled tab strip + central `match self.tab`. Built once in `new`.
    scene: Scene,
    /// The scene's runtime navigation state (active tab + fold flags), persisted across
    /// frames. Kept in sync with holger's authoritative [`Self::tab`]: seeded from it before
    /// each render and read back after, so palette/menu/demo dispatch and the observable
    /// `state_json.tab` keep working while the scene's TabView drives the click path.
    nav: NavState,
    /// The last [`scene::render`]'s observable facts (device class, per-pane rects, slot
    /// states) — surfaced under `state_json.scene` so a robot asserts the adopted scene +
    /// its responsive device class without pixels.
    last_render: Value,
    /// Whether the shell chrome (look switcher + static toggle) is currently folded behind
    /// the ⚙ drawer — set true on a phone-class viewport ([`Device::from_width`]). The
    /// canvas-first responsive fold; surfaced under `state_json.controls_folded`.
    controls_folded: bool,
    /// The SHARED device-code **sign-in pane** (`facett_auth_ui::LoginPane`) mounted on the
    /// Sign-in tab, driven by holger's own [`HolgerLoginSeam`] over `nornir-auth-client` (LAW
    /// #5 — one pane, no per-app twin). Presentational: it holds the login state machine and
    /// paints it; every transition comes from the seam. Its `state_json` is surfaced under
    /// `state_json.login` (subject + credential NAMES only — never a token value).
    #[cfg(not(feature = "mini"))]
    login_pane: LoginPane,
    /// The registry rail's repository filter (substring, case-insensitive). Empty ⇒ the
    /// whole roster. Pure data between frames; reported under `state_json.scene.render.
    /// slots.rail-roster.filter` so a robot can assert what the rail is showing.
    rail_filter: String,
    /// Mini: the Browse tab's server-side name filter (`ListArtifacts.name_filter`).
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    browse_filter: String,
    /// Mini: the Archive tab's server-side path prefix (`ListArchiveFiles.prefix`).
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    archive_prefix: String,
    /// Mini: the Search tab's name-axis query input.
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    search_input: String,
    /// Where the ⬇ save writes the fetched artifact (prefilled per fetch). Present in
    /// every tier — the Artifact tab's download control is not mini-only.
    save_path: String,
}

/// Live wrapper around a [`DemoWalk`](crate::demo::DemoWalk): the script plus the
/// egui-time origin it measures `elapsed_ms` from. FC-7: the walk is time-injected;
/// this only records the frame-time origin and rebases it each loop.
///
/// FULL TIER ONLY — the guided tour pulls REAL crates from crates.io through the
/// `world-connector` (→ reqwest), which is exactly what the read-only tiers exist
/// not to link.
#[cfg(feature = "demo")]
struct DemoRun {
    walk: crate::demo::DemoWalk,
    /// egui `input.time` (seconds) captured on the first demo frame; `None` until.
    origin: Option<f64>,
}

impl HolgerUiApp {
    /// Build the app over a connected data layer and prime the first views.
    pub fn new(mut data: UiData) -> Self {
        data.refresh_status();
        // Server truth for the static "silent running" mode: a server that
        // accepts no writes drives the UI read-only by default (the checkbox
        // below stays a manual override the operator can flip either way).
        data.refresh_profile();
        data.refresh_repos();
        // The runnable demo default for Tool #5 is a dry-run plan: it validates
        // with no creds and shows the plan immediately (the operator unticks
        // dry-run to actually push).
        data.airgap.dry_run = true;
        // The "Skade Vinter" winter SHOWCASE (frost badge · ice-drip decor · icy look ·
        // winter `state_json`) all rides on `static_mode`, seeded here from the server's
        // read-only profile. The mini tier IS a read-only server, but it is a plain
        // operator UI — not the demo showcase — so it never lights the winter chrome and
        // carries no manual toggle. Everything winter keys off this one flag, so pinning
        // it false is the whole opt-out.
        #[cfg(not(feature = "mini"))]
        let static_mode = data.profile.read_only;
        #[cfg(feature = "mini")]
        let static_mode = false;
        let repo = data
            .repos
            .selected_repo()
            .map(|r| r.name.clone())
            .unwrap_or_default();
        // Wire the live lineage feed: connect the view-model to the receiver and keep
        // the sender for the Lineage tab's demo drip. A real deployment installs a
        // `ChannelAuditLog` tee on the server's `Arc<dyn AuditLog>` with the same
        // channel, so every fetch/put streams straight into `data.lineage`.
        #[cfg_attr(feature = "mini", allow(unused_variables))]
        let (lineage_tx, lineage_rx) = crate::data::channel();
        data.lineage.connect(lineage_rx);
        // Build holger's shell as the SHARED declarative scene (facett_app::scene) + seed its
        // runtime nav from it (LAW #5 — ADOPTION). The pane tree is constant, so the persisted
        // `nav` stays valid; only the menubar's Repositories submenu is data-driven, so
        // `render_scene` rebuilds the scene per frame to track the live roster. Fail loudly in
        // debug if the addressability invariant (unique pane + menu names) is ever broken.
        let scene = scene::holger_scene(&data.repos.repos);
        debug_assert!(
            scene.validate().is_ok(),
            "shell scene names must be unique: {:?}",
            scene.validate()
        );
        let nav = NavState::new(&scene);
        Self {
            data,
            tab: Tab::Status,
            repo,
            namespace: String::new(),
            name: String::new(),
            version: String::new(),
            #[cfg(not(feature = "mini"))]
            upload_path: String::new(),
            #[cfg(not(feature = "mini"))]
            notice: None,
            #[cfg(not(feature = "mini"))]
            upload_outcome: UploadOutcome::default(),
            static_mode,
            ice: IceDrip::new(16, 0x5CADE_u64).enabled(true).with_period(2.8),
            splash_since: None,
            splash_done: false,
            logo: RabbitLogo3D::new("holger"),
            #[cfg(feature = "about-card")]
            logo_texture: None,
            tool: ToolsFn::HolgerDs,
            #[cfg(not(feature = "mini"))]
            security_graph: false,
            #[cfg(not(feature = "mini"))]
            graph: None,
            #[cfg(not(feature = "mini"))]
            graph_kinds: Vec::new(),
            #[cfg(not(feature = "mini"))]
            graph_sig: (usize::MAX, None, usize::MAX),
            #[cfg(not(feature = "mini"))]
            helix: None,
            #[cfg(not(feature = "mini"))]
            helix_sig: (false, String::new(), usize::MAX, usize::MAX),
            #[cfg(not(feature = "mini"))]
            lineage_mode: LineageMode::Graph3d,
            #[cfg(not(feature = "mini"))]
            lineage_graph: None,
            #[cfg(not(feature = "mini"))]
            lineage_helix: None,
            #[cfg(not(feature = "mini"))]
            lineage_sig: (usize::MAX, usize::MAX, usize::MAX),
            #[cfg(not(feature = "mini"))]
            lineage_tx,
            #[cfg(not(feature = "mini"))]
            lineage_demo: crate::data::demo_events().into_iter().collect(),
            #[cfg(not(feature = "mini"))]
            lineage_autofill: true,
            look_preset: default_look_preset(),
            session_client_cert: None,
            session_client_key: None,
            #[cfg(feature = "demo")]
            demo: None,
            palette: CommandPalette::default(),
            about_open: false,
            #[cfg(feature = "about-card")]
            about_card: None,
            scene,
            nav,
            last_render: Value::Null,
            controls_folded: false,
            // Mount the shared sign-in pane over holger's live seam. If a non-expired
            // token is already cached (`current_identity`), the pane opens straight into
            // the signed-in surface; otherwise it opens signed-out.
            #[cfg(not(feature = "mini"))]
            login_pane: LoginPane::new(Box::new(HolgerLoginSeam::from_env()))
                .with_title("Sign in to holger"),
            rail_filter: String::new(),
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            browse_filter: String::new(),
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            archive_prefix: String::new(),
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            search_input: String::new(),
            save_path: String::new(),
        }
    }

    /// 🎬 Arm **demo mode**: install the hands-free guided-tour walk and skip the
    /// splash so the tour starts immediately. `beat_ms` is the pause between beats.
    /// The live render loop then applies one due beat per frame off the egui clock.
    #[cfg(feature = "demo")]
    pub fn enable_demo(&mut self, beat_ms: u64) {
        self.skip_splash();
        self.demo = Some(DemoRun { walk: crate::demo::DemoWalk::tour(beat_ms), origin: None });
    }

    /// 🎬 The headless-test hook: apply every beat due at `elapsed_ms` against the
    /// real app hooks (deterministic, no egui clock). Returns the labels applied.
    /// The live [`demo_tick`](Self::demo_tick) is the same logic fed by frame time.
    #[cfg(feature = "demo")]
    pub fn demo_drive_to(&mut self, elapsed_ms: u64) -> Vec<String> {
        let mut applied = Vec::new();
        // Pull due beats one at a time so each `apply` runs in cursor order.
        loop {
            let step = match self.demo.as_mut() {
                Some(run) => run.walk.due(elapsed_ms),
                None => None,
            };
            match step {
                Some(s) => {
                    self.apply_demo_action(s.action);
                    applied.push(s.label);
                }
                None => break,
            }
        }
        applied
    }

    /// `true` when the hands-free tour is armed. Only the `demo` (embedded) build
    /// has one to arm, so every other tier answers a constant `false` — which lets
    /// the panes that ask (Helix's data signature, the render loop) stay one code
    /// path instead of growing a `#[cfg]` at each question.
    #[inline]
    #[cfg_attr(feature = "mini", allow(dead_code))]
    pub(crate) fn demo_running(&self) -> bool {
        #[cfg(feature = "demo")]
        {
            self.demo.is_some()
        }
        #[cfg(not(feature = "demo"))]
        {
            false
        }
    }

    /// `true` when demo mode is armed (for the live repaint loop). The read-only
    /// tiers compile no demo, so it is constantly `false` there.
    #[cfg(feature = "demo")]
    pub fn demo_active(&self) -> bool {
        self.demo.is_some()
    }

    /// 🎬 Per-frame live drive: emit any beat due at the current egui frame time,
    /// looping forever (rebasing the clock each pass) so a left-running demo window
    /// stays alive. Applies the beat through the real `pub` hooks, then requests a
    /// repaint so the motion between beats keeps animating.
    #[cfg(feature = "demo")]
    fn demo_tick(&mut self, ctx: &egui::Context, now: f64) {
        let elapsed_ms = {
            let Some(run) = self.demo.as_mut() else { return };
            if run.walk.is_done() {
                run.walk.rewind();
                run.origin = Some(now);
            }
            let origin = *run.origin.get_or_insert(now);
            ((now - origin).max(0.0) * 1000.0) as u64
        };
        let applied = self.demo_drive_to(elapsed_ms);
        if !applied.is_empty() {
            self.notice = applied.last().map(|l| format!("🎬 demo: {l}"));
        }
        // Keep painting so the next beat lands and the animations in between render.
        ctx.request_repaint_after(std::time::Duration::from_millis(120));
    }

    /// Apply one [`DemoAction`](crate::demo::DemoAction) through the exact `pub`
    /// hook a human click would run.
    #[cfg(feature = "demo")]
    fn apply_demo_action(&mut self, action: crate::demo::DemoAction) {
        use crate::demo::DemoAction::*;
        match action {
            Goto(t) => self.tab = t,
            SelectRepo(i) => self.select_repo(i),
            SelectGraphNode(i) => self.select_graph_node(i),
            SelectTool(tf) => {
                self.tab = Tab::Tools;
                self.tool = tf;
            }
            SecurityGraph(on) => {
                self.tab = Tab::Tools;
                self.tool = ToolsFn::Security;
                self.security_graph = on;
            }
            Look(i) => {
                self.set_look_preset(i);
            }
        }
    }

    /// Record the session's mTLS client identity (the `--cert`/`--key` the UI
    /// connected with), so Tool #5 Airgap can reuse it for an `auth=mtls` push.
    pub fn set_session_client_identity(
        &mut self,
        cert: Option<std::path::PathBuf>,
        key: Option<std::path::PathBuf>,
    ) {
        self.session_client_cert = cert;
        self.session_client_key = key;
    }

    /// Toggle the Tool #5 Airgap dry-run flag (test/automation hook for driving
    /// the validate() happy/red path without the egui checkbox).
    pub fn airgap_set_dry_run(&mut self, dry_run: bool) {
        self.data.airgap.dry_run = dry_run;
    }

    /// Set the Artifact-tab query fields (test hook: the tab's text edits live in
    /// an egui Grid and aren't individually label-addressable for TypeInto, so a
    /// deterministic setter drives the same state the operator would type).
    pub fn set_artifact_query(&mut self, repository: &str, name: &str, version: &str) {
        self.repo = repository.to_string();
        self.name = name.to_string();
        self.version = version.to_string();
    }

    /// Set the Artifact-tab **⬇ save destination** (the download path box). A test
    /// hook: the box prefills to the user's download dir per fetch, so a headless
    /// drive pins it to a deterministic (writable or deliberately-bad) path and then
    /// clicks the real `⬇ Save artifact` button, whose outcome lands on
    /// `state_json["artifact"]["saved_to" / "save_error"]`.
    pub fn set_save_path(&mut self, path: &str) {
        self.save_path = path.to_string();
    }

    /// Select repository row `i` — the exact path the Repos-tab row click runs.
    /// A test hook because the repo name is rendered BOTH as the selectable row
    /// and in the facett capability Table, so a label-addressed click is
    /// ambiguous; this drives the same selection deterministically.
    pub fn select_repo(&mut self, i: usize) {
        self.data.select_repo(i);
        if let Some(r) = self.data.repos.repos.get(i) {
            self.repo = r.name.clone();
        }
    }


    // ── look & feel (unified `facett::look::Theme`) ─────────────────────────────

    /// The active look's [`LookTheme`].
    pub fn look_theme(&self) -> LookTheme {
        LOOKS[self.look_preset.min(LOOKS.len() - 1)].1()
    }

    /// The active look's holger label (`dark` / `light` / `device`) — what the
    /// switcher shows and `state_json` reports, not facett's internal theme name.
    pub fn look_name(&self) -> &'static str {
        LOOKS[self.look_preset.min(LOOKS.len() - 1)].0
    }

    /// Switch the active look (clamped); returns the new look's label.
    pub fn set_look_preset(&mut self, idx: usize) -> String {
        self.look_preset = idx.min(LOOKS.len() - 1);
        self.look_name().to_string()
    }

    /// Apply the active preset to `ctx` — installs the full egui Style and
    /// publishes the derived legacy palette so the facett Table facets follow
    /// (COH-1). Re-applying takes effect next frame, no restart.
    pub fn apply_look(&self, ctx: &egui::Context) {
        self.look_theme().apply(ctx);
    }

    /// The look state the operator sees, as observable JSON for headless robot
    /// tests: the active preset name, the full preset roster, and the resolved
    /// legacy palette (the colours every facett view actually paints with). This
    /// is what proves "the active preset + a coherent palette" without a screen.
    pub fn look_state_json(&self) -> Value {
        let theme = self.look_theme();
        let pal = theme.to_legacy_palette();
        let hex = |c: egui::Color32| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
        json!({
            "preset": self.look_name(),
            "preset_index": self.look_preset,
            "presets": look_names(),
            "dark": theme.is_dark(),
            "palette": {
                "bg": hex(pal.bg),
                "text": hex(pal.text),
                "text_dim": hex(pal.text_dim),
                "accent": hex(pal.accent),
                "panel_bg": hex(pal.panel_bg),
                "node_fill": hex(pal.node_fill),
            },
        })
    }

    /// **The ⚙ Settings pane — holger's preferences surface, and the ONE home of its
    /// LOOK & FEEL.**
    ///
    /// The look-preset switcher used to live in two places at the shell ROOT: an
    /// inline strip on the chrome bar (folded behind a `⚙ Controls` menu on a narrow
    /// viewport) and a root menu-bar **Look** submenu. Both are gone; appearance is a
    /// PREFERENCE, not navigation, and the root menu is for navigation and actions.
    /// This is the same split dwarves-demo draws (its `settings` tab, glyph 🎛, holds
    /// the theme picker while its menus hold nothing about appearance) — so an
    /// operator moving between the suite's apps finds the look control in the same
    /// place in each.
    ///
    /// Nothing was lost in the move: the same [`LOOKS`] roster, driven through the
    /// same [`set_look_preset`](Self::set_look_preset), still re-themes every view
    /// (the preset is applied to the egui ctx at the top of every frame, and its
    /// derived legacy palette is published so the facett Table/graph facets follow —
    /// the COH-1 bridge). The `look:<name>` command still works from Ctrl-K, which is
    /// a searchable index of everything, not a menu.
    ///
    /// Returns the pane's observable state — the SAME [`look_state_json`] the shell
    /// publishes, so a robot can assert on the pane without a second oracle.
    ///
    /// [`look_state_json`]: Self::look_state_json
    fn settings_pane(&mut self, ui: &mut egui::Ui) -> Value {
        ui.heading("🎛 Appearance");
        ui.label(
            "The unified facett look & feel — one preset re-themes every view (tables, \
             graphs, chrome). A local preference: nothing is sent to the server.",
        );
        ui.add_space(6.0);
        let mut new_look: Option<usize> = None;
        let look_preset = self.look_preset;
        ui.horizontal_wrapped(|ui| {
            ui.label("look:")
                .on_hover_text("Unified facett look & feel — re-themes every view");
            for (i, name) in look_names().iter().enumerate() {
                if ui.selectable_label(look_preset == i, name.as_str()).clicked() {
                    new_look = Some(i);
                }
            }
        });
        if let Some(i) = new_look {
            self.set_look_preset(i);
        }
        ui.add_space(6.0);
        // Honest about the one case where the pick is overridden rather than silently
        // ignored: the Skade-Vinter showcase pins the icy preset for the whole shell.
        if self.static_mode {
            ui.weak(
                "❄ the Skade Vinter static showcase is engaged — it pins the winter \
                 preset over this pick until it is switched off (Action ▸ Toggle static)",
            );
        } else {
            ui.weak(format!("active preset: {}", self.look_name()));
        }
        self.look_state_json()
    }

    /// The whole app's observable state — what a robot driver asserts on
    /// ("see what the user sees as data", the nornir `Observable::state_json`
    /// contract). Active tab, static mode, selected tool, splash/graph state,
    /// the repo count behind the views, and the active look.
    pub fn state_json(&self) -> Value {
        let tab = match self.tab {
            Tab::Status => "status",
            Tab::Repos => "repos",
            Tab::Browse => "browse",
            Tab::Archive => "archive",
            Tab::Artifact => "artifact",
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            Tab::Search => "search",
            Tab::Graph => "graph",
            Tab::Helix => "helix",
            Tab::Lineage => "lineage",
            Tab::Tools => "tools",
            Tab::Upload => "upload",
            Tab::SignIn => "signin",
            Tab::Settings => "settings",
        };
        let tool = match self.tool {
            ToolsFn::HolgerDs => "holger-ds",
            ToolsFn::Migrate => "migrate",
            ToolsFn::Security => "security",
            ToolsFn::Deploy => "deploy",
            ToolsFn::Airgap => "airgap",
            ToolsFn::Demo => "demo",
        };
        // The currently selected Tools pane's render-time self-report (the same
        // signal the `pane_rendered` functional-status emitter fires): `ok=true`
        // when it draws a usable operator surface, `false` ("RED on empty") when
        // blanked/disabled in static read-only mode. The robot asserts this.
        // FULL TIER ONLY — the read-only tiers have no Tools pane to report on.
        #[cfg(not(feature = "mini"))]
        let (tp_pane, tp_ok, tp_detail) = self.tools_pane_report();
        #[allow(unused_mut)]
        let mut j = json!({
            "tab": tab,
            // The shell's OWN tab roster (the registered tab-bar slot ids, in
            // `TAB_ORDER`) — the tabs oracle the wire-remote per-tab scenario library
            // (`scenarios::holger_tab_scenarios`) is asserted complete against. A
            // removed / renamed / added tab moves this list, so the drive test that
            // compares the recorded scenario set to `/tabs` goes RED.
            "tabs": TAB_ORDER.iter().map(|t| t.slot_id()).collect::<Vec<_>>(),
            "static_mode": self.static_mode,
            "write_enabled": !self.static_mode,
            "tool": tool,
            "splash_done": self.splash_done,
            // The version presented on the splash (under the wordmark) — asserted
            // by the robot so "the splash shows a version string" is checked.
            "version": HOLGER_UI_VERSION,
            "repo_count": self.data.repos.repos.len(),
            // The command palette as data: open flag, query, the full command id set,
            // current fuzzy hits, and the last-invoked id — so a robot asserts the
            // Ctrl-K launcher (and per-repo commands) without a screen.
            "palette": self.palette.state_json(&self.build_commands()),
            // The data-layer views behind each tab, so the robot can assert what
            // the user sees on Status/Repos/Browse/Archive/Artifact as DATA.
            "status": self.data.status.state_json(),
            // Server-reported operating profile (server truth for static mode).
            "profile": self.data.profile.state_json(),
            "repos": self.data.repos.state_json(),
            "browse": self.data.browse.state_json(),
            "archive": self.data.archive.state_json(),
            "artifact": self.data.artifact.state_json(),
            // The effective look: the icy Skade-Vinter showcase while static, else
            // the operator's selected preset.
            "look": if self.static_mode { "skade_vinter".to_string() } else { self.look_name().to_string() },
            // The About identity surface (shared common-framework parity): open flag +
            // holger's identity (wordmark/version/logo) + the shared framework it is
            // built on — asserted by the robot without pixels.
            "about": crate::about::state_json(self.about_open),
            // The ADOPTED shared pane/scene host (facett_app::scene) as DATA. `scene_state_json`
            // merges BOTH observable surfaces into one object: the last `scene::render`'s facts
            // (scene name, the responsive `device` class + `width`, the per-pane render map) AND
            // the declarative shell's own structure (the pane tree, which panes are foldable, the
            // live fold + active-tab state, the menubar group count) — so a robot asserts the
            // whole shell, its menubar, its folds and its responsive canvas-first fold without
            // pixels. `controls_folded` mirrors the ⚙ drawer state.
            "scene": self.scene_state_json(),
            "controls_folded": self.controls_folded,
        });
        // The FULL-TIER-only observables, spliced in rather than written inline:
        // `json!` takes no `#[cfg]` on a member, and the read-only tiers compile
        // none of the panes behind these keys.
        #[cfg(not(feature = "mini"))]
        {
            // Render-time introspection for the selected Tools pane: which pane is
            // shown, whether it drew a usable surface (`ok`), and a one-line detail.
            // `ok=false` is the "RED on empty" contract (static-disabled write-tools).
            j["tools_pane"] =
                json!({ "pane": tp_pane, "ok": tp_ok, "rendered": true, "detail": tp_detail });
            // The Upload tab is chrome (writable gate + notice), not a data-layer
            // view, so its observable state is assembled here.
            j["upload"] = json!({
                "writable": self.data.repos.upload_enabled(),
                "target": self.data.repos.selected_repo().map(|r| r.name.clone()),
                "notice": self.notice,
                // The real-write outcome as DATA (LAW #6): success/failure is a
                // readable value (`ok`/`bytes`/`stage`/`error`), not a transient label.
                "outcome": self.upload_outcome.state_json(),
            });
            // Operator Tools panes (the data-layer views) so the robot asserts
            // them: Tool #1 holger-ds config + run result, Tool #3 security
            // verdict, Tool #5 airgap populate plan + run.
            j["holger_ds"] = self.data.holger_ds.state_json();
            j["security"] = self.data.security.state_json();
            j["dep_graph"] = self.data.dep_graph.state_json();
            j["airgap"] = self.data.airgap_state_json();
            j["migrate"] = self.data.migrate.state_json();
            // Tool #4 Deploy (Skíðblaðnir) — config + last deploy result.
            j["deploy"] = self.data.deploy.state_json();
            // Tool #6 Demo appliance — the last jera-dispatched demo-container result
            // (job id + spec + status), or null until the operator runs it. Proof the
            // Tools demo goes THROUGH the shared job manager, not a bespoke spawn.
            j["demo_appliance"] =
                json!(self.data.demo_appliance.as_ref().map(|d| d.state_json()));
            // The live artifact-lineage (proxy-cache-fill) graph as DATA — nodes/
            // edges/time-events — plus which render mode the Lineage tab is showing.
            j["lineage"] = self.data.lineage.state_json();
            j["lineage_mode"] = json!(match self.lineage_mode {
                LineageMode::Graph3d => "graph3d",
                LineageMode::Helix => "helix",
            });
            j["graph_built"] = json!(self.graph.is_some());
            j["graph"] = self.graph_state_json();
            // The Helix tab's wound time-helix DAG as DATA (built lazily when the tab
            // is first shown): `helix_built` mirrors `graph_built`, and `helix` carries
            // the HelixView's own `state_json` (or null before the coil is wound).
            j["helix_built"] = json!(self.helix.is_some());
            j["helix"] = self.helix_state_json();
            // The SHARED sign-in pane (facett_auth_ui) as DATA: the device-code state
            // machine's current node (signed_out / awaiting_user / signed_in / error),
            // the subject + credential NAMES when signed in, and the prompt when
            // awaiting. NO token/secret VALUE is present — the seam exposes none, so a
            // robot asserts the mounted pane without a secret ever reaching state.
            j["login"] = self.login_pane.state_json();
        }
        j
    }

    /// Toggle the **About** popup (test/automation hook + the `Help · About` command
    /// path). Returns the new open state.
    pub fn toggle_about(&mut self) -> bool {
        self.about_open = !self.about_open;
        self.about_open
    }

    /// Dispatch the **Tool #6 demo appliance** — the same hook the pane's "Start demo
    /// appliance…" button runs: records the demo container bring-up as a jera job (and
    /// boots it live under the `containers` feature). Test/automation hook so a robot
    /// drives the exact dispatch a click would, then asserts the observable job id.
    #[cfg(not(feature = "mini"))]
    pub fn run_demo_tool(&mut self) {
        self.tab = Tab::Tools;
        self.tool = ToolsFn::Demo;
        self.data.run_demo();
    }

    fn status_tab(&mut self, ui: &mut Ui) {
        if ui.button("⟳ refresh").clicked() {
            self.data.refresh_status();
        }
        ui.separator();
        let s = &self.data.status;
        if let Some(err) = &s.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        }
        egui::Grid::new("status_grid").striped(true).show(ui, |ui| {
            ui.label("status");
            ui.label(if s.status.is_empty() { "—" } else { &s.status });
            ui.end_row();
            ui.label("version");
            ui.label(if s.version.is_empty() { "—" } else { &s.version });
            ui.end_row();
            ui.label("uptime (s)");
            ui.label(s.uptime_seconds.to_string());
            ui.end_row();
        });
    }

    /// **Repos · grid — the MASTER half of the Repos body.**
    ///
    /// Just the repo×capability table, in its own scene pane. It used to sit at the
    /// BOTTOM of one imperative flow, under the refresh row, the error line, the repo
    /// combo and four action buttons — so every row of chrome above it pushed the grid
    /// down and there was no divider to win the space back. Now the grid is a window: it
    /// takes 62% of the body by default, the operator drags the divider to change that,
    /// and maximising it hides the inspector entirely.
    fn repos_grid(&mut self, ui: &mut Ui) {
        // facett Table facet for the repo×capability grid. The `writable` column is a
        // write concept: on the read-only mini tier it reads "false" for every row, so
        // mini drops it (a column that can only ever hold one value informs nobody).
        #[cfg(not(feature = "mini"))]
        let columns = vec!["name".into(), "type".into(), "writable".into(), "archive".into()];
        #[cfg(feature = "mini")]
        let columns = vec!["name".into(), "type".into(), "archive".into()];
        let mut table = Table::new("repositories", columns);
        for r in &self.data.repos.repos {
            #[cfg(not(feature = "mini"))]
            let row = vec![
                r.name.clone(),
                r.repo_type.clone(),
                r.writable.to_string(),
                r.has_archive.to_string(),
            ];
            #[cfg(feature = "mini")]
            let row = vec![r.name.clone(), r.repo_type.clone(), r.has_archive.to_string()];
            table.push_row(row);
        }
        if let Some(sel) = self.data.repos.selected {
            table.select_row(sel);
        }
        table.ui(ui);
    }

    /// **Repos · actions — the INSPECTOR half of the Repos body.**
    ///
    /// Everything you DO with the selected row: refresh, the error line, the scrolling
    /// repository selector, and the per-repo quick actions. Returns the pane's own
    /// observable state (the roster size + the live selection) so a robot can assert the
    /// inspector rendered without reading a pixel.
    fn repos_actions(&mut self, ui: &mut Ui) -> Value {
        if ui.button("⟳ refresh").clicked() {
            self.data.refresh_repos();
        }
        ui.separator();
        if let Some(err) = &self.data.repos.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        }

        // Selection row (collect first so we don't hold a borrow across the click).
        let entries: Vec<(usize, String)> = self
            .data
            .repos
            .repos
            .iter()
            .enumerate()
            .map(|(i, r)| (i, r.name.clone()))
            .collect();
        let selected = self.data.repos.selected;
        // A scrolling dropdown of ALL repositories — one compact control that scales
        // past a wrapped row when a server carries many repos (egui's ComboBox popup
        // scroll-bounds the list). Quick per-repo actions sit NEXT to it as buttons:
        // attaching a context menu to the combo response fought egui's popup layer and
        // suppressed the dropdown, so the actions are plain buttons instead.
        let mut row_action: Option<&str> = None;
        // The repository dropdown is a FULL-tier convenience: mini's rail Roster is the
        // one always-visible, filterable repo picker, so a second picker here would be a
        // straight duplicate (the exact L5 twin the palette/menu cleanup removed).
        #[cfg(not(feature = "mini"))]
        {
            let current = selected
                .and_then(|i| entries.get(i))
                .map(|(_, n)| n.clone())
                .unwrap_or_else(|| "(select a repository)".to_string());
            let mut pick = None;
            ui.label(format!("repository ({}):", entries.len()));
            egui::ComboBox::from_id_salt("repos-dropdown")
                .selected_text(current)
                .show_ui(ui, |ui| {
                    for (i, name) in &entries {
                        if ui.selectable_label(selected == Some(*i), name).clicked() {
                            pick = Some(*i);
                        }
                    }
                });
            if let Some(i) = pick {
                self.data.select_repo(i);
                self.repo = entries[i].1.clone();
            }
            ui.separator();
        }
        // NB: these per-repo quick-action buttons are deliberately NOT labelled
        // plain "Browse"/"Archive" — that would SHADOW the tab-bar labels of the
        // same name, so a robot (or an operator's accessibility tool) could not
        // address the Browse/Archive TABS by label while the Repos pane is open
        // (a duplicate-label ambiguity). The `▸` action prefix keeps them clear
        // AND unique, so the tab bar stays label-addressable from every pane.
        //
        // They stack VERTICALLY now: the inspector is a narrow column, and a row of
        // four buttons in a 38%-wide pane wraps into an unreadable ragged block.
        ui.add_enabled_ui(selected.is_some(), |ui| {
            // ▸ Browse / ▸ Archive only mirror the tab strip (both tabs auto-load the
            // roster selection), and ▸ Scan opens the full-tier Tools / Móðguðr pane —
            // mini carries none of them. Copy name is a real function (clipboard) and stays.
            #[cfg(not(feature = "mini"))]
            {
                if ui.button("▸ Browse repo").clicked() {
                    row_action = Some("row:browse");
                }
                if ui.button("▸ Scan repo").clicked() {
                    row_action = Some("row:scan");
                }
                if ui.button("▸ Archive repo").clicked() {
                    row_action = Some("row:archive");
                }
            }
            if ui.button("▸ Copy name").clicked() {
                row_action = Some("row:copy");
            }
        });
        // The quick-action buttons run on the currently selected repo: Browse / Scan /
        // Archive open the matching tab; Copy puts the name on the clipboard.
        if let Some(action) = row_action {
            if let Some(i) = self.data.repos.selected {
                if action == "row:copy" {
                    ui.ctx().copy_text(entries[i].1.clone());
                } else {
                    self.apply_repo_row_nav(i, action);
                }
            }
        }

        // `mut` for the full tier's upload_enabled insert below.
        #[cfg_attr(feature = "mini", allow(unused_mut))]
        let mut state = json!({
            "total": entries.len(),
            "selected": self.data.repos.selected,
            "selected_repo": self.data.repos.selected_repo().map(|r| r.name.clone()),
        });
        // Upload is a write concept — meaningless on the read-only mini tier.
        #[cfg(not(feature = "mini"))]
        {
            state["upload_enabled"] = json!(self.data.repos.upload_enabled());
        }
        state
    }

    /// **Browse · artifacts — the MASTER half.** Nothing but the artifact listing.
    ///
    /// The auto-load lives here (not in the inspector) because the master is drawn FIRST
    /// in the split, so the table shows the fetched listing on the very frame the
    /// selection changed rather than one frame later.
    #[cfg(not(feature = "mikro"))]
    fn browse_listing(&mut self, ui: &mut Ui) {
        // Snapshot the selected repo name to a local String before mutating
        // `self.data` (the refresh borrows it mutably).
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        // Auto-load on first view / when the selected repo changes — the pane used
        // to require a manual ⟳ refresh, so it looked empty until clicked. The guard
        // (browse.repository != selected) converges: refresh_browse stamps the repo,
        // so it fires once per selection, not every frame.
        if let Some(repo) = &target {
            if self.data.browse.repository != *repo {
                #[cfg(feature = "mini")]
                let filter = (!self.browse_filter.trim().is_empty())
                    .then(|| self.browse_filter.trim().to_string());
                #[cfg(not(feature = "mini"))]
                let filter = None;
                self.data.refresh_browse(repo, filter);
            }
        }

        // facett Table facet for the artifact listing.
        let mut table = Table::new(
            "browse",
            vec![
                "name".into(),
                "version".into(),
                "size".into(),
                "type".into(),
            ],
        );
        for e in &self.data.browse.entries {
            table.push_row(vec![
                e.name.clone(),
                e.version.clone(),
                e.size_bytes.to_string(),
                e.content_type.clone(),
            ]);
        }
        table.ui(ui);
    }

    /// **Browse · source & paging — the INSPECTOR half.**
    ///
    /// Which repository the listing came from, the refresh, the error line, and the
    /// pagination footer. The footer is exactly the readout that used to sit UNDER the
    /// table in one vertical flow: a long listing pushed "N shown / load more" off the
    /// bottom of the pane and there was no divider to win it back. It is its own window
    /// now. Returns the pane's observable state.
    #[cfg(not(feature = "mikro"))]
    fn browse_source(&mut self, ui: &mut Ui) -> Value {
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        ui.label(format!(
            "browsing repo: {}",
            target.clone().unwrap_or_else(|| "(none selected)".into())
        ));
        // Mini: a server-side name filter (`ListArtifacts.name_filter`) — the data
        // layer always supported it; this is the box that finally passes it.
        #[cfg(feature = "mini")]
        {
            let resp = ui.add_enabled(
                target.is_some(),
                egui::TextEdit::singleline(&mut self.browse_filter).hint_text("🔎 filter by name"),
            );
            if resp.changed() {
                if let Some(repo) = target.clone() {
                    let f = (!self.browse_filter.trim().is_empty())
                        .then(|| self.browse_filter.trim().to_string());
                    self.data.refresh_browse(&repo, f);
                }
            }
        }
        let do_refresh = ui
            .add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
            .clicked();
        if do_refresh {
            if let Some(repo) = target {
                #[cfg(feature = "mini")]
                let f = (!self.browse_filter.trim().is_empty())
                    .then(|| self.browse_filter.trim().to_string());
                #[cfg(not(feature = "mini"))]
                let f = None;
                self.data.refresh_browse(&repo, f);
            }
        }
        ui.separator();
        if let Some(err) = &self.data.browse.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        }

        // Pagination: count + a load-more button when the listing has a continuation
        // token.
        let loaded = self.data.browse.entries.len();
        let has_more = !self.data.browse.next_page_token.is_empty();
        ui.label(format!(
            "{loaded} shown{}",
            if has_more { " (more available)" } else { "" }
        ));
        if has_more && ui.button("load more").clicked() {
            self.data.load_more_browse();
        }

        json!({
            "repository": self.data.browse.repository,
            "shown": loaded,
            "has_more": has_more,
            "error": self.data.browse.error,
        })
    }

    /// **Archive · files — the MASTER half.** The raw archive entry list, alone.
    ///
    /// This is the body Rickard's concrete case describes: the STATS readout (files /
    /// uncompressed bytes / archive path) sat ABOVE the file table in one vertical flow,
    /// so as it grew it pushed the table down and off the bottom, with no divider to
    /// adjust. The two are now separate windows with a draggable divider between them.
    #[cfg(not(feature = "mikro"))]
    fn archive_files(&mut self, ui: &mut Ui) {
        // Snapshot the selected repo name to a local String before mutating
        // `self.data` (refresh_archive borrows it mutably).
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        // Auto-load on first view / repo change (same as Browse — was manual-only).
        if let Some(repo) = &target {
            if self.data.archive.repository != *repo {
                #[cfg(feature = "mini")]
                let prefix = (!self.archive_prefix.trim().is_empty())
                    .then(|| self.archive_prefix.trim().to_string());
                #[cfg(not(feature = "mini"))]
                let prefix = None;
                self.data.refresh_archive(repo, prefix);
            }
        }

        // facett Table facet for the raw archive file paths (single column).
        let mut table = Table::new("archive", vec!["path".into()]);
        for path in &self.data.archive.files {
            table.push_row(vec![path.clone()]);
        }
        table.ui(ui);
    }

    /// **Archive · stats — the INSPECTOR half.** The archive's own readout: which repo,
    /// refresh, errors, and the file-count / byte-count / archive-path grid. Returns the
    /// pane's observable state.
    #[cfg(not(feature = "mikro"))]
    fn archive_stats(&mut self, ui: &mut Ui) -> Value {
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        ui.label(format!(
            "archive of repo: {}",
            target.clone().unwrap_or_else(|| "(none selected)".into())
        ));
        // Mini: a server-side path-prefix filter (`ListArchiveFiles.prefix`) — the
        // data layer always supported it; this is the box that finally passes it.
        #[cfg(feature = "mini")]
        {
            let resp = ui.add_enabled(
                target.is_some(),
                egui::TextEdit::singleline(&mut self.archive_prefix).hint_text("🔎 path prefix"),
            );
            if resp.changed() {
                if let Some(repo) = target.clone() {
                    let p = (!self.archive_prefix.trim().is_empty())
                        .then(|| self.archive_prefix.trim().to_string());
                    self.data.refresh_archive(&repo, p);
                }
            }
        }
        let do_refresh = ui
            .add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
            .clicked();
        if do_refresh {
            if let Some(repo) = target {
                #[cfg(feature = "mini")]
                let p = (!self.archive_prefix.trim().is_empty())
                    .then(|| self.archive_prefix.trim().to_string());
                #[cfg(not(feature = "mini"))]
                let p = None;
                self.data.refresh_archive(&repo, p);
            }
        }
        ui.separator();
        if let Some(err) = &self.data.archive.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        }

        // Archive stats (file count / total uncompressed bytes / archive name).
        let a = &self.data.archive;
        egui::Grid::new("archive_stats").striped(true).show(ui, |ui| {
            ui.label("files");
            ui.label(a.file_count.to_string());
            ui.end_row();
            ui.label("uncompressed bytes");
            ui.label(a.total_uncompressed_bytes.to_string());
            ui.end_row();
            ui.label("archive");
            ui.label(if a.archive_path.is_empty() {
                "—"
            } else {
                &a.archive_path
            });
            ui.end_row();
        });

        json!({
            "repository": a.repository,
            "files": a.file_count,
            "uncompressed_bytes": a.total_uncompressed_bytes,
            "archive": a.archive_path,
            "error": a.error,
        })
    }

    /// **Artifact · query — the MASTER half.** The artifact COORDINATE (repository /
    /// namespace / name / version) and the fetch that resolves it.
    fn artifact_query(&mut self, ui: &mut Ui) {
        egui::Grid::new("artifact_inputs").show(ui, |ui| {
            ui.label("repository");
            ui.text_edit_singleline(&mut self.repo);
            ui.end_row();
            ui.label("namespace");
            ui.text_edit_singleline(&mut self.namespace);
            ui.end_row();
            ui.label("name");
            ui.text_edit_singleline(&mut self.name);
            ui.end_row();
            ui.label("version");
            ui.text_edit_singleline(&mut self.version);
            ui.end_row();
        });
        if ui.button("fetch").clicked() {
            let id = ArtifactId {
                namespace: if self.namespace.is_empty() {
                    None
                } else {
                    Some(self.namespace.clone())
                },
                name: self.name.clone(),
                version: self.version.clone(),
            };
            let repo = self.repo.clone();
            self.data.fetch_artifact(&repo, id);
        }
    }

    /// **Artifact · metadata — the INSPECTOR half.** What the fetch RESOLVED TO: the
    /// verdict line, and the resolved artifact's metadata as a grid. Splitting it out is
    /// what lets the metadata grow (SBOM rows, checksums) without ever pushing the query
    /// form off the top of the pane — the divider decides who gives, not the flow.
    fn artifact_metadata(&mut self, ui: &mut Ui) -> Value {
        let a = &self.data.artifact;
        if let Some(err) = &a.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        } else if a.repository.is_empty() {
            ui.label("enter an artifact id and fetch");
        } else if a.found {
            // The long summary line repeated every field of the grid right below it —
            // mini keeps just the verdict and lets the grid carry the resolved facts.
            #[cfg(feature = "mini")]
            ui.label("✔ found");
            #[cfg(not(feature = "mini"))]
            ui.label(format!(
                "✔ {} / {} {} — {} bytes ({})",
                a.repository, a.name, a.version, a.size_bytes, a.content_type
            ));
            ui.separator();
            egui::Grid::new("artifact_metadata").num_columns(2).striped(true).show(ui, |ui| {
                ui.label("repository");
                ui.label(&a.repository);
                ui.end_row();
                ui.label("name");
                ui.label(&a.name);
                ui.end_row();
                ui.label("version");
                ui.label(&a.version);
                ui.end_row();
                ui.label("size (bytes)");
                ui.label(a.size_bytes.to_string());
                ui.end_row();
                ui.label("content type");
                ui.label(&a.content_type);
                ui.end_row();
            });
        } else {
            ui.colored_label(egui::Color32::YELLOW, "not found");
        }

        // The read-tier extras, present in EVERY tier (the operator UI included) — ⬇
        // save (the bytes are already in memory from the fetch), the coordinate's
        // custom properties (auto-loaded with the fetch), and the hosted SBOM on
        // demand. These were mini-only, so the shipped full operator UI was missing
        // the whole SBOM / property / download surface; an operator inspects an
        // artifact's SBOM + properties and downloads it just like the mini client.
        if self.data.artifact.found {
            ui.separator();
            if self.save_path.is_empty() {
                self.save_path = default_save_path(&self.data.artifact);
            }
            ui.label("save to");
            ui.text_edit_singleline(&mut self.save_path);
            if ui.button("⬇ Save artifact").clicked() {
                let path = self.save_path.clone();
                self.data.save_artifact(&path);
            }
            if let Some(p) = self.data.artifact.saved_to.clone() {
                ui.colored_label(egui::Color32::GREEN, format!("saved: {p}"));
            }
            if let Some(e) = self.data.artifact.save_error.clone() {
                ui.colored_label(egui::Color32::RED, e);
            }

            let props = self.data.artifact.properties.clone();
            if !props.is_empty() {
                ui.separator();
                ui.label("properties");
                egui::Grid::new("artifact_properties").num_columns(2).striped(true).show(ui, |ui| {
                    for p in &props {
                        ui.label(&p.key);
                        ui.label(p.values.join(" · "));
                        ui.end_row();
                    }
                });
            }

            ui.separator();
            if ui.button("📄 Fetch SBOM").clicked() {
                self.data.fetch_sbom_current();
            }
            if let Some(sbom) = self.data.artifact.sbom.clone() {
                ui.label(format!("SBOM: {} bytes · {}", sbom.size_bytes, sbom.content_address));
                egui::ScrollArea::vertical()
                    .id_salt("sbom-preview")
                    .max_height(160.0)
                    .show(ui, |ui| {
                        ui.label(RichText::new(sbom.preview).monospace().small());
                    });
            } else if let Some(e) = self.data.artifact.sbom_error.clone() {
                ui.weak(e);
            }
        }
        self.data.artifact.state_json()
    }

    /// **Upload · form — the MASTER half.** The artifact id + file path, and the upload
    /// itself. The write GATE and its outcome live in the inspector beside it.
    #[cfg(not(feature = "mini"))]
    fn upload_form(&mut self, ui: &mut Ui) {
        let writable = self.data.repos.upload_enabled();
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        egui::Grid::new("upload_inputs").show(ui, |ui| {
            ui.label("name");
            ui.text_edit_singleline(&mut self.name);
            ui.end_row();
            ui.label("version");
            ui.text_edit_singleline(&mut self.version);
            ui.end_row();
            ui.label("file path");
            ui.text_edit_singleline(&mut self.upload_path);
            ui.end_row();
        });
        let do_upload = ui
            .add_enabled(writable, egui::Button::new("⬆ Upload artifact"))
            .clicked();
        if do_upload {
            if let Some(repo) = target {
                let mut outcome = UploadOutcome { attempted: true, repo: Some(repo.clone()), ..Default::default() };
                match std::fs::read(&self.upload_path) {
                    Ok(bytes) => {
                        let id = ArtifactId {
                            namespace: None,
                            name: self.name.clone(),
                            version: self.version.clone(),
                        };
                        self.notice = Some(match self.data.put_artifact(&repo, &id, &bytes) {
                            Ok(()) => {
                                outcome.ok = Some(true);
                                outcome.bytes = Some(bytes.len() as u64);
                                format!("uploaded {} bytes to {repo}", bytes.len())
                            }
                            Err(e) => {
                                outcome.ok = Some(false);
                                outcome.stage = Some("put");
                                outcome.error = Some(e.to_string());
                                format!("upload failed: {e}")
                            }
                        });
                    }
                    Err(e) => {
                        outcome.ok = Some(false);
                        outcome.stage = Some("read");
                        outcome.error = Some(e.to_string());
                        self.notice = Some(format!("read failed: {e}"));
                    }
                }
                self.upload_outcome = outcome;
            }
        }
    }

    /// Robot/test hook: set the **upload source file path** (the Upload form's "file
    /// path" box), so a headless drive can point the upload at a real (or deliberately
    /// missing) file and then click the real `upload` button. The write's outcome
    /// lands on `state_json["upload"]` — see [`UploadOutcome`].
    #[cfg(not(feature = "mini"))]
    pub fn set_upload_path(&mut self, path: &str) {
        self.upload_path = path.to_string();
    }

    /// Robot/test hook: set the artifact **name + version** the Upload form writes
    /// under (the id's coordinate). The target repo comes from the Repos selection
    /// (`select_repo`); the source bytes from [`set_upload_path`](Self::set_upload_path).
    #[cfg(not(feature = "mini"))]
    pub fn set_upload_coord(&mut self, name: &str, version: &str) {
        self.name = name.to_string();
        self.version = version.to_string();
    }

    /// **Upload · target — the INSPECTOR half.** The write GATE: which repository the
    /// upload lands in, whether it is writable, and the outcome notice of the last
    /// attempt. The notice used to be appended to the bottom of the form, so a long
    /// error message pushed the form's own controls up and out of reach; it is its own
    /// window now. Returns the pane's observable state.
    #[cfg(not(feature = "mini"))]
    fn upload_target(&mut self, ui: &mut Ui) -> Value {
        let writable = self.data.repos.upload_enabled();
        let target = self.data.repos.selected_repo().map(|r| r.name.clone());
        ui.label(format!(
            "target repo: {}",
            target.clone().unwrap_or_else(|| "(none selected)".into())
        ));
        if !writable {
            ui.colored_label(
                egui::Color32::YELLOW,
                "selected repo is read-only — pick a writable repo on the Repos tab",
            );
        }
        if let Some(n) = &self.notice {
            ui.separator();
            ui.label(n);
        }
        json!({
            "writable": writable,
            "target": target,
            "notice": self.notice,
            // The slot mirrors the top-level outcome so the pane's OWN state_json is
            // not "writable"-only (LAW #6): the write result is a value here too.
            "outcome": self.upload_outcome.state_json(),
        })
    }

    /// The **Sign in** tab — mounts the ONE shared `facett_auth_ui::LoginPane` (LAW #5),
    /// driven by holger's own [`HolgerLoginSeam`] over the shared `nornir-auth-client`
    /// device-code grant. The pane paints its own state machine (Sign in → device-code
    /// prompt → signed-in subject + credential NAMES → Sign out); holger holds no bespoke
    /// login chrome here. The pane auto-polls to completion while awaiting the user, so no
    /// per-frame host driving is needed — this just renders it through the `Facet::ui` seam.
    #[cfg(not(feature = "mini"))]
    fn signin_tab(&mut self, ui: &mut Ui) {
        self.login_pane.ui(ui);
    }

    /// **Tools · functions — the MASTER half.** The operator's ACTION LIST: one entry per
    /// tool, stacked in a narrow column beside the output.
    ///
    /// The `ui.heading("Tools")` this pane used to open with is gone on purpose: the pane
    /// now wears a chrome title bar carrying its own name, and a heading reading exactly
    /// `Tools` would be a second AccessKit node with the same label as the TAB header —
    /// which makes `query_by_label("Tools")` ambiguous and both nodes unreachable.
    ///
    /// The entries stack VERTICALLY (they were a `horizontal_wrapped` row): a wrapped row
    /// of six buttons in a 26%-wide master column is an unreadable ragged block, and a
    /// list is what an action list should be anyway.
    #[cfg(not(feature = "mini"))]
    fn tools_functions(&mut self, ui: &mut Ui) -> Value {
        ui.label("Operator tools — a mirror of the holger CLI, with facett views.");
        ui.separator();
        {
            let ui = &mut *ui;
            // Tool #1 holger-ds publishes+fetches a synthetic corpus — a WRITE,
            // so it is gated in static mode exactly like #2-#5 (was ungated).
            let h = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::HolgerDs, "1 · holger-ds"),
            );
            if h.clicked() {
                self.tool = ToolsFn::HolgerDs;
            }
            let m = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::Migrate, "2 · migrate / suck"),
            );
            if m.clicked() {
                self.tool = ToolsFn::Migrate;
            }
            let s = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::Security, "3 · security · Móðguðr"),
            );
            if s.clicked() {
                self.tool = ToolsFn::Security;
            }
            let d = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::Deploy, "4 · deploy · Skíðblaðnir"),
            );
            if d.clicked() {
                self.tool = ToolsFn::Deploy;
            }
            // Tool #5 — Airgap populate. Read-path-safe to *plan*; the actual
            // build/push is a write, so the button is disabled in static mode.
            let g = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::Airgap, "5 · airgap populate"),
            );
            if g.clicked() {
                self.tool = ToolsFn::Airgap;
            }
            // Tool #6 — the shared-framework demo appliance (start a demo registry
            // container THROUGH jera). Declaring + dispatching is read-path-safe, but
            // it stands up infra, so it is gated in static read-only mode like #2-#5.
            let dm = ui.add_enabled(
                !self.static_mode,
                egui::Button::selectable(self.tool == ToolsFn::Demo, "6 · demo appliance"),
            );
            if dm.clicked() {
                self.tool = ToolsFn::Demo;
            }
        }
        let (pane, ok, detail) = self.tools_pane_report();
        json!({ "pane": pane, "ok": ok, "detail": detail })
    }

    /// **Tools · output — the INSPECTOR half.** The selected tool's whole surface: its
    /// form, its run button and its result. It used to sit BELOW the button row in one
    /// flow, so a long benchmark / scan / deploy result grew downward until it ran off the
    /// bottom of the pane with nothing to drag. Now it is a window with a divider.
    #[cfg(not(feature = "mini"))]
    fn tools_output(&mut self, ui: &mut Ui) -> Value {
        let red = egui::Color32::from_rgb(255, 120, 120);
        match self.tool {
            ToolsFn::HolgerDs => {
                ui.heading("holger-ds — artifact-delivery benchmark");
                ui.label("Scalable synthetic publish+fetch throughput, measured over the REAL package protocol.");
                ui.label("Targets: this holger (self-host) · or an EXTERNAL Nexus/Artifactory base URL (resourced fairly).");
                let ds = &mut self.data.holger_ds;
                ui.horizontal(|ui| {
                    ui.label("scale:");
                    for s in ["micro", "small", "large"] {
                        if ui.selectable_label(ds.scale == s, s).clicked() {
                            ds.scale = s.to_string();
                        }
                    }
                });
                ui.horizontal(|ui| {
                    ui.label("external target (blank = self-host):");
                    ui.text_edit_singleline(&mut ds.target);
                });
                ui.separator();
                ui.label(RichText::new(ds.cli_command()).monospace());
                // Actually run the benchmark over the package protocol. micro is
                // inline + quick; the result lands in the pane + state_json.
                // It publishes a synthetic corpus (a write), so it is disabled in
                // static read-only mode.
                // The RUN button exists only where the driver does. It stands up a
                // real holger HTTP gateway IN THIS PROCESS, so it rides on `bench`
                // (⇒ `gui-embedded`); the thin client still shows the pane, the
                // config and the exact CLI command to run it elsewhere.
                #[cfg(not(feature = "bench"))]
                ui.weak(
                    "run it with the command above, or with the `holger-server-ui-embedded` \
                     build — this client links no in-process gateway",
                );
                #[cfg(feature = "bench")]
                let run = ui
                    .add_enabled(
                        !self.static_mode,
                        egui::Button::new("Run benchmark… (over the package protocol)"),
                    )
                    .on_hover_text("Builds a synthetic crate corpus and bombards Holger's native HTTP door — measured over the real package protocol.")
                    .on_disabled_hover_text("disabled in static read-only mode (publish is a write)");
                #[cfg(feature = "bench")]
                if run.clicked() {
                    self.data.run_holger_ds();
                    let ok = self
                        .data
                        .holger_ds
                        .result
                        .as_ref()
                        .map(|r| r.error.is_none())
                        .unwrap_or(false);
                    functional_status(
                        "holger-server-ui/tools/holger-ds",
                        "benchmark_ran",
                        ok,
                        &self.data.holger_ds.scale,
                    );
                }
                let ds = &self.data.holger_ds;
                if !ds.status.is_empty() {
                    ui.separator();
                    if let Some(res) = &ds.result {
                        if let Some(err) = &res.error {
                            ui.colored_label(egui::Color32::from_rgb(255, 120, 120), format!("error: {err}"));
                        } else {
                            ui.colored_label(
                                egui::Color32::from_rgb(90, 220, 140),
                                RichText::new(format!("● {:.1} MB/s · {:.0} ops/s", res.mbs, res.ops_per_sec)).strong(),
                            );
                            ui.label(format!(
                                "scale {} · target {} · {} files / {} crates · {} ops",
                                res.scale, res.target, res.files, res.crates, res.ops
                            ));
                        }
                    } else {
                        ui.label(&ds.status);
                    }
                }
            }
            ToolsFn::Migrate => {
                ui.heading("Migrate — \"suck\" Nexus / Artifactory → znippy");
                ui.label("Pulls an upstream registry into one or many znippy archives. Owned by the holger agent (single home).");
                if self.static_mode {
                    ui.colored_label(red, "disabled in static mode");
                } else {
                    let m = &mut self.data.migrate;
                    ui.horizontal(|ui| {
                        ui.label("source:");
                        ui.selectable_value(&mut m.source, "nexus".to_string(), "nexus");
                        ui.selectable_value(&mut m.source, "artifactory".to_string(), "artifactory");
                    });
                    egui::Grid::new("migrate_inputs").show(ui, |ui| {
                        ui.label("registry url");
                        ui.text_edit_singleline(&mut m.url);
                        ui.end_row();
                        ui.label("output .znippy");
                        ui.text_edit_singleline(&mut m.output);
                        ui.end_row();
                        ui.label("repository (blank = all)");
                        ui.text_edit_singleline(&mut m.repository);
                        ui.end_row();
                    });
                    ui.separator();
                    ui.label(RichText::new(m.cli_command()).monospace());
                    // Actually invoke the agent connector (nexus/artifactory →
                    // znippy) and report the outcome in the pane.
                    let run = ui
                        .button("Run migrate… (via holger-agent)")
                        .on_hover_text("Lists the upstream registry's repositories and sucks them into the output .znippy via the holger agent connector.");
                    if run.clicked() {
                        self.data.run_migrate();
                        let ok = self
                            .data
                            .migrate
                            .result
                            .as_ref()
                            .map(|r| r.error.is_none() && r.ran)
                            .unwrap_or(false);
                        functional_status(
                            "holger-server-ui/tools/migrate",
                            "suck_ran",
                            ok,
                            &self.data.migrate.source,
                        );
                    }
                    let m = &self.data.migrate;
                    if !m.status.is_empty() {
                        ui.separator();
                        match &m.result {
                            Some(r) if r.error.is_some() => {
                                ui.colored_label(red, format!("error: {}", r.error.as_deref().unwrap_or_default()));
                            }
                            Some(r) if r.ran => {
                                ui.colored_label(
                                    egui::Color32::from_rgb(90, 220, 140),
                                    RichText::new(format!("● migrated {} repositorie(s) → {}", r.repositories, r.output)).strong(),
                                );
                            }
                            _ => {
                                ui.label(&m.status);
                            }
                        }
                    }
                }
            }
            ToolsFn::Security => {
                ui.heading("Security — Móðguðr");
                ui.label("Vuln / license / provenance verdict (Engine A). Maps the selected repo's archive listing to modgunn and folds per-artifact verdicts into a Pass/Warn/Block report.");
                if self.static_mode {
                    ui.colored_label(red, "not available in static (validate-then-switch only)");
                } else {
                    let target = self
                        .data
                        .repos
                        .selected_repo()
                        .map(|r| (r.name.clone(), r.repo_type.clone()));
                    ui.label(format!(
                        "target repo: {}",
                        target.as_ref().map(|(n, _)| n.as_str()).unwrap_or("(none selected)")
                    ));
                    let do_scan = ui
                        .add_enabled(target.is_some(), egui::Button::new("Scan selected repo"))
                        .clicked();
                    if do_scan {
                        if let Some((name, ty)) = target {
                            self.data.run_security_scan(&name, &ty);
                            functional_status(
                                "holger-server-ui/tools/security",
                                "scan_ran",
                                self.data.security.error.is_none() && self.data.security.loaded,
                                &name,
                            );
                        }
                    }
                    ui.separator();
                    let s = &self.data.security;
                    if let Some(err) = &s.error {
                        ui.colored_label(egui::Color32::RED, format!("error: {err}"));
                    } else if s.loaded {
                        let (label, color) = match s.decision.as_str() {
                            "block" => ("● BLOCK", egui::Color32::from_rgb(255, 80, 80)),
                            "warn" => ("● WARN", egui::Color32::from_rgb(240, 200, 90)),
                            _ => ("● PASS", egui::Color32::from_rgb(90, 220, 140)),
                        };
                        ui.colored_label(color, RichText::new(label).strong());
                        ui.label(format!(
                            "ecosystem {} · scanned {} · pass {} · warn {} · block {} · skipped {}",
                            s.ecosystem, s.scanned, s.pass, s.warn, s.block, s.skipped
                        ));
                        if !s.source.is_empty() {
                            ui.label(RichText::new(format!("advisories: {}", s.source)).weak());
                        }
                        // Móðguðr view mode: the verdict table, or the dependency
                        // graph (every component as a node coloured by verdict).
                        ui.horizontal(|ui| {
                            ui.selectable_value(&mut self.security_graph, false, "Table");
                            ui.selectable_value(&mut self.security_graph, true, "Graph");
                        });
                        if self.security_graph {
                            render_security_graph(&self.data.dep_graph, ui);
                        } else {
                            let mut table = Table::new(
                                "security_findings",
                                vec!["name".into(), "version".into(), "decision".into(), "advisories".into()],
                            );
                            for f in &s.findings {
                                table.push_row(vec![
                                    f.name.clone(),
                                    f.version.clone(),
                                    f.decision.clone(),
                                    f.ids.join(", "),
                                ]);
                            }
                            table.ui(ui);
                        }
                    } else {
                        ui.label("pick a repo on the Repos tab, then Scan.");
                    }
                }
            }
            ToolsFn::Deploy => {
                use holger_airgap::Target;
                ui.heading("Deploy — Skíðblaðnir");
                ui.label("Deploy the SELECTED repo's sealed znippy bundle to a test target via the airgap engine (push-only carry of the already-sealed archive).");
                if self.static_mode {
                    ui.colored_label(red, "disabled in static mode");
                } else {
                    // The bundle to deploy is the selected repo's sealed archive.
                    let target = self.data.repos.selected_repo().map(|r| r.name.clone());
                    ui.label(format!(
                        "bundle repo: {}",
                        target.as_deref().unwrap_or("(none selected)")
                    ));
                    let dep = &mut self.data.deploy;
                    ui.horizontal(|ui| {
                        ui.label("target:");
                        ui.selectable_value(&mut dep.target, Target::Holger, "holger");
                        ui.selectable_value(&mut dep.target, Target::Nexus, "nexus");
                        ui.selectable_value(&mut dep.target, Target::Artifactory, "artifactory");
                    });
                    let tgt = self.data.deploy.target;
                    let do_deploy = ui
                        .add_enabled(target.is_some(), egui::Button::new("Deploy to test…"))
                        .on_hover_text("Reads the selected repo's sealed znippy bundle (archive_info) and plans an airgap push-only deploy to the chosen test target.")
                        .clicked();
                    if do_deploy {
                        if let Some(name) = target {
                            self.data.run_deploy(&name, tgt);
                            let ok = self
                                .data
                                .deploy
                                .result
                                .as_ref()
                                .map(|r| r.error.is_none() && r.ran)
                                .unwrap_or(false);
                            functional_status(
                                "holger-server-ui/tools/deploy",
                                "deploy_planned",
                                ok,
                                &name,
                            );
                        }
                    }
                    let dep = &self.data.deploy;
                    if !dep.status.is_empty() {
                        ui.separator();
                        match &dep.result {
                            Some(r) if r.error.is_some() => {
                                ui.colored_label(red, format!("error: {}", r.error.as_deref().unwrap_or_default()));
                            }
                            Some(r) if r.ran => {
                                ui.colored_label(
                                    egui::Color32::from_rgb(90, 220, 140),
                                    RichText::new(format!("● deploy planned · {} files → {}", r.file_count, r.target)).strong(),
                                );
                                ui.label(RichText::new(&r.plan).monospace());
                            }
                            _ => {
                                ui.label(&dep.status);
                            }
                        }
                    }
                }
            }
            ToolsFn::Airgap => {
                use holger_airgap::{Auth, Scale, Stack, Target};
                ui.heading("Airgap populate");
                ui.label("Build→carry→push a software stack into the target via the documented populate driver. Plan is pure Rust; execution delegates to distribution/airgap/populate.");
                if self.static_mode {
                    ui.colored_label(red, "disabled in static mode");
                } else {
                    // Reuse the session's connect-time mTLS identity for an
                    // auth=mtls push (so the operator isn't told to re-supply certs).
                    let sess_cert = self.session_client_cert.clone();
                    let sess_key = self.session_client_key.clone();
                    let ag = &mut self.data.airgap;
                    ui.horizontal(|ui| {
                        ui.label("scale:");
                        ui.selectable_value(&mut ag.scale, Scale::Micro, "micro");
                        ui.selectable_value(&mut ag.scale, Scale::Small, "small");
                        ui.selectable_value(&mut ag.scale, Scale::Large, "large");
                    });
                    ui.horizontal(|ui| {
                        ui.label("stack:");
                        ui.selectable_value(&mut ag.stack, None, "preset");
                        for s in [Stack::Airflow, Stack::Cargo, Stack::Npm, Stack::Maven, Stack::Docker, Stack::All] {
                            ui.selectable_value(&mut ag.stack, Some(s), s.as_arg());
                        }
                    });
                    ui.horizontal(|ui| {
                        ui.label("target:");
                        ui.selectable_value(&mut ag.target, Target::Holger, "holger");
                        ui.selectable_value(&mut ag.target, Target::Nexus, "nexus");
                        ui.selectable_value(&mut ag.target, Target::Artifactory, "artifactory");
                    });
                    ui.horizontal(|ui| {
                        ui.label("auth:");
                        ui.selectable_value(&mut ag.auth, Auth::Mtls, "mtls");
                        ui.selectable_value(&mut ag.auth, Auth::Oidc, "oidc");
                    });
                    ui.horizontal(|ui| {
                        ui.checkbox(&mut ag.dry_run, "dry-run (plan only)")
                            .on_hover_text("Plan only — no build/push. Untick to actually populate (needs creds).");
                    });
                    // Feed the session's client cert/key into an mTLS push so it
                    // doesn't fail "needs --client-cert and --client-key". If none
                    // were supplied at connect-time, validate() surfaces a clear
                    // message below (unless dry-run, which needs no creds).
                    if ag.auth == Auth::Mtls && !ag.dry_run {
                        if ag.client_cert.is_none() {
                            ag.client_cert = sess_cert;
                        }
                        if ag.client_key.is_none() {
                            ag.client_key = sess_key;
                        }
                    }
                    ui.separator();
                    // The pure-Rust plan (the dry-run surface) + clear validation.
                    // Compute owned values so the `ag` mutable borrow is released
                    // before the run button calls back into `self.data`.
                    let plan_or_err: Result<String, String> = match ag.validate() {
                        Ok(()) => Ok(ag.plan()),
                        Err(e) => Err(e.to_string()),
                    };
                    let dry = ag.dry_run;
                    match plan_or_err {
                        Ok(plan) => {
                            ui.label(RichText::new(plan).monospace());
                            let label = if dry {
                                "Run populate… (dry-run plan)"
                            } else {
                                "Run populate… (delegates to driver)"
                            };
                            if ui.button(label).clicked() {
                                self.data.run_airgap();
                                let ok = self
                                    .data
                                    .airgap_run
                                    .as_ref()
                                    .map(|r| r.error.is_none() && r.success)
                                    .unwrap_or(false);
                                functional_status(
                                    "holger-server-ui/tools/airgap",
                                    "populate_ran",
                                    ok,
                                    if dry { "dry-run" } else { "execute" },
                                );
                            }
                        }
                        Err(e) => {
                            ui.colored_label(red, format!("cannot run: {e}"));
                            ui.label("tick dry-run to plan without creds, or connect the UI with --cert/--key for an mTLS push.");
                        }
                    }
                    // Surface the last run's result/plan in the pane.
                    if let Some(run) = &self.data.airgap_run {
                        ui.separator();
                        if let Some(err) = &run.error {
                            ui.colored_label(red, format!("run error: {err}"));
                        } else {
                            let color = if run.success {
                                egui::Color32::from_rgb(90, 220, 140)
                            } else {
                                egui::Color32::from_rgb(240, 200, 90)
                            };
                            ui.colored_label(color, RichText::new(format!("● {}", run.status)).strong());
                            if !run.log.is_empty() {
                                ui.label(RichText::new(&run.log).monospace());
                            }
                        }
                    }
                }
            }
            ToolsFn::Demo => {
                ui.heading("Demo appliance — start a demo registry (via jera)");
                ui.label(
                    "Stands up a pinned demo artifact registry THROUGH the shared job manager jera \
                     (a durable BOOT_CONTAINER job, routed through draupnir's OCI engine) — the same \
                     Tools-menu container-demo pattern korp uses. holger can then proxy / migrate from it.",
                );
                if self.static_mode {
                    ui.colored_label(red, "disabled in static mode");
                } else {
                    let spec = crate::infra::demo_registry_spec();
                    egui::Grid::new("demo_infra").striped(true).show(ui, |ui| {
                        ui.label("image");
                        ui.label(RichText::new(&spec.image).monospace());
                        ui.end_row();
                        ui.label("name");
                        ui.label(RichText::new(&spec.name).monospace());
                        ui.end_row();
                        ui.label("port");
                        ui.label(spec.ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
                        ui.end_row();
                        ui.label("url (once up)");
                        ui.label(RichText::new(crate::infra::demo_url()).monospace());
                        ui.end_row();
                    });
                    ui.separator();
                    let run = ui
                        .button("Start demo appliance… (as a jera job)")
                        .on_hover_text(
                            "Records the container bring-up as a durable jera BOOT_CONTAINER job. \
                             Build with --features containers to actually boot it through draupnir's OCI engine.",
                        );
                    if run.clicked() {
                        self.data.run_demo();
                        let ok = self
                            .data
                            .demo_appliance
                            .as_ref()
                            .map(|d| !d.job_id.is_empty() && d.error.is_none())
                            .unwrap_or(false);
                        functional_status(
                            "holger-server-ui/tools/demo",
                            "demo_dispatched",
                            ok,
                            &spec.image,
                        );
                    }
                    if let Some(d) = &self.data.demo_appliance {
                        ui.separator();
                        if let Some(err) = &d.error {
                            ui.colored_label(red, format!("dispatch error: {err}"));
                        } else {
                            ui.colored_label(
                                egui::Color32::from_rgb(90, 220, 140),
                                RichText::new(format!("● {} · jera job {}", d.status, d.job_id)).strong(),
                            );
                            ui.label(format!("{} → {}", d.image, d.url));
                        }
                    }
                }
            }
        }
        // 🧪 Render-time introspection — the selected Tools pane self-reports that
        // it drew a usable operator surface (`ok=true`), or is blank/disabled in
        // static read-only mode ("RED on empty"). Fires each frame the pane is
        // shown so `nornir test --features testmatrix` SEES all five Tools surfaces
        // without a human, mirroring the gRPC handlers' server-side markers. Gated
        // whole (not just the emit) so a plain `gui` build allocates nothing.
        #[cfg(feature = "testmatrix")]
        {
            let (pane, ok, detail) = self.tools_pane_report();
            functional_status(&format!("holger-server-ui/tools/{pane}"), "pane_rendered", ok, &detail);
        }
        let (pane, ok, detail) = self.tools_pane_report();
        json!({ "pane": pane, "ok": ok, "detail": detail })
    }

    /// Render-time introspection for the currently selected Tools pane: its slug,
    /// whether it drew a usable operator surface (`ok`), and a one-line detail.
    /// The four write-tools (#2 migrate · #3 security · #4 deploy · #5 airgap) blank
    /// to a disabled notice under static read-only mode, so they self-report **RED
    /// on empty** there; #1 holger-ds always draws its config. Consumed by both the
    /// render-time emitter above and `state_json.tools_pane` (the robot's oracle).
    #[cfg(not(feature = "mini"))]
    fn tools_pane_report(&self) -> (&'static str, bool, String) {
        let live = !self.static_mode;
        let gated = |detail: String| if live { detail } else { "static-disabled".to_string() };
        match self.tool {
            ToolsFn::HolgerDs => {
                ("holger-ds", true, format!("scale={}", self.data.holger_ds.scale))
            }
            ToolsFn::Migrate => {
                ("migrate", live, gated(format!("source={}", self.data.migrate.source)))
            }
            ToolsFn::Security => {
                let repo = self
                    .data
                    .repos
                    .selected_repo()
                    .map(|r| format!("repo={}", r.name))
                    .unwrap_or_else(|| "no-repo".to_string());
                ("security", live, gated(repo))
            }
            ToolsFn::Deploy => {
                ("deploy", live, gated(format!("target={}", self.data.deploy.target.as_arg())))
            }
            ToolsFn::Airgap => {
                ("airgap", live, gated(format!("target={}", self.data.airgap.target.as_arg())))
            }
            ToolsFn::Demo => {
                ("demo", live, gated("demo-registry".to_string()))
            }
        }
    }

    /// The current graph data signature — `(repo_count, selected repo, artifact
    /// file count for the selected repo)`. The graph rebuilds when this changes.
    #[cfg(not(feature = "mini"))]
    fn graph_data_sig(&self) -> (usize, Option<usize>, usize) {
        let n = self.data.repos.repos.len();
        let sel = self.data.repos.selected;
        // Artifact nodes come from the selected repo's *already-loaded* archive
        // listing (no new gRPC call) — count them so a refresh re-grows the graph.
        let sel_name = self.data.repos.selected_repo().map(|r| r.name.as_str());
        let arch_n = if sel_name.is_some() && sel_name == Some(self.data.archive.repository.as_str())
        {
            self.data.archive.files.len()
        } else {
            0
        };
        (n, sel, arch_n)
    }

    /// Build the holger graph: server → repositories → (artifacts / upstreams).
    /// Nodes are coloured by [`GraphNodeKind`] (the HOLGER NEON PALETTE); edges are
    /// server→repo, repo→artifact, repo→upstream. Returns the graph plus a parallel
    /// vec of node-kind labels for `state_json`.
    #[cfg(not(feature = "mini"))]
    fn build_graph(&self) -> (Graph3D, Vec<&'static str>) {
        let mut nodes: Vec<(String, egui::Color32)> = Vec::new();
        let mut kinds: Vec<&'static str> = Vec::new();
        let mut edges: Vec<(usize, usize)> = Vec::new();

        // Node 0: the holger server (the anchor).
        nodes.push(("holger".into(), GraphNodeKind::Server.color()));
        kinds.push(GraphNodeKind::Server.label());

        // Repositories (indices 1..=repo_count), each edged from the server.
        let repos = &self.data.repos.repos;
        for (i, r) in repos.iter().enumerate() {
            let kind = repo_node_kind(r);
            nodes.push((r.name.clone(), kind.color()));
            kinds.push(kind.label());
            edges.push((0, i + 1));
        }

        // Upstream/proxy target nodes for every proxy-flavoured repository.
        for (i, r) in repos.iter().enumerate() {
            if repo_node_kind(r) == GraphNodeKind::Upstream {
                let up = nodes.len();
                nodes.push((format!("{}↑upstream", r.name), GraphNodeKind::Upstream.color()));
                kinds.push(GraphNodeKind::Upstream.label());
                edges.push((i + 1, up));
            }
        }

        // Artifact nodes for the *selected* repo from its already-loaded archive
        // listing (LAW 2: real data when present; no new gRPC call). Each archive
        // file becomes a gold→lime artifact node edged from its repository.
        if let Some(sel) = self.data.repos.selected {
            let sel_name = self.data.repos.selected_repo().map(|r| r.name.as_str());
            if sel_name.is_some() && sel_name == Some(self.data.archive.repository.as_str()) {
                for path in &self.data.archive.files {
                    let art = nodes.len();
                    nodes.push((path.clone(), GraphNodeKind::Artifact.color()));
                    kinds.push(GraphNodeKind::Artifact.label());
                    edges.push((sel + 1, art));
                }
            }
        }

        // `with_decor(DecorPolicy::Full)` turns ON the beauty: neon glow + click
        // shockwave + subgraph lighting/dim, all from the facett kernel (no
        // hand-rolled widgets). Under the `device` theme the operator can still
        // dial it down; the explicit override keeps the sellpoint gorgeous here.
        let mut g = Graph3D::new(nodes, edges, Layout3D::Force)
            .with_title("holger")
            .with_decor(DecorPolicy::Full);
        g.layout();
        (g, kinds)
    }

    /// Feed the graph a node selection (the same effect a click in the 3D view
    /// has) — lights that node's subgraph and dims the rest. Used by keyboard /
    /// programmatic selection and by the robot-UI test. No-op if no graph is built
    /// or the index is out of range.
    #[cfg(not(feature = "mini"))]
    pub fn select_graph_node(&mut self, idx: usize) {
        if let Some(g) = &mut self.graph {
            g.select(Some(idx));
        }
    }

    /// The graph's observable state for `state_json`: the selected node + its lit
    /// subgraph (highlighted neighbours), plus the node-kind palette mapping —
    /// asserted as DATA by the robot. `null` when the Graph tab hasn't built one.
    #[cfg(not(feature = "mini"))]
    fn graph_state_json(&self) -> Value {
        let Some(g) = &self.graph else {
            return Value::Null;
        };
        let inner = g.state_json();
        let sel = inner["selected_node"].as_u64().map(|i| i as usize);
        let sel_label = sel.and_then(|i| g.nodes.get(i)).map(|n| n.label.clone());
        let sel_kind = sel.and_then(|i| self.graph_kinds.get(i)).copied();
        let neigh_len = inner["highlighted_neighbours"]
            .as_array()
            .map(|a| a.len())
            .unwrap_or(0);
        json!({
            "nodes": inner["nodes"],
            "edges": inner["edges"],
            "effects_policy": inner["effects_policy"],
            "palette": inner["palette"],
            "kinds": self.graph_kinds,
            "selected_node": sel,
            "selected_label": sel_label,
            "selected_kind": sel_kind,
            "highlighted_neighbours": inner["highlighted_neighbours"].clone(),
            "highlighted_count": neigh_len,
            "dimmed_alpha": inner["dimmed_alpha"],
        })
    }

    /// The **Helix** tab's built [`HelixView`] as DATA — its `state_json` (title,
    /// `coil_count`, `total_nodes`, `winds_forward`, per-coil node/edge counts and the
    /// per-node kind/status) when the tab has wound its coil, else [`Value::Null`]. Mirrors
    /// [`Self::graph_state_json`]: a robot asserts the time-helix DAG the operator sees
    /// WITHOUT pixels — after a Móðguðr scan the coil winds the REAL scanned repo's
    /// artifacts (title `holger · <repo>`, a `blocked` node per block verdict), so an
    /// empty/mis-wired build goes RED, not green.
    #[cfg(not(feature = "mini"))]
    fn helix_state_json(&self) -> Value {
        match &self.helix {
            Some(h) => h.state_json(),
            None => Value::Null,
        }
    }

    /// The 3D holger graph (server → repositories → artifacts/upstreams),
    /// force-directed, neon-lit. Click a repo node to light its subgraph.
    /// **Graph · canvas — the MASTER half.** Nothing but the 3D graph.
    ///
    /// The rebuild control and the caption used to sit in a row ABOVE the canvas inside
    /// one flow, so the canvas could only ever be "the screen minus whatever chrome grew
    /// above it". The canvas is a window now: drag the divider, or maximise it and the
    /// inspector goes to zero area.
    #[cfg(not(feature = "mini"))]
    fn graph_canvas(&mut self, ui: &mut Ui) {
        let sig = self.graph_data_sig();
        if self.graph.is_none() || self.graph_sig != sig {
            // Preserve the operator's current selection across a data-driven rebuild.
            let prev_sel = self.graph.as_ref().and_then(|g| {
                g.state_json()["selected_node"].as_u64().map(|i| i as usize)
            });
            let (mut g, kinds) = self.build_graph();
            if let Some(s) = prev_sel {
                if s < kinds.len() {
                    g.select(Some(s));
                }
            }
            self.graph = Some(g);
            self.graph_kinds = kinds;
            self.graph_sig = sig;
        }
        let now = ui.input(|i| i.time);
        if let Some(g) = &mut self.graph {
            g.set_clock(now);
            g.ui(ui);
        }
        ui.ctx().request_repaint();
    }

    /// **Graph · inspector — the INSPECTOR half.** The rebuild control, what the graph
    /// IS, and the live selection: which node the operator lit, its kind, and how many
    /// neighbours the click highlighted. Returns the graph's observable state (the same
    /// object the app's `state_json` carries under `/graph`).
    #[cfg(not(feature = "mini"))]
    fn graph_inspector(&mut self, ui: &mut Ui) -> Value {
        if ui.button("⟳ rebuild").clicked() {
            self.graph = None;
        }
        ui.label("repo graph — neon 3D (holger → repos → artifacts/upstreams); click a node to light its subgraph");
        ui.separator();
        let st = self.graph_state_json();
        if st.is_null() {
            ui.weak("(the graph builds on the next frame)");
            return st;
        }
        egui::Grid::new("graph_inspector_grid").num_columns(2).striped(true).show(ui, |ui| {
            ui.label("nodes");
            ui.label(st["nodes"].to_string());
            ui.end_row();
            ui.label("edges");
            ui.label(st["edges"].to_string());
            ui.end_row();
            ui.label("selected");
            ui.label(st["selected_label"].as_str().unwrap_or("—").to_string());
            ui.end_row();
            ui.label("kind");
            ui.label(st["selected_kind"].as_str().unwrap_or("—").to_string());
            ui.end_row();
            ui.label("lit neighbours");
            ui.label(st["highlighted_count"].to_string());
            ui.end_row();
        });
        st
    }

    /// The Helix tab — the facett-helix **TIME-HELIX DAG view**: holger's
    /// repository/artifact dependency DAG wound onto a coil that advances along a
    /// synthesized time axis. Real data comes from the Móðguðr (Tools#3) scan's
    /// [`DepGraphData`](crate::data::DepGraphData); with none loaded (or in the
    /// hands-free demo tour) it falls back to `HelixView::demo()` so the tab is
    /// never blank. The view is cached and rebuilt only when [`Self::helix_data_sig`]
    /// changes (a fresh scan), preserving the traverse cursor between frames.
    /// **Helix · canvas — the MASTER half.** The wound coil, alone.
    #[cfg(not(feature = "mini"))]
    fn helix_canvas(&mut self, ui: &mut Ui) {
        let sig = self.helix_data_sig();
        if self.helix.is_none() || self.helix_sig != sig {
            self.helix = Some(self.build_helix());
            self.helix_sig = sig;
        }
        let now = ui.input(|i| i.time);
        if let Some(h) = &mut self.helix {
            h.set_clock(now);
            h.ui(ui);
        }
        ui.ctx().request_repaint();
    }

    /// **Helix · inspector — the INSPECTOR half.** The rebuild control, what the coil is
    /// wound FROM, and the wound scene's own facts (coils / nodes / direction). Returns
    /// the `HelixView`'s `state_json` (null until the coil is wound).
    #[cfg(not(feature = "mini"))]
    fn helix_inspector(&mut self, ui: &mut Ui) -> Value {
        if ui.button("⟳ rebuild").clicked() {
            self.helix = None;
        }
        ui.label(
            "time-helix — the dependency DAG wound along a time axis; scan a repo on \
             Tools · Móðguðr to wind its real graph (else a demo scene plays)",
        );
        ui.separator();
        let st = self.helix_state_json();
        if st.is_null() {
            ui.weak("(no coil wound yet)");
            return st;
        }
        egui::Grid::new("helix_inspector_grid").num_columns(2).striped(true).show(ui, |ui| {
            ui.label("title");
            ui.label(st["title"].as_str().unwrap_or("—").to_string());
            ui.end_row();
            ui.label("coils");
            ui.label(st["coil_count"].to_string());
            ui.end_row();
            ui.label("nodes");
            ui.label(st["total_nodes"].to_string());
            ui.end_row();
            ui.label("winds forward");
            ui.label(st["winds_forward"].to_string());
            ui.end_row();
        });
        st
    }

    /// The Helix data signature — `(demo?, dep-graph repository, node count, edge
    /// count)`. The helix rebuilds when this changes (a Móðguðr scan lands a new
    /// repo's dependency graph, or the demo tour toggles). Demo mode is folded in so
    /// leaving/entering the hands-free tour re-seeds the scene.
    #[cfg(not(feature = "mini"))]
    fn helix_data_sig(&self) -> (bool, String, usize, usize) {
        let g = &self.data.dep_graph;
        (self.demo_running(), g.repository.clone(), g.nodes.len(), g.edges.len())
    }

    /// Build the [`HelixView`] from holger's data. Móðguðr's [`DepGraphData`] is a
    /// single repository's scanned artifacts (+ optional warehouse-sourced dep
    /// edges) but carries **no per-version timestamp**, so a time axis is
    /// synthesized from node order: the repository root is a `Crate` at `t = 0` and
    /// each artifact is a `Task` at `t = index + 1`, laned by its verdict
    /// (pass/warn/block). Warehouse edges become intra-coil deps; artifacts with no
    /// edge are hung off the root so the coil is one connected structure. Advisory
    /// ids ride in as `Cve` events. With no scan loaded (or during the demo tour) it
    /// returns the self-contained `HelixView::demo()` multi-coil scene.
    #[cfg(not(feature = "mini"))]
    fn build_helix(&self) -> HelixView {
        let g = &self.data.dep_graph;
        if self.demo_running() || g.nodes.is_empty() {
            return HelixView::demo();
        }
        let mut dag = PlanDag::new();
        // The repository root anchors the coil at t = 0 (a Crate lane).
        let root = dag.add_idea(g.repository.clone(), "repo", 0.0, Kind::Crate);
        // Each scanned artifact becomes a Task node along the synthesized time axis,
        // laned (angular strand) by its verdict decision.
        let mut ids: Vec<facett_helix::NodeId> = Vec::with_capacity(g.nodes.len());
        for (i, n) in g.nodes.iter().enumerate() {
            let t = (i + 1) as f64;
            let label = if n.version.is_empty() {
                n.name.clone()
            } else {
                format!("{} {}", n.name, n.version)
            };
            let nid = dag.add_idea(label, n.decision.clone(), t, Kind::Task);
            // Verdict → run status (block = Blocked, warn = Active, pass = default).
            match n.decision.as_str() {
                "block" => {
                    dag.set_status(nid, Status::Blocked);
                }
                "warn" => {
                    dag.set_status(nid, Status::Active);
                }
                _ => {}
            }
            // Advisory ids ride in as a security (Cve) event on the node.
            if !n.ids.is_empty() {
                dag.add_event(nid, t, EventKind::Cve, n.ids.join(", "));
            }
            ids.push(nid);
        }
        // Warehouse-sourced dependency edges become intra-coil deps.
        let mut linked = vec![false; ids.len()];
        for (from, to) in &g.edges {
            if let (Some(&fa), Some(&ta)) = (ids.get(*from), ids.get(*to)) {
                if dag.add_dep(fa, ta) {
                    linked[*from] = true;
                }
            }
        }
        // Artifacts with no resolved edge hang off the root so the coil is one graph.
        for (i, nid) in ids.iter().enumerate() {
            if !linked[i] {
                dag.add_dep(*nid, root);
            }
        }
        HelixView::from_dag(format!("holger · {}", g.repository), dag)
    }

    /// The **Lineage** tab — the live proxy-cache-filling facet. As holger caches
    /// and serves artifacts, this fills and rotates: the [`LineageView`](crate::data::LineageView)
    /// (fed live via a connected `mpsc` channel — the same seam a real
    /// `ChannelAuditLog` audit tee uses) is rendered two ways, toggled here:
    /// **3D fill** (facett-graph3d, nodes clustered by ecosystem, slowly rotating and
    /// growing) and **time helix** (facett-helix, each arrival wound onto a per-
    /// ecosystem coil). With no real feed wired, a demo backlog drips one arrival per
    /// frame so the graph visibly builds; the widgets rebuild when the lineage data
    /// signature changes and the injected clock keeps the motion continuous.
    /// **Lineage · canvas — the MASTER half.** The filling cache graph, alone.
    ///
    /// The feed pump lives here (not in the inspector) because the master is drawn FIRST,
    /// so an arrival dripped this frame is already in the widget the same frame.
    #[cfg(not(feature = "mini"))]
    fn lineage_canvas(&mut self, ui: &mut Ui) {
        // Live: drip one demo arrival (if any), then drain the feed into the view.
        if self.lineage_autofill {
            self.drip_lineage_event();
        }
        self.data.lineage.pump();

        // Rebuild the facett widgets when the lineage data grew (a node pops in).
        let sig = (
            self.data.lineage.nodes.len(),
            self.data.lineage.edges.len(),
            self.data.lineage.events.len(),
        );
        if self.lineage_sig != sig {
            self.lineage_graph = None;
            self.lineage_helix = None;
            self.lineage_sig = sig;
        }

        let now = ui.input(|i| i.time);
        match self.lineage_mode {
            LineageMode::Graph3d => {
                if self.lineage_graph.is_none() {
                    self.lineage_graph = Some(self.build_lineage_graph());
                }
                if let Some(g) = &mut self.lineage_graph {
                    g.set_clock(now);
                    g.ui(ui);
                }
            }
            LineageMode::Helix => {
                if self.lineage_helix.is_none() {
                    self.lineage_helix = Some(self.build_lineage_helix());
                }
                if let Some(h) = &mut self.lineage_helix {
                    h.set_clock(now);
                    h.ui(ui);
                }
            }
        }
        // Keep the fill animating (rotation + one arrival per frame) while open.
        ui.ctx().request_repaint();
    }

    /// **Lineage · feed — the INSPECTOR half.** The render-mode switch, the auto-fill
    /// drip and the live counters. The counter line used to sit between the toolbar and
    /// the canvas in one flow — every row it grew came straight out of the canvas's
    /// height. Now the divider decides. Returns the live lineage view's state.
    #[cfg(not(feature = "mini"))]
    fn lineage_feed(&mut self, ui: &mut Ui) -> Value {
        if ui.button("⟳ rebuild").clicked() {
            self.lineage_graph = None;
            self.lineage_helix = None;
        }
        ui.selectable_value(&mut self.lineage_mode, LineageMode::Graph3d, "◉ 3D fill");
        ui.selectable_value(&mut self.lineage_mode, LineageMode::Helix, "🌀 time helix");
        ui.separator();
        ui.checkbox(&mut self.lineage_autofill, "▶ auto-fill")
            .on_hover_text("drip cache arrivals through the live feed so the graph fills + rotates");
        if ui.button("+ arrival").clicked() {
            self.drip_lineage_event();
        }
        ui.separator();
        let l = &self.data.lineage;
        ui.label(
            RichText::new(format!(
                "cache fill — {} artifacts · {} events · {} bytes · {} pending",
                l.artifact_count(),
                l.total_events,
                l.total_bytes,
                self.lineage_demo.len(),
            ))
            .weak(),
        );
        self.data.lineage.state_json()
    }

    /// Send the next demo arrival through the LIVE lineage feed (the same path a
    /// real audit tee uses); no-op once the backlog is drained. `pump()` then folds
    /// it into the view on this or the next frame.
    #[cfg(not(feature = "mini"))]
    fn drip_lineage_event(&mut self) {
        if let Some(ev) = self.lineage_demo.pop_front() {
            let _ = self.lineage_tx.send(ev);
        }
    }

    /// Build the rotating 3D cache-fill graph from the live [`LineageView`]: every
    /// node (artifacts coloured by ecosystem cluster; repo/upstream by kind) with the
    /// lineage edges (requested-by / proxied-from) mapped to node indices. Spin is on
    /// so the graph slowly rotates; `DecorPolicy::Full` lights the neon glow.
    #[cfg(not(feature = "mini"))]
    fn build_lineage_graph(&self) -> Graph3D {
        let l = &self.data.lineage;
        let mut nodes: Vec<(String, egui::Color32)> = Vec::with_capacity(l.nodes.len());
        let mut index: HashMap<&str, usize> = HashMap::with_capacity(l.nodes.len());
        for (i, n) in l.nodes.iter().enumerate() {
            nodes.push((n.label.clone(), lineage_node_color(n)));
            index.insert(n.id.as_str(), i);
        }
        let mut edges: Vec<(usize, usize)> = Vec::with_capacity(l.edges.len());
        for e in &l.edges {
            if let (Some(&a), Some(&b)) = (index.get(e.from.as_str()), index.get(e.to.as_str())) {
                edges.push((a, b));
            }
        }
        let mut g = Graph3D::new(nodes, edges, Layout3D::Force)
            .with_title("holger · cache fill")
            .with_decor(DecorPolicy::Full);
        g.view.spin = true; // slowly rotating spatial fill (spin moved into Graph3DView)
        g.layout();
        g
    }

    /// Build the time-helix companion from the live [`LineageView`]: each arrival is
    /// an idea wound onto its ecosystem's coil at its arrival index along the time
    /// axis, chained to the previous arrival on the same lane so each ecosystem forms
    /// one connected coil. Falls back to `HelixView::demo()` before any arrival.
    #[cfg(not(feature = "mini"))]
    fn build_lineage_helix(&self) -> HelixView {
        let l = &self.data.lineage;
        if l.events.is_empty() {
            return HelixView::demo();
        }
        let mut dag = PlanDag::new();
        let mut last_on_lane: HashMap<&'static str, facett_helix::NodeId> = HashMap::new();
        for (i, ev) in l.events.iter().enumerate() {
            let lane = ev.ecosystem.label();
            let label = short_artifact(&ev.artifact);
            let nid = dag.add_idea(label, lane, i as f64, Kind::Crate);
            if let Some(prev) = last_on_lane.get(lane) {
                dag.add_dep(nid, *prev);
            }
            last_on_lane.insert(lane, nid);
        }
        HelixView::from_dag("holger · cache fill", dag)
    }
}

/// The neon colour for a lineage node: artifacts take their ecosystem cluster
/// colour; repository and upstream nodes reuse the HOLGER NEON PALETTE kinds so the
/// Lineage graph reads coherently against the Graph tab.
#[cfg(not(feature = "mini"))]
fn lineage_node_color(n: &LineageNode) -> egui::Color32 {
    match n.kind {
        NodeKind::Repo => GraphNodeKind::Repository.color(),
        NodeKind::Upstream => GraphNodeKind::Upstream.color(),
        NodeKind::Artifact => {
            let [r, g, b] = n.ecosystem.color();
            egui::Color32::from_rgb(r, g, b)
        }
    }
}

/// Trim a long artifact id / archive path to a compact helix label (basename +
/// version), so the coil stays legible as it fills.
#[cfg(not(feature = "mini"))]
fn short_artifact(artifact: &str) -> String {
    let tail = artifact.rsplit('/').next().unwrap_or(artifact);
    if tail.len() > 28 {
        tail[tail.len() - 28..].to_string()
    } else {
        tail.to_string()
    }
}

impl HolgerUiApp {
    /// Render the whole app from a parent `ui`. eframe drives this through the
    /// `eframe::App::ui` impl below; the `egui_kittest` robot harness drives it
    /// through [`HolgerUiApp::draw_ui`].
    // Under `mini` the chrome control group (the ⚓ static toggle) is gated out, so the
    // locals it captures (`controls`, `new_static`, …) go unused for that one tier —
    // silence them here rather than thread `#[cfg]` through each.
    #[cfg_attr(feature = "mini", allow(unused_variables, unused_mut))]
    fn render(&mut self, ui: &mut egui::Ui) {
        // Unified look & feel. In the static **Skade Vinter** showcase the whole UI
        // re-themes to the icy winter palette via the COH-1 bridge — `apply` installs
        // the full egui Style, publishes the derived legacy palette so every facett
        // Table follows, and publishes the Full effects policy so the ice-drip decor
        // lights up. Normal operation keeps the operator's selected preset.
        let ctx = ui.ctx().clone();
        // 🎬 Demo mode: apply the next due tour beat FIRST (off the egui frame
        // clock) so this frame paints the result — a real, animated walk.
        #[cfg(feature = "demo")]
        if self.demo.is_some() {
            let now = ui.input(|i| i.time);
            self.demo_tick(&ctx, now);
        }
        if self.static_mode {
            LookTheme::skade_vinter().apply(&ctx);
        } else {
            self.apply_look(&ctx);
        }
        // Write tabs are unavailable in static (read-only) mode.
        if self.static_mode && self.tab == Tab::Upload {
            self.tab = Tab::Status;
        }

        // Startup splash: the recovered holger+znippy hero logo (compiled-in PNG)
        // + wordmark + version, with the spinning znippy rabbit as a small accent
        // beneath. Shows ~2.6s then auto-dismisses (click to skip).
        if !self.splash_done {
            let now = ui.input(|i| i.time);
            let since = *self.splash_since.get_or_insert(now);
            let clicked = ui.input(|i| i.pointer.any_click());
            if now - since > 2.6 || clicked {
                self.splash_done = true;
            } else {
                self.logo.set_clock(now);
                // The hero PNG rides on `about-card` (it is the same asset the About
                // card decodes, and `image` is its decoder). The mikro tier drops both
                // and shows the wordmark + version splash with no bitmap.
                #[cfg(feature = "about-card")]
                let logo_tex = self.splash_logo_texture(&ctx);
                #[cfg(not(feature = "about-card"))]
                let logo_tex: Option<egui::TextureHandle> = None;
                egui::CentralPanel::default().show(ui, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(12.0);
                        // The recovered logo is the splash hero — scaled to fit
                        // (portrait 1024×1536), centered, above the wordmark.
                        if let Some(tex) = &logo_tex {
                            let [w, h] = tex.size();
                            let max_h = (ui.available_height() * 0.55).clamp(180.0, 420.0);
                            let scale = (max_h / h as f32).min(1.0);
                            let size = egui::vec2(w as f32 * scale, h as f32 * scale);
                            ui.add(egui::Image::new((tex.id(), size)).fit_to_exact_size(size));
                        }
                        ui.add_space(8.0);
                        // Caps wordmark + tier tag on the splash too, matching the chrome bar.
                        #[cfg(feature = "mini")]
                        {
                            ui.heading("HOLGER");
                            ui.label(RichText::new("mini").small().strong());
                        }
                        #[cfg(not(feature = "mini"))]
                        ui.heading("holger");
                        ui.label(
                            RichText::new(format!("v{HOLGER_UI_VERSION}"))
                                .monospace()
                                .strong(),
                        );
                        ui.label("airgap artifact server · znippy-backed");
                    });
                    self.logo.ui(ui);
                });
                ui.ctx().request_repaint();
                return;
            }
        }

        // NB: the grouped menu bar (View / Operate / Repositories / Action / Look / Help) is
        // no longer hand-wired here — it is now DECLARED in the shell scene and drawn by
        // facett-menu from `scene.menubar` (see the `scene` child module). It carries the same
        // command set as the Ctrl-K palette and a pick still routes through the ONE
        // `dispatch_command` a click/palette-invoke uses, so this is a migration of the same
        // menubar onto the declarative layer, not a second one.

        // ── The shell chrome (holger's non-tab controls) — RESPONSIVE, canvas-first. ─────
        // The heading + ⌘K + ℹ About + the ⚓ static toggle live here (NOT in the scene's
        // tab strip). On a phone-class viewport (`Device::from_width` over the available
        // width) the control group folds behind one ⚙ drawer button so the tab canvas gets
        // the screen — the same mobile canvas-first fold facett-demo uses, expressed with
        // facett-scene's public device classifier. All widgets write LOCAL accumulators
        // (never `self` inside a nested closure), applied to `self` after.
        //
        // **The LOOK switcher is deliberately NOT here any more.** Appearance is a
        // preference, not shell navigation, so it lives in the ⚙ Settings pane
        // ([`settings_pane`](Self::settings_pane)) — the same place dwarves-demo's 🎛
        // `settings` tab keeps it. The ⚓ static toggle STAYS: it is the Skade-Vinter
        // showcase switch, which gates WRITES (`write_enabled`, the hidden Upload tab)
        // and only re-themes as a side effect — a functional control, not a look one.
        let phone = matches!(Device::from_width(ui.available_width()), Device::Phone);
        self.controls_folded = phone;
        let mut open_palette = false;
        let mut open_about = false;
        let mut new_static = self.static_mode;
        let static_mode = self.static_mode;
        let badge = if self.data.profile.read_only && !self.data.profile.label.is_empty() {
            format!("❄ SKAÐI · {}", self.data.profile.label)
        } else {
            "❄ SKAÐI · SKADE VINTER — STATIC SHOWCASE · READ-ONLY".to_string()
        };
        // The folding control group (the ⚓ static toggle) — captures only locals, so it
        // is safe to run either inline or inside the `⚙` drawer's menu closure.
        let controls = |ui: &mut egui::Ui, new_static: &mut bool| {
            ui.checkbox(new_static, "⚓ static").on_hover_text(
                "demo: Skade Vinter static showcase (icy winter scene, read-only) — \
                 defaulted from the server-reported profile (gRPC Admin ServerProfile); \
                 this checkbox is a manual override",
            );
        };
        // The app chrome (identity + global controls): the holger wordmark + version, the
        // Ctrl-K palette + About buttons, the ⚓ static (Skade Vinter) toggle, the winter frost
        // badge, and the look switcher — the last two folding behind the ⚙ drawer on a
        // phone-class viewport. The MENUBAR and the TAB STRIP live in the declarative scene
        // (`render_scene` below), so this bar carries only the surrounding chrome.
        egui::Panel::top("chrome").show(ui, |ui| {
            ui.horizontal(|ui| {
                // Mini wears the wordmark in caps with a small tier tag beside it, so the
                // window says at a glance which of the three tiers (embedded/mini/full) this
                // is; the full UI keeps the lowercase wordmark unchanged.
                #[cfg(feature = "mini")]
                {
                    ui.heading("HOLGER");
                    ui.label(RichText::new("mini").small().strong())
                        .on_hover_text("mini tier — read-only registry surface (no write, federation, or tools)");
                }
                #[cfg(not(feature = "mini"))]
                ui.heading("holger");
                ui.label(RichText::new(format!("v{HOLGER_UI_VERSION}")).monospace().weak());
                // Which server this window is looking at — without it, two open
                // registries are indistinguishable. Empty for in-process handles.
                #[cfg(feature = "mini")]
                if !self.data.endpoint.is_empty() {
                    ui.separator();
                    ui.label(RichText::new(self.data.endpoint.clone()).monospace().weak())
                        .on_hover_text("the holger server this window is connected to");
                }
                ui.separator();
                if ui
                    .button("⌘ K")
                    .on_hover_text("Command palette — jump to any tab, tool, or repo (Ctrl-K)")
                    .clicked()
                {
                    open_palette = true;
                }
                // Mini drops the top-bar About button — the identity card is already one
                // click away under the menu-bar Help ▸ About holger (and the Ctrl-K palette).
                #[cfg(not(feature = "mini"))]
                if ui
                    .button("ℹ About")
                    .on_hover_text("About holger — identity, version, and the shared framework it is built on")
                    .clicked()
                {
                    open_about = true;
                }
                // Full tier: the shell control group (the ⚓ static / Skade-Vinter toggle).
                // The mini tier drops it wholesale — mini is plainly read-only, so it has
                // no static toggle. Look & feel is NOT here on any tier: it lives in
                // exactly ONE place, the ⚙ Settings pane.
                #[cfg(not(feature = "mini"))]
                {
                    ui.separator();
                    if phone {
                        // Canvas-first drawer: fold the controls behind one compact ⚙ menu.
                        ui.menu_button("⚙ Controls", |ui| controls(ui, &mut new_static))
                            .response
                            .on_hover_text("Shell controls (folded on a narrow viewport)");
                    } else {
                        controls(ui, &mut new_static);
                    }
                }
            });
            if static_mode {
                // Frost badge for the winter showcase (server label when it drove read-only).
                ui.label(
                    RichText::new(badge)
                        .color(egui::Color32::from_rgb(180, 222, 245))
                        .strong(),
                );
            }
        });
        // Apply the chrome's picks to `self` (no `self` was borrowed inside the closures).
        if open_palette {
            self.palette.open();
        }
        if open_about {
            self.about_open = true;
        }
        if new_static != self.static_mode {
            self.static_mode = new_static;
        }

        // ── The shell — ADOPTED from the shared facett-scene (facett_app::scene). ────────
        // holger's tabs are a declarative `Scene` (Tabs root, one FOLDABLE `Slot` per tab) plus
        // a grouped MENUBAR, rendered through facett's OWN TabView + pane engine + facett-menu
        // — this REPLACES both the hand-rolled `selectable_value` tab strip with its central
        // `match self.tab` dispatch AND the hand-wired `menu_bar` call (LAW #5 — ADOPTION).
        // `self.tab` stays holger's authoritative tab model: `render_scene` seeds it into the
        // nav before rendering (so palette/menu/demo dispatch still moves the tab) and reads it
        // back after (so a TabView click moves `self.tab` + `state_json.tab`), re-applying the
        // static-mode Upload guard on the way out. A menu pick routes through the same
        // `dispatch_command` the palette uses.
        self.last_render = self.render_scene(ui);

        // Skade Vinter: overlay the ice-drip decor over the whole viewport — icicles
        // hang from the top edge, melt-droplets fall under gravity and fade, frost
        // shimmers along the edge. Time-driven; only while in the winter showcase.
        if self.static_mode {
            let now = ui.input(|i| i.time) as f32;
            self.ice.set_clock(now);
            let screen = ui.ctx().content_rect();
            let painter = ui.ctx().layer_painter(egui::LayerId::new(
                egui::Order::Foreground,
                egui::Id::new("skade_vinter_ice"),
            ));
            self.ice.paint(&painter, screen);
            ui.ctx().request_repaint();
        }

        // The About identity popup — the shared `facett_about::AboutCard` (LAW #5): the
        // ONE render body korp/holger/nornir/dwarves share, fed holger's identity. Built
        // lazily on first open (decodes the hero logo once, LFS-tolerant → text identity
        // when the blob is un-pulled), then drawn each frame while open.
        #[cfg(feature = "about-card")]
        if self.about_open {
            let id = crate::about::identity();
            let card = self
                .about_card
                .get_or_insert_with(|| facett_about::AboutCard::new(&ctx, &id).with_max_width(360.0));
            card.show(&ctx, &mut self.about_open, &id);
        }

        // The command palette overlays everything (Ctrl-K / Ctrl-Shift-P). Built +
        // drawn LAST so its foreground scrim + search box land above the tabs, the
        // content, and the winter decor. A pick runs the same hook a click would.
        let commands = self.build_commands();
        if let Some(id) = self.palette.ui(&ctx, &commands) {
            self.dispatch_command(&id);
        }
    }

    /// Robot/harness entry: render the whole app into a `Ui` (no eframe `Frame`),
    /// so `egui_kittest` / `nornir-robotui` can drive the real app:
    /// `Harness::builder().build_ui_state(|ui, app| app.draw_ui(ui), app)`.
    ///
    /// This is now the SAME call the production [`eframe::App::ui`] impl makes —
    /// `render(ui)`, straight into the root `Ui`. It used to wrap the app in a
    /// `CentralPanel` that production does not have, so the robot suite was
    /// driving a subtly different widget tree (an extra panel node, and its frame
    /// margins) from the app a user actually sees. Taking a `Ui` also drops the
    /// deprecated top-level `CentralPanel::show`.
    pub fn draw_ui(&mut self, ui: &mut egui::Ui) {
        self.render(ui);
    }

    /// Skip the startup splash so headless/robot tests start on the tabs.
    pub fn skip_splash(&mut self) {
        self.splash_done = true;
    }

    /// The recovered hero logo as an egui texture — decode-once from the
    /// compiled-in [`HOLGER_LOGO_PNG`] (no runtime file read), then cached in
    /// `self.logo_texture` for the rest of the splash. Returns `None` only if the
    /// embedded PNG fails to decode (it never should — it's validated at build).
    #[cfg(feature = "about-card")]
    fn splash_logo_texture(&mut self, ctx: &egui::Context) -> Option<egui::TextureHandle> {
        if let Some(tex) = &self.logo_texture {
            return Some(tex.clone());
        }
        let rgba = image::load_from_memory(HOLGER_LOGO_PNG).ok()?.to_rgba8();
        let size = [rgba.width() as usize, rgba.height() as usize];
        let color = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw());
        let tex = ctx.load_texture("holger-znippy-logo", color, egui::TextureOptions::LINEAR);
        self.logo_texture = Some(tex.clone());
        Some(tex)
    }
}

impl eframe::App for HolgerUiApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        self.render(ui);
    }
}

#[cfg(test)]
mod look_tests {
    //! Robot-UI cell for the unified facett look & feel (the "all consumers,
    //! holger too" migration). Runs only under `--features gui` (where `app`
    //! compiles). It drives the *real* holger-server-ui app's look surface headlessly and
    //! asserts its observable state — the active preset + a coherent palette — and
    //! that switching the preset actually re-themes every facett view via the
    //! published legacy palette (COH-1). No display, no GPU.
    //!
    //! LAW: inject real input + assert real output. We inject a concrete preset
    //! index (a "click" on the look switcher) and assert the rendered palette
    //! changes accordingly and that egui picked up the new style — never just
    //! "didn't panic".

    use super::*;
    use server_lib::exposed::fast_routes::FastRoutes;
    use server_lib::LocalHolger;
    use traits::HolgerObject;

    /// Emit one functional-status row for a real check. Gated behind
    /// `--features testmatrix` (this module already requires `gui`) so release
    /// builds strip it (the dep is optional).
    #[cfg(feature = "testmatrix")]
    fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
        nornir_testmatrix::functional_status(component, check, ok, detail);
    }

    /// A holger-server-ui app over an empty (no-repo) LocalHolger — enough to exercise
    /// the look surface, which is independent of the data layer.
    fn app() -> HolgerUiApp {
        let routes = FastRoutes::new(Vec::new());
        let holger: std::sync::Arc<dyn HolgerObject> = std::sync::Arc::new(LocalHolger::new(routes));
        let data = UiData::new(holger).expect("runtime");
        HolgerUiApp::new(data)
    }

    /// **Every tab is reachable from the Ctrl-K palette — exactly once.**
    ///
    /// The palette's `goto:` set used to be a hand-written list: a twin of
    /// `TAB_ORDER` that made "add a tab" a three-place edit (enum + order array +
    /// this list), where forgetting the third gave the new tab a scene pane and a
    /// View-menu entry but NO palette command — a pane you could click to but never
    /// summon, and nothing failed. It is now derived from `TAB_ORDER` +
    /// `Tab::label()`, the same source the pane tree and the menubar use.
    ///
    /// RED-when-broken in both directions: a tab with no command, or a stray
    /// `goto:` command for a tab that no longer exists.
    #[test]
    fn every_tab_has_exactly_one_palette_command() {
        let mut app = app();
        // The writable (non-static) shell: static mode legitimately hides Upload —
        // asserted separately by `static_mode_drops_upload_from_the_palette_and_nothing_else`.
        app.static_mode = false;
        let goto: Vec<String> = app
            .build_commands()
            .iter()
            .filter(|c| c.id.starts_with("goto:"))
            .map(|c| c.id.to_string())
            .collect();

        let expected: Vec<String> =
            TAB_ORDER.iter().map(|t| format!("goto:{}", t.slot_id())).collect();
        assert_eq!(goto, expected, "the palette's tab set IS TAB_ORDER, in order");

        // And each is dispatchable — the id is not just present, it moves the tab.
        for t in TAB_ORDER {
            app.run_command(&format!("goto:{}", t.slot_id()));
            assert_eq!(
                app.state_json()["tab"], t.slot_id(),
                "goto:{} must actually select that tab", t.slot_id()
            );
        }
    }

    /// Static ("Skade Vinter") mode is the read-only showcase: Upload has nowhere to
    /// go, so it is the ONE tab dropped from the palette — and only it.
        // FULL TIER ONLY: the read-only tiers have no Upload tab to drop, so "drops
    // exactly one" is not a statement about them.
    #[cfg(not(feature = "mini"))]
    #[test]
    fn static_mode_drops_upload_from_the_palette_and_nothing_else() {
        let mut app = app();
        app.static_mode = true;
        let goto: Vec<String> = app
            .build_commands()
            .iter()
            .filter(|c| c.id.starts_with("goto:"))
            .map(|c| c.id.to_string())
            .collect();
        assert!(!goto.iter().any(|c| c == "goto:upload"), "static mode hides Upload: {goto:?}");
        assert_eq!(goto.len(), TAB_ORDER.len() - 1, "…and drops nothing else: {goto:?}");
    }

    /// The Ctrl-K command palette is a real, headless-addressable surface: its
    /// command set carries every tab + tool + action, and firing a command id runs
    /// the same hook a click would (tab switch, tool select). Inject a command id,
    /// assert the observable state moved — never just "didn't panic".
    ///
    /// **This test did not run.** Its `#[test]` had drifted up above the doc comment of
    /// the test BELOW it — so `every_tab_has_exactly_one_palette_command` carried the
    /// attribute twice (a warning nobody read) and this function carried none, compiling
    /// as dead code and reporting nothing. A duplicated attribute is exactly how a cell
    /// leaves the matrix without a single failure.
        // FULL TIER ONLY: it dispatches TOOL commands, and the read-only tiers carry
    // no Tools pane.
    #[cfg(not(feature = "mini"))]
    #[test]
    fn command_palette_dispatches_tabs_and_tools() {
        let mut app = app();

        // The command set is exposed as data (stable ids), tabs + tools + actions.
        let s = app.state_json();
        let ids: Vec<String> = s["palette"]["commands"]
            .as_array()
            .expect("palette exposes its command set")
            .iter()
            .map(|v| v.as_str().unwrap_or("").to_string())
            .collect();
        let has = |id: &str| ids.iter().any(|i| i == id);
        assert!(has("goto:browse"), "every tab is a command: {ids:?}");
        assert!(has("tool:security"), "every tool is a command: {ids:?}");
        assert!(has("action:refresh"), "actions are commands: {ids:?}");

        // Fire a tab command → the active tab moves (same path a click runs).
        app.run_command("goto:browse");
        assert_eq!(app.state_json()["tab"], "browse", "goto:browse switches the tab");

        // Fire a tool command → Tools tab + that tool selected, and the palette
        // records the fired id on its `invoked` observable.
        app.run_command("tool:security");
        let s = app.state_json();
        let dispatched = s["tab"] == "tools" && s["tool"] == "security";
        assert!(dispatched, "tool:security → Tools/Security: {s}");
        assert_eq!(s["palette"]["invoked"], "tool:security", "palette records the fired id");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "command_palette_dispatch",
            has("goto:browse") && dispatched,
            "goto:browse→browse, tool:security→tools/security",
        );
    }

    /// The right-click repo-row menu routes through the same navigation hooks a
    /// click/palette pick does: a row action selects the row and opens its tab.
    /// Inject the action, assert the observable tab/tool moved (not just "no panic").
    #[test]
    fn repo_row_context_action_navigates() {
        let mut app = app();
        // Scan → Tools/Security (select_repo(0) no-ops on the empty test roster, but
        // the navigation still applies — the action's contract is tab movement).
        app.apply_repo_row_nav(0, "row:scan");
        let s = app.state_json();
        let scan_ok = s["tab"] == "tools" && s["tool"] == "security";
        assert!(scan_ok, "row:scan → Tools/Security: {s}");
        // Archive → Archive tab.
        app.apply_repo_row_nav(0, "row:archive");
        let archive_ok = app.state_json()["tab"] == "archive";
        assert!(archive_ok, "row:archive → Archive tab");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "repo_row_context_action",
            scan_ok && archive_ok,
            "row:scan→tools/security, row:archive→archive",
        );
    }

    /// **About (shared common-framework identity surface).** The `Help · About holger`
    /// command opens the popup, and the app's `state_json.about` reports holger's
    /// identity — wordmark, a real version string, and the shared framework it is built
    /// on (incl. jera). Inject the command, assert the observable open flag flips AND
    /// the identity is present — never just "didn't panic". RED-when-broken: if the
    /// About wiring broke (command not dispatched / identity dropped) this fails.
    #[test]
    fn about_command_opens_popup_and_reports_identity_and_framework() {
        let mut app = app();

        // The About command is in the command set (Help group), and firing it opens
        // the popup — the same path the ℹ About button / menu runs.
        let ids: Vec<String> = app.state_json()["palette"]["commands"]
            .as_array()
            .expect("palette exposes commands")
            .iter()
            .map(|v| v.as_str().unwrap_or("").to_string())
            .collect();
        assert!(ids.iter().any(|i| i == "about:open"), "About is a command: {ids:?}");
        assert_eq!(app.state_json()["about"]["open"], serde_json::json!(false), "closed initially");

        app.run_command("about:open");
        let s = app.state_json();
        let opened = s["about"]["open"] == serde_json::json!(true);
        assert!(opened, "about:open opens the popup: {}", s["about"]);
        assert_eq!(s["about"]["wordmark"], "holger", "identity wordmark");
        let version = s["about"]["version"].as_str().unwrap_or("");
        assert!(!version.is_empty(), "About presents a real version string: {}", s["about"]);
        let fw: Vec<String> = s["about"]["framework"]
            .as_array()
            .expect("framework list")
            .iter()
            .map(|v| v.as_str().unwrap_or("").to_string())
            .collect();
        let names_jera = fw.iter().any(|c| c.contains("jera"));
        assert!(names_jera, "About names the shared jera job manager: {fw:?}");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "about_command_opens_and_reports_identity",
            opened && !version.is_empty() && names_jera,
            "Help·About opens the identity popup (wordmark/version/shared-framework incl. jera)",
        );
    }

    /// **Tool #6 demo appliance dispatches THROUGH jera.** Driving the demo tool (the
    /// same hook the pane's "Start demo appliance…" button runs) records the container
    /// bring-up as a jera job; the app's `state_json.demo_appliance` carries back the
    /// job id + the pinned demo image. Inject the dispatch, assert the observable job
    /// id is non-empty + the image is the demo registry — proof it went through the
    /// shared job manager, not a bespoke spawn. RED-when-broken: no job id / no dispatch.
    #[cfg(not(feature = "mini"))]
    #[test]
    fn demo_tool_dispatches_a_jera_job_observably() {
        let mut app = app();
        assert!(app.state_json()["demo_appliance"].is_null(), "no dispatch yet");

        app.run_demo_tool();
        let s = app.state_json();
        assert_eq!(s["tool"], "demo", "the demo tool is selected");
        let d = &s["demo_appliance"];
        let job_id = d["job_id"].as_str().unwrap_or("");
        let dispatched = d["dispatched"].as_bool().unwrap_or(false);
        assert!(!job_id.is_empty(), "the demo dispatch records a jera job id: {d}");
        assert!(dispatched, "the demo is dispatched through jera: {d}");
        assert!(
            d["image"].as_str().unwrap_or("").contains("registry"),
            "the dispatched demo container is the registry image: {d}"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "demo_tool_dispatches_jera_job",
            !job_id.is_empty() && dispatched,
            "Tools·Demo starts the demo container as a jera BOOT_CONTAINER job (observable job id)",
        );
    }

    /// The look surface exposes holger's collapsed roster (`dark`, `light`,
    /// `device`) and defaults to `dark` — the value an operator would see selected
    /// on first launch.
    #[test]
    fn default_preset_is_dark_and_roster_is_collapsed() {
        let app = app();
        let s = app.look_state_json();

        // The roster the switcher renders is holger's collapsed look list.
        let presets = s["presets"].as_array().expect("look state has a presets roster");
        let names: Vec<&str> = presets.iter().map(|p| p.as_str().unwrap_or("")).collect();
        assert_eq!(
            names,
            vec!["dark", "light", "device"],
            "the look switcher lists the collapsed roster: {s}"
        );

        // The active look is the default (`dark`) and matches the index.
        assert_eq!(s["preset"], "dark", "first-launch look is dark: {s}");
        assert_eq!(s["preset_index"].as_u64(), Some(default_look_preset() as u64));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "look_default_preset_and_roster",
            names == vec!["dark", "light", "device"]
                && s["preset"] == "dark"
                && s["preset_index"].as_u64() == Some(default_look_preset() as u64),
            &format!("roster {names:?}, default = dark"),
        );
    }

    /// Injecting a preset switch (the operator clicking "device") changes the
    /// active preset AND the resolved palette the facett views paint with — proof
    /// the migration re-themes coherently, not just that it stored an index.
    #[test]
    fn switching_preset_rethemes_the_resolved_palette() {
        let mut app = app();

        // Find device + dark by name so the test is order-stable.
        let names = look_names();
        let device = names.iter().position(|n| n == "device").expect("device look exists");
        let dark = names.iter().position(|n| n == "dark").expect("dark look exists");

        // Drive: select dark, capture its palette.
        let name = app.set_look_preset(dark);
        assert_eq!(name, "dark");
        let win_state = app.look_state_json();
        let win_bg = win_state["palette"]["bg"].clone();

        // Drive: select device (a click on the switcher), capture its palette.
        let name = app.set_look_preset(device);
        assert_eq!(name, "device");
        let dev_state = app.look_state_json();

        // Observe: the active preset + a different, fully-populated palette.
        assert_eq!(dev_state["preset"], "device", "active preset switched: {dev_state}");
        let dev_bg = dev_state["palette"]["bg"].clone();
        assert_ne!(win_bg, dev_bg, "device must paint a different surface than dark");

        // The palette is coherent — every role is a real hex colour, none empty.
        let roles = ["bg", "text", "text_dim", "accent", "panel_bg", "node_fill"];
        for role in roles {
            let c = dev_state["palette"][role].as_str().unwrap_or("");
            assert!(
                c.starts_with('#') && c.len() == 7,
                "palette role `{role}` must be a #rrggbb colour, got {c:?}: {dev_state}"
            );
        }
        let all_hex = roles.iter().all(|role| {
            let c = dev_state["palette"][role].as_str().unwrap_or("");
            c.starts_with('#') && c.len() == 7
        });
        let _ = all_hex;

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "look_switch_rethemes_palette",
            dev_state["preset"] == "device" && win_bg != dev_bg && all_hex,
            &format!("windows-dark bg={win_bg} != device bg={dev_bg}, all 6 roles #rrggbb={all_hex}"),
        );
    }

    /// `apply` installs the preset's full egui Style AND publishes the derived
    /// legacy palette into the context, so every facett component (the Table
    /// facets) follows with no per-view wiring (COH-1). We apply headlessly and
    /// read the published palette back through `facett::theme(ui)`.
    #[test]
    fn apply_publishes_the_coherent_legacy_palette_into_egui() {
        let mut app = app();
        let device = look_names().iter().position(|n| n == "device").unwrap();
        app.set_look_preset(device);

        let ctx = egui::Context::default();
        app.apply_look(&ctx);

        // The egui Style picked up the preset's spacing (not egui's default).
        //
        // egui 0.35 REMOVED `Context::style()` (style is per-theme now: `style_of(theme)`,
        // plus the theme-independent `global_style()` that `apply` writes). The assertion
        // here was migrated; the `testmatrix` mirror below still read `ctx.style()` and
        // nobody saw it, because the mirror is feature-gated and no default build compiles
        // it. Both now read ONE binding, so the row cannot drift from the assert again.
        let theme = app.look_theme();
        let want_spacing = theme.metrics.item_spacing_vec();
        let got_spacing = ctx.global_style().spacing.item_spacing;
        assert_eq!(
            got_spacing, want_spacing,
            "apply installed the preset's spacing into the egui Style"
        );

        // The legacy palette every facett view reads is the device preset's.
        let mut got = String::new();
        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
            got = facett::theme(ui).name.to_string();
        });
        assert_eq!(got, "device", "facett views resolve the applied preset's palette");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "look_apply_publishes_legacy_palette",
            got_spacing == want_spacing && got == "device",
            &format!("egui spacing matches preset; facett::theme resolves \"{got}\""),
        );
    }

    /// **RED-when-broken: ⚙ Settings is the ONE home of holger's look & feel, and the
    /// control still works there.**
    ///
    /// The look-preset switcher used to be advertised twice at the shell ROOT — inline
    /// on the chrome bar (folding behind `⚙ Controls` on a narrow viewport) and as a
    /// top-level menu-bar **Look** submenu. It now lives in the ⚙ Settings pane, the
    /// same place dwarves-demo's 🎛 `settings` tab keeps it.
    ///
    /// Asserted on the REAL rendered app through the AccessKit tree (`draw_ui`, the
    /// same body eframe runs), never on the source:
    /// * with a non-Settings tab active NOTHING on screen is a look control — no
    ///   `look:` strip, no `Look` menu button, no `⚙ Controls` drawer holding one;
    /// * selecting Settings paints the Appearance block with EVERY preset in
    ///   [`LOOKS`] — nothing was dropped in the move;
    /// * and clicking a preset in the pane really re-themes: the app's `look`
    ///   observable follows, and `facett::theme` resolves the new palette.
    #[test]
    fn settings_pane_is_the_one_home_of_look_and_feel() {
        use egui_kittest::kittest::Queryable;
        use egui_kittest::Harness;

        let names = look_names();
        assert!(names.len() >= 2, "a roster worth switching: {names:?}");

        // ── (a) the ROOT carries no look & feel ─────────────────────────────────
        let mut app = app();
        app.skip_splash();
        app.static_mode = false;
        app.tab = Tab::Status;
        let mut h = Harness::builder()
            .with_size(egui::vec2(1600.0, 1000.0))
            .build_ui_state(|ui, a: &mut HolgerUiApp| a.draw_ui(ui), app);
        h.run();
        assert!(
            h.query_by_label("look:").is_none(),
            "the root chrome must not carry the look switcher — it belongs in ⚙ Settings"
        );
        assert!(
            h.query_by_label("Look").is_none(),
            "the root menubar must not carry a Look menu — it belongs in ⚙ Settings"
        );
        assert!(
            h.query_by_label("⚙ Controls").is_none(),
            "…and no ⚙ Controls drawer is hiding one on this (wide) viewport"
        );

        // ── (b) ⚙ Settings paints the Appearance block with the WHOLE roster ────
        h.state_mut().tab = Tab::Settings;
        h.run();
        assert!(
            h.query_by_label_contains("Appearance").is_some(),
            "⚙ Settings heads its look & feel block with Appearance"
        );
        assert!(h.query_by_label("look:").is_some(), "⚙ Settings carries the look switcher");
        for name in &names {
            assert!(
                h.query_by_label(name.as_str()).is_some(),
                "⚙ Settings offers the `{name}` preset — no preset lost in the move"
            );
        }

        // ── (c) …and the control is LIVE: click a preset in the pane, the whole app
        // re-themes. Pick whichever preset is not the current one, so the click is a
        // real change rather than a re-select. ─────────────────────────────────────
        let before = h.state().look_name().to_string();
        let target = names.iter().find(|n| *n != &before).expect("a second preset").clone();
        h.get_by_label(target.as_str()).click();
        h.run();
        let after = h.state().state_json();
        assert_eq!(
            after["look"], target,
            "clicking `{target}` in ⚙ Settings switched the app's look: {after}"
        );
        assert_eq!(
            h.state().look_state_json()["preset"], target,
            "…and the look oracle the pane itself publishes agrees"
        );

        // The re-theme really reached the shared palette every facett view paints with.
        let want = h.state().look_theme().name;
        let ctx = egui::Context::default();
        h.state().apply_look(&ctx);
        let mut got = String::new();
        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
            got = facett::theme(ui).name.to_string();
        });
        assert_eq!(got, want, "the Settings pick drives the coherent facett palette (COH-1)");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-server-ui",
            "look_and_feel_lives_in_settings_not_at_root",
            true,
            "the look-preset switcher moved off the root chrome bar and out of the root menu-bar \
             Look submenu into the ⚙ Settings scene pane (glyph 🎛, matching dwarves-demo); \
             asserted on the real rendered app via AccessKit — absent at root, present in \
             Settings with every preset, and a click there re-themes the app and the shared \
             facett palette",
        );
    }
}