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

nordisk/nornir

src/viz/app.rs

//! egui app — paints the lanes + edges and the "time machine" panel.

use std::path::PathBuf;
use std::sync::OnceLock;

use anyhow::Result;
use eframe::egui::{
    self, Align2, Color32, ColorImage, CornerRadius, FontId, Pos2, Rect, Sense, Stroke,
    TextureHandle, TextureOptions, Vec2,
};
use uuid::Uuid;

use super::graph::{DepGraphView, draw_dep_graph};
use super::model::{Timeline, load_timeline};
use super::timetravel::{TimeTravelState, draw_timetravel};
// nornir's semantic ramps over the SHARED facett-core palette (L5) — the palette
// itself is `facett_core::theme::Theme`, re-exported by `viz::facett_theme`.
use super::facett_theme::ThemeExt;

const SPLASH_DURATION_SECS: f32 = 2.5;
const SPLASH_FADE_SECS: f32 = 1.0;
const WATERMARK_ALPHA: u8 = 22;

/// Human build identifier for the viz client, e.g.
/// `v0.4.11 (a1b2c3d, built 2026-06-12 22:50 UTC)`. The SHA + build time come
/// from `build.rs` (`NORNIR_BUILD_SHA`/`NORNIR_BUILD_EPOCH`); they change every
/// build, so this is how you tell a fresh client from a stale one. Shown in the
/// window title and the ℹ About popup, and emitted in `state_json["about"]`.
pub fn client_build() -> String {
    let ver = env!("CARGO_PKG_VERSION");
    let sha = option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown");
    let epoch: i64 = option_env!("NORNIR_BUILD_EPOCH")
        .and_then(|s| s.parse().ok())
        .unwrap_or(0);
    let when = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
        .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
        .unwrap_or_default();
    if when.is_empty() {
        format!("v{ver} ({sha})")
    } else {
        format!("v{ver} ({sha}, built {when})")
    }
}

// Media moved to `.nornir/assets/` in 1e8d062; the old `assets/` path 404'd and
// broke the `viz` build.
static NORNIR_WEBP_BYTES: &[u8] = include_bytes!("../../.nornir/assets/nornir.webp");

fn decode_nornir() -> Option<ColorImage> {
    static CACHE: OnceLock<Option<ColorImage>> = OnceLock::new();
    CACHE
        .get_or_init(|| {
            let img = image::load_from_memory(NORNIR_WEBP_BYTES).ok()?;
            let rgba = img.to_rgba8();
            let (w, h) = (rgba.width() as usize, rgba.height() as usize);
            Some(ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
        })
        .clone()
}

#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Tab {
    /// 🧬 The root/server pane — workspace lifecycle (add/kill/populate/refresh)
    /// + server status. Not a workspace-scoped data view; the home of root ops.
    Nornir,
    Timeline,
    DepGraph,
    CallGraph,
    Architecture,
    /// 🚇 The Metro tab — nornir's button → gRPC → warehouse
    /// coverage CHAINS drawn as transit lines via facett-graphview::MetroView,
    /// lit by the latest surface_coverage run (S5b).
    Metro,
    /// 🧬 The Time-Helix tab — each repo's release timeline wound as a glowing
    /// helix advancing along a time axis, cross-repo deps bridging the coils, via
    /// facett-helix::HelixView. Falls back to the demo helix on an empty workspace.
    Helix,
    /// 🩸 The Bloodstream tab — the whole workspace as an organic circulatory
    /// system (crates=organs seated anatomically, dep edges=arteries, the
    /// call-chain=streaming blood), with recursive drill-down into a crate's
    /// non-workspace transitive crates.io closure. Built natively over
    /// `bloodstream::Organism`; never blank (defaults to nornir's own body).
    Bloodstream,
    Funnel,
    TimeTravel,
    LiveRun,
    Release,
    Knowledge,
    Warehouse,
    /// 🏢 The Warehouse Deck — the whole warehouse as a facett `WarehouseDeck`:
    /// a wall of N panes (several 3D graphs + grids + charts at once).
    WarehouseDeck,
    Mcp,
    Search,
    Gates,
    /// 💬 The Chat tab — a "local Claude" / agent-agnostic chat panel wired to the
    /// ACTIVE local model (an in-process candle GGUF loaded from the /mnt/work4t
    /// cache), plus the MCP-to-nornir client config an agent uses. EPIC H / #45.
    Chat,
    Security,
    /// ⚒ The Dwarves tab — nornir's own **build runner** (`src/viz/dwarves_tab.rs`):
    /// reflects a repo's `BuildSurface` into choosable rows (binary × features ×
    /// profile × target), runs `nornir build --attest --seal`, and test-boots the
    /// result as a job. Renders without a workspace (demo content: holger's
    /// build-space).
    ///
    /// It used to carry a second ⚒ Forge pane that ran dwarves' brokkr pipeline
    /// engine IN-PROCESS off a hard-coded demo `brokkr.toml`. dwarves-demo's own
    /// Pipeline tab is that pane, on the shared facett viewer and driven by the real
    /// engine — so this was a twin, and it was one of the call sites forcing nornir's
    /// `dwarves` path-dep (the cargo cycle that blocked the fat forge). The forge UI
    /// lives with the forge; a running forge is reachable at `dwarves-server` :7900.
    Dwarves,
    /// 📦 The built-in (married) holger registry pane — the in-process dev pair
    /// (/cache crates.io-mirror + /sparring rehearsal registry), read via
    /// holger's functional API. Always a tab; its pane content is gated behind
    /// `embed-holger` (degrades to a note when holger isn't embedded).
    Holger,
    Manual,
    /// 🤖 The Claude tab — a facett-Elm conversation pane over the Anthropic
    /// Messages API (`src/viz/claude_tab.rs`). Renders without a workspace (it
    /// talks to the API, not the warehouse); the HTTP call is behind the
    /// `ClaudeTransport` seam so a key-absent host shows "set ANTHROPIC_API_KEY"
    /// and CI drives a fake transport.
    Claude,
    /// 🧠 The Models tab — browse known LLM / embedding models, pick a device
    /// (CPU/CUDA/ROCm), and DOWNLOAD one as a nornir job (`src/viz/models_tab.rs`).
    /// The download runs behind the `ModelDownloader` seam (real = `ollama pull`;
    /// CI drives a fake), each as a job with a progress row + cancel.
    Models,
    /// 🎛 **Settings** — the preferences pane, and the ONE home of the viz's LOOK &
    /// FEEL. The app-wide facett palette picker used to sit on the root top bar,
    /// wedged between the 🐞 action-log toggle and ℹ About; appearance is a
    /// preference, not navigation, so it moved here. This mirrors dwarves-demo's
    /// `settings` tab (same 🎛 glyph, same split: root = navigation + actions,
    /// Settings = appearance + preferences), so an operator moving between the
    /// suite's apps finds the palette control in the same place in each.
    ///
    /// Renders WITHOUT a workspace or warehouse — it reads nothing but the app's own
    /// palette — so it sits with Claude/Models ahead of the onboarding guard.
    Settings,
}

/// Index the top-bar tab strip wraps onto its second row. SINGLE source of truth
/// (bug #23): both the rendered bar (`draw_top_bar`) and the readable-data dump
/// (`top_bar_state`) pass THIS so the dump can never drift from the render again.
pub(crate) const TOP_BAR_SPLIT: usize = 10;

impl Tab {
    /// Every tab, in header order — for the external state dump.
    pub const ALL: [Tab; 26] = [
        Tab::Nornir,
        Tab::Timeline,
        Tab::DepGraph,
        Tab::CallGraph,
        Tab::Architecture,
        Tab::Metro,
        Tab::Helix,
        Tab::Bloodstream,
        Tab::Funnel,
        Tab::TimeTravel,
        Tab::LiveRun,
        Tab::Release,
        Tab::Knowledge,
        Tab::Warehouse,
        Tab::WarehouseDeck,
        Tab::Mcp,
        Tab::Search,
        Tab::Gates,
        Tab::Chat,
        Tab::Security,
        Tab::Dwarves,
        Tab::Holger,
        Tab::Manual,
        Tab::Claude,
        Tab::Models,
        // 🎛 Settings LAST — appended, so every tab above keeps the header index the
        // persisted tab, deep-links and robot `active_tab` assertions already use.
        Tab::Settings,
    ];

    /// Resolve a tab from its `state_json()["tab"]` debug name (the names in
    /// [`Tab::ALL`]). `None` for an unknown name. Used by the robot-UI-tester
    /// control channel (`viz.click`) and the headless test injectors.
    pub fn from_name(name: &str) -> Option<Tab> {
        Tab::ALL.into_iter().find(|t| format!("{t:?}") == name)
    }

    /// The STABLE pane id (= the `Tab` debug name) used as an AccessKit `id_salt`
    /// at this pane's render root and as the registry key in
    /// [`super::surfaces::viz_surfaces`]. Distinct from [`Tab::label`] (the
    /// human-readable header glyph+words): the id is the machine-addressable salt
    /// the atom-walk drift gate matches on, so it must never collide across panes.
    pub fn id(self) -> &'static str {
        // The Debug impl is the canonical stable name (state_json / click contract).
        match self {
            Tab::Nornir => "Nornir",
            Tab::Timeline => "Timeline",
            Tab::DepGraph => "DepGraph",
            Tab::CallGraph => "CallGraph",
            Tab::Architecture => "Architecture",
            Tab::Metro => "Metro",
            Tab::Helix => "Helix",
            Tab::Bloodstream => "Bloodstream",
            Tab::Funnel => "Funnel",
            Tab::TimeTravel => "TimeTravel",
            Tab::LiveRun => "LiveRun",
            Tab::Release => "Release",
            Tab::Knowledge => "Knowledge",
            Tab::Warehouse => "Warehouse",
            Tab::WarehouseDeck => "WarehouseDeck",
            Tab::Mcp => "Mcp",
            Tab::Search => "Search",
            Tab::Gates => "Gates",
            Tab::Chat => "Chat",
            Tab::Security => "Security",
            Tab::Dwarves => "Dwarves",
            Tab::Holger => "Holger",
            Tab::Manual => "Manual",
            Tab::Claude => "Claude",
            Tab::Models => "Models",
            Tab::Settings => "Settings",
        }
    }

    /// The full header text (icon glyph + words) the top-bar paints for this tab
    /// — the exact button label a user reads and a robot/AccessKit click matches
    /// by string. Drives the [`TabItem`](super::facett_ui::TabItem) labels.
    pub fn label(self) -> &'static str {
        match self {
            Tab::Nornir => "🧬 nornir",
            Tab::Timeline => "🧵 Timeline",
            Tab::DepGraph => "🔗 Dep Graph",
            Tab::CallGraph => "🕸 Call Graph",
            Tab::Architecture => "🏛 Architecture",
            Tab::Metro => "🚇 Metro",
            Tab::Helix => "🧬 Time Helix",
            Tab::Bloodstream => "🩸 Bloodstream",
            Tab::Funnel => "🗂 Funnel",
            Tab::TimeTravel => "⏳ Time Travel",
            Tab::LiveRun => "📡 Live Run",
            Tab::Release => "🚀 Release",
            Tab::Knowledge => "🗺 Knowledge",
            Tab::Warehouse => "🗄 Warehouse",
            Tab::WarehouseDeck => "🏢 Wh Deck",
            Tab::Mcp => "📞 MCP",
            Tab::Search => "🔍 Search",
            Tab::Gates => "🚦 Gates",
            Tab::Chat => "💬 Chat",
            Tab::Security => "🛡 Security",
            Tab::Dwarves => "⚒ Dwarves",
            Tab::Holger => "📦 Holger",
            Tab::Manual => "📖 Manual",
            Tab::Claude => "🤖 Claude",
            Tab::Models => "🧠 Models",
            // 🎛 — the SAME glyph dwarves-demo's `tab_icon` gives its `settings` tab,
            // so the preferences pane wears one face across the suite.
            Tab::Settings => "🎛 Settings",
        }
    }

    /// The [`TabItem`](super::facett_ui::TabItem) list for the top-bar
    /// [`TabBar`](super::facett_ui::TabBar), in header order — id = the debug
    /// name (`state_json`/click contract), label = [`Tab::label`].
    fn tab_items() -> Vec<super::facett_ui::TabItem> {
        Tab::ALL
            .iter()
            // 🔗 Dep Graph + 🕸 Call Graph are CONSOLIDATED under 🏛 Architecture
            // (its `Deps` and `Metro` sub-views), so they are no longer top-level
            // header buttons — that removed the duplicate where Call Graph rendered
            // the same surface as Architecture. The `Tab::{DepGraph,CallGraph}`
            // variants live on for the control channel / back-compat links.
            .filter(|t| !matches!(t, Tab::DepGraph | Tab::CallGraph))
            .map(|t| super::facett_ui::TabItem::new(format!("{t:?}"), t.label()))
            .collect()
    }
}

impl std::fmt::Debug for Tab {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Tab::Nornir => "Nornir",
            Tab::Timeline => "Timeline",
            Tab::DepGraph => "DepGraph",
            Tab::CallGraph => "CallGraph",
            Tab::Architecture => "Architecture",
            Tab::Metro => "Metro",
            Tab::Helix => "Helix",
            Tab::Bloodstream => "Bloodstream",
            Tab::Funnel => "Funnel",
            Tab::TimeTravel => "TimeTravel",
            Tab::LiveRun => "LiveRun",
            Tab::Release => "Release",
            Tab::Knowledge => "Knowledge",
            Tab::Warehouse => "Warehouse",
            Tab::WarehouseDeck => "WarehouseDeck",
            Tab::Mcp => "Mcp",
            Tab::Search => "Search",
            Tab::Gates => "Gates",
            Tab::Chat => "Chat",
            Tab::Security => "Security",
            Tab::Dwarves => "Dwarves",
            Tab::Holger => "Holger",
            Tab::Manual => "Manual",
            Tab::Claude => "Claude",
            Tab::Models => "Models",
            Tab::Settings => "Settings",
        };
        f.write_str(s)
    }
}

/// Where the timeline comes from: a local Iceberg warehouse (embedded, opens
/// it directly) or a running `nornir-server` (thin client over the
/// `Viz.Timeline` gRPC). Both yield the same [`Timeline`], so the rest of the
/// app is source-agnostic.
pub enum Source {
    Local(PathBuf),
    Remote { endpoint: String, token: String },
}

impl Source {
    fn load(&self, workspace: &str) -> Result<Timeline> {
        match self {
            Source::Local(root) => load_timeline(root, workspace),
            Source::Remote { endpoint, token } => {
                super::remote::fetch_timeline(endpoint, token, workspace)
            }
        }
    }
}

pub struct UrdrThreadsApp {
    source: Source,
    source_label: String,
    workspace_name: String,
    /// Local checkout root the Knowledge/Security tabs scan `workspace_root/<repo>`
    /// under. Kept so a workspace switch can re-scope those local-disk scanners.
    workspace_root: PathBuf,
    /// Workspaces offered in the picker (server-registered for remote; just the
    /// current one for local). Switching re-scopes every server-backed tab.
    workspaces: Vec<String>,
    timeline: Result<Timeline>,
    selected: Option<Uuid>,
    /// Repo selected in the Dep Graph tab (for the cargo-deps drill-down panel).
    dep_selected_repo: Option<String>,
    /// Dep Graph tab view state — deep-closure toggle (C5), direct/transitive
    /// edge classification (C7), pan/zoom + click-collapse navigation (I3).
    dep_view: DepGraphView,
    last_reload: std::time::Instant,
    tab: Tab,
    /// 🎬 The declarative master-shell runtime state (fold / draggable-split ratios
    /// / active route) for [`super::shell_scene::nornir_shell_scene`]. The scene
    /// itself is rebuilt cheaply each frame (28-pane tree); `nav` is the live state
    /// `scene::render` reads and any interaction mutates. Observable in
    /// `state_json()["scene"]`.
    nav: facett_app::scene::NavState,
    /// The last `scene::render` observable facts (device class, per-pane rects,
    /// live split ratios, folded map, slot states) — folded into `state_json`.
    last_scene_render: serde_json::Value,
    started_at: std::time::Instant,
    nornir_tex: Option<TextureHandle>,
    timetravel: TimeTravelState,
    live: super::live::LiveRunState,
    /// 🚀 Release tab — the `release_events` op DAG (op-log + lit-up dep-graph).
    release: super::release_tab::ReleaseTabState,
    /// 💬 Chat tab (EPIC H / #45) — a local-model chat panel wired to the ACTIVE
    /// in-process Generator (candle GGUF from /mnt/work4t) + the MCP-to-nornir
    /// client config an agent uses.
    chat: super::chat_tab::ChatTabState,
    knowledge: super::knowledge::KnowledgeState,
    warehouse: super::warehouse_tab::WarehouseBrowser,
    /// 🏢 Warehouse Deck — the whole warehouse as a facett `WarehouseDeck` wall.
    warehouse_deck: super::warehouse_deck_tab::WarehouseDeckPane,
    callgraph: super::callgraph::CallGraphState,
    /// 🏛 Architecture tab — the EPIC ARCH wiring board (UI-component ↔ gRPC ↔
    /// core-fn ↔ warehouse-table), merged from each member's persisted board
    /// (local) or fetched over `Viz.Architecture` (remote).
    arch: super::arch_tab::ArchTabState,
    /// 🚇 Metro tab — hosts the facett `MetroView`, fed the
    /// `metro_feed::build_metro_map` producer over the arch tab's loaded facts (S5b).
    metro: super::metro_tab::MetroTabState,
    /// 🧬 Time-Helix tab — hosts the facett `HelixView`, fed a `Scene` built from
    /// the selected workspace's release timelines (falls back to the demo helix).
    helix: super::helix_tab::HelixTabState,
    /// 🩸 Bloodstream tab — the workspace as an organic circulatory system, painted
    /// natively over `bloodstream::Organism` with recursive external-dep drill-down.
    bloodstream: super::bloodstream_tab::BloodstreamView,
    funnel: super::funnel_tab::FunnelTabState,
    mcp: super::mcp_tab::McpTab,
    /// Server-backed clickable tabs (remote mode only).
    search: super::ops_tabs::SearchState,
    gates: super::ops_tabs::GatesState,
    /// 📡 Build-info LIVE-TAIL (slice-5 capstone): subscribes the §f3 build-info
    /// Flight stream per sub-job `step_id` (`build::flight_sink::subscribe`) and
    /// folds it into per-step status transitions + the log/metric tail. Rendered
    /// in the 📡 Live Run tab beside the release-run feed; folded into
    /// `state_json["build_info_live"]`.
    build_info_tail: super::build_info_live::BuildInfoTail,
    security: super::security_tab::SecurityTab,
    /// ⚒ Dwarves tab — the 🔨 build runner (repo `BuildSurface` → choosable rows →
    /// `nornir build --attest --seal`). Seeded with holger's build-space as demo
    /// content.
    dwarves: super::dwarves_tab::DwarvesTabState,
    /// 📦 Holger registry pane — the built-in (married) in-process holger dev
    /// pair (/cache + /sparring), read via holger's functional API. Only present
    /// when holger is actually embedded; the 📦 tab degrades to a note otherwise.
    #[cfg(feature = "embed-holger")]
    holger: super::holger_tab::HolgerTab,
    /// 🚦 Pre-flight capability checks, surfaced as a popup after the splash.
    preflight: super::preflight::Preflight,
    ws_panel: super::ops_tabs::WorkspacePanel,
    /// 🧬 The `nornir` root pane — server + workspace-lifecycle operations
    /// (add/kill/populate/refresh + server status). Server-backed.
    nornir_root: super::nornir_tab::NornirRootView,
    /// 🧬 The RELEASE DASHBOARD shown at the top of the nornir tab: a Release
    /// button + a gate-overlaid dependency graph (shared `graph_render`), so
    /// go/no-go release state is one glance.
    nornir_dashboard: super::nornir_dashboard::NornirDashboard,
    /// 📖 The Manual tab — nornir's own `docs/book-svg/` rendered via the
    /// facett-docview vector viewer (lazy-loaded on first show).
    manual: super::manual_tab::ManualTab,
    /// 🤖 The Claude tab — a facett-Elm conversation pane over the Anthropic
    /// Messages API, behind the `ClaudeTransport` seam (fake-driven in CI).
    claude: super::claude_tab::ClaudeTab,
    /// 🧠 The Models tab — browse/download LLM+embedding models as jobs behind
    /// the `ModelDownloader` seam (fake-driven in CI).
    models: super::models_tab::ModelsTab,
    /// Configured repo names (for the Gates/Bench repo pickers).
    repos: Vec<String>,
    /// Auto-reload the timeline (and refresh the warehouse table list) on a
    /// timer so a server-monitored workspace animates as it republishes.
    auto_refresh: bool,
    refresh_secs: u64,
    /// Human-readable connection status (set at build: "connected: N
    /// workspace(s)" / "CONNECT FAILED: …" / "local (no server)"). Surfaced in
    /// the UI header AND the external state dump so the test matrix sees it.
    connect_status: String,
    /// Throttle for the external state dump (`NORNIR_VIZ_STATE`).
    last_dump: std::time::Instant,
    /// Last (tab, workspace) we logged a click for — change detection.
    last_click: (Tab, String),
    /// TOTAL user-action debug log (ring buffer + stderr + file sink). Always-on;
    /// every tab switch / button / query / RPC pushes here. See `action_log.rs`.
    action_log: super::action_log::ActionLog,
    /// Whether the bottom action-trail overlay panel is shown (toggle in the bar).
    show_action_log: bool,
    /// Whether the ℹ About popup window is open (toggle in the top bar).
    show_about: bool,
    /// Cached `Health.Ping` server identity (version + repo count), fetched lazily
    /// the first time the About popup opens in remote mode. `None` until queried;
    /// `Some(Err)` if the ping failed.
    server_info: Option<Result<super::remote::ServerInfo, String>>,
    /// The **active facett palette** (C8) — the single app-wide theme every pane
    /// paints with. The top-bar picker mutates it; on change we re-apply egui
    /// `Visuals` and broadcast it to every tab (`broadcast_palette`) so the whole
    /// viz re-skins together. Emitted in `state_json["palette"]` (LAW #6) so a
    /// switch is observable headlessly.
    palette: super::facett_theme::Theme,
    /// Whether `palette`'s visuals have been pushed to the egui `Context` for the
    /// current value (so we only call `set_visuals` on change, not every frame).
    palette_applied: bool,
    /// **LAW4b** — `true` when a `ViewportCommand::Screenshot` has been fired but
    /// the corresponding `Event::Screenshot` has not yet been consumed.  Reflected
    /// in `state_json["screenshot"]["pending"]` so a robot-driver can poll for
    /// completion without a screen.
    screenshot_pending: bool,
    /// **LAW4b** — path written by the most recent screenshot (or `None` before
    /// the first one).  Reflected in `state_json["screenshot"]["last_path"]`.
    screenshot_last_path: Option<String>,
    /// **LAW4b** — `out_path` carried over from the pending screenshot command,
    /// consumed when `Event::Screenshot` arrives.
    screenshot_pending_path: Option<String>,
    /// "Ask at close" guard (VIZ-TEST-JOBS): a window-close while jobs are running
    /// is cancelled and this confirm modal opens instead. The running count comes
    /// from the 🧬 nornir tab's job list (last poll).
    close_confirm_open: bool,
    /// Set once the user confirms "close anyway" — lets the next close request
    /// through unguarded.
    allow_close: bool,
    /// 🎬 **Demo mode** (hands-free guided tour). `Some` when launched with
    /// `nornir viz --demo`: each frame the running viz emits the next due
    /// [`DemoStep`](super::demo::DemoStep) as a real `viz.click` on the control
    /// channel, so the tour ROBOT-DRIVES the same poll+apply path a human/MCP
    /// click takes — and the motion plays. `None` for a normal window.
    demo: Option<DemoRun>,
    /// 🎬 Stable saga session id for this app instance — stamped onto every
    /// `SagaEvent` the nav/click choke records (feature `saga`), so a whole
    /// window's journey groups into one replayable session. Unique per app build
    /// (pid + a process-global counter) so parallel in-process test apps don't
    /// collide.
    saga_session_id: String,
}

/// A process-global, monotonic per-app-instance saga session id.
fn next_saga_session_id() -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("viz-{}-{n}", std::process::id())
}

/// Live wrapper around a [`DemoDriver`](super::demo::DemoDriver): the script plus
/// the wall-clock start it measures `elapsed_ms` from. FC-7: the driver itself is
/// time-injected; only this struct touches a real clock, and a looped tour rebases
/// `started` so the schedule stays stable across passes.
struct DemoRun {
    driver: super::demo::DemoDriver,
    started: std::time::Instant,
}

impl UrdrThreadsApp {
    pub fn new(warehouse_root: PathBuf, workspace_name: String) -> Self {
        Self::with_repos(warehouse_root, workspace_name, PathBuf::new(), Vec::new())
    }

    /// Extended constructor: also seed the Knowledge tab with a workspace
    /// root (the parent containing repo dirs) and the configured repo names.
    pub fn with_repos(
        warehouse_root: PathBuf,
        workspace_name: String,
        workspace_root: PathBuf,
        repos: Vec<String>,
    ) -> Self {
        let log_dir = warehouse_root
            .parent()
            .map(|p| p.join("logs"))
            .unwrap_or_else(|| warehouse_root.join("logs"));
        let label = warehouse_root.display().to_string();
        // Local mode has a single workspace — no server set to auto-select from.
        Self::build(
            Source::Local(warehouse_root),
            label,
            workspace_name,
            None,
            workspace_root,
            repos,
            log_dir,
        )
    }

    /// Thin-client constructor: load the timeline from a running `nornir-server`
    /// over gRPC instead of opening a local warehouse. The live-run tab streams
    /// the active release run from the server over `Release.Progress` (no local
    /// log dir needed) — so a viz pointed at a remote server (e.g. over Tailscale)
    /// animates the run in real time, the same as a local one.
    pub fn with_remote(
        endpoint: String,
        token: String,
        workspace_name: String,
        workspace_root: PathBuf,
        repos: Vec<String>,
    ) -> Self {
        Self::with_remote_preferring(endpoint, token, workspace_name, None, workspace_root, repos)
    }

    /// As [`with_remote`](Self::with_remote), but with a `preferred_workspace`
    /// hint (derived from `--config`'s `workspace_<name>` path): when no explicit
    /// `--workspace`/`NORNIR_WORKSPACE` is given, the picker selects this
    /// workspace **if the server registers it**, instead of the alphabetically
    /// first one. Stops a config-scoped viz (e.g. `workspace_njord`) from
    /// silently opening an unrelated workspace (`holger`) just because it sorts
    /// first. Falls back to first when the hint is absent or unregistered.
    pub fn with_remote_preferring(
        endpoint: String,
        token: String,
        workspace_name: String,
        preferred_workspace: Option<String>,
        workspace_root: PathBuf,
        repos: Vec<String>,
    ) -> Self {
        let label = format!("server {endpoint}");
        Self::build(
            Source::Remote { endpoint, token },
            label,
            workspace_name,
            preferred_workspace,
            workspace_root,
            repos,
            PathBuf::new(),
        )
    }

    fn build(
        source: Source,
        source_label: String,
        mut workspace_name: String,
        preferred_workspace: Option<String>,
        workspace_root: PathBuf,
        repos: Vec<String>,
        log_dir: PathBuf,
    ) -> Self {
        // Picker contents come FIRST so the picker offers real workspaces:
        // remote mode lists the server's registered set. We do NOT auto-select
        // the first one — the server has no implicit default route, so the user
        // must pick a workspace from the top-bar (the empty-selection state shows
        // a "select a workspace" prompt). The ONLY startup auto-selection is an
        // explicit config-derived workspace (`--config workspace_njord` → `njord`)
        // that the server actually registers. An explicit --workspace name stays
        // selectable even if unregistered.
        let mut connect_status = String::from("local (no server)");
        let workspaces = match &source {
            Source::Remote { endpoint, token } => {
                let mut ws = match super::remote::list_workspaces(endpoint, token) {
                    Ok(ws) => {
                        eprintln!(
                            "nornir-viz: Workspaces.List → {} workspace(s): {ws:?}",
                            ws.len()
                        );
                        connect_status = format!("connected {endpoint}: {} workspace(s)", ws.len());
                        ws
                    }
                    Err(e) => {
                        eprintln!("nornir-viz: Workspaces.List FAILED (picker empty): {e:#}");
                        connect_status = format!("CONNECT FAILED {endpoint}: {e:#}");
                        Vec::new()
                    }
                };
                ws.sort();
                ws.dedup();
                if workspace_name.is_empty() {
                    // Auto-select ONLY a config-derived workspace (e.g.
                    // `workspace_njord` → `njord`) when the server actually
                    // registers it — that's an explicit `--config` selection, not
                    // a default route. With no such match `workspace_name` stays
                    // empty and the user picks from the top-bar (NO "first
                    // registered" fallback — the server has no default workspace).
                    let preferred = preferred_workspace
                        .as_deref()
                        .filter(|p| ws.iter().any(|w| w == p))
                        .map(str::to_string);
                    if let Some(p) = preferred {
                        eprintln!("nornir-viz: auto-selected workspace `{p}` (matched --config)");
                        workspace_name = p;
                    } else {
                        eprintln!(
                            "nornir-viz: no workspace selected — pick one from the top-bar \
                             (server has no default; {} registered)",
                            ws.len()
                        );
                    }
                } else if !ws.iter().any(|w| w == &workspace_name) {
                    ws.push(workspace_name.clone());
                }
                ws
            }
            Source::Local(_) => vec![workspace_name.clone()],
        };

        // Remote without a local config → get the repo list (for the repo-driven
        // tabs: Security / Knowledge / Gates / Bench) from the server's workspace
        // record, so the buttons populate without passing --config.
        let mut repos = repos;
        if repos.is_empty() {
            if let Source::Remote { endpoint, token } = &source {
                if let Ok(info) = super::remote::get_workspace(endpoint, token, &workspace_name) {
                    repos = info.members.into_iter().map(|(m, _)| m).collect();
                    eprintln!("nornir-viz: repos from server `{workspace_name}` → {repos:?}");
                }
            }
        }

        // No workspace selected (a fresh server with an EMPTY registry): skip the
        // build-time warehouse load — `Viz.Timeline` with an empty workspace returns
        // `NotFound: workspace `` is not served`, which would otherwise leak into
        // `timeline.error` and render red. The onboarding panel renders instead;
        // sub-views re-hydrate once a workspace is registered + selected. (Mirrors
        // the same guard in `reload()`.)
        let timeline = if workspace_name.is_empty() {
            Ok(super::model::Timeline::default())
        } else {
            source.load(&workspace_name)
        };
        // Durable source = the warehouse `release_events` table (LAW
        // persist-to-warehouse-stream-not-tmp): local mode hydrates the live pane
        // from the warehouse on load/reload and tails the workspace log dir as a
        // fast live accelerator; remote mode streams the run from the server over
        // Release.Progress (the server's warehouse is the durable source).
        let live = match &source {
            Source::Remote { endpoint, token } => super::live::LiveRunState::new_remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
            Source::Local(p) => super::live::LiveRunState::new(log_dir, p.clone()),
        };
        // The warehouse browser opens the warehouse directly when local, else
        // browses it over the server's Warehouse.Tables/Scan gRPC.
        let warehouse = match &source {
            Source::Local(p) => super::warehouse_tab::WarehouseBrowser::local(p.clone()),
            Source::Remote { endpoint, token } => super::warehouse_tab::WarehouseBrowser::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // 🏢 Warehouse Deck: local opens the warehouse for raw Arrow frames;
        // remote builds the SAME deck over the server's Warehouse.Tables/Scan
        // gRPC (stringified rows re-materialised as Utf8 batches), like the
        // 🗄 Warehouse browser — so the 3D deck works in thin mode too.
        let warehouse_deck = match &source {
            Source::Local(p) => super::warehouse_deck_tab::WarehouseDeckPane::local(p.clone()),
            Source::Remote { endpoint, token } => {
                super::warehouse_deck_tab::WarehouseDeckPane::remote(
                    endpoint.clone(),
                    token.clone(),
                    workspace_name.clone(),
                )
            }
        };
        let callgraph = match &source {
            Source::Local(p) => {
                // Local mode still has a NAMED workspace — thread it through so the
                // pane's LAW-2 empty-state / populate-job matching
                // (`repo_pane::classify_empty` → `populate_active_for`) is scoped to
                // THIS workspace instead of the empty-string catch-all, and
                // `state_json` reports the real workspace, exactly like the remote
                // arm. Without it a local callgraph reported `"workspace":""`.
                let mut cg = super::callgraph::CallGraphState::local(p.clone());
                cg.set_workspace(workspace_name.clone());
                cg
            }
            Source::Remote { endpoint, token } => super::callgraph::CallGraphState::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // 🏛 Architecture tab: local opens the warehouse + merges every member's
        // latest board; remote reads the server's merge over Viz.Architecture.
        let mut arch = match &source {
            Source::Local(p) => super::arch_tab::ArchTabState::local(p.clone()),
            Source::Remote { endpoint, token } => super::arch_tab::ArchTabState::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // Seed the configured members so the placeholder can name who still needs
        // `nornir arch generate` (parity with the Test matrix's repo seeding).
        arch.set_members(repos.clone());
        // N4 — seed the workspace GIT root so the source viewer can READ the real
        // on-disk clones (`repo_dir_resolved(source_root, <repo>)`) in BOTH modes:
        // fat mode's checkout root AND, via the arch tab's registry fallback, the
        // thin-mode server-clone dir `~/.nornir/workspaces/<ws>/git`.
        arch.set_source_root(workspace_root.clone());
        // Seed the click-to-code source: the local checkout root + the first
        // member, so a clicked 🏛 node resolves to `file:line` via a fresh
        // `symbol_facts` scan + the inline source preview. Local mode only —
        // remote has no checkout, so resolution returns nothing (the graph still
        // works; the inline preview is the part that needs the source).
        if let (Source::Local(_), Some(first)) = (&source, repos.first()) {
            let repo_root = crate::config::repo_dir_resolved(&workspace_root, first);
            arch.set_resolve_source(repo_root, first.clone());
        }
        let funnel = match &source {
            Source::Local(p) => super::funnel_tab::FunnelTabState::local(p.clone()),
            Source::Remote { endpoint, token } => super::funnel_tab::FunnelTabState::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        let mcp = match &source {
            Source::Local(p) => super::mcp_tab::McpTab::local(p.clone()),
            Source::Remote { endpoint, token } => super::mcp_tab::McpTab::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // 🚀 Release tab reads `release_events` from the local warehouse directly;
        // remote mode reads the same rows over Viz.ReleaseEvents.
        let mut release = match &source {
            Source::Local(p) => super::release_tab::ReleaseTabState::local(p.clone()),
            Source::Remote { endpoint, token } => super::release_tab::ReleaseTabState::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // Wire the Gate / Doctor section's inputs (local mode only — remote has no
        // checkout to scan). Repo checkouts are `workspace_root/<repo>`; the doctor
        // skips repos without a Cargo.toml. Default (empty) forbidden-version policy.
        if matches!(&source, Source::Local(_)) {
            release.set_gate_inputs(
                &workspace_root,
                &repos,
                crate::release::doctor::DepPolicy::default(),
            );
        }
        // 🗺 Knowledge map: local mode scans `workspace_root/<repo>` here; remote
        // mode has no checkout, so the server runs the scan over Viz.Knowledge.
        let knowledge = match &source {
            Source::Local(p) => {
                // the map is the workspace's code room (`<warehouse>/mimir_v2/`)
                super::knowledge::KnowledgeState::new(workspace_root.clone(), repos.clone())
                    .with_warehouse(p.clone(), workspace_name.clone())
            }
            Source::Remote { endpoint, token } => super::knowledge::KnowledgeState::new_remote(
                workspace_root.clone(),
                repos.clone(),
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // 📦 Holger registry pane: read the in-process dev pair under
        // `<warehouse>/holger-cache` (the `nornir cache up` data path) via
        // holger's functional API. Local mode points at that on-disk dir; remote
        // mode has no local holger to read, so it fetches the SERVER's registry
        // over `Viz.HolgerRegistry` (the server runs `read_registry` on its own
        // data dir) — so the thin viz lists the same crates-io / cache / sparring
        // rows the server serves instead of an empty stub. The grpc/http here
        // match the `cache up` defaults so the bring-up button binds the same
        // addresses.
        #[cfg(feature = "embed-holger")]
        let holger = match &source {
            Source::Local(p) => super::holger_tab::HolgerTab::disk(
                p.join("holger-cache"),
                "127.0.0.1:18443".to_string(),
                "127.0.0.1:18444".to_string(),
            ),
            // Remote/thin: there is no local holger-cache to read, so fetch the
            // server's registry over Viz.HolgerRegistry (the server runs
            // `read_registry` on its own data dir). This is the fix for the pane
            // showing "data dir: <injected>" + "no repositories configured" in
            // server mode — it now lists crates-io / cache / sparring.
            Source::Remote { endpoint, token } => super::holger_tab::HolgerTab::remote(
                endpoint.clone(),
                token.clone(),
                workspace_name.clone(),
            ),
        };
        // TOTAL action log (N5). The durable warehouse `viz_actions` sink is
        // attached in LOCAL mode (the viz owns the warehouse there); remote mode
        // keeps file/stderr only — the server is the durable side. The `/tmp`
        // file stays the fast live-channel regardless (write both, per the law).
        let mut action_log = super::action_log::ActionLog::new();
        // `NORNIR_VIZ_NO_DURABLE_ACTIONLOG` skips the durable warehouse sink so the
        // action-log drain thread does NOT hold the catalog's exclusive lock for
        // the app's lifetime. Useful when another in-process consumer (e.g. the
        // Funnel tab's `Store::open`, or the R6 robot test) needs to open the same
        // warehouse; the file/stderr action-log channels stay live regardless.
        let durable_actionlog = std::env::var_os("NORNIR_VIZ_NO_DURABLE_ACTIONLOG").is_none();
        if let Source::Local(p) = &source {
            if durable_actionlog {
                action_log.attach_warehouse(p.clone());
            }
        }
        action_log.set_context(&workspace_name, "Timeline");

        // Thin/remote client? (borrow before `source` is moved into the struct)
        let preflight_thin = matches!(&source, Source::Remote { .. });
        // 🧬 root pane — built local()/remote() per source, before `source` moves.
        let nornir_root = if preflight_thin {
            super::nornir_tab::NornirRootView::remote()
        } else {
            super::nornir_tab::NornirRootView::local()
        };
        // 🧬 RELEASE DASHBOARD — wire its gate inputs in local mode (the same
        // checkout root + repos the 🚀 Release pane's gate uses); remote has no
        // checkout to scan, so the dashboard's gate-graph is local-only.
        let mut nornir_dashboard = super::nornir_dashboard::NornirDashboard::default();
        if preflight_thin {
            nornir_dashboard.set_remote();
        } else {
            nornir_dashboard.set_gate_inputs(
                &workspace_root,
                &repos,
                crate::release::doctor::DepPolicy::default(),
            );
        }
        let ws_for_models = workspace_name.clone();
        let mut app = Self {
            source,
            source_label,
            workspace_name,
            workspace_root: workspace_root.clone(),
            workspaces,
            timeline,
            selected: None,
            dep_selected_repo: None,
            dep_view: DepGraphView::new(),
            last_reload: std::time::Instant::now(),
            tab: Tab::Timeline,
            nav: facett_app::scene::NavState::new(&super::shell_scene::nornir_shell_scene()),
            last_scene_render: serde_json::Value::Null,
            started_at: std::time::Instant::now(),
            nornir_tex: None,
            timetravel: TimeTravelState::default(),
            live,
            release,
            chat: super::chat_tab::ChatTabState::new(),
            knowledge,
            warehouse,
            warehouse_deck,
            callgraph,
            arch,
            metro: super::metro_tab::MetroTabState::local(),
            helix: super::helix_tab::HelixTabState::local(),
            bloodstream: super::bloodstream_tab::BloodstreamView::local(),
            funnel,
            mcp,
            search: super::ops_tabs::SearchState::default(),
            gates: super::ops_tabs::GatesState::default(),
            build_info_tail: super::build_info_live::BuildInfoTail::new(),
            dwarves: super::dwarves_tab::DwarvesTabState::new(workspace_root.clone()),
            security: super::security_tab::SecurityTab::new(workspace_root, repos.clone()),
            #[cfg(feature = "embed-holger")]
            holger,
            preflight: super::preflight::Preflight::run(preflight_thin),
            ws_panel: super::ops_tabs::WorkspacePanel::default(),
            nornir_root,
            nornir_dashboard,
            manual: super::manual_tab::ManualTab::new(),
            claude: super::claude_tab::ClaudeTab::new(),
            models: super::models_tab::ModelsTab::new(crate::jobs::JobSink::noop(), ws_for_models),
            repos,
            auto_refresh: true,
            refresh_secs: 5,
            connect_status,
            last_dump: std::time::Instant::now(),
            last_click: (Tab::Timeline, String::new()),
            action_log,
            show_action_log: false,
            show_about: false,
            server_info: None,
            palette: super::facett_theme::Theme::default(),
            palette_applied: false,
            screenshot_pending: false,
            screenshot_last_path: None,
            screenshot_pending_path: None,
            close_confirm_open: false,
            allow_close: false,
            demo: None,
            saga_session_id: next_saga_session_id(),
        };
        // Seed every pane with the default palette so they're palette-driven from
        // frame one (later switches go through `set_palette`).
        app.broadcast_palette();
        // 📡 Build-info auto-refresh: in remote mode, start the background poller so
        // the live-tail pane re-`pull`s each tracked sub-job ticket on a timer while
        // it is open (mirrors the other live panes' warehouse re-read). Gated
        // `build-info-flight` (the feature that links the Flight consumer seam); the
        // default viz build keeps the injectable `ingest` path and pays no Flight tax.
        #[cfg(feature = "build-info-flight")]
        {
            let remote = if let Source::Remote { endpoint, token } = &app.source {
                Some((endpoint.clone(), token.clone()))
            } else {
                None
            };
            if let Some((endpoint, token)) = remote {
                app.build_info_tail.start_poller(
                    endpoint,
                    Some(token),
                    super::build_info_live::POLL_EVERY,
                );
            }
        }
        // 💬 Emit the MCP-to-nornir client config to a discoverable file next to the
        // warehouse (`<root>/nornir-mcp.json`) so an agent operator can point their
        // MCP client at nornir without hunting. Best-effort — never fatal.
        if let Source::Local(root) = &app.source {
            match super::chat_tab::write_mcp_config_file(root) {
                Ok(p) => app.action_log.push(
                    super::action_log::Kind::Life,
                    format!("MCP-to-nornir config written → {}", p.display()),
                ),
                Err(e) => app.action_log.push(
                    super::action_log::Kind::Error,
                    format!("MCP config write failed: {e}"),
                ),
            }
        }
        app
    }

    /// Set the app-wide facett palette (C8) and broadcast it to every pane so the
    /// whole viz re-skins together. Marks the egui `Visuals` stale so the next
    /// `draw_ui` re-applies them. Idempotent — a no-op when the palette is
    /// unchanged. `pub` so the headless test matrix can switch the palette and
    /// then assert `state_json["palette"]` + each pane's `palette` changed.
    pub fn set_palette(&mut self, palette: super::facett_theme::Theme) {
        // The shared `facett_core::theme::Theme` carries no `PartialEq`; its
        // `name` is unique across `Theme::ALL`, so the name IS the identity.
        if super::facett_theme::same_palette(&palette, &self.palette) {
            return;
        }
        self.palette = palette;
        self.palette_applied = false;
        self.broadcast_palette();
        super::trace::emit_event(
            "ui.palette",
            &serde_json::json!({ "palette": palette.name }),
        );
    }

    /// Push the active palette into every pane's `theme` field, so each pane's
    /// custom painting (status fills, accents, zebra/hover/selection, gridlines)
    /// uses the same facett palette (C8). Called on construction + every switch.
    fn broadcast_palette(&mut self) {
        let p = self.palette;
        self.dwarves.set_palette(p);
        self.release.set_palette(p);
        self.chat.set_palette(p);
        self.live.set_palette(p);
        self.knowledge.set_palette(p);
        self.warehouse.set_palette(p);
        self.mcp.set_palette(p);
        self.claude.set_palette(p);
        self.models.set_palette(p);
        self.callgraph.set_palette(p);
        self.arch.set_palette(p);
        self.metro.set_palette(p);
        self.helix.set_palette(p);
        self.bloodstream.set_palette(p);
        self.funnel.set_palette(p);
        self.security.set_palette(p);
        #[cfg(feature = "embed-holger")]
        self.holger.set_palette(p);
        self.preflight.set_palette(p);
        self.search.set_palette(p);
        self.gates.set_palette(p);
        self.build_info_tail.set_palette(p);
        self.timetravel.set_palette(p);
        self.dep_view.set_palette(p);
        self.nornir_root.set_palette(p);
        self.nornir_dashboard.set_palette(p);
    }

    /// Build the external state snapshot — every picker's options + selection,
    /// the tab set, timeline/warehouse population, connection status, and any
    /// empty-state placeholders. Written to `NORNIR_VIZ_STATE` so the test
    /// matrix (and the operator) can see exactly what the UI is showing without
    /// a screen. See `dump_state`.
    ///
    /// `pub` so the headless test matrix in `tests/viz_matrix.rs` can call
    /// this without starting a GUI — construct the app with `with_repos` /
    /// `with_remote`, then call `state_json()` directly to get the full UI
    /// state as a JSON value (same JSON the operator sees via
    /// `NORNIR_VIZ_STATE`). No display, no `eframe::run` needed.
    /// The reusable [`TabBar`](super::facett_ui::TabBar) component's readable-data
    /// dump (D3b, LAW #6), describing the top-bar exactly as it is rendered: the
    /// tab strip + active id + row split, plus the pickers/toggles the bar hosts
    /// (workspace, live, about, action_log, palette) with their current values.
    /// Built from the SAME `TabBar` the top-bar paints, so the dump can't drift
    /// from the render. Folded into `state_json["top_bar"]`.
    pub fn top_bar_state(&self) -> serde_json::Value {
        let tab_items = Tab::tab_items();
        let active_id = format!("{:?}", self.tab);
        super::facett_ui::TabBar::new("top", &tab_items, &active_id)
            .split_at(TOP_BAR_SPLIT)
            .with_control(super::facett_ui::BarControl::picker(
                "workspace",
                &self.workspace_name,
            ))
            .with_control(super::facett_ui::BarControl::toggle(
                "live",
                self.auto_refresh,
            ))
            .with_control(super::facett_ui::BarControl::toggle(
                "about",
                self.show_about,
            ))
            .with_control(super::facett_ui::BarControl::toggle(
                "action_log",
                self.show_action_log,
            ))
            // No `palette` control: the 🎨 picker moved off the root top bar into the
            // 🎛 Settings tab. The dump mirrors what the bar RENDERS (that is the whole
            // point of `top_bar_state`), so it must not advertise a control that is no
            // longer there. The active palette is still reported in `state_json`.
            .state_json()
    }

    pub fn state_json(&self) -> serde_json::Value {
        let (releases, tl_err) = match &self.timeline {
            Ok(tl) => (tl.lanes.iter().map(|l| l.nodes.len()).sum::<usize>(), None),
            Err(e) => (0, Some(format!("{e:#}"))),
        };
        let mut placeholders = Vec::new();
        if releases == 0 {
            placeholders.push("no releases recorded yet".to_string());
        }
        // Pre-compute the larger pane blocks so the top-level `json!` macro stays
        // under its recursion limit (it expands every key inline).
        let nornir_root_json = self.nornir_root.state_json();
        let nornir_dashboard_json = self.nornir_dashboard.state_json();
        // ONBOARDING slice (pre-computed to keep the top `json!` under its recursion
        // limit). `active` ⇒ a fresh server with an empty registry / no workspace
        // selected; the data tabs then show the add-a-workspace panel and DON'T load
        // the warehouse, so `timeline_error` is null — proving the
        // `workspace `` is not served` NotFound never reached the user.
        // `unpopulated` ⇒ a NAMED workspace is selected + registered, but its
        // warehouse isn't served yet (`Viz.Timeline` → `is not served`). That's the
        // onboarding "click ⬇ Populate" state, NOT an error — so we mark the
        // onboarding slice active AND suppress `timeline_error` (it would otherwise
        // surface as a red "Failed to load warehouse"). `active` is true for either
        // onboarding shape: empty registry (no selection) OR registered-unpopulated.
        let unpopulated = self.timeline_is_unpopulated_onboarding();
        let onboarding_active = self.workspace_name.is_empty() || unpopulated;
        let onboarding_json = serde_json::json!({
            "active": onboarding_active,
            "unpopulated": unpopulated,
            "workspace": self.workspace_name,
            "no_workspaces": self.workspaces.is_empty(),
            // The not-served NotFound is the *cause* of an onboarding state, not a
            // user-facing error — so it rides in the onboarding slice, never in
            // `timeline.error` (asserted by the matrix: onboarding, not red).
            "timeline_error": if unpopulated { None } else { tl_err.clone() },
        });
        // Don't leak the not-served NotFound into the top-level `timeline.error`
        // when it's really an onboarding state (it must not render red).
        let tl_err = if unpopulated { None } else { tl_err };
        let mut top = serde_json::json!({
            "source": self.source_label,
            "connect_status": self.connect_status,
            "tab": format!("{:?}", self.tab),
            "all_tabs": Tab::ALL.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
            // The reusable facett TabBar component's own readable-data dump (D3b,
            // LAW #6): the tab strip (id/label/active), the row split, and the
            // pickers/toggles it hosts (workspace, live, about, action_log,
            // palette) with their current values — "see what the user sees".
            "top_bar": self.top_bar_state(),
            // 🎨 The app-wide facett palette (C8) — the single active theme every
            // pane paints with. Switching it re-skins the whole viz; the change is
            // observable headlessly here + in each pane's own `palette` key (LAW #6).
            "palette": self.palette.name,
            "palettes": super::facett_theme::Theme::names(),
            "workspace_picker": {
                "options": self.workspaces,
                "selected": self.workspace_name,
                "count": self.workspaces.len(),
            },
            // 🌿 Working-tree freshness (AUT6): per-member dirty + content digest
            // of the server's clone, plus `any_dirty`. A commit SHA can't see
            // uncommitted edits; this is the staleness signal the ℹ info panel
            // renders (⚠ uncommitted) and the headless matrix asserts. LAW #6.
            "workspace_freshness": self.ws_panel.state_json(),
            // 🧬 The `nornir` root pane (this feature): the server + workspace
            // lifecycle operation buttons it hosts (add/kill/populate/refresh +
            // status), the Add-workspace form values, the kill-confirm state, and
            // the last result of each op — so the headless matrix mechanically
            // discovers every root operation and reads back what it rendered.
            "nornir": nornir_root_json,
            // "Ask at close" guard (VIZ-TEST-JOBS): whether the confirm modal is
            // open + the running-job count that armed it, so the headless matrix
            // can assert the guard fires while jobs run.
            "close_guard": {
                "confirm_open": self.close_confirm_open,
                "running": self.nornir_root.running_jobs(),
            },
            // 📖 Manual (facett-docview): resolved dir + load state + the viewer's
            // own state_json (page count, current page, zoom) when loaded.
            "manual": self.manual.state_json(),
            "repos": self.repos,
            "knowledge": self.knowledge.state_json(),
            // 🗂 Funnel tab (LAW 6): selected plan + per-plan node/edge/ready
            // counts + the E1 demo block, so the headless matrix sees the
            // funnel DAG's data (it contributed nothing before E1).
            "funnel": self.funnel.state_json(),
            // 🏛 Architecture (LAW 6): the EPIC ARCH wiring board the tab paints —
            // node/edge counts + per-kind counts (component/grpc/corefn/table), the
            // node + edge lists with kinds, which members contributed a board, the
            // selected node + its downstream-traced labels, and the active palette.
            "architecture": self.arch.state_json(),
            // 🕸 Call Graph (RESTORED as the Architecture `CallGraph` lens): the
            // REAL function/symbol call graph (`callgraph.rs`) as readable data —
            // selected repo/crate, laid-out node/edge counts, top fns by degree,
            // the 3D layout + truncation flag, and `built` (a graph was actually
            // built). This lens had become unreachable when the top-level button
            // was dropped; it is now a distinct Arch sub-view again. The 🚇 Metro
            // call-PATH map (button→gRPC) is a SEPARATE lens, observable under
            // `architecture.metro` (LAW #6).
            "callgraph": self.callgraph.state_json(),
            // 📡 Live Run (C3): the live release-run pane — the durable warehouse
            // `release_events` baseline it hydrated from (survives a reboot, visible
            // to other clients), plus the run header + rendered event lines, so the
            // headless matrix proves the pane reads from the warehouse, not `/tmp`.
            "live": self.live.state_json(),
            // 📡 Build-info live-tail (slice-5 capstone): the §f3 build-info feed
            // folded per sub-job `step_id` — each step's status transitions + its
            // log/metric tail, subscribed via `build::flight_sink::subscribe`. So
            // the headless matrix proves the pane tails the feed, not pixels.
            "build_info_live": self.build_info_tail.state_json(),
            // 🚀 Release op DAG (B3/B4): the release-op rows + per-component status
            // the tab renders, so the headless matrix sees the lit-up graph's data.
            "release": self.release.state_json(),
            // 🔨 Build (Tier-2 #5/#26): the build-thing surface reflected into
            // choosable rows (binary × features × profile × target) + each card's
            // status + any completed build's provenance/warehouse row, so the
            // headless matrix sees the tab's data without a draw.
            "build": self.dwarves.build.state_json(),
            // ⚒ Dwarves: the build-runner tab's own emitter block.
            "dwarves": self.dwarves.state_json(),
            // 💬 Chat (EPIC H / #45): the active local model + the transcript +
            // `last_response` (what the robot chat test asserts) + the discoverable
            // MCP-to-nornir client config, so the whole local-model surface reads
            // back as DATA (LAW #6).
            "chat": self.chat.state_json(),
            // ℹ About popup (C1): client version + endpoint + workspace + warehouse
            // + (remote) server version — the real identity fields the popup shows.
            "about": self.about_info(),
            "timeline": { "releases": releases, "error": tl_err },
            // 🔗 Dep Graph (C5/C7/I3): the laid-out graph the tab paints — deep
            // toggle, node positions, edges classified direct vs transitive, and
            // the collapse set (LAW #6). Computed fresh from the current timeline
            // + view settings so the headless matrix sees it even without a draw.
            "dep_graph": self.dep_graph_state(),
            // ⏳ Time Travel (C4): the cursor (release idx + pinned release), the
            // text⇄graph view mode, and — in graph mode — the dependency graph as
            // it was at that release (reusing depgraph_layout's nodes/positions +
            // direct/transitive edge classification). So the headless matrix sees
            // the time-pinned, coloured graph without a draw.
            "timetravel": self.timetravel_state(),
            // 🔍 Search (LAW 6): the BM25 query + corpus + symbol/vector/call-graph
            // form fields AND the rendered result counts/rows, so an agent reads
            // back exactly what's typed and what's shown.
            "search": self.search.state_json(),
            // 🚦 Gates (LAW 6): the selected/typed repo + the rendered gate report
            // (passed/failed gate names) + whether a regression trace is loaded.
            "gates": self.gates.state_json(),
            // 🗄 Warehouse (LAW 6): the table list + selected table + chart toggle
            // and chosen columns + the loaded preview shape (columns + row count).
            "warehouse": self.warehouse.state_json(),
            // 🏢 Warehouse Deck (LAW 6): the composed facett WarehouseDeck state —
            // pane_count + per-kind counts (graph3d/grid/chart) + each pane's
            // nodes/edges/rows + the per-pane widget state + the trace ledger.
            "warehouse_deck": self.warehouse_deck.state_json(),
            // 📞 MCP (LAW 6): the per-tool usage rows (calls/errors/avg-ms) + total.
            "mcp": self.mcp.state_json(),
            // 🤖 Claude (LAW 6): the conversation turns + last-assistant text + the
            // `sending` flag + selected model + whether ANTHROPIC_API_KEY is present.
            "claude": self.claude.state_json(),
            // 🧠 Models (LAW 6): the catalog + selected device + which backends/GPU
            // libs are present + every download job with its live progress/status.
            "models": self.models.state_json(),
            // 🛡 Security (LAW 6): the scan-button repos + which is scanning + the
            // rendered SBOM/vuln/license result.
            "security": self.security.state_json(),
            // 🎛 Settings (LAW 6): the preferences pane's own emitter slice — the
            // ACTIVE palette and the roster it offers. This is the pane that owns
            // the viz's look & feel now that the picker has moved off the root top
            // bar, so it reports its surface as data like every other pane.
            "settings": {
                "palette": self.palette.name,
                "palettes": super::facett_theme::Theme::names(),
            },
            "preflight": self.preflight.state_json(),
            // **LAW4b** — live-app self-screenshot state: whether a screenshot was
            // fired but not yet delivered, and the last file path written. Robot
            // drivers poll `screenshot.pending == false` to know the PNG is ready.
            "screenshot": {
                "pending": self.screenshot_pending,
                "last_path": self.screenshot_last_path,
            },
            // Kept for back-compat with existing consumers; the full table list +
            // selection now also lives under `warehouse`.
            "warehouse_tables": self.warehouse.table_names(),
            "placeholders": placeholders,
            // The action-log trail: total count + the last few entries, so the
            // headless test matrix + the operator see "what was clicked" in the
            // same dump as the rest of the UI state.
            "action_log": {
                "file": self.action_log.file_path(),
                "count": self.action_log.count(),
                "recent": self.action_log.recent(12).iter()
                    .map(|e| format!("{} [{}] {}", e.stamp, e.kind.tag(), e.detail))
                    .collect::<Vec<_>>(),
                // N5 — the durable side: this session's id + its recent actions
                // read BACK from the warehouse `viz_actions` table (proves the
                // trail is durable + queryable, not just a /tmp file). Empty
                // array in remote mode (no local warehouse attached).
                "session_id": self.action_log.session_id(),
                "warehouse_recent": serde_json::from_str::<serde_json::Value>(
                    &self.action_log.warehouse_recent_json(12),
                ).unwrap_or(serde_json::Value::Array(Vec::new())),
            },
        });
        // 🧬 RELEASE DASHBOARD (this feature, LAW #6): the gate-overlaid graph the
        // nornir tab paints — present flag, the Release button's enabled state (the
        // gate verdict), the scoped repo + lit blast radius, the SHARED gate block,
        // the per-repo node badges, and each cycle's suggested cut edge. Inserted
        // here (not inline in the `json!` above) to keep the macro under its
        // recursion limit. So the robot test asserts go/no-go WITHOUT pixels.
        if let serde_json::Value::Object(map) = &mut top {
            map.insert("nornir_dashboard".into(), nornir_dashboard_json);
            // 📦 Holger (LAW 6): the built-in (married) holger dev pair, read via
            // holger's IN-PROCESS functional API — whether it's embedded + the
            // data dir, the repositories (/cache + /sparring) with per-repo
            // crate/version counts + recent ids, and the bring-up state. Inserted
            // here (not inline in the `json!` above) to keep the macro under its
            // recursion limit. Degrades to `embedded: false` when holger is off.
            map.insert("holger".into(), self.holger_state());
        }
        // ONBOARDING slice — inserted here (not inline in the `json!` above) to keep
        // the macro under its recursion limit. See `onboarding_json` for the meaning.
        top["onboarding"] = onboarding_json;
        // 🚇 METRO slice (S5b) — likewise inserted post-macro (the `json!` above is
        // already at its recursion ceiling). The facett MetroView's drawn map as
        // DATA: lines, per-station {label, kind, lit}, per-line + overall green.
        top["metro"] = self.metro.state_json();
        // 🧬 HELIX slice — the facett HelixView's drawn scene as DATA (nodes/coils/
        // links + the traverse/wind state), plus `from_data` (real vs demo scene).
        top["helix"] = self.helix.state_json();
        // 🩸 BLOODSTREAM slice — the drawn circulatory system as DATA: organs (crate
        // + anatomical slot + in-degree + expand state), vessels, the blood flow
        // phase, and the recursive external closure loaded under each drilled organ
        // (name/version/depth) — so a robot drive asserts the deep drill-down.
        top["bloodstream"] = facett_jobview::Facet::state_json(&self.bloodstream);
        // 🎬 SCENE slice — the DECLARATIVE master shell as observable DATA: the pane
        // tree + which panes are foldable/drawers/draggable-splits, the host-filled
        // tab slots, the live folded + split-ratio maps, menu/route counts, and the
        // last render facts. Inserted post-macro (the `json!` above is at its
        // recursion ceiling). Proves the shell layout is data a robot can assert.
        top["scene"] = self.scene_state();
        top
    }

    /// The About-popup identity block (C1): always carries SOMETHING real — the
    /// **client version** (`CARGO_PKG_VERSION`), the **endpoint**, the **selected
    /// workspace**, and the **warehouse path / mode**. In remote mode it also
    /// carries the **server version** from a cached `Health.Ping` (queried the
    /// first time the popup opens); until queried, `server_version` is null and a
    /// note flags it as a pending RPC.
    pub fn about_info(&self) -> serde_json::Value {
        let (endpoint, warehouse, mode) = match &self.source {
            Source::Local(p) => (String::new(), p.display().to_string(), "local"),
            Source::Remote { endpoint, .. } => (endpoint.clone(), String::new(), "remote"),
        };
        let (server_version, server_repo_count, server_note) = match &self.server_info {
            Some(Ok(info)) => (Some(info.version.clone()), Some(info.repo_count), None),
            Some(Err(e)) => (None, None, Some(format!("Health.Ping failed: {e}"))),
            None if mode == "remote" => (
                None,
                None,
                Some("server version: open About to query Health.Ping".to_string()),
            ),
            None => (None, None, None),
        };
        serde_json::json!({
            "client_version": env!("CARGO_PKG_VERSION"),
            "client_build": client_build(),
            "build_sha": option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown"),
            "mode": mode,
            "endpoint": endpoint,
            "workspace": self.workspace_name,
            "warehouse": warehouse,
            "source_label": self.source_label,
            "connect_status": self.connect_status,
            "server_version": server_version,
            "server_repo_count": server_repo_count,
            "server_note": server_note,
        })
    }

    /// The 📦 Holger pane's slice of `state_json` (LAW #6). When holger is
    /// embedded (`embed-holger`), this is the live pane's own dump (repos +
    /// counts + bring-up). When it isn't, the pane degrades to a note so the tab
    /// is still observable headlessly: `embedded: false`.
    #[cfg(feature = "embed-holger")]
    fn holger_state(&self) -> serde_json::Value {
        self.holger.state_json()
    }

    #[cfg(not(feature = "embed-holger"))]
    fn holger_state(&self) -> serde_json::Value {
        serde_json::json!({
            "embedded": false,
            "note": "holger is not embedded in this build (embed-holger off) — \
                     the registry pane is unavailable.",
        })
    }

    /// ONBOARDING vs ERROR (bug: registered-but-unpopulated named workspace).
    ///
    /// A workspace can be **registered** (it shows in the picker, `Workspaces.List`
    /// returns it) but **not yet served** — no warehouse built (a fresh `nornir
    /// workspace add` before the first populate, a `lazy` register, or a monitored
    /// workspace whose eager build hasn't finished). `Viz.Timeline` then answers
    /// `NotFound: workspace `<name>` is not served`. That is NOT a failure — it is
    /// the **onboarding / not-populated** resting state: the user just needs to
    /// click ⬇ Populate. Returns `true` for exactly that case (a `NotFound` carrying
    /// the not-served / no-workspace marker), so the data tabs render a graceful
    /// "not populated — click Populate" panel instead of a red "Failed to load
    /// warehouse" error. The EMPTY-REGISTRY (no workspace selected) onboarding is a
    /// separate, earlier guard (`workspace_name.is_empty()`); this one fires when a
    /// NAMED workspace is selected but its warehouse isn't served yet.
    fn timeline_is_unpopulated_onboarding(&self) -> bool {
        // Only a *named* (selected) workspace can be registered-but-unpopulated;
        // the empty-registry / no-selection case is handled by its own guard.
        if self.workspace_name.is_empty() {
            return false;
        }
        match &self.timeline {
            // The server serves every registered workspace (8fc1d7b), so a
            // registered one nobody populated answers an EMPTY timeline — no
            // lanes, no snapshot — rather than the NotFound below. Same state.
            Ok(tl) => tl.lanes.is_empty() && tl.latest_snapshot.is_none(),
            Err(e) => {
                let msg = format!("{e:#}");
                // The server's not-served / no-workspace NotFound markers. Match the
                // text (the error arrives as an anyhow-wrapped `tonic::Status`, so
                // the code isn't structurally typed through the viz client).
                msg.contains("is not served")
                    || msg.contains("not served")
                    || msg.contains("no workspace selected")
                    // fat mode: the workspace's warehouse was never built
                    || msg.contains("no `catalog.redb` at")
            }
        }
    }

    /// The 🔗 Dep Graph tab's laid-out structure for `state_json` (LAW #6):
    /// deep toggle, classified edges, node positions, collapse set. Computed
    /// from the current timeline + view settings; `Null` when no timeline /
    /// snapshot is loaded.
    fn dep_graph_state(&self) -> serde_json::Value {
        match &self.timeline {
            Ok(tl) => self.dep_view.state_json_for(tl, self.selected),
            // No timeline (empty/locked warehouse) — still EMIT a readable slice
            // (LAW #6 / coverage-guard): a structured "empty, here's why" block, not
            // a bare null the headless matrix can't distinguish from "no emitter".
            Err(e) => serde_json::json!({
                "empty": true,
                "node_count": 0,
                "edge_count": 0,
                "error": format!("{e:#}"),
            }),
        }
    }

    /// The ⏳ Time Travel tab's cursor + (graph-mode) time-pinned dependency
    /// graph for `state_json` (LAW #6). `Null` when no timeline is loaded.
    fn timetravel_state(&self) -> serde_json::Value {
        match &self.timeline {
            Ok(tl) => self.timetravel.state_json(tl),
            // No timeline — emit a readable "empty" slice (LAW #6 / coverage-guard)
            // rather than a bare null, so the headless matrix sees the surface.
            Err(e) => serde_json::json!({
                "empty": true,
                "releases": 0,
                "error": format!("{e:#}"),
            }),
        }
    }

    /// Lazily fetch the server's `Health.Ping` identity (remote mode only),
    /// caching the result so the About popup doesn't ping every frame.
    fn ensure_server_info(&mut self) {
        if self.server_info.is_some() {
            return;
        }
        if let Source::Remote { endpoint, token } = &self.source {
            self.server_info =
                Some(super::remote::ping(endpoint, token).map_err(|e| format!("{e:#}")));
        }
    }

    /// Headless test driver — render **every tab** once through a bare egui
    /// context, exercising each tab's data-load + draw so a per-tab panic
    /// (index-out-of-bounds, bad data) is caught by a test instead of crashing
    /// the live GUI. No window/GPU needed (egui produces shapes, not pixels).
    ///
    /// The auto-refresh wall-clock timer is suspended for the duration of the
    /// sweep: a single headless pass is meant to leave EVERY tab loaded for the
    /// caller's `state_json()` inspection, but the live timer's `reload()` clears
    /// a pane's cached data *without* reloading it (the reload happens on that
    /// pane's next `draw`). If the timer fired mid-sweep on a tab drawn AFTER a
    /// data tab (e.g. while we're on Mcp, past Release), it would wipe the
    /// Release pane we already loaded → the caller sees an empty surface even
    /// though the warehouse read succeeded. Suspending it keeps the sweep a
    /// deterministic single-pass; the original setting is restored afterwards.
    pub fn render_all_tabs_headless(&mut self) {
        let prev_auto_refresh = self.auto_refresh;
        self.auto_refresh = false;
        for t in Tab::ALL {
            self.tab = t;
            let ctx = egui::Context::default();
            // egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
            // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
            ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
        }
        self.auto_refresh = prev_auto_refresh;
    }

    /// Write [`state_json`](Self::state_json) to `$NORNIR_VIZ_STATE` (default
    /// `/tmp/nornir_viz_state.json`). Throttled; called every frame.
    fn dump_state(&mut self) {
        if self.last_dump.elapsed().as_millis() < 300 {
            return;
        }
        self.last_dump = std::time::Instant::now();
        let path = std::env::var("NORNIR_VIZ_STATE")
            .unwrap_or_else(|_| "/tmp/nornir_viz_state.json".to_string());
        if let Ok(s) = serde_json::to_string_pretty(&self.state_json()) {
            let _ = std::fs::write(&path, s);
        }
    }

    /// 🎬 Arm **demo mode**: install the hands-free guided-tour driver. Called
    /// from the `nornir-ui --demo` launch path (and the headless test). `beat_ms`
    /// is the pause between beats; the live update loop then emits one due beat per
    /// frame on the real clock. A demo window keeps repainting (the tour requests
    /// it) so the animations between clicks play.
    pub fn enable_demo(&mut self, beat_ms: u64) {
        // Keep the frame loop alive so spins/scroll-animations advance between
        // beats (a demo window is never "idle").
        self.auto_refresh = true;
        self.action_log.push(
            super::action_log::Kind::Life,
            format!("🎬 demo mode armed — guided tour, {beat_ms}ms/beat"),
        );
        self.demo = Some(DemoRun {
            driver: super::demo::DemoDriver::tour(beat_ms),
            started: std::time::Instant::now(),
        });
    }

    /// 🎬 Drive-step demo mode: emit the next due [`DemoStep`](super::demo::DemoStep)
    /// onto the control channel (`$NORNIR_VIZ_CMD`) — a REAL `viz.click` the very
    /// next [`poll_control_channel`](Self::poll_control_channel) consumes + applies,
    /// so the tour goes through the exact same path a human/MCP click takes (motion
    /// included). At most one beat per frame (the channel is consume-once). Loops
    /// forever, rebasing the clock each pass, so a left-running window stays alive.
    /// Returns the emitted step's label, if any (for the headless test).
    fn demo_emit(&mut self, ctx: &egui::Context) -> Option<String> {
        let Some(run) = self.demo.as_mut() else {
            return None;
        };
        // Loop forever: once the pass is done, rebase the clock + rewind so the
        // next pass keeps the same beat timing (a left-running window stays alive).
        if run.driver.is_done() {
            run.driver.rewind();
            run.started = std::time::Instant::now();
        }
        let elapsed = run.started.elapsed().as_millis() as u64;
        let step = run.driver.due(elapsed)?;
        let _ = super::control::write_command(&step.cmd);
        self.action_log.push(
            super::action_log::Kind::Life,
            format!("🎬 demo → {}", step.label),
        );
        // Keep painting so the next beat lands and the motion in between renders.
        ctx.request_repaint_after(std::time::Duration::from_millis(120));
        Some(step.label)
    }

    /// Robot-UI-tester drive-half: poll the control channel (`$NORNIR_VIZ_CMD`)
    /// once per frame and apply any pending command — switch the active tab
    /// (by `state_json()["tab"]` debug name) and/or the workspace. The command
    /// file is consumed (removed) by [`control::take_pending`], so each command
    /// fires exactly once; the result shows up in the next `$NORNIR_VIZ_STATE`
    /// dump that `viz.state` reads back. See `control.rs`.
    fn poll_control_channel(&mut self) {
        let Some(pending) = super::control::take_pending() else {
            return;
        };
        match pending {
            Ok(cmd) => self.apply_command(&cmd),
            Err(e) => {
                self.action_log
                    .push(super::action_log::Kind::Error, format!("viz.click: {e}"));
            }
        }
    }

    /// Apply one robot-drive [`VizCommand`](super::control::VizCommand): set the
    /// tab (validated by name) and/or switch the workspace, logging each as a
    /// CLICK in the action trail so the drive is auditable in the same log as a
    /// human click. Exposed `pub` so the inject-assert test can drive the exact
    /// same code path the live control channel runs without touching files.
    pub fn apply_command(&mut self, cmd: &super::control::VizCommand) {
        if let Some(name) = &cmd.tab {
            if let Some(t) = Tab::from_name(name) {
                if self.tab != t {
                    self.action_log.push(
                        super::action_log::Kind::Click,
                        format!("viz.click tab → {name}"),
                    );
                }
                self.tab = t;
            } else {
                self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("viz.click: unknown tab {name:?}"),
                );
            }
        }
        if let Some(ws) = &cmd.workspace {
            if *ws != self.workspace_name {
                self.action_log.push(
                    super::action_log::Kind::Click,
                    format!("viz.click workspace → {ws}"),
                );
                self.switch_workspace(ws.clone());
            }
        }
        if let Some(pal) = &cmd.palette {
            match super::facett_theme::Theme::by_name(pal) {
                Some(t) if !super::facett_theme::same_palette(&t, &self.palette) => {
                    self.action_log.push(
                        super::action_log::Kind::Click,
                        format!("viz.click palette → {}", t.name),
                    );
                    self.set_palette(t);
                }
                Some(_) => {} // already active — no-op
                None => self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("viz.click: unknown palette {pal:?}"),
                ),
            }
        }
        // R6 — set a named form field on the live viz (CLI/robot parity with
        // RobotSession::set_field). Routed to the owning tab by key prefix.
        if let Some(field) = &cmd.set_field {
            match self.apply_set_field(&field.name, &field.value) {
                Ok(()) => self.action_log.push(
                    super::action_log::Kind::Click,
                    format!("viz.set_field {} = {:?}", field.name, field.value),
                ),
                Err(e) => self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("viz.set_field {}: {e}", field.name),
                ),
            }
        }
        // R6 — click a named button/action on the live viz (parity with
        // RobotSession::click_by_id; a named key is the durable address here).
        if let Some(key) = &cmd.click_id {
            match self.apply_click_id(key) {
                Ok(()) => self.action_log.push(
                    super::action_log::Kind::Click,
                    format!("viz.click_id {key}"),
                ),
                Err(e) => self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("viz.click_id {key}: {e}"),
                ),
            }
        }
        // LAW4b — screenshot command: arm the screenshot state machine so the
        // next draw_ui frame fires ViewportCommand::Screenshot.
        if let Some(req) = &cmd.screenshot {
            self.request_screenshot(req.out_path.clone());
        }
    }

    /// The stable saga session id for this app instance (for the e2e proof test
    /// + the Test tab's ⏺ Record/⏹ Stop status line).
    pub fn saga_session_id(&self) -> &str {
        &self.saga_session_id
    }

    // ── 🎬 Saga capture adapter (feature `saga`) ─────────────────────────────
    /// The session context stamped onto every recorded `SagaEvent`: this app
    /// instance's stable session id + app identity/version/commit.
    #[cfg(feature = "saga")]
    fn saga_ctx(&self) -> nornir_saga::event::SessionCtx {
        nornir_saga::event::SessionCtx::new(
            &self.saga_session_id,
            "local",
            "nornir-viz",
            env!("CARGO_PKG_VERSION"),
            option_env!("NORNIR_BUILD_SHA").unwrap_or("unknown"),
        )
    }

    /// Record the current tab as a replayable nav event. `component` is the tab
    /// header's AccessKit label ([`Tab::label`]) so a replay's `ClickLabel`
    /// re-drives the exact same header widget.
    #[cfg(feature = "saga")]
    fn saga_record_nav(&self) {
        let route = format!("/tab/{:?}", self.tab);
        let state = serde_json::json!({ "tab": format!("{:?}", self.tab) }).to_string();
        nornir_saga::record(
            nornir_saga::event::SagaEvent::nav(&route, self.tab.label(), "open", &state)
                .in_session(&self.saga_ctx()),
        );
    }

    /// Record a button/control activation as a replayable click event.
    /// `component` MUST be the widget's AccessKit label so a replay's
    /// `ClickLabel(component)` re-drives it. Public so any pane's on-click can
    /// stamp a saga click through the same session ctx.
    #[cfg(feature = "saga")]
    pub fn saga_record_click(&self, component: &str, action: &str) {
        let route = format!("/tab/{:?}", self.tab);
        let state = serde_json::json!({ "tab": format!("{:?}", self.tab) }).to_string();
        nornir_saga::record(
            nornir_saga::event::SagaEvent::click(&route, component, action, &state)
                .in_session(&self.saga_ctx()),
        );
    }

    /// R6 — apply a named `set_field` command to the owning tab. The key is
    /// `"<tab>.<field>"`; today the Funnel tab's intake/planner/demo fields are
    /// wired (the live drive-half of the same verbs the in-process
    /// `RobotSession::set_field` exposes). Returns `Err` for an unknown key.
    fn apply_set_field(&mut self, name: &str, value: &str) -> Result<(), String> {
        if let Some(field) = name.strip_prefix("funnel.") {
            return self.funnel.set_field_for_drive(field, value);
        }
        // 💬 Chat live-drive: chat.input / chat.model / chat.system / chat.max_tokens.
        if let Some(field) = name.strip_prefix("chat.") {
            return self.chat.set_field_for_drive(field, value);
        }
        Err(format!("unknown field key {name:?}"))
    }

    /// R6 — apply a named `click_id` command (a button/action) to the owning tab.
    fn apply_click_id(&mut self, key: &str) -> Result<(), String> {
        if let Some(button) = key.strip_prefix("funnel.") {
            return self.funnel.click_for_drive(button, &self.action_log);
        }
        // 🩸 Bloodstream drill-down: `bloodstream.expand:<crate>` drills into (or
        // collapses) an organ's recursive external closure; `collapse_all` clears.
        if let Some(action) = key.strip_prefix("bloodstream.") {
            return self.bloodstream.click_for_drive(action);
        }
        // 💬 Chat live-drive: chat.send / chat.clear (runs the ACTIVE local model).
        if let Some(button) = key.strip_prefix("chat.") {
            return self.chat.click_for_drive(button, &self.action_log);
        }
        Err(format!("unknown button key {key:?}"))
    }

    /// **LAW4b** — arm a pending screenshot: record the desired `out_path` and
    /// set `screenshot_pending = true`.  The actual `ViewportCommand::Screenshot`
    /// is fired in `draw_ui` each frame while `screenshot_pending` is `true`, so
    /// the command reaches the egui backend during the normal paint loop.
    /// Called from `apply_command` (control-channel drive) and directly from the
    /// Ctrl+Shift+S keyboard handler.
    pub fn request_screenshot(&mut self, out_path: Option<String>) {
        self.screenshot_pending_path = out_path;
        self.screenshot_pending = true;
        self.action_log.push(
            super::action_log::Kind::Click,
            format!(
                "LAW4b screenshot requested → {}",
                self.screenshot_pending_path
                    .as_deref()
                    .unwrap_or("<warehouse only>")
            ),
        );
    }

    /// **LAW4b** — encode a `ColorImage` to PNG bytes and persist:
    /// 1. Write to `out_path` on disk (when `Some`).
    /// 2. Append to the warehouse `robot_test_results` blob store so the
    ///    screenshot is durable and queryable (LAW 5 persist-to-warehouse).
    /// Updates `screenshot_last_path` for the `state_json` dump.
    fn handle_screenshot_image(&mut self, img: &egui::ColorImage, out_path: Option<String>) {
        // Encode ColorImage (RGBA u8) → PNG bytes via the `image` crate.
        let [w, h] = [img.width() as u32, img.height() as u32];
        let rgba: Vec<u8> = img
            .pixels
            .iter()
            .flat_map(|px| [px.r(), px.g(), px.b(), px.a()])
            .collect();
        let png_bytes = encode_rgba_png(w, h, &rgba);
        match png_bytes {
            Err(e) => {
                self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("LAW4b screenshot PNG encode failed: {e}"),
                );
                return;
            }
            Ok(bytes) => {
                // 1. Write to disk when a path was requested.
                let actual_path = if let Some(ref p) = out_path {
                    match std::fs::write(p, &bytes) {
                        Ok(()) => {
                            self.action_log.push(
                                super::action_log::Kind::Click,
                                format!("LAW4b screenshot written → {p} ({} bytes)", bytes.len()),
                            );
                            Some(p.clone())
                        }
                        Err(e) => {
                            self.action_log.push(
                                super::action_log::Kind::Error,
                                format!("LAW4b screenshot write failed {p}: {e}"),
                            );
                            None
                        }
                    }
                } else {
                    None
                };
                self.screenshot_last_path = actual_path.or(out_path);

                // ── emit-fallback (testmatrix only): the PNG write is a GPU-frame
                // side effect a headless test can't observe via state_json until
                // the frame fires. Record that a real PNG (non-empty, written to
                // the requested path) landed. ok ⇔ the file exists on disk with
                // the encoded bytes. Stripped from release.
                #[cfg(feature = "testmatrix")]
                {
                    let written_ok = self
                        .screenshot_last_path
                        .as_deref()
                        .map(|p| std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false))
                        .unwrap_or(false);
                    crate::selftest::emit(
                        "viz/app self-screenshot (LAW4b egui capture)",
                        "screenshot_command_pending_then_png_written",
                        written_ok,
                        &format!(
                            "{}x{}px → {} bytes PNG written to {:?}",
                            w,
                            h,
                            bytes.len(),
                            self.screenshot_last_path,
                        ),
                    );
                }

                // 2. Append to the warehouse `robot_test_results` blob store
                //    (LAW 5: persist to warehouse, not just /tmp).
                // This runs best-effort in local mode (where we have a warehouse
                // path); in remote/empty mode we log a note but don't fail.
                let snapshot_id = chrono::Utc::now()
                    .format("screenshot-%Y%m%d-%H%M%S")
                    .to_string();
                let filename = self
                    .screenshot_last_path
                    .as_deref()
                    .and_then(|p| std::path::Path::new(p).file_name())
                    .and_then(|n| n.to_str())
                    .unwrap_or("screenshot.png")
                    .to_string();
                let blob = crate::warehouse::blob_store::BlobRow::new(filename, bytes);
                if let Source::Local(wh_path) = &self.source {
                    let wh_path = wh_path.clone();
                    match crate::warehouse::iceberg::IcebergWarehouse::open(&wh_path) {
                        Ok(wh) => {
                            match crate::warehouse::blob_store::put_blobs_dedup(
                                &wh,
                                "robot_test_results",
                                &snapshot_id,
                                vec![blob],
                            ) {
                                Ok(n) => self.action_log.push(
                                    super::action_log::Kind::Rpc,
                                    format!("LAW4b screenshot → warehouse robot_test_results ({n} blobs)"),
                                ),
                                Err(e) => self.action_log.push(
                                    super::action_log::Kind::Error,
                                    format!("LAW4b screenshot warehouse append: {e:#}"),
                                ),
                            }
                        }
                        Err(e) => self.action_log.push(
                            super::action_log::Kind::Error,
                            format!("LAW4b screenshot warehouse open: {e:#}"),
                        ),
                    }
                } else {
                    self.action_log.push(
                        super::action_log::Kind::Click,
                        "LAW4b screenshot captured (remote mode: warehouse persist skipped)",
                    );
                }
            }
        }
    }

    /// **testmatrix-only test hook** — feed a synthetic captured frame straight
    /// into [`Self::handle_screenshot_image`] (the GPU-frame path), without a
    /// real GPU/display. Lets the self-test exercise the PNG-write side effect
    /// and read back the emit-fallback row. Compiled out of release.
    #[cfg(feature = "testmatrix")]
    pub fn test_feed_screenshot_frame(&mut self, w: usize, h: usize, out_path: Option<String>) {
        // A solid non-blank RGBA frame so the encoded PNG is genuinely non-empty.
        let rgba: Vec<u8> = std::iter::repeat([40u8, 120, 200, 255])
            .take(w * h)
            .flatten()
            .collect();
        let img = ColorImage::from_rgba_unmultiplied([w, h], &rgba);
        self.screenshot_pending = false;
        self.handle_screenshot_image(&img, out_path);
    }

    /// **testmatrix-only test hook** — inject a synthetic call graph into the
    /// CallGraph pane and a synthetic funnel plan into the Funnel-3D pane, then
    /// headlessly DRAW both tabs so their `#[cfg(feature = "testmatrix")]` egui
    /// emit-fallback rows (`callgraph_rendered_non_blank`,
    /// `funnel3d_rendered_non_blank`) fire as a NON-blank PASS — exercising the
    /// render side-effect without a warehouse/GPU. Compiled out of release.
    #[cfg(feature = "testmatrix")]
    pub fn test_render_callgraph_and_funnel3d_nonblank(&mut self) {
        // 1. CallGraph: inject a small ring graph + draw the CallGraph tab.
        self.callgraph
            .test_inject_graph(6, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)]);
        self.tab = Tab::CallGraph;
        let ctx = egui::Context::default();
        // egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
        // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
        ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();

        // 2. Funnel-3D: load the deterministic demo DAG, flip the tab to 3D
        // (which builds the Funnel3D cloud from the plan), then draw the tab.
        let _ = self.funnel.run_demo_for_test(8);
        let _ = self.funnel.set_3d_for_test(true);
        self.tab = Tab::Funnel;
        for _ in 0..2 {
            let ctx = egui::Context::default();
            // egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
            // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
            ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
        }
    }

    /// **testmatrix-only test hook** — select the DepGraph tab and headlessly
    /// DRAW it so `draw_dep_graph`'s `#[cfg(feature = "testmatrix")]` emit-fallback
    /// row (`dep_graph_rendered_non_blank`) fires. The layout is built from the
    /// loaded timeline, so the caller must open the app over a warehouse seeded
    /// with a dep_graph snapshot + release lineage (else the layout is empty and
    /// the emit — correctly — does not fire). Compiled out of release.
    #[cfg(feature = "testmatrix")]
    pub fn test_render_dep_graph_nonblank(&mut self) {
        self.selected = None;
        self.tab = Tab::DepGraph;
        for _ in 0..2 {
            let ctx = egui::Context::default();
            // egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
            // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
            ctx.run_ui(egui::RawInput::default(), |ui| self.draw_ui(ui)).textures_delta.clear();
        }
    }

    /// Log a click trail to stderr whenever the tab or workspace changes, so the
    /// operator watching the server log sees "what I clicked".
    fn log_click(&mut self) {
        let now = (self.tab, self.workspace_name.clone());
        if now != self.last_click {
            eprintln!(
                "nornir-viz CLICK: tab={:?} workspace={} | {}",
                self.tab, self.workspace_name, self.connect_status
            );
            self.last_click = now;
        }
    }

    fn reload(&mut self) {
        // The INPUT of a reload: which source + workspace we're asking for.
        super::trace::emit_in(
            "server.reload",
            &serde_json::json!({ "source": self.source_label, "workspace": self.workspace_name }),
        );
        // No workspace selected (e.g. a fresh server with an EMPTY registry): skip
        // the warehouse load — `Viz.Timeline` with an empty workspace returns
        // NotFound (`workspace `` is not served`). The onboarding panel renders
        // instead; sub-views re-hydrate once a workspace is registered + selected.
        if self.workspace_name.is_empty() {
            self.timeline = Ok(super::model::Timeline::default());
            return;
        }
        self.timeline = self.source.load(&self.workspace_name);
        // Re-read the release-op DAG on the next Release-tab draw too, so a fresh
        // `nornir release run` shows without restarting the viz.
        self.release.reload();
        // 📡 Live Run re-hydrates from the durable warehouse `release_events`
        // table (LAW persist-to-warehouse-stream-not-tmp): on reload/reboot the run
        // is re-read from the warehouse, not just whatever `/tmp` log survived.
        self.live.reload();
        self.last_reload = std::time::Instant::now();
        match &self.timeline {
            Ok(tl) => {
                let releases: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
                self.action_log.push(
                    super::action_log::Kind::Rpc,
                    format!(
                        "reload timeline workspace={} → {releases} release(s)",
                        self.workspace_name,
                    ),
                );
                // The OUTPUT data: the per-lane (per-repo) release counts the
                // timeline actually carries — what the user sees painted.
                super::trace::emit_out(
                    "server.reload",
                    &serde_json::json!({
                        "workspace": self.workspace_name,
                        "releases": releases,
                        "lanes": tl.lanes.iter()
                            .map(|l| serde_json::json!({ "repo": l.repo, "releases": l.nodes.len() }))
                            .collect::<Vec<_>>(),
                    }),
                );
            }
            Err(e) => {
                self.action_log.push(
                    super::action_log::Kind::Error,
                    format!("reload failed: {e:#}"),
                );
                super::trace::emit(
                    "server.reload",
                    super::trace::Phase::Out,
                    &serde_json::json!({ "workspace": self.workspace_name, "error": format!("{e:#}") }),
                );
            }
        }
    }

    /// The server bundle for the ops tabs (None in local-warehouse mode).
    fn server(&self) -> Option<super::ops_tabs::Server> {
        match &self.source {
            Source::Remote { endpoint, token } => Some(super::ops_tabs::Server {
                endpoint: endpoint.clone(),
                token: token.clone(),
            }),
            Source::Local(_) => None,
        }
    }

    /// Draw the 🏛 Architecture CONTAINER: the arch tab paints the sub-view
    /// selector strip + the lenses it owns (Live / Wiring / Hållpunkter / Metro),
    /// then leaves the central area free for the two lenses whose renderers live on
    /// the APP — `Deps` (needs the timeline) and `CallGraph` (the warehouse-backed
    /// `CallGraphState`). This keeps ONE renderer per surface and makes the restored
    /// function call graph reachable as a distinct Architecture lens.
    fn draw_architecture(&mut self, ui: &mut egui::Ui) {
        self.arch.draw(ui);
        match self.arch.sub_view() {
            // 🔗 Deps (CONSOLIDATED under Arch): the dep graph painted here, the
            // same `draw_dep_graph` the old top-level Dep Graph tab used.
            super::arch_tab::ArchSubView::Deps => {
                egui::CentralPanel::default().show_inside(ui, |ui| match &self.timeline {
                    Ok(tl) if !tl.is_empty() => {
                        draw_dep_graph(
                            ui,
                            tl,
                            self.selected,
                            &mut self.dep_selected_repo,
                            &mut self.dep_view,
                        );
                    }
                    Ok(_) => {
                        ui.weak("no releases recorded yet — the dependency graph is built from the warehouse timeline.");
                    }
                    Err(e) => {
                        ui.colored_label(super::facett_theme::RED, format!("dep graph load failed:\n{e:#}"));
                    }
                });
            }
            // 🕸 Call Graph (RESTORED): the real function/symbol call graph from
            // `callgraph.rs`, drilled repo → crate → fn calls over `call_edges`.
            // Drawn here because its renderer owns the warehouse-backed
            // `CallGraphState`; the arch tab left the central area free for it.
            super::arch_tab::ArchSubView::CallGraph => {
                egui::CentralPanel::default().show_inside(ui, |ui| {
                    self.callgraph.draw(ui);
                });
            }
            _ => {}
        }
    }

    /// Re-list the server's workspaces into the picker (after a root-pane Add /
    /// Kill mutated the set). No-op in local mode. If the *currently selected*
    /// workspace was killed, drop the selection back to the empty "pick one"
    /// state — we never auto-jump to another workspace (the server has no default
    /// route, and the user picks from the top-bar). If nothing was selected yet,
    /// stay unselected even as workspaces appear.
    fn refresh_workspaces(&mut self) {
        if let Source::Remote { endpoint, token } = &self.source {
            if let Ok(ws) = super::remote::list_workspaces(endpoint, token) {
                self.workspaces = ws;
                // Only react to the active selection vanishing — never auto-select
                // when there was no selection to begin with.
                if !self.workspace_name.is_empty()
                    && !self.workspaces.contains(&self.workspace_name)
                {
                    self.switch_workspace(String::new());
                }
            }
        }
    }

    /// React to a 🧬 root-pane op that mutated the server's workspace set (Add /
    /// Kill / Populate / Refresh). Always re-lists the picker so it reflects the
    /// change. If the op was an **Add** (a workspace was just registered), AUTO-
    /// SELECT that workspace — drive the same picker switch a top-bar click does —
    /// so the user immediately watches `W` populate live instead of having to pick
    /// it manually. `switch_workspace` already reloads + re-scopes every tab, so we
    /// skip the standalone `reload()` in that case (no double-fetch). Otherwise
    /// (Kill / Populate / Refresh, no new workspace) we just reload the active view.
    fn on_root_mutation(&mut self) {
        self.refresh_workspaces();
        if let Some(new_ws) = self.nornir_root.take_just_registered() {
            // A delete + re-add of the SAME name must still re-scope and reload:
            // switch_workspace() no-ops when the name is unchanged, and the kill
            // path never clears `workspace_name`, so a re-add of the just-killed
            // workspace would otherwise leave every pane pointed at stale data.
            // Clear first to force the full re-scope + reload.
            if new_ws == self.workspace_name {
                self.workspace_name.clear();
            }
            self.switch_workspace(new_ws);
        } else {
            self.reload();
        }
    }

    /// Switch the active workspace (the picker): re-scope every server-backed tab
    /// (timeline, warehouse, call graph) and reload immediately.
    fn switch_workspace(&mut self, ws: String) {
        if ws == self.workspace_name {
            return;
        }
        self.action_log.push(
            super::action_log::Kind::Life,
            format!("switch workspace {} → {}", self.workspace_name, ws),
        );
        self.workspace_name = ws.clone();
        self.selected = None;
        self.warehouse.set_workspace(ws.clone());
        self.funnel.set_workspace(ws.clone());
        self.mcp.set_workspace(ws.clone());
        self.callgraph.set_workspace(ws.clone());
        // 🏛 Architecture: re-point the thin RPC at the new workspace name now;
        // its members are re-seeded below once the new repo set is known.
        self.arch.set_workspace_name(ws.clone());
        // Re-point the thin-mode RPC-backed tabs at the new workspace so their
        // Viz.ReleaseEvents / Viz.BakeoffResults / Viz.ReleaseEvents-hydrate
        // refetch for the right server workspace (no-ops in local mode).
        self.release.set_workspace(ws.clone());
        self.live.set_workspace(ws.clone());
        // Re-scope the Knowledge tab: the workspace's repo set changes per
        // workspace, so fetch the new members (server mode) and hand the tab the
        // fresh root + repos. It drops the stale scans and arms a background
        // rescan, so the map re-scopes for the new workspace without blocking.
        if let Source::Remote { endpoint, token } = &self.source {
            match super::remote::get_workspace(endpoint, token, &ws) {
                Ok(info) => {
                    self.repos = info.members.into_iter().map(|(m, _)| m).collect();
                    eprintln!("nornir-viz: repos from server `{ws}` → {:?}", self.repos);
                }
                Err(e) => eprintln!("nornir-viz: Workspaces.Get(`{ws}`) failed on switch: {e:#}"),
            }
        }
        // 🏛 Architecture: re-scope to the new workspace's members (drops the
        // cached board so the next draw re-merges/re-fetches for the new set).
        self.arch.set_workspace(self.repos.clone());
        // N4 — re-seed the workspace git root so the source viewer keeps reading the
        // real clones after a workspace switch (both modes).
        self.arch.set_source_root(self.workspace_root.clone());
        // Re-seed the click-to-code source for the new workspace's first member
        // (local mode — remote has no checkout).
        if let (Source::Local(_), Some(first)) = (&self.source, self.repos.first()) {
            let repo_root = crate::config::repo_dir_resolved(&self.workspace_root, first);
            self.arch.set_resolve_source(repo_root, first.clone());
        }
        // 🧬 RELEASE DASHBOARD: re-scope its gate inputs to the new workspace's
        // repos (local mode), so the gate-overlaid graph re-scans the right set.
        if matches!(&self.source, Source::Local(_)) {
            self.nornir_dashboard.set_gate_inputs(
                &self.workspace_root,
                &self.repos,
                crate::release::doctor::DepPolicy::default(),
            );
        } else {
            self.nornir_dashboard.set_remote();
        }
        self.knowledge
            .set_workspace(self.workspace_root.clone(), self.repos.clone());
        self.knowledge.set_workspace_name(ws.clone());
        // 🛡 Security scans the workspace's configured repos too — re-scope it
        // with the same fresh root + members, else its repo buttons keep
        // listing the previously selected workspace.
        self.security
            .set_workspace(self.workspace_root.clone(), self.repos.clone());
        self.reload();
        self.warehouse.refresh_tables();
    }

    /// **Age `started_at` past the intro splash** — the ONE writer of that
    /// wind-back.
    ///
    /// The opaque intro overlay swallows hit-tests for its first
    /// `SPLASH_DURATION_SECS`, so every headless harness has to get past it before
    /// a driver can click anything. Fourteen `inject_*_for_test` helpers each
    /// carried a byte-identical copy of the two-line wind-back; they all call this
    /// now (LAW 5 — one writer, not fourteen agreeing copies).
    ///
    /// It is also the whole of what [`crate::viz::robot_remote`] needs. That module
    /// used to call `inject_test_results_for_test(Vec::new())` purely for this side
    /// effect, and when `ce553b9` cut the 🧪 Test tab and its injector the call was
    /// left behind — a hard compile error that nothing caught, because the
    /// `robot-remote` feature is built by no default and no CI command, so it had
    /// not been compiled since. Seeding NO data is the honest thing here: the
    /// robot-drive mount is meant to serve the app as it really starts.
    #[doc(hidden)]
    pub fn age_past_splash_for_test(&mut self) {
        self.started_at = std::time::Instant::now()
            - std::time::Duration::from_secs_f32(SPLASH_DURATION_SECS + 1.0);
    }

    /// Test-only data injector for the headless snapshot harness
    /// (`tests/viz_snapshots.rs`).
    ///
    /// Local `workspace_*` warehouses are empty in this checkout (the live
    /// release / dep-graph / bench data lives on the `nornir-server` and is
    /// reached over gRPC via [`with_remote`](Self::with_remote)). To render a
    /// viz tab **with real, non-zero data** as a committed PNG proof — without
    /// standing up a server — the snapshot test builds a concrete [`Timeline`]
    /// from the public [`crate::viz::model`] types and injects it here.
    ///
    /// Besides swapping in the timeline + repo list, this:
    ///   * selects `tab` by its `state_json()["tab"]` debug name (so the test
    ///     drives a specific view without depending on the private `Tab` enum),
    ///   * ages `started_at` past the splash so the opaque intro overlay does
    ///     not cover the captured frame, and
    ///   * disables `auto_refresh` so the next `draw_ui` frame does not reload
    ///     the (empty) source warehouse over the injected timeline.
    ///
    /// Returns `true` if `tab` matched a known tab name.
    #[doc(hidden)]
    pub fn inject_timeline_for_test(
        &mut self,
        tab: &str,
        timeline: Timeline,
        repos: Vec<String>,
    ) -> bool {
        let matched = match tab {
            "Nornir" => {
                self.tab = Tab::Nornir;
                true
            }
            "Timeline" => {
                self.tab = Tab::Timeline;
                true
            }
            "DepGraph" => {
                self.tab = Tab::DepGraph;
                true
            }
            "CallGraph" => {
                self.tab = Tab::CallGraph;
                true
            }
            "Architecture" => {
                self.tab = Tab::Architecture;
                true
            }
            "Bloodstream" => {
                self.tab = Tab::Bloodstream;
                true
            }
            "Funnel" => {
                self.tab = Tab::Funnel;
                true
            }
            "TimeTravel" => {
                self.tab = Tab::TimeTravel;
                true
            }
            "LiveRun" => {
                self.tab = Tab::LiveRun;
                true
            }
            "Release" => {
                self.tab = Tab::Release;
                true
            }
            "Knowledge" => {
                self.tab = Tab::Knowledge;
                true
            }
            "Warehouse" => {
                self.tab = Tab::Warehouse;
                true
            }
            "Mcp" => {
                self.tab = Tab::Mcp;
                true
            }
            "Search" => {
                self.tab = Tab::Search;
                true
            }
            "Gates" => {
                self.tab = Tab::Gates;
                true
            }
            "Chat" => {
                self.tab = Tab::Chat;
                true
            }
            "Security" => {
                self.tab = Tab::Security;
                true
            }
            // ⚒ Dwarves (N5) — the merged forge tab. "Build"/"Brokkr" are kept as
            // back-compat aliases (old control-channel links / robot keys) that
            // route to the same tab.
            "Dwarves" | "Build" | "Brokkr" => {
                self.tab = Tab::Dwarves;
                true
            }
            "Holger" => {
                self.tab = Tab::Holger;
                true
            }
            "Manual" => {
                self.tab = Tab::Manual;
                true
            }
            "Claude" => {
                self.tab = Tab::Claude;
                true
            }
            "Models" => {
                self.tab = Tab::Models;
                true
            }
            // These four were MISSING and the map had drifted behind `Tab::ALL`
            // exactly the way the MCP tab list did: 🚇 Metro, 🧬 Time Helix, 🏢
            // Warehouse Deck and 🎛 Settings all exist as panes and as `VIZ_TABS`
            // entries, but this control channel answered `false` for them — so a
            // headless driver that (correctly) avoids synthetic pointer buttons
            // had NO way to reach them and silently drove the previous tab
            // instead. `tab_control_channel_accepts_every_tab_id` in
            // `tests/robot_settings_tab.rs` is the gate that keeps this in step.
            "Metro" => {
                self.tab = Tab::Metro;
                true
            }
            "Helix" => {
                self.tab = Tab::Helix;
                true
            }
            "WarehouseDeck" => {
                self.tab = Tab::WarehouseDeck;
                true
            }
            "Settings" => {
                self.tab = Tab::Settings;
                true
            }
            _ => false,
        };
        if !repos.is_empty() {
            self.repos = repos;
        }
        self.timeline = Ok(timeline);
        self.auto_refresh = false;
        // Push `started_at` back so the splash overlay has fully faded out.
        self.age_past_splash_for_test();
        matched
    }

    /// Test-only inject seam for the 📦 Holger pane: replace the live pane with
    /// one seeded from a fixed set of repo summaries, so the headless matrix can
    /// assert the pane lists the repos / reports counts WITHOUT a live holger
    /// server or an on-disk znippy archive. Mirrors the `read_registry`
    /// functional-API output (one [`RepoSummary`] per repo). No-op when holger
    /// isn't embedded.
    #[doc(hidden)]
    #[cfg(feature = "embed-holger")]
    pub fn inject_holger_for_test(&mut self, repos: Vec<crate::holger_embed::RepoSummary>) {
        self.holger = super::holger_tab::HolgerTab::inject(repos);
        self.holger.set_palette(self.palette);
    }

    /// Test-only: inject the 🔍 Search tab's `Index.Stats` result (no live server)
    /// and select the tab — so a headless drive can read the LAW-2 `index_health`
    /// classification back out of `state_json()["search"]` (an EMPTY code-index —
    /// the FAT-workspace symlink-walker symptom — must read `"empty"`, never a
    /// silent green). Mirrors `knowledge_scan_blocking_for_test`.
    #[doc(hidden)]
    pub fn inject_search_index_stats_for_test(
        &mut self,
        total: u64,
        by_corpus: Vec<(String, String)>,
    ) {
        self.tab = Tab::Search;
        self.search.inject_index_stats_for_test(total, by_corpus);
    }

    /// Test-only: inject release-op rows into the 🚀 Release tab directly (no
    /// warehouse on disk), then select the tab — so the inject-and-assert harness
    /// can read the op-log rows + per-component status back out of
    /// `state_json()["release"]`. Mirrors `inject_timeline_for_test` for the
    /// release-events surface.
    #[doc(hidden)]
    pub fn inject_release_events_for_test(
        &mut self,
        rows: Vec<crate::warehouse::release_events::ReleaseEventRow>,
    ) {
        self.tab = Tab::Release;
        self.release.inject_for_test(rows);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: inject a Gate / Doctor model into the 🚀 Release tab (no checkout
    /// to scan), then select the tab — so the robot/inject-assert harness can read
    /// the `state_json()["release"]["gate"]` block back. Mirrors
    /// `inject_release_events_for_test` for the gate surface.
    #[doc(hidden)]
    pub fn inject_release_gate_for_test(
        &mut self,
        doctor: crate::release::doctor::DoctorReport,
        edition: nornir_release_core::edition::EditionReport,
    ) {
        self.tab = Tab::Release;
        self.release.inject_gate_for_test(doctor, edition);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: inject a Gate / Doctor model into the 🧬 nornir RELEASE
    /// DASHBOARD (no checkout to scan) + select the Nornir tab, so the
    /// robot/inject-assert harness reads `state_json()["nornir_dashboard"]` back.
    /// Mirrors `inject_release_gate_for_test` for the dashboard surface.
    #[doc(hidden)]
    pub fn inject_dashboard_gate_for_test(
        &mut self,
        doctor: crate::release::doctor::DoctorReport,
        edition: nornir_release_core::edition::EditionReport,
    ) {
        self.tab = Tab::Nornir;
        self.nornir_dashboard.inject_gate_for_test(doctor, edition);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: drive a node click on the 🧬 dashboard graph (parity with a
    /// pointer click), scoping the release to `repo` + lighting its blast radius.
    /// Returns whether the repo is a node in the current graph.
    #[doc(hidden)]
    pub fn dashboard_select_for_test(&mut self, repo: &str) -> bool {
        self.nornir_dashboard.select_repo_for_test(repo)
    }

    /// Test-only: open the 🧬 dashboard's 🧙 RELEASE WIZARD (what the Release button
    /// does), so the robot/inject-assert harness reads
    /// `state_json()["nornir_dashboard"]["release_wizard"]` back.
    #[doc(hidden)]
    pub fn dashboard_open_wizard_for_test(&mut self) {
        self.nornir_dashboard.open_wizard_for_test();
    }

    /// Test-only: arm/disarm a wizard step's override (advance-anyway past a red gate).
    #[doc(hidden)]
    pub fn dashboard_wizard_override_for_test(&mut self, step: &str, armed: bool) -> bool {
        self.nornir_dashboard
            .wizard_set_override_for_test(step, armed)
    }

    /// Test-only: advance the wizard one step if its gate allows; returns the new step.
    #[doc(hidden)]
    pub fn dashboard_wizard_advance_for_test(&mut self) -> String {
        self.nornir_dashboard.wizard_advance_for_test()
    }

    /// Test-only: seed the Rehearse tier as passed/failed (no real stage run).
    #[doc(hidden)]
    pub fn dashboard_wizard_inject_rehearse_for_test(&mut self, ok: bool, lines: Vec<String>) {
        self.nornir_dashboard
            .wizard_inject_rehearse_for_test(ok, lines);
    }

    /// Test-only: type the promote confirm phrase into the wizard.
    #[doc(hidden)]
    pub fn dashboard_wizard_promote_confirm_for_test(&mut self, input: &str) {
        self.nornir_dashboard
            .wizard_set_promote_confirm_for_test(input);
    }

    /// Test-only: inject a merged [`ArchGraph`](crate::arch::ArchGraph) into the
    /// 🏛 Architecture tab directly (no warehouse on disk / no server), exactly as
    /// `merged_arch_from_warehouse` / `Viz.Architecture` would deliver, then select
    /// the tab — so the inject-and-assert harness can read the board's node/edge/
    /// per-kind counts back out of `state_json()["architecture"]` and snapshot it.
    #[doc(hidden)]
    pub fn inject_arch_for_test(
        &mut self,
        graph: crate::arch::ArchGraph,
        members_with_data: Vec<String>,
    ) {
        self.tab = Tab::Architecture;
        self.arch.inject_for_test(graph, members_with_data);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: inject a pre-built [`WarehouseDeck`](facett_warehousedeck::WarehouseDeck)
    /// into the 🏢 Warehouse Deck pane (no warehouse on disk / no server), exactly
    /// as `WarehouseDeckPane::build` would assemble from the warehouse, then select
    /// the tab — so the inject-and-assert harness reads the pane's pane-count /
    /// per-kind counts back out of `state_json()["warehouse_deck"]`.
    #[doc(hidden)]
    pub fn inject_warehouse_deck_for_test(&mut self, deck: facett_warehousedeck::WarehouseDeck) {
        self.tab = Tab::WarehouseDeck;
        self.warehouse_deck.inject_for_test(deck);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: swap the 🔨 Build tab's build runner (inject a mock) so a headless
    /// robot can drive the BUILD button WITHOUT spawning a real `nornir build`
    /// subprocess, then dismiss the splash + stop auto-refresh. The Build tab renders
    /// without a workspace/warehouse (its demo build-space, holger, is embedded), so
    /// no seeding is needed. Drives `tests/viz_build_robot.rs`.
    #[doc(hidden)]
    pub fn set_build_runner_for_test(
        &mut self,
        runner: std::sync::Arc<dyn super::build_tab::BuildRunner>,
    ) {
        self.dwarves.build.set_runner_for_test(runner);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// 🤖 Inject a [`ClaudeTransport`](super::claude::ClaudeTransport) into the
    /// Claude tab — the test seam that drives a real send through a
    /// [`FakeTransport`](super::claude::FakeTransport) (no network) and dismisses
    /// the splash. Drives the Claude robot test in `tests/robot_viz.rs`.
    #[doc(hidden)]
    pub fn set_claude_transport_for_test(
        &mut self,
        transport: std::sync::Arc<dyn super::claude::ClaudeTransport>,
    ) {
        self.claude.set_transport_for_test(transport);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// 🤖 Drive the Claude tab's send directly (set the prompt, then Send) —
    /// used by the robot test after injecting a fake transport.
    #[doc(hidden)]
    pub fn claude_send_for_test(&mut self, prompt: impl Into<String>) {
        self.claude.set_prompt_for_test(prompt);
        self.claude.send_for_test();
    }

    /// 🤖 Poll the Claude tab's in-flight send (test seam — the robot pumps this
    /// between frames until the assistant turn lands).
    #[doc(hidden)]
    pub fn claude_poll_for_test(&mut self) {
        self.claude.poll();
    }

    /// 🧠 Inject a [`ModelDownloader`](super::models_tab::ModelDownloader) into the
    /// Models tab — the test seam that drives a real download through a
    /// [`FakeDownloader`](super::models_tab::FakeDownloader) (no network/disk) and
    /// dismisses the splash. Drives the Models robot test in `tests/robot_viz.rs`.
    #[doc(hidden)]
    pub fn set_model_downloader_for_test(
        &mut self,
        downloader: std::sync::Arc<dyn super::models_tab::ModelDownloader>,
    ) {
        self.models.set_downloader_for_test(downloader);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// 🧠 Start a download for catalog entry `idx` on the selected device (test
    /// seam). Returns the new job id.
    #[doc(hidden)]
    pub fn models_download_for_test(&mut self, idx: usize) -> String {
        self.models.download_for_test(idx)
    }

    /// 🧠 Select the download device (test seam).
    #[doc(hidden)]
    pub fn models_set_device_for_test(&mut self, device: super::models_tab::Device) {
        self.models.set_device_for_test(device);
    }

    /// 🧠 Poll the Models tab's in-flight downloads (test seam — the robot pumps
    /// this between frames until the download reaches a terminal status).
    #[doc(hidden)]
    pub fn models_poll_for_test(&mut self) {
        self.models.poll_for_test();
    }

    /// 🧠 Cancel a download by job id (test seam). Returns whether one matched.
    #[doc(hidden)]
    pub fn models_cancel_for_test(&mut self, job_id: &str) -> bool {
        self.models.cancel_for_test(job_id)
    }

    /// Test-only: drive a click on the 🏛 Architecture node with `label` (parity
    /// with a pointer click), so a test can assert the downstream-trace highlight
    /// via `state_json()["architecture"]`. Returns whether a node matched.
    #[doc(hidden)]
    pub fn arch_select_for_test(&mut self, label: &str) -> bool {
        self.arch.select_by_label_for_test(label)
    }

    /// Test-only: inject scanned symbols into the 🏛 Architecture tab so the
    /// click-to-code resolver + the boundary-landmark classifier run without a
    /// checkout/warehouse scan.
    #[doc(hidden)]
    pub fn inject_arch_symbols_for_test(
        &mut self,
        symbols: Vec<crate::knowledge::symbols::SymbolRow>,
    ) {
        self.arch.inject_symbols_for_test(symbols);
    }

    /// Test-only: drive the 🏛 Architecture tab's click-to-code on `label` (parity
    /// with a node click), WITHOUT launching the editor, so a test asserts the
    /// resolved `file:line` + inline preview via `state_json()["architecture"]`.
    /// Returns whether a symbol resolved.
    #[doc(hidden)]
    pub fn arch_open_symbol_for_test(&mut self, label: &str) -> bool {
        self.arch.open_symbol(label, false)
    }

    /// Test-only: switch the 🏛 Architecture container's sub-view (parity with a
    /// selector chip click). Returns whether the id was valid.
    #[doc(hidden)]
    pub fn arch_set_sub_view_for_test(&mut self, id: &str) -> bool {
        self.arch.set_sub_view_by_id_for_test(id)
    }

    /// Test-only: inject scanned symbols + call edges into the 🏛 Architecture
    /// tab + build the 🚇 metro map, so a test reads the button→gRPC paths back
    /// from `state_json()["architecture"]["metro"]` without a checkout scan.
    #[doc(hidden)]
    pub fn inject_arch_scan_for_test(
        &mut self,
        symbols: Vec<crate::knowledge::symbols::SymbolRow>,
        calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
    ) {
        self.arch.inject_scan_for_test(symbols, calls);
        self.arch.build_metro_for_test();
    }

    /// Test-only: inject scanned facts WITH the SCIP-provenance fuel tag (the same
    /// tag `query::load_preferred` returns — `"resolved/scip"` vs `"syn"`), then
    /// rebuild the 🚇 metro, so a test asserts the per-line provenance overlay in
    /// `state_json()["architecture"]["metro"]` without a `--features scip` warehouse.
    #[doc(hidden)]
    pub fn arch_inject_metro_fuel_for_test(
        &mut self,
        symbols: Vec<crate::knowledge::symbols::SymbolRow>,
        calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
        source: &str,
    ) {
        self.arch
            .inject_scan_with_source_for_test(symbols, calls, source);
        self.arch.build_metro_for_test();
    }

    /// Test-only: select a 🚇 metro line by button id (parity with the line
    /// selector click), so a test asserts the selected line's `state_json` slice.
    #[doc(hidden)]
    pub fn arch_select_metro_for_test(&mut self, button_id: &str) {
        self.arch.select_metro_button_for_test(button_id);
    }

    /// Test-only: hydrate the 📡 Live Run pane from injected `release_events`
    /// rows (no warehouse on disk), exactly as the durable warehouse read would,
    /// then select the tab — so the inject-and-assert harness can read the
    /// hydrated run + rendered event lines back out of `state_json()["live"]`.
    /// Mirrors `inject_release_events_for_test` for the live surface (C3).
    #[doc(hidden)]
    pub fn inject_live_release_events_for_test(
        &mut self,
        rows: Vec<crate::warehouse::release_events::ReleaseEventRow>,
    ) {
        self.tab = Tab::LiveRun;
        self.live.inject_for_test(rows);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: hydrate the 📡 Live Run pane from the **real on-disk warehouse**
    /// `release_events` table synchronously (the durable read path the pane runs on
    /// load/reload), then select the tab — so an inject-and-assert test can seed
    /// `release_events` rows to a warehouse, point the app at it, and read the
    /// hydrated run back out of `state_json()["live"]` (C3, proves warehouse-sourced).
    #[doc(hidden)]
    pub fn live_hydrate_from_warehouse_for_test(&mut self) {
        self.tab = Tab::LiveRun;
        self.live.reload();
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only: drive the 🔗 Dep Graph view's deep toggle (C5) and collapse
    /// set (I3) directly, so the inject-and-assert harness can flip to the
    /// transitive-closure explode / collapse a node and read the resulting
    /// laid-out graph back out of `state_json()["dep_graph"]`.
    #[doc(hidden)]
    pub fn set_dep_view_for_test(&mut self, deep: bool, collapsed: &[&str]) {
        self.dep_view.deep = deep;
        self.dep_view.collapsed = collapsed.iter().map(|s| s.to_string()).collect();
    }

    /// Test-only: drive the ⏳ Time Travel tab's cursor (`idx`), the C4 text⇄graph
    /// toggle, and the reused dep-view deep flag directly, so the inject-and-assert
    /// harness can pin to a point in time, switch to graph view, and read the
    /// time-pinned graph (nodes/edges + direct/transitive classes) back out of
    /// `state_json()["timetravel"]`. Mirrors `set_dep_view_for_test`.
    #[doc(hidden)]
    pub fn set_timetravel_for_test(&mut self, idx: usize, graph: bool, deep: bool) {
        self.tab = Tab::TimeTravel;
        self.timetravel.idx = idx;
        self.timetravel.graph = graph;
        self.timetravel.dep_view.deep = deep;
    }

    /// Test-only (slice-5 capstone): inject §f3 build-info rows into the 📡
    /// build-info LIVE-TAIL pane directly — no Flight server — exactly as if
    /// [`subscribe`](crate::build::flight_sink::subscribe) had returned them, then
    /// select the 📡 Live Run tab. The inject-and-assert harness reads the growing
    /// tail + per-sub-job status back out of `state_json()["build_info_live"]`.
    /// Call it repeatedly to prove the tail GROWS across batches.
    #[doc(hidden)]
    pub fn inject_build_info_for_test(
        &mut self,
        rows: Vec<nornir_build_thing::build_info::BuildInfoRow>,
    ) {
        self.tab = Tab::LiveRun;
        self.build_info_tail.inject_for_test(rows);
        self.auto_refresh = false;
        self.age_past_splash_for_test();
    }

    /// Test-only (slice-5 auto-refresh): the 📡 build-info live-tail pane's inbox
    /// sender, so a test can simulate the background poller delivering a freshly-
    /// pulled batch on an interval (each `send` == one interval's `pull` rows).
    /// The pane's own `draw` drains it — proving the pane AUTO-UPDATES without a
    /// re-inject. Selects the 📡 Live Run tab so the pane is drawn + drained.
    #[doc(hidden)]
    pub fn build_info_poll_sender_for_test(
        &mut self,
    ) -> std::sync::mpsc::Sender<Vec<nornir_build_thing::build_info::BuildInfoRow>> {
        self.tab = Tab::LiveRun;
        self.auto_refresh = false;
        self.age_past_splash_for_test();
        self.build_info_tail.poll_sender_for_test()
    }

    /// Test-only: re-scope the Knowledge tab to a fresh `workspace_root`/`repos`
    /// (what a workspace switch does), so a test can assert the scans reset.
    #[doc(hidden)]
    pub fn knowledge_set_workspace_for_test(
        &mut self,
        workspace_root: PathBuf,
        repos: Vec<String>,
    ) {
        self.workspace_root = workspace_root.clone();
        self.repos = repos.clone();
        self.knowledge.set_workspace(workspace_root, repos);
    }

    /// Test-only: make the Knowledge tab ask its code room now (a draw does it
    /// lazily), so a headless test reads the loaded state back.
    #[doc(hidden)]
    pub fn knowledge_load_for_test(&mut self) {
        self.knowledge.load_now();
    }

    /// Test-only: mutable handle to the Funnel tab, so the E1 viz test can drive
    /// the demo / nuke / confirm-guard through the same code paths the buttons
    /// invoke and then assert `state_json()["funnel"]`.
    #[doc(hidden)]
    pub fn funnel_tab_for_test(&mut self) -> &mut super::funnel_tab::FunnelTabState {
        &mut self.funnel
    }

    /// Test-only: drive the workspace picker exactly as a click does — re-scope
    /// every tab (timeline, warehouse, release, leaderboard, knowledge, security,
    /// …) to `ws` and reload. The workspace×function matrix uses this to prove
    /// each function follows a switch with no stale leak from the previous one.
    #[doc(hidden)]
    pub fn switch_workspace_for_test(&mut self, ws: String) {
        self.switch_workspace(ws);
    }

    /// Test-only: drive the 🧬 root-pane Add-workspace flow exactly as a successful
    /// `Workspaces.Register` does — latch `name` as just-registered, then run the
    /// post-mutation handler (`on_root_mutation`) the `draw` call site invokes. The
    /// state test asserts this AUTO-SELECTS `name` (Add(W) ⇒ selected == W).
    #[doc(hidden)]
    pub fn register_workspace_for_test(&mut self, name: &str) {
        self.nornir_root.set_just_registered_for_test(name);
        self.on_root_mutation();
    }
}

/// **LAW4b** — encode a raw RGBA byte slice to PNG using the `image` crate
/// (already in the `viz` feature graph via `docs-export`).
fn encode_rgba_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>> {
    use image::{ImageBuffer, Rgba};
    let buf: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba.to_vec())
        .ok_or_else(|| anyhow::anyhow!("RGBA image buffer mismatch ({}×{})", width, height))?;
    let mut out = Vec::new();
    buf.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
    Ok(out)
}

const LANE_HEIGHT: f32 = 80.0;
const LANE_PAD: f32 = 24.0;
const NODE_RADIUS: f32 = 14.0;
const LEFT_GUTTER: f32 = 140.0;

/// A gate/release status → colour on the active facett palette (C8). Delegates
/// to the palette's shared semantic ramp so the timeline + side panel re-skin
/// with the theme.
fn status_color(theme: &super::facett_theme::Theme, status: &str) -> Color32 {
    theme.status_color(status)
}

impl eframe::App for UrdrThreadsApp {
    // eframe 0.34 made `ui(&mut Ui, frame)` the required entry point (the old
    // `update(ctx, frame)` is deprecated). The given `ui` is a bare root with no
    // CentralPanel, so the existing ctx-panel layout in `draw_ui` works unchanged
    // when driven off `ui.ctx()`.
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        self.draw_ui(ui);
    }
}

impl UrdrThreadsApp {
    /// Full per-frame UI build, independent of `eframe::Frame` (it's unused) so
    /// headless tests can render every tab through a bare `egui::Context`.
    pub fn draw_ui(&mut self, ui: &mut egui::Ui) {
        let ctx = &ui.ctx().clone();
        // Apply the active facett palette's egui Visuals (C8) — themes the
        // standard widgets (panels, buttons, selection, hyperlinks) to match the
        // custom-painted panes. Only on change, so we don't fight egui every frame.
        if !self.palette_applied {
            ctx.set_visuals(self.palette.visuals());
            // Spacious look-and-feel: widen the global egui spacing once, in the
            // same "set it at the top" pass as the palette, so every standard
            // widget inherits the roomier rhythm with no per-component change.
            super::facett_theme::apply_spacious_spacing(ctx);
            self.palette_applied = true;
        }
        // Auto-refresh: reload the timeline + warehouse table list on a timer so a
        // server-monitored workspace animates live as the poll loop republishes.
        if self.auto_refresh && self.last_reload.elapsed().as_secs() >= self.refresh_secs {
            self.reload();
            self.warehouse.refresh_tables();
            ctx.request_repaint();
        }
        if self.auto_refresh {
            // Keep the frame loop ticking even when idle so the timer fires.
            ctx.request_repaint_after(std::time::Duration::from_secs(1));
        }

        // Robot-UI-tester drive-half: apply any pending `viz.click` command from
        // the control channel BEFORE we paint, so this frame reflects it (and the
        // post-frame `dump_state` shows the result to the next `viz.state`). Keep
        // the loop ticking briefly so a command lands within a frame or two even
        // when the window is otherwise idle.
        // 🎬 Demo mode: emit the next due tour beat onto the control channel FIRST,
        // so the poll below consumes + applies it this same frame (a real click).
        self.demo_emit(ctx);
        self.poll_control_channel();
        ctx.request_repaint_after(std::time::Duration::from_millis(250));

        // **LAW4b** — Ctrl+Shift+S keyboard shortcut: request a self-screenshot.
        // The path defaults to /tmp/nornir_viz_screenshot_<timestamp>.png.
        ctx.input(|i| {
            if i.key_pressed(egui::Key::S) && i.modifiers.ctrl && i.modifiers.shift {
                let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
                let path = format!("/tmp/nornir_viz_screenshot_{ts}.png");
                self.request_screenshot(Some(path));
            }
        });

        // ── "Ask at close" guard (VIZ-TEST-JOBS) ────────────────────────────
        // If the window is being closed while jobs are still running (detached
        // server jobs the user may not want to abandon), cancel the close and pop
        // a confirm modal. The running count is the 🧬 nornir tab's job list (last
        // poll); `allow_close` lets a confirmed close through unguarded.
        let close_requested = ctx.input(|i| i.viewport().close_requested());
        if close_requested && !self.allow_close && self.nornir_root.running_jobs() > 0 {
            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
            self.close_confirm_open = true;
        }
        if self.close_confirm_open {
            let running = self.nornir_root.running_jobs();
            egui::Window::new("Jobs still running")
                .collapsible(false)
                .resizable(false)
                .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
                .show(ctx, |ui| {
                    ui.label(format!("{running} job(s) still running. Close anyway?"));
                    ui.horizontal(|ui| {
                        if ui.button("Close anyway").clicked() {
                            self.allow_close = true;
                            self.close_confirm_open = false;
                            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                        }
                        if ui.button("Keep running").clicked() {
                            self.close_confirm_open = false;
                        }
                    });
                });
        }

        // **LAW4b** — fire the ViewportCommand::Screenshot when a screenshot has
        // been requested.  The command is fired every frame until egui delivers
        // the Event::Screenshot below; in practice one frame is enough.
        if self.screenshot_pending {
            ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
            ctx.request_repaint(); // ensure the next frame arrives promptly
        }

        // **LAW4b** — consume Event::Screenshot: encode the ColorImage to PNG,
        // write to the requested path, and append to the warehouse blob store.
        // The event is delivered by egui in `ctx.input()`; we drain it here,
        // before the UI is painted, so the state_json dump on the same frame
        // already sees `pending=false` + `last_path` filled in.
        let mut screenshot_image: Option<std::sync::Arc<egui::ColorImage>> = None;
        ctx.input(|i| {
            for ev in &i.raw.events {
                if let egui::Event::Screenshot { image, .. } = ev {
                    screenshot_image = Some(image.clone());
                }
            }
        });
        if let Some(img) = screenshot_image {
            self.screenshot_pending = false;
            let out_path = self.screenshot_pending_path.take();
            self.handle_screenshot_image(&img, out_path);
        }

        // Tab change detection for the action log (the selectable_values below
        // mutate `self.tab` in place; we compare against this pre-row snapshot).
        let tab_before = self.tab;

        // ── Top-bar via the reusable facett TabBar component (D3b) ─────────────
        // The bespoke inline chrome (workspace picker + 2-row tab strip + 🎨
        // palette + ℹ About + live toggle) is now painted THROUGH
        // `facett_ui::TabBar`. The bar owns the tab strip + theming; the host
        // pickers/actions are passed in as left/right slot closures so the bar
        // stays generic. Slot closures only accumulate *intents* into locals
        // (so they don't fight the bar over a `&mut self` borrow); intents are
        // applied to `self` after the panel closes. See
        // `.nornir/top-bar-component.md`.
        let tab_items = Tab::tab_items();
        let active_id = format!("{:?}", self.tab);
        let palette = self.palette; // Copy — drives the bar's theming.

        // Intent accumulators set by the slot closures, applied post-panel.
        let mut chosen_ws: Option<String> = None;
        let mut reload_clicked = false;
        let mut sync_clicked = false;
        let mut toggle_action_log = false;
        let mut toggle_about = false;
        let mut tab_clicked: Option<String> = None;

        // Read-only snapshots the (Fn-only) left slot needs.
        let ws_now = self.workspace_name.clone();
        let workspaces = self.workspaces.clone();
        let source_label = self.source_label.clone();
        let refresh_secs = self.refresh_secs;
        let mut auto_refresh = self.auto_refresh; // checkbox mutates this local
        let last_reload_secs = self.last_reload.elapsed().as_secs();
        let action_log_count = self.action_log.count();
        let show_action_log = self.show_action_log;
        let show_about = self.show_about;
        // ⟳ Sync now needs &mut ws_panel + a server handle; precompute the
        // server so the closure captures only the disjoint `ws_panel` field.
        let srv = self.server();
        let ws_panel = &mut self.ws_panel;

        egui::Panel::top("top").show(ui, |ui| {
            let bar = super::facett_ui::TabBar::new("top", &tab_items, &active_id)
                .split_at(TOP_BAR_SPLIT)
                .with_control(super::facett_ui::BarControl::picker("workspace", &ws_now))
                .with_control(super::facett_ui::BarControl::toggle("live", auto_refresh))
                .with_control(super::facett_ui::BarControl::toggle("about", show_about))
                .with_control(super::facett_ui::BarControl::toggle(
                    "action_log",
                    show_action_log,
                ));

            tab_clicked = bar.show(
                ui,
                &palette,
                // ── LEFT slot: heading + workspace picker + source/reload/live + sync ──
                |ui| {
                    ui.heading("🧵 Urðr Threads");
                    ui.separator();
                    // Workspace picker — switching re-scopes timeline/warehouse/callgraph.
                    let mut chosen = ws_now.clone();
                    // No implicit default — an empty selection is "none", and the user
                    // must pick one from this picker (the server has no default route).
                    let label = if chosen.is_empty() {
                        "⚠ none — pick one".to_string()
                    } else {
                        chosen.clone()
                    };
                    egui::ComboBox::from_id_salt("ws_picker")
                        .selected_text(format!("workspace: {label}"))
                        .show_ui(ui, |ui| {
                            for ws in &workspaces {
                                ui.selectable_value(&mut chosen, ws.clone(), ws);
                            }
                        });
                    if chosen != ws_now {
                        chosen_ws = Some(chosen);
                    }
                    ui.separator();
                    ui.label(format!("source: {source_label}"));
                    if ui.button("↻ reload").clicked() {
                        reload_clicked = true;
                    }
                    ui.checkbox(&mut auto_refresh, "live")
                        .on_hover_text(format!("auto-refresh every {refresh_secs}s"));
                    ui.separator();
                    // Workspace info toggle + ⟳ Sync now (remote mode). A sync polls
                    // the remote (server-side republish) — reload the view at once so
                    // a cleared/changed warehouse shows now, not on the next tick.
                    if ws_panel.draw_controls(ui, srv.as_ref(), &ws_now) {
                        sync_clicked = true;
                    }
                    ui.label(format!("(last reload {last_reload_secs}s ago)"));
                    ui.separator();
                },
                // ── RIGHT slot: 🐞 action-log toggle + ℹ About + 🎨 palette picker ──
                |ui| {
                    // 🐞 action-log overlay toggle + the live entry counter, so the
                    // operator can pop the trail open without a terminal.
                    let lbl = format!("🐞 log ({action_log_count})");
                    if ui
                        .selectable_label(show_action_log, lbl)
                        .on_hover_text(
                            "toggle the user-action debug trail (also at $NORNIR_VIZ_ACTIONLOG)",
                        )
                        .clicked()
                    {
                        toggle_action_log = true;
                    }
                    ui.separator();
                    // ℹ About — client/server version + endpoint + workspace + warehouse.
                    if ui
                        .selectable_label(show_about, "ℹ About")
                        .on_hover_text("client + server version, endpoint, workspace, warehouse")
                        .clicked()
                    {
                        toggle_about = true;
                    }
                    // NB: the 🎨 app-wide facett palette picker used to sit HERE, on
                    // the root top bar. The bar is navigation (the tab strip, the
                    // workspace picker) and actions (reload / live / sync / about);
                    // appearance is a PREFERENCE, so it moved into the 🎛 **Settings**
                    // tab (`draw_settings`) — the same place dwarves-demo's `settings`
                    // pane keeps it, so an operator moving between the suite's apps
                    // finds the palette control in one place.
                },
            );
        });

        // ── Apply the slot intents to `self` (the bar's closures only touched
        // locals, so no borrow fight). Behaviour matches the old inline chrome. ─
        self.auto_refresh = auto_refresh;
        if let Some(ws) = chosen_ws {
            self.switch_workspace(ws);
        }
        if reload_clicked {
            self.action_log
                .push(super::action_log::Kind::Click, "↻ reload");
            #[cfg(feature = "saga")]
            self.saga_record_click("↻ reload", "reload");
            self.reload();
            self.warehouse.refresh_tables();
        }
        if sync_clicked {
            self.action_log
                .push(super::action_log::Kind::Click, "⟳ sync now");
            self.reload();
            self.warehouse.refresh_tables();
            self.callgraph.invalidate();
        }
        if toggle_action_log {
            self.show_action_log = !self.show_action_log;
        }
        if toggle_about {
            self.show_about = !self.show_about;
            if self.show_about {
                self.action_log
                    .push(super::action_log::Kind::Click, "ℹ About");
                // Query the server version once, on open (remote mode).
                self.ensure_server_info();
            }
        }
        // A tab header click (real AccessKit/robot click or pointer) → switch
        // the active tab, exactly as the old `selectable_value` rows did.
        if let Some(id) = tab_clicked {
            if let Some(t) = Tab::from_name(&id) {
                self.tab = t;
            }
        }

        // Keep the action log's durable-row context (workspace + active tab)
        // current, so every `viz_actions` row is stamped with where the user was
        // when they took the action (N5). Cheap: a single mutex update.
        self.action_log
            .set_context(&self.workspace_name, &format!("{:?}", self.tab));

        // Log the tab switch (edge-triggered) the moment the row mutates it.
        if self.tab != tab_before {
            self.action_log.push(
                super::action_log::Kind::Tab,
                format!("{tab_before:?} → {:?}", self.tab),
            );
            // Structured twin: the tab switch as a typed event so an agent
            // following $NORNIR_VIZ_TRACE knows which view's data comes next.
            super::trace::emit_event(
                "ui.tab",
                &serde_json::json!({
                    "from": format!("{tab_before:?}"),
                    "to": format!("{:?}", self.tab),
                }),
            );
            // 🎬 Saga capture (feature `saga`): the nav as a REPLAYABLE event whose
            // `component` is the header button's AccessKit label, so a replay's
            // ClickLabel(component) re-drives the exact same widget. No-op (inlined
            // away) in a release build without the feature.
            #[cfg(feature = "saga")]
            self.saga_record_nav();
        }

        // 🎬 The right "Time machine" detail inspector + the bottom 🐞 action-log
        // trail are NO LONGER hand-wired panels here — they are declarative panes in
        // `shell_scene::nornir_shell_scene` (a DRAGGABLE master/detail split + a
        // foldable action-log drawer), rendered by `scene::render` in the central
        // area below and filled by this app's `SceneHost` impl
        // (`draw_detail_body` / `draw_action_log_body`). The top-bar 🐞 toggle drives
        // the drawer's fold (synced just before the render). See `.nornir/scene-design.md`.

        // ℹ About window (C1): always shows SOMETHING real — client version,
        // endpoint, workspace, warehouse — plus the server version (remote mode).
        if self.show_about {
            let about = self.about_info();
            let theme = self.palette;
            let mut open = self.show_about;
            egui::Window::new("ℹ About — Urðr Threads")
                .open(&mut open)
                .resizable(false)
                .collapsible(false)
                .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
                .show(ctx, |ui| {
                    let row = |ui: &mut egui::Ui, k: &str, v: &str| {
                        ui.horizontal(|ui| {
                            ui.label(egui::RichText::new(k).strong());
                            ui.monospace(v);
                        });
                    };
                    ui.heading("🧵 Urðr Threads");
                    ui.label("the nornir warehouse visualizer");
                    ui.separator();
                    // The build identity — prominent, so it's obvious this is a
                    // fresh client (version + git SHA + build time).
                    ui.label(
                        egui::RichText::new(client_build())
                            .strong()
                            .monospace()
                            .color(super::facett_theme::GREEN),
                    );
                    let mode = about["mode"].as_str().unwrap_or("?");
                    row(ui, "mode", mode);
                    if mode == "remote" {
                        row(ui, "endpoint", about["endpoint"].as_str().unwrap_or("—"));
                        match about["server_version"].as_str() {
                            Some(v) => {
                                let rc = about["server_repo_count"].as_u64().unwrap_or(0);
                                row(ui, "server version", &format!("{v}  ({rc} repos)"));
                            }
                            None => {
                                let note = about["server_note"]
                                    .as_str()
                                    .unwrap_or("server version: unknown");
                                ui.colored_label(super::facett_theme::AMBER, note);
                            }
                        }
                    } else {
                        row(ui, "warehouse", about["warehouse"].as_str().unwrap_or("—"));
                    }
                    row(ui, "workspace", about["workspace"].as_str().unwrap_or("—"));
                    ui.separator();
                    ui.label(
                        egui::RichText::new(about["connect_status"].as_str().unwrap_or(""))
                            .size(11.0)
                            .color(theme.text_dim),
                    );
                });
            self.show_about = open;
        }

        // 🎬 Central master shell: render the DECLARATIVE scene (a draggable
        // master/detail split + a foldable action-log drawer + the Time-machine
        // inspector), DRIVING the existing panes through this app's `SceneHost`
        // impl. The 28-pane scene is rebuilt cheaply each frame; `self.nav` holds the
        // live fold/split-ratio state the render reads and any interaction mutates.
        // The old right `SidePanel`/bottom `TopBottomPanel`/central dispatch are now
        // the `detail`/`action_log`/`active_pane` slots. See `.nornir/scene-design.md`.
        egui::CentralPanel::default().show(ui, |ui| {
            // The scene for the ACTIVE tab: identical shell, except that a tab with
            // honest master/detail structure carries its own DRAGGABLE body split
            // (`<Tab>_main` │ `<Tab>_inspector`) where the single `panes` slot sits.
            // Pane names are per-tab, so each tab keeps its own live ratio + folds.
            let scene = super::shell_scene::nornir_shell_scene_for(self.tab);
            // The top-bar 🐞 toggle owns the trail's visibility → drive the drawer fold.
            self.nav
                .set_fold(super::shell_scene::ACTION_LOG_SLOT, !self.show_action_log);
            // Take `nav` OUT so `self` is free to be the SceneHost during render; a
            // fresh throwaway seeds the field for the borrow's duration.
            let mut nav =
                std::mem::replace(&mut self.nav, facett_app::scene::NavState::new(&scene));
            let render = facett_app::scene::render(&scene, &mut nav, ui, self);
            // A scene toggle of the drawer (menu/fold header) flows back to the flag.
            self.show_action_log = !nav.is_folded(super::shell_scene::ACTION_LOG_SLOT);
            self.nav = nav;
            self.last_scene_render = render;
        });

        // Splash + watermark overlay (drawn last so it sits on top
        // of everything but doesn't block clicks).
        self.draw_nornir_overlay(ctx);

        // 🚦 Pre-flight popup — only AFTER the splash has played, so it appears
        // "after the logo popup" as intended. Feed it the warehouse MCP call
        // count (scanning the telemetry once) so a 0-call host gets the
        // "wire up your Claude agent" warning, then draw it over everything.
        if self.started_at.elapsed().as_secs_f32() >= SPLASH_DURATION_SECS {
            self.mcp.ensure_loaded();
            // Build the `claude mcp add` command from THIS client's live
            // connection (no hardcoded token in the binary). We do NOT pin
            // NORNIR_WORKSPACE — the MCP should expose every workspace + its
            // repos, not lock to one (see .nornir: multi-workspace-mcp).
            let add_cmd = match &self.source {
                Source::Remote { endpoint, token } => format!(
                    "# wire nornir's 56 MCP tools to Claude Code, pointed at this server:\n\
                     claude mcp add nornir \\\n  \
                     --env NORNIR_SERVER={endpoint} \\\n  \
                     --env NORNIR_SERVER_TOKEN={token} \\\n  \
                     -- nornir-mcp\n\
                     # then in a session:  /mcp   → `nornir` connected (all workspaces)"
                ),
                Source::Local(_) => "# wire nornir's 56 MCP tools to Claude Code:\n\
                     claude mcp add nornir -- nornir-mcp\n\
                     # then in a session:  /mcp   → `nornir` connected"
                    .to_string(),
            };
            self.preflight
                .set_mcp_calls(self.mcp.total_calls(), self.mcp.is_loaded(), add_cmd);
            self.preflight.draw(ctx);
        } else {
            // keep repainting so the popup appears promptly when the splash ends
            ctx.request_repaint_after(std::time::Duration::from_millis(100));
        }

        // External observability: log click trail (stderr) + dump the full UI
        // state (pickers, tabs, populations, placeholders) so the test matrix
        // and the operator can see what's on screen without a display.
        self.log_click();
        self.dump_state();
    }
}

/// 🎬 nornir's app IS the [`SceneHost`](facett_app::scene::SceneHost): the declarative
/// master shell (`super::shell_scene`) DRIVES the existing panes through this impl —
/// the `active_pane` slot renders the active tab (delegating to every `Tab::id()`
/// slot), `detail` the Time-machine inspector, `action_log` the trail — and menu
/// commands drive reload / About / tab navigation. The scene reinvents nothing.
impl facett_app::scene::SceneHost for UrdrThreadsApp {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        if id == super::shell_scene::ACTIVE_PANE_SLOT {
            self.draw_active_pane(ui);
            return serde_json::json!({ "tab": self.tab.id() });
        }
        if id == super::shell_scene::DETAIL_SLOT {
            self.draw_detail_body(ui);
            return serde_json::json!({ "selected": self.selected.map(|u| u.to_string()) });
        }
        if id == super::shell_scene::ACTION_LOG_SLOT {
            self.draw_action_log_body(ui);
            return serde_json::json!({ "count": self.action_log.count() });
        }
        // A DECOMPOSED tab body: `<Tab>_main` / `<Tab>_inspector` — one half of the
        // draggable master/detail split the tab's internals now declare.
        if let Some((tab, part)) = super::shell_scene::body_slot(id) {
            return self.draw_body_part(tab, part, ui);
        }
        // Every `Tab::id()` is itself a host-fillable slot (the `active_pane` slot
        // delegates here): render that tab's pane, then restore the active tab.
        if let Some(t) = Tab::from_name(id) {
            let prev = self.tab;
            self.tab = t;
            self.draw_active_pane(ui);
            self.tab = prev;
            return serde_json::json!({ "tab": t.id() });
        }
        ui.weak(format!("no widget for slot: {id}"));
        serde_json::Value::Null
    }

    fn command(&mut self, id: &str) {
        match id {
            "refresh" => {
                self.action_log
                    .push(super::action_log::Kind::Click, "↻ reload (menu)");
                self.reload();
                self.warehouse.refresh_tables();
            }
            "about" => {
                self.show_about = !self.show_about;
                if self.show_about {
                    self.ensure_server_info();
                }
            }
            other => {
                // `go:<TabId>` menu items navigate the active tab (real switch).
                if let Some(rest) = other.strip_prefix("go:") {
                    if let Some(t) = Tab::from_name(rest) {
                        self.tab = t;
                    }
                }
            }
        }
    }
}

impl UrdrThreadsApp {
    /// **The per-frame LIVE populate poll**, driven regardless of which tab is on
    /// screen. The per-repo panes (🧠 knowledge / 🏛 arch / 🔗 depgraph / 🚇 metro /
    /// 🧪 test) render "⏳ in route for populate…" + auto-refetch off the snapshot
    /// `repo_pane::publish_active_jobs` keeps fresh, but that poll used to run ONLY
    /// inside the 🧬 nornir tab's `draw_jobs` — so a populate watched from a per-repo
    /// pane saw a STALE snapshot and never showed live progress. Throttled (it shares
    /// the nornir tab's ~1.5s cadence, so it's one RPC per cycle); keeps repainting
    /// while a populate runs so those panes update live.
    ///
    /// Extracted from `draw_active_pane` so a tab whose body is a master/detail SPLIT
    /// (two host slots, [`super::shell_scene::body_split`]) polls exactly ONCE per
    /// frame — from its main half — instead of once per half.
    fn poll_populate_jobs(&mut self, ui: &egui::Ui) {
        let srv = self.server();
        // PLAN #6: the local warehouse path for populate-status reads in local mode
        // (None in remote mode → the RPC path is used).
        let local_wh = match &self.source {
            Source::Local(p) => Some(p.clone()),
            Source::Remote { .. } => None,
        };
        let active_ws = self.workspace_name.clone();
        let populating =
            self.nornir_root
                .poll_active_jobs(srv.as_ref(), &active_ws, local_wh.as_ref());
        if populating {
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(800));
        }
    }

    /// **ONBOARDING (not an error): no workspace is selected.** A fresh server with an
    /// EMPTY registry has no workspace to select, so every non-🧬 tab would otherwise
    /// call `Viz.Timeline` with an empty workspace and surface NotFound (`workspace ``
    /// is not served`). Direct the user to add one instead. RAGNARÖK (LAW 2) bites a
    /// POPULATED surface that renders empty — an empty registry is a legitimate resting
    /// state, shown as onboarding, not red.
    ///
    /// Emits the state as DATA (LAW 6) so the matrix/robot can assert the surface RAN
    /// and is in the empty-registry onboarding state. Shared by the single-pane path
    /// and the main half of a split tab body, so both tell the same story once.
    fn draw_no_workspace_onboarding(&mut self, ui: &mut egui::Ui) {
        let have_workspaces = !self.workspaces.is_empty();
        ui.vertical_centered(|ui| {
            ui.add_space(40.0);
            ui.heading("No workspace selected");
            if have_workspaces {
                // Workspaces ARE registered — the server has no default route, so the
                // user must pick one from the top-bar.
                ui.label("Select a workspace from the top bar to get started.");
            } else {
                ui.label("This server has no workspaces registered yet.");
                ui.add_space(8.0);
                ui.label("Add one in the 🧬 nornir tab → ➕ add workspace, or run:");
                ui.code("nornir workspace register <name> --descriptor <git-ssh-url | path>");
            }
            ui.add_space(8.0);
            ui.label(&self.connect_status);
        });
        super::trace::emit_out(
            "viz.onboarding",
            &serde_json::json!({
                "no_workspaces": self.workspaces.is_empty(),
                "workspace_name_empty": true,
                "served": false,
                "errored": false,
            }),
        );
    }

    /// **🎛 The Settings pane — the viz's preferences surface, and the ONE home of its
    /// LOOK & FEEL.**
    ///
    /// The app-wide facett palette picker used to live on the root top bar's RIGHT
    /// slot, between the 🐞 action-log toggle and ℹ About. The top bar is NAVIGATION
    /// (the tab strip, the workspace picker) and ACTIONS (reload / live / sync /
    /// about); appearance is a PREFERENCE, so it moved here — the same split
    /// dwarves-demo draws with its 🎛 `settings` tab.
    ///
    /// Nothing was lost in the move: the same [`Theme::names`](super::facett_theme::Theme)
    /// roster, driven through the same [`set_palette`](Self::set_palette), still
    /// re-skins EVERY pane at once (status colours, accents, zebra/hover/selection,
    /// gridlines, typography) plus the egui widgets — `set_palette` broadcasts to
    /// every pane and clears `palette_applied`, so the next frame installs the new
    /// visuals. `$NORNIR_VIZ_THEME` / `--theme` (the C8 CLI-parity resolution) are
    /// untouched, and the action-log still records the switch.
    fn draw_settings(&mut self, ui: &mut egui::Ui) {
        ui.heading("🎛 Settings");
        ui.add_space(4.0);
        ui.label(
            egui::RichText::new("Appearance")
                .strong()
                .color(self.palette.text),
        );
        ui.label(
            egui::RichText::new(
                "The app-wide facett palette. Switching re-skins EVERY pane at once — \
                 status colours, accents, zebra/hover/selection, gridlines, typography — \
                 plus the egui widgets. A local preference: nothing is sent to the server.",
            )
            .color(self.palette.text_dim),
        );
        ui.add_space(8.0);

        let current = self.palette.name.to_string();
        let mut chosen: Option<String> = None;
        ui.horizontal_wrapped(|ui| {
            ui.label("🎨 palette:");
            for name in super::facett_theme::Theme::names() {
                if ui.selectable_label(name == current, name).clicked() {
                    chosen = Some(name.to_string());
                }
            }
        });
        if let Some(name) = chosen {
            if let Some(t) = super::facett_theme::Theme::by_name(&name) {
                self.action_log.push(
                    super::action_log::Kind::Click,
                    format!("🎨 palette → {}", t.name),
                );
                self.set_palette(t);
            }
        }
        ui.add_space(8.0);
        ui.label(
            egui::RichText::new(format!("active palette: {}", self.palette.name))
                .color(self.palette.text_dim),
        );
        ui.add_space(4.0);
        ui.label(
            egui::RichText::new(
                "Headless / CLI parity (C8): the same choice is selectable without the \
                 GUI via $NORNIR_VIZ_THEME or --theme.",
            )
            .color(self.palette.text_dim),
        );

        // LAW 6 — the surface reports what it did, as data, so the matrix/robot can
        // assert the pane RAN and which palette it is showing.
        super::trace::emit_out(
            "viz.settings",
            &serde_json::json!({
                "palette": self.palette.name,
                "palettes": super::facett_theme::Theme::names(),
                "served": true,
                "errored": false,
            }),
        );
    }

    /// **Render ONE HALF of a decomposed tab body** — the scene's `<Tab>_main` /
    /// `<Tab>_inspector` slot (see [`super::shell_scene::body_split`]).
    ///
    /// Stage 2 of the scene adoption: the shell used to hand each tab ONE opaque pane,
    /// so minimise / maximise / drag operated on the whole tab and could do nothing
    /// INSIDE it. A tab with honest master/detail structure now declares two named
    /// panes, and this routes each to the matching half of the existing tab widget —
    /// the tab's own drawing code is reused, not rewritten.
    ///
    /// `self.tab` is made authoritative for the duration (the halves read it, and the
    /// scene may render a slot for a tab that isn't active during a deep-link), then
    /// restored. Returns the half's observable state, folded into the render's `slots`.
    fn draw_body_part(
        &mut self,
        tab: Tab,
        part: super::shell_scene::BodyPart,
        ui: &mut egui::Ui,
    ) -> serde_json::Value {
        use super::shell_scene::BodyPart;
        let prev = self.tab;
        self.tab = tab;
        let main = matches!(part, BodyPart::Main);
        // The AccessKit `id_salt` at this half's render root — the slot id itself, so
        // the two halves can never collide with each other or with the whole-tab salt.
        let salt = if main {
            super::shell_scene::main_slot_id(tab)
        } else {
            super::shell_scene::inspector_slot_id(tab)
        };
        let mut served = true;
        ui.push_id(salt.clone(), |ui| {
            // Once per frame, from the main half only.
            if main {
                self.poll_populate_jobs(ui);
            }
            if self.workspace_name.is_empty() {
                served = false;
                if main {
                    self.draw_no_workspace_onboarding(ui);
                } else {
                    ui.weak("(no workspace selected)");
                }
                return;
            }
            match (tab, main) {
                // 🗄 Warehouse — the table roster (master) │ the row preview + chart
                // (detail). Was an internal `SidePanel::left("wh_tables")` the shell
                // could not see, fold, minimise or report the rects of.
                (Tab::Warehouse, true) => self.warehouse.draw_tables(ui),
                (Tab::Warehouse, false) => self.warehouse.draw_preview(ui),
                // 🗺 Knowledge — the bubble chart (main) under the per-crate summary
                // table (inspector). The two used to share one vertical flow, which is
                // exactly how a growing readout pushed the canvas off the screen.
                (Tab::Knowledge, true) => self.knowledge.draw_chart(ui),
                (Tab::Knowledge, false) => self.knowledge.draw_summary(ui),
                // 📡 Live Run — the release-run feed (main) over the §f3 build-info
                // live-tail (inspector). Two independent feeds that used to share one
                // scroll, so the busier one silently swallowed the other.
                (Tab::LiveRun, true) => self.live.draw(ui),
                (Tab::LiveRun, false) => self.build_info_tail.draw(ui),
                _ => {
                    ui.weak(format!("no widget for slot: {salt}"));
                }
            }
        });
        self.tab = prev;
        serde_json::json!({
            "tab": tab.id(),
            "part": if main { "main" } else { "inspector" },
            "served": served,
        })
    }

    /// Render the ACTIVE tab's pane into `ui` — the central pane body (formerly the
    /// `CentralPanel` dispatch), now the scene's `active_pane` slot filled by this
    /// app's [`facett_app::scene::SceneHost`] impl. Preserves the S3c stable
    /// AccessKit `id_salt` (`Tab::id()`) at the pane render root so every atom stays
    /// addressable, and keeps the per-frame active-jobs poll + onboarding/timeline
    /// guards intact (its inner `return`s return from the `push_id` closure as before).
    fn draw_active_pane(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let pane_salt = self.tab.id();
        ui.push_id(pane_salt, |ui| {
            // LIVE populate progress — drive the active-jobs poll EVERY frame,
            // regardless of which tab is on screen. The per-repo panes (🧠 knowledge /
            // 🏛 arch / 🔗 depgraph / 🚇 metro / 🧪 test) render "⏳ in route for
            // populate…" + auto-refetch off the snapshot `repo_pane::publish_active_jobs`
            // keeps fresh, but that poll used to run ONLY inside the 🧬 nornir tab's
            // `draw_jobs` — so a populate watched from a per-repo pane saw a STALE
            // snapshot and never showed live progress. Polling here (throttled, shares
            // the nornir tab's ~1.5s cadence so it's one RPC per cycle) feeds those
            // panes always; keep repainting while a populate runs so they update live.
            self.poll_populate_jobs(ui);
            // 🧬 The `nornir` root pane — server + workspace lifecycle ops. Renders
            // without a timeline (it's where workspaces are born/killed). On a
            // mutating op (add/kill/populate/refresh) re-list the workspaces +
            // reload so the picker and views reflect the change immediately.
            if self.tab == Tab::Nornir {
                let srv = self.server();
                // PLAN #6: the local warehouse path for populate-status reads in
                // local mode (None in remote mode → the RPC path is used).
                let local_wh = match &self.source {
                    Source::Local(p) => Some(p.clone()),
                    Source::Remote { .. } => None,
                };
                let active_ws = self.workspace_name.clone();
                // 🧬 RELEASE DASHBOARD first (the go/no-go glance): the Release
                // button + the gate-overlaid dependency graph + node→history (the
                // history reuses the app's already-loaded timeline). The server +
                // workspace-lifecycle ops follow below it, scrollable together.
                egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                    let tl = self.timeline.as_ref().ok();
                    self.nornir_dashboard.draw(ui, tl);
                    ui.add_space(12.0);
                    ui.separator();
                    let mutated = self.nornir_root.draw(
                        ui,
                        srv.as_ref(),
                        &self.workspaces,
                        &active_ws,
                        local_wh.as_ref(),
                        &self.action_log,
                    );
                    if mutated {
                        self.on_root_mutation();
                    }
                });
                return;
            }
            // ⚒ Dwarves (N5) — the merged build runner + CI forge. Renders WITHOUT a
            // workspace/warehouse: building a repo doesn't need release history, the
            // demo build-space (holger) is always available, and the forge is seeded
            // with a demo pipeline (runs drive the `StepRunner` seam, default the
            // instant `DemoRunner`). Placed before the onboarding guard so a fresh
            // server can still drive a build / open the forge.
            if self.tab == Tab::Dwarves {
                self.dwarves.draw(ui);
                return;
            }
            // 🤖 Claude — the Messages-API conversation pane. Renders WITHOUT a
            // workspace/warehouse (it talks to the Anthropic API, not the timeline),
            // so it sits alongside Build ahead of the onboarding guard. The HTTP call
            // is behind the `ClaudeTransport` seam; a key-absent host shows the
            // "set ANTHROPIC_API_KEY" hint rather than erroring.
            if self.tab == Tab::Claude {
                self.claude.draw(ui);
                return;
            }
            // 🧠 Models — browse/download LLM+embedding models. Renders WITHOUT a
            // workspace/warehouse (downloads are host-local, behind the
            // `ModelDownloader` seam), so it sits alongside Build/Claude ahead of the
            // onboarding guard.
            if self.tab == Tab::Models {
                self.models.draw(ui);
                return;
            }
            // 🎛 Settings — the preferences pane (the app-wide facett PALETTE picker,
            // moved off the root top bar). Reads nothing but the app's own palette, so
            // it renders WITHOUT a workspace/warehouse and sits with Claude/Models
            // ahead of the onboarding guard: a fresh install can still re-skin itself.
            if self.tab == Tab::Settings {
                self.draw_settings(ui);
                return;
            }
            // ONBOARDING (not an error): a fresh server with an EMPTY registry has no
            // workspace to select, so every non-🧬 tab would otherwise call
            // `Viz.Timeline` with an empty workspace and surface NotFound
            // (`workspace `` is not served`). Direct the user to add one instead.
            // RAGNARÖK (LAW 2) bites a POPULATED surface that renders empty — an empty
            // registry is a legitimate resting state, shown as onboarding, not red.
            if self.workspace_name.is_empty() {
                self.draw_no_workspace_onboarding(ui);
                return;
            }
            // Live Run and Knowledge tabs render even when there's no
            // timeline yet — they work without warehouse data.
            if self.tab == Tab::LiveRun {
                self.live.draw(ui);
                // 📡 Build-info live-tail (slice-5): the §f3 feed folded per
                // sub-job, painted beneath the release-run feed in the same pane.
                ui.separator();
                self.build_info_tail.draw(ui);
                return;
            }
            if self.tab == Tab::Release {
                self.release.draw(ui);
                return;
            }
            // 💬 Chat renders without a timeline (it talks to the ACTIVE local
            // model, not the warehouse), like Live Run / Test.
            if self.tab == Tab::Chat {
                self.chat.draw(ui, &self.action_log);
                return;
            }
            if self.tab == Tab::Knowledge {
                self.knowledge.draw(ui);
                return;
            }
            if self.tab == Tab::Warehouse {
                self.warehouse.draw(ui);
                return;
            }
            if self.tab == Tab::WarehouseDeck {
                self.warehouse_deck.draw(ui);
                return;
            }
            if self.tab == Tab::Mcp {
                self.mcp.draw(ui);
                return;
            }
            // 📦 Holger — the built-in (married) registry pane. Renders without a
            // timeline (it reads the in-process holger dev pair, not the
            // warehouse). When holger isn't embedded the tab degrades to a note.
            if self.tab == Tab::Holger {
                #[cfg(feature = "embed-holger")]
                {
                    self.holger.draw(ui);
                }
                #[cfg(not(feature = "embed-holger"))]
                {
                    ui.heading("📦 Holger registry");
                    ui.label(
                        "holger is not embedded in this build (the `embed-holger` feature is \
                         off), so the registry pane is unavailable. Build with the default \
                         features to embed the /cache + /sparring dev pair.",
                    );
                }
                return;
            }
            // 📖 Manual — nornir's own docs/book-svg rendered via facett-docview.
            // Renders without a timeline (it's static documentation, loaded lazily).
            if self.tab == Tab::Manual {
                self.manual.draw(ui);
                return;
            }
            if self.tab == Tab::CallGraph {
                // 🕸 Call Graph (back-compat top-level variant): route to the arch
                // container's restored CallGraph sub-view — the real function/symbol
                // call graph (`callgraph.rs`), NOT the Metro call-path map. The
                // shared draw path below renders `self.callgraph` for that lens.
                self.arch.set_sub_view(super::arch_tab::ArchSubView::CallGraph);
                self.draw_architecture(ui);
                return;
            }
            if self.tab == Tab::Architecture {
                self.draw_architecture(ui);
                return;
            }
            // 🚇 Metro (S5b): feed the facett MetroView a fresh MetroMap built by
            // the `metro_feed` producer over the 🏛 Architecture tab's loaded
            // facts (one loader, no second scan), then let the Facet draw it.
            if self.tab == Tab::Metro {
                let map = self.arch.facett_metro_map();
                self.metro.set_workspace_name(self.workspace_name.clone());
                self.metro.set_map(map);
                self.metro.draw(ui);
                return;
            }
            // 🧬 Time Helix: feed the facett HelixView a Scene built from the
            // selected workspace's release timelines (demo helix when there are no
            // releases yet, so the tab is never blank).
            if self.tab == Tab::Helix {
                let tl = self.timeline.as_ref().ok();
                self.helix.draw(ui, tl);
                return;
            }
            // 🩸 Bloodstream: paint the workspace as a circulatory system, fed the
            // live timeline's crates + dep edges (falls back to nornir's OWN body
            // from the embedded lockfile, so it is never blank). The recursive
            // external drill-down resolves against the local checkout's lockfile.
            if self.tab == Tab::Bloodstream {
                let tl = self.timeline.as_ref().ok();
                self.bloodstream.draw(ui, tl, &self.workspace_root);
                return;
            }
            if self.tab == Tab::Funnel {
                self.funnel.draw(ui, &self.action_log);
                return;
            }
            let srv = self.server();
            if self.tab == Tab::Search {
                self.search.draw(ui, srv.as_ref(), &self.workspace_name, &self.action_log);
                return;
            }
            if self.tab == Tab::Gates {
                self.gates.draw(ui, srv.as_ref(), &self.workspace_name, &self.repos, &self.action_log);
                return;
            }
            if self.tab == Tab::Security {
                self.security.draw(ui, srv.as_ref(), &self.workspace_name);
                return;
            }
            // ONBOARDING (registered-but-unpopulated NAMED workspace): the server
            // answered `Viz.Timeline` with `NotFound: workspace `<name>` is not
            // served` because the workspace is registered but its warehouse isn't
            // built yet. That's NOT a failure — show a graceful "not populated —
            // click ⬇ Populate" panel (route to the 🧬 nornir tab), never a red
            // "Failed to load warehouse" error. (The empty-registry / no-selection
            // onboarding is handled by the earlier `workspace_name.is_empty()` guard.)
            let unpopulated_onboarding = self.timeline_is_unpopulated_onboarding();
            match &self.timeline {
            _ if unpopulated_onboarding => {
                ui.vertical_centered(|ui| {
                    ui.add_space(40.0);
                    ui.heading(format!("Workspace `{}` is not populated yet", self.workspace_name));
                    ui.label("This workspace is registered but its warehouse hasn't been built.");
                    ui.add_space(8.0);
                    ui.label("Open the 🧬 nornir tab and click ⬇ Populate, or run:");
                    ui.code(format!("nornir workspace populate {}", self.workspace_name));
                    ui.add_space(8.0);
                    ui.label(&self.connect_status);
                });
                // EMIT the onboarding state as DATA (LAW 6): the surface RAN and is
                // in the registered-but-unpopulated onboarding state — NOT the red
                // `Failed to load warehouse` / `is not served` error path.
                super::trace::emit_out(
                    "viz.onboarding",
                    &serde_json::json!({
                        "no_workspaces": self.workspaces.is_empty(),
                        "workspace_name_empty": false,
                        "unpopulated": true,
                        "workspace": self.workspace_name,
                        "served": false,
                        "errored": false,
                    }),
                );
            }
            Err(e) => {
                ui.colored_label(super::facett_theme::RED, format!("Failed to load warehouse:\n{e:#}"));
            }
            Ok(tl) if tl.is_empty() => {
                ui.label(format!(
                    "No releases recorded for workspace `{}` yet.",
                    self.workspace_name
                ));
                ui.label(&self.connect_status);
            }
            Ok(tl) => match self.tab {
                Tab::Timeline => {
                    self.selected = draw_timeline(ui, tl, self.selected, &self.palette);
                    // EMIT-DOCTRINE (emit-fallback): the Timeline paint can't be
                    // observed from outside — self-report a functional_status row
                    // carrying the painted lane/release counts so the matrix proves
                    // the tab rendered non-blank (a blank timeline is a RED row).
                    let lanes = tl.lanes.len();
                    let nodes: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
                    nornir_testmatrix::functional_status(
                        "nornir/viz/timeline",
                        "timeline_rendered",
                        nodes > 0,
                        &format!("{lanes} lane(s), {nodes} release node(s) painted"),
                    );
                }
                Tab::DepGraph => {
                    if self.selected.is_none() {
                        ui.label("Showing latest snapshot — click a release in 🧵 Timeline to time-travel here.");
                    } else if let Some(rid) = self.selected {
                        if let Some(snap) = tl.snapshot_for(&rid) {
                            ui.label(format!(
                                "Pinned to release {} (snapshot {})",
                                rid, snap.snapshot_id
                            ));
                        }
                    }
                    draw_dep_graph(ui, tl, self.selected, &mut self.dep_selected_repo, &mut self.dep_view);
                }
                Tab::TimeTravel => {
                    // Keyboard shortcuts while on this tab.
                    ctx.input(|i| {
                        if i.key_pressed(egui::Key::ArrowRight) {
                            if let Ok(tl) = &self.timeline {
                                if self.timetravel.idx + 1 < tl.release_order.len() {
                                    self.timetravel.idx += 1;
                                }
                            }
                        }
                        if i.key_pressed(egui::Key::ArrowLeft) {
                            self.timetravel.idx = self.timetravel.idx.saturating_sub(1);
                        }
                        if i.key_pressed(egui::Key::Space) {
                            self.timetravel.playing = !self.timetravel.playing;
                            self.timetravel.last_tick = std::time::Instant::now();
                        }
                    });
                    if let Some(rid) = draw_timetravel(ui, tl, &mut self.timetravel) {
                        self.selected = Some(rid);
                    }
                }
                Tab::Search | Tab::Gates | Tab::Security => unreachable!("handled above"),
                Tab::Dwarves => unreachable!("handled above"),
                Tab::Nornir => unreachable!("handled above"),
                Tab::LiveRun => unreachable!("handled above"),
                Tab::Release => unreachable!("handled above"),
                Tab::Chat => unreachable!("handled above"),
                Tab::Knowledge => unreachable!("handled above"),
                Tab::Warehouse => unreachable!("handled above"),
                Tab::WarehouseDeck => unreachable!("handled above"),
                Tab::Mcp => unreachable!("handled above"),
                Tab::CallGraph => unreachable!("handled above"),
                Tab::Architecture => unreachable!("handled above"),
                Tab::Metro => unreachable!("handled above"),
                Tab::Helix => unreachable!("handled above"),
                Tab::Bloodstream => unreachable!("handled above"),
                Tab::Funnel => unreachable!("handled above"),
                Tab::Holger => unreachable!("handled above"),
                Tab::Manual => unreachable!("handled above"),
                Tab::Claude => unreachable!("handled above"),
                Tab::Models => unreachable!("handled above"),
                Tab::Settings => unreachable!("handled above"),
            },
            }
        });
    }

    /// The right "Time machine" inspector body — the scene's `detail` slot. Formerly
    /// the `SidePanel::right("detail")`; now a foldable pane on the DRAGGABLE
    /// master/detail split (the fold header carries the title, so this omits it).
    fn draw_detail_body(&mut self, ui: &mut egui::Ui) {
        ui.separator();
        // Workspace info + sync result (when the ℹ toggle is on).
        self.ws_panel
            .draw_panel(ui, self.server().as_ref(), &self.workspace_name);
        match (self.selected, &self.timeline) {
            (Some(rid), Ok(tl)) => {
                ui.label(format!("release_id\n  {rid}"));
                ui.separator();
                ui.label("git checkout commands:");
                for lane in &tl.lanes {
                    if let Some(node) = lane.nodes.iter().find(|n| n.release_id == rid) {
                        let cmd = format!("cd {} && git checkout {}", lane.repo, node.sha);
                        ui.horizontal(|ui| {
                            ui.monospace(&cmd);
                            if ui.button("📋").on_hover_text("copy").clicked() {
                                ui.ctx().copy_text(cmd.clone());
                            }
                        });
                        let color = status_color(&self.palette, &node.gate_status);
                        ui.colored_label(
                            color,
                            format!(
                                "  {} • status={} • tests {}/{} • branch={} • dirty={}",
                                lane.repo,
                                node.gate_status,
                                node.tests_passed,
                                node.tests_failed,
                                node.branch,
                                node.dirty,
                            ),
                        );
                        if !node.published_versions.is_empty() {
                            ui.label(format!(
                                "  published: {}",
                                node.published_versions
                                    .iter()
                                    .map(|(c, v)| format!("{c}@{v}"))
                                    .collect::<Vec<_>>()
                                    .join(", ")
                            ));
                        }
                        ui.add_space(4.0);
                    }
                }
            }
            (None, _) => {
                ui.label("click a node in the timeline →");
            }
            (_, Err(e)) => {
                ui.colored_label(super::facett_theme::RED, format!("load error:\n{e:#}"));
            }
        }
    }

    /// The bottom action-trail body — the scene's `action_log` DRAWER slot. Formerly
    /// the `TopBottomPanel::bottom("action_log")`; now a foldable drawer that
    /// auto-collapses on a phone-class width. The 🐞 title rides on the fold header.
    fn draw_action_log_body(&mut self, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            ui.label(format!("({} total)", self.action_log.count()));
            ui.separator();
            ui.monospace(self.action_log.file_path());
        });
        ui.separator();
        egui::ScrollArea::vertical()
            .stick_to_bottom(true)
            .auto_shrink([false, false])
            .show(ui, |ui| {
                // Render the TAIL of the shared action-log FILE (a running `nornir
                // bench`'s `[BENCH]` progress streams here too — the file is the
                // single source of truth), colour-coded on the active palette.
                for line in self.action_log.tail_lines(64 * 1024, 256) {
                    let color = if line.contains("[ERROR]") {
                        super::facett_theme::RED
                    } else if line.contains("[BENCH]") {
                        super::facett_theme::AMBER
                    } else if line.contains("[RPC]") {
                        self.palette.accent
                    } else if line.contains("[TAB]") {
                        super::facett_theme::GREEN
                    } else if line.contains("[QUERY]") {
                        super::facett_theme::AMBER
                    } else if line.contains("[LIFE]") {
                        self.palette.text_dim
                    } else {
                        self.palette.text
                    };
                    ui.monospace(egui::RichText::new(line).size(11.0).color(color));
                }
            });
        // Keep the tail live while the drawer is open (bench progress).
        ui.ctx()
            .request_repaint_after(std::time::Duration::from_millis(300));
    }

    /// 🎬 The declarative master shell as observable DATA (LAW #6): the pane tree,
    /// which panes are foldable / drawers / draggable splits / chromed, the host-filled
    /// tab slots, the live `folded` + `minimized` + `maximized` + `split_ratio` maps,
    /// the menu/route counts, and the last `scene::render` facts. Folded into
    /// `state_json()["scene"]`.

The file goes on; the console shows all of it.