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

nordisk/nornir

src/viz/arch_tab.rs

//! 🏛 **Architecture** tab — the EPIC ARCH wiring board, drawn **natively in
//! egui inside the running viz**.
//!
//! The wiring graph (UI-component ↔ gRPC ↔ core-fn ↔ warehouse-table) already
//! exists as the static circuit-board SVG `nornir arch svg` emits and as the
//! `arch_graph` MCP tool's JSON — but it could not be *viewed in the app*. This
//! pane closes that gap: it reads the persisted [`crate::arch::ArchGraph`] for the
//! active workspace and paints it with egui shapes — nodes coloured by
//! [`crate::arch::NodeKind`] via the active facett palette, edges showing the
//! wiring, navigable (pan/zoom, click a node → highlight its downstream wiring,
//! the same trace `nornir arch trace` computes). It is **egui-native** (no
//! usvg/resvg rasterisation), reusing the DepGraph / CallGraph rendering approach.
//!
//! Source split (mirrors every other pane):
//!   * **local / fat** — open the warehouse directly and
//!     [`merge`](crate::arch::warehouse::merged_arch_from_warehouse) every
//!     member's latest persisted board into one workspace-wide graph.
//!   * **thin / remote** — the server owns the redb lock, so the merge runs
//!     server-side and ships back over the `Viz.Architecture` RPC
//!     ([`crate::viz::remote::fetch_architecture`]).
//!
//! Empty state: if no member has a persisted board yet, the pane shows a clean
//! "run `nornir arch generate`" placeholder — never a hard error, never a TODO.

use std::collections::{BTreeSet, HashMap, VecDeque};
use std::path::PathBuf;
use std::time::Duration;

use eframe::egui::{self, Color32, FontId, Pos2, RichText, ScrollArea, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};

#[cfg(test)]
use crate::arch::ArchEdge;
use crate::arch::metro::{self, ButtonRpc, MetroLine};
use crate::arch::{ArchEdgeKind, ArchGraph, ArchNode, NodeKind, SymbolLoc};
use crate::coverage::{Boundary, classify};
use crate::viz::trace;

use super::facett_theme::{Theme, ThemeExt};

/// How often the **Live system** sub-view re-reads the warehouse (repaint-on-
/// change), mirroring `bench_live`/`coverage_live`.
const LIVE_RELOAD_EVERY: Duration = Duration::from_millis(2000);

/// The 🏛 Architecture tab is a CONTAINER: an internal sub-view selector picks
/// which architecture lens to draw. (Deps is rendered by the app — the tab only
/// owns which lens is active so the app routes the dep graph in; the other three
/// are owned + drawn here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchSubView {
    /// 🔗 The dependency graph (cross-repo + intra-repo). Drawn by the app
    /// (`draw_dep_graph`) because it needs the timeline; consolidated under Arch.
    Deps,
    /// 📡 The wiring board, warehouse-driven + repaint-on-change.
    Live,
    /// 🏛 The EPIC ARCH wiring board (arch_graph/arch_svg), static.
    Wiring,
    /// 📍 Hållpunkter — the ui / gRPC / emitter LANDMARK functions as nodes.
    Boundaries,
    /// 🚇 Metro map — per-button click → gRPC CALL PATH, drawn as subway lines
    /// with every function a station + boundary fns labelled interchanges. This
    /// is the call-PATH map, NOT the function call graph (that is `CallGraph`).
    Metro,
    /// 🕸 The function/symbol call graph (`caller → callee` from `call_edges`),
    /// drilled repo → crate → fn calls. Drawn by the app (it owns the warehouse-
    /// backed [`crate::viz::callgraph::CallGraphState`] renderer), the same way
    /// `Deps` is — the tab leaves the central area free for it. Restored here as a
    /// distinct lens (the consolidation folded it under Arch, but it had become
    /// unreachable when the top-level 🕸 Call Graph button was dropped).
    CallGraph,
}

impl ArchSubView {
    /// Every sub-view, in selector order — the strip the tab paints + the set the
    /// headless matrix enumerates.
    pub const ALL: [ArchSubView; 6] = [
        ArchSubView::Deps,
        ArchSubView::Live,
        ArchSubView::Wiring,
        ArchSubView::Boundaries,
        ArchSubView::CallGraph,
        ArchSubView::Metro,
    ];

    /// Stable id (the `state_json` / click contract name).
    pub fn id(self) -> &'static str {
        match self {
            ArchSubView::Deps => "Deps",
            ArchSubView::Live => "Live",
            ArchSubView::Wiring => "Wiring",
            ArchSubView::Boundaries => "Boundaries",
            ArchSubView::CallGraph => "CallGraph",
            ArchSubView::Metro => "Metro",
        }
    }

    /// The human label the selector chip paints.
    pub fn label(self) -> &'static str {
        match self {
            ArchSubView::Deps => "🔗 Dep Graph",
            ArchSubView::Live => "📡 Live system",
            ArchSubView::Wiring => "🏛 Wiring",
            ArchSubView::Boundaries => "📍 Hållpunkter",
            ArchSubView::CallGraph => "🕸 Call Graph",
            ArchSubView::Metro => "🚇 Metro (call path)",
        }
    }

    fn from_id(s: &str) -> Option<ArchSubView> {
        ArchSubView::ALL.into_iter().find(|v| v.id() == s)
    }
}

/// The decoded `Viz.Architecture` payload (the server's merged board + which
/// members contributed + any per-member load errors). Shared between the embedded
/// warehouse merge and the remote RPC decode, so a thin client renders the exact
/// same graph the fat path produces.
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct ArchPayload {
    pub graph: ArchGraph,
    #[serde(default)]
    pub members_with_data: Vec<String>,
    #[serde(default)]
    pub errors: Vec<String>,
    /// node id → coverage verdict (`"covered"` | `"allowlisted"` | `"missing"`),
    /// cross-checked against the `surface_coverage` matrix: does a test exist for
    /// this marker? Empty when no coverage was recorded. Populated on BOTH the
    /// local (warehouse) and the remote (server) side so thin == fat — the
    /// green/red cross-check is the same warehouse data either way.
    #[serde(default)]
    pub coverage: std::collections::BTreeMap<String, String>,
    /// The merged `symbol_facts` of every member, read from the WAREHOUSE
    /// (`KnowledgeView::load_latest`). The fuel the 🚇 Metro map needs in THIN
    /// mode — where there is no local checkout to scan, so the call-PATH lines are
    /// built from these warehouse-persisted facts instead. Empty in the fat path
    /// (the pane scans the checkout directly); populated server-side so the thin
    /// metro draws the EXACT same button→gRPC paths a local scan would.
    #[serde(default)]
    pub symbols: Vec<crate::knowledge::symbols::SymbolRow>,
    /// The merged `call_edges` (caller→callee) of every member, read from the
    /// WAREHOUSE alongside [`Self::symbols`]. Together they let the thin metro
    /// build real lines (not click-handler-head stubs) without a checkout.
    #[serde(default)]
    pub calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
    /// The SCIP-provenance of the [`Self::symbols`]/[`Self::calls`] fuel, as the
    /// server's `query::load_preferred` chokepoint tagged it: `"resolved/scip"`
    /// (the resolved SCIP index answered for ≥1 member), `"syn"` (the name-based
    /// warehouse map), or `""`/`"warehouse"` (no facts / an older server that
    /// didn't ship the tag). Lets the THIN metro style + label resolved-vs-syn
    /// edges the SAME way the fat path does, instead of blindly tagging
    /// `"warehouse"`. `#[serde(default)]` ⇒ an older server (no field) ⇒ `""`.
    #[serde(default)]
    pub metro_fuel_source: String,
    /// `(gRPC handler fn, table)` — the warehouse table(s) nearest to each
    /// handler along CALLS + TOUCHES_TABLE, from the code room: the 🚇 lines'
    /// TERMINI. `#[serde(default)]` ⇒ an older server (no field) ⇒ every line
    /// ends in a placeholder terminus, as before.
    #[serde(default)]
    pub access: Vec<(String, String)>,
}

/// Where the graph comes from — a local warehouse (fat) or the server (thin).
enum Src {
    Local(PathBuf),
    Remote { endpoint: String, token: String },
}

/// A laid-out node (graph node + its on-canvas position, before pan/zoom).
struct Laid {
    node: ArchNode,
    pos: Pos2,
}

/// INPUT of an architecture load — which source + workspace was asked for.
#[derive(Serialize)]
struct LoadIn {
    source: String,
    workspace: String,
}

/// OUTPUT of an architecture render — the exact board the user sees, as readable
/// data (LAW #6): counts per kind + edges + which node is selected.
#[derive(Serialize)]
struct RenderOut {
    node_count: usize,
    edge_count: usize,
    components: usize,
    grpc: usize,
    cli: usize,
    backends: usize,
    codecs: usize,
    sinks: usize,
    core_fns: usize,
    tables: usize,
    /// Markers with a covering test (`surface_coverage` verdict `covered`).
    tested: usize,
    /// Markers in the surface with no test (`missing`) — the red ones.
    untested: usize,
    /// Markers that are known-uncovered but allowlisted (tracked gaps).
    allowlisted: usize,
    /// Markers with no matching coverage row (not claimed either way).
    coverage_unknown: usize,
    selected: Option<String>,
}

/// The 🚇 Metro map's populated-state AGGREGATES — the verify-without-looking
/// numbers the emitter ships (and `state_json` exposes). A blank/stub metro is
/// red in this data (`metro_edges == 0` or `metro_emitter_nodes == 0` or
/// `metro_stub_lines > 0`), so correctness is asserted from the emit, never pixels.
#[derive(Serialize, Clone, Copy, Default)]
struct MetroAggregates {
    line_count: usize,
    reached_lines: usize,
    metro_nodes: usize,
    metro_edges: usize,
    metro_emitter_nodes: usize,
    metro_stub_lines: usize,
}

pub struct ArchTabState {
    src: Src,
    workspace: String,
    /// `true` once a load has been attempted (so we don't reload every frame).
    loaded: bool,
    /// When the last board load ran — the shared `repo_pane` throttle clock so an
    /// EMPTY board auto-reloads (a background `arch_generate`/populate fills it
    /// without a click) instead of latching blank. `None` ⇒ never loaded.
    last_fetch_at: Option<std::time::Instant>,
    /// The merged board to render (empty until loaded / when no member has data).
    graph: ArchGraph,
    /// Members that contributed a board (for the placeholder / status line).
    members_with_data: Vec<String>,
    /// Configured members (for the "which members lack a board" placeholder).
    members: Vec<String>,
    /// Per-member load errors (surfaced; never blank the board).
    errors: Vec<String>,
    /// A human error string if the whole load failed (e.g. RPC down).
    load_error: Option<String>,
    /// Laid-out nodes + adjacency (index space), rebuilt when `graph` changes.
    laid: Vec<Laid>,
    /// id → index into `laid`.
    idx: HashMap<String, usize>,
    /// Forward adjacency over all edge kinds (for the downstream-trace highlight).
    adj: HashMap<usize, Vec<usize>>,
    /// Clicked node (index into `laid`) — highlights its downstream wiring.
    selected: Option<usize>,
    /// The downstream-reachable set from `selected` (BFS), recomputed on click.
    traced: BTreeSet<usize>,
    pan: Vec2,
    zoom: f32,
    /// Edge-trigger for the `architecture.render` trace event (once per data
    /// change / selection change, not once per frame).
    render_dirty: bool,
    /// node id → coverage verdict from the `surface_coverage` matrix
    /// (`"covered"`/`"allowlisted"`/`"missing"`). The cross-check that paints a
    /// marker green (a test exists) or red (none). Empty = no coverage recorded.
    coverage: std::collections::BTreeMap<String, String>,
    theme: Theme,
    // ── container: which sub-view (lens) is active ───────────────────────────
    /// The active sub-view (Deps / Live / Wiring / Hållpunkter).
    sub: ArchSubView,
    // ── click-to-code (the symbol → file:line bridge) ────────────────────────
    /// Local checkout root (`workspace_root/<member>`-ish) used to resolve a
    /// clicked node label to source via a fresh `symbol_facts` scan, and to read
    /// the inline preview window. Empty in remote mode (no checkout → no resolve).
    repo_root: PathBuf,
    /// The repo to scan for symbol resolution (the first configured member).
    resolve_repo: String,
    /// Scanned symbols, lazily loaded the first time a node is clicked (so the
    /// tab doesn't scan on open). `None` until attempted; `Some(vec)` after.
    symbols: Option<Vec<crate::knowledge::symbols::SymbolRow>>,
    /// Scanned call edges (caller→callee), cached alongside `symbols` from the
    /// same scan — the fuel for the 🚇 Metro map's button→gRPC paths.
    calls: Option<Vec<crate::knowledge::symbols::CallEdgeRow>>,
    /// The metro lines (one per button), built lazily on first Metro draw from
    /// the cached scan. `None` until built; rebuilt when the scan is re-scoped.
    metro_lines: Option<Vec<MetroLine>>,
    /// **SCIP-provenance of the metro fuel** — the stable source tag the
    /// `query::load_preferred` chokepoint returned for the call graph the lines
    /// are built from: `"resolved/scip"` (the resolved SCIP index answered),
    /// `"syn"` (the warehouse syn map), `"syn/scan"` (a live checkout scan), or
    /// `"warehouse"` (thin-mode facts the server shipped). Surfaced in
    /// `state_json` (LAW 6) so a reader can SEE which index resolved the edges
    /// vs the syn fallback, and used to style the rail (resolved = solid accent,
    /// syn-fallback = dimmed). Empty until `ensure_symbols` runs.
    metro_fuel_source: String,
    /// `(gRPC handler fn, table)` from the code room (`ArchPayload::access`):
    /// where each 🚇 line ends. Empty = no handler reaches a table (or an
    /// older server): placeholder termini.
    access: Vec<(String, String)>,
    /// The currently-selected metro button id (the highlighted line). Defaults to
    /// the first registry button so the lens is never blank.
    metro_selected: String,
    /// The last click-to-code resolution: the queried symbol + its `file:line`
    /// candidates (best first). Drives the inline source-preview panel + the
    /// `state_json` last-clicked record + the `architecture.open` trace.
    last_open: Option<OpenResult>,
    /// `true` while the Live sub-view should repaint-on-change (the `live` toggle
    /// parity with bench_live/coverage_live). Reuses `reload()` on the timer.
    live: bool,
    last_live_reload: std::time::Instant,
    /// N4 — the workspace GIT root used to locate the server-cloned sources on
    /// disk (`repo_dir_resolved(source_root, <repo>)` → the real checkout). Set by
    /// the app in BOTH local and thin modes so the source viewer reads the actual
    /// file bytes (not a stub) even when the warehouse is served remotely but the
    /// clones live locally under `~/.nornir/workspaces/<ws>/git/<repo>/…`.
    source_root: PathBuf,
    /// N4 — a ⊙ fit was requested: on the next central-panel draw, reframe the
    /// zoom so the whole graph's content extent fits the viewport (needs the live
    /// viewport size, which is only known inside the panel).
    fit_pending: bool,
    /// N4 — the set of identifiers that are click-through NAVIGABLE in the source
    /// viewer: symbol names known to the loaded wiring (graph node labels +
    /// scanned `symbol_facts` + `call_edges` idents). Lazily built, dropped when
    /// the graph/symbols change. A token in a source line that is in this set
    /// renders as a link that jumps to its definition (Wikipedia-style walk).
    navigable: Option<BTreeSet<String>>,
    /// N2 — background "Deep-scan this repo" state: recomputes `call_edges` on
    /// demand (thin: a server-side workspace republish that persists them; local:
    /// a live syn scan of the checkout seeded straight into the caches).
    deep_scan: std::sync::Arc<std::sync::Mutex<DeepScan>>,
}

/// N2 — shared state of the manual deep-scan kick. Thin mode republishes the
/// workspace server-side (persisting `symbol_facts` + `call_edges`); local mode
/// scans the checkout and returns the rows to seed on the UI thread.
#[derive(Default)]
struct DeepScan {
    inflight: bool,
    error: Option<String>,
    /// Local-mode scan result to apply on the UI thread: (symbols, calls).
    result: Option<(
        Vec<crate::knowledge::symbols::SymbolRow>,
        Vec<crate::knowledge::symbols::CallEdgeRow>,
    )>,
    /// Thin-mode: the server republish finished OK → drop the load latch so the
    /// board/metro refetch the freshly-persisted `call_edges` from the warehouse.
    refetch: bool,
    /// A short human note for the status line after a completed scan.
    note: Option<String>,
}

/// One click-to-code resolution — the queried symbol, the resolved candidates,
/// whether the editor was launched, and the inline source-preview window.
#[derive(Clone, Default)]
struct OpenResult {
    query: String,
    hits: Vec<SymbolLoc>,
    /// `Some(true)` opened in editor, `Some(false)` `code` absent, `None` not tried.
    editor_opened: Option<bool>,
    /// The 1-based first line of `preview` + the source lines around the hit.
    preview_start: u32,
    preview: Vec<String>,
}

/// A marker's test-matrix status, derived from its `surface_coverage` verdict.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Cov {
    /// A covering test exists (`verdict = covered`).
    Tested,
    /// Known-uncovered but allowlisted (`verdict = allowlisted`) — a tracked gap.
    Allowlisted,
    /// In the surface, no test (`verdict = missing`) — RED.
    Untested,
    /// No coverage row matched this node — we don't claim either way.
    Unknown,
}

impl Cov {
    fn from_verdict(v: &str) -> Cov {
        match v {
            "covered" => Cov::Tested,
            "allowlisted" => Cov::Allowlisted,
            "missing" => Cov::Untested,
            _ => Cov::Unknown,
        }
    }
    fn label(self) -> &'static str {
        match self {
            Cov::Tested => "tested",
            Cov::Allowlisted => "allowlisted",
            Cov::Untested => "untested",
            Cov::Unknown => "unknown",
        }
    }
}

impl ArchTabState {
    pub fn local(root: PathBuf) -> Self {
        Self::with(Src::Local(root), String::new())
    }
    pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
        Self::with(Src::Remote { endpoint, token }, workspace)
    }
    fn with(src: Src, workspace: String) -> Self {
        Self {
            src,
            workspace,
            loaded: false,
            last_fetch_at: None,
            graph: ArchGraph::default(),
            members_with_data: Vec::new(),
            members: Vec::new(),
            errors: Vec::new(),
            load_error: None,
            laid: Vec::new(),
            idx: HashMap::new(),
            adj: HashMap::new(),
            selected: None,
            traced: BTreeSet::new(),
            pan: Vec2::ZERO,
            zoom: 1.0,
            render_dirty: false,
            coverage: std::collections::BTreeMap::new(),
            theme: Theme::default(),
            // N3 — the Architecture tab OPENS on the 🕸 Call Graph lens: the 3-D
            // rotating node cloud (the app draws `CallGraphState`, which auto-spins
            // the instant it's active). The 2-D metro/wiring/dep lenses stay one
            // click away in the sub-view selector strip (the toggle).
            sub: ArchSubView::CallGraph,
            repo_root: PathBuf::new(),
            resolve_repo: String::new(),
            symbols: None,
            calls: None,
            metro_lines: None,
            metro_fuel_source: String::new(),
            access: Vec::new(),
            metro_selected: metro::button_registry()
                .first()
                .map(|b| b.id.to_string())
                .unwrap_or_default(),
            last_open: None,
            live: false,
            last_live_reload: std::time::Instant::now(),
            source_root: PathBuf::new(),
            fit_pending: false,
            navigable: None,
            deep_scan: std::sync::Arc::new(std::sync::Mutex::new(DeepScan::default())),
        }
    }

    /// N4 — seed the workspace GIT root the source viewer resolves on-disk clones
    /// against (`repo_dir_resolved(source_root, <repo>)`). Set by the app in both
    /// local and thin modes; empty falls back to the registry default.
    pub fn set_source_root(&mut self, root: PathBuf) {
        self.source_root = root;
    }

    /// The active sub-view (lens). `pub` so the app routes the Deps lens to the
    /// dep graph + the headless matrix reads which lens is showing.
    pub fn sub_view(&self) -> ArchSubView {
        self.sub
    }

    /// Switch the active sub-view (selector click / robot drive). Emits the
    /// switch as a trace event so it's observable headlessly.
    pub fn set_sub_view(&mut self, sub: ArchSubView) {
        if self.sub != sub {
            self.sub = sub;
            trace::emit_event(
                "architecture.subview",
                &serde_json::json!({ "sub": sub.id() }),
            );
            self.render_dirty = true;
        }
    }

    /// Seed the local checkout root + the repo to scan for click-to-code symbol
    /// resolution (the inline source preview + `code --goto`). Set by the app on
    /// build/switch; empty in remote mode (no checkout, no resolve).
    pub fn set_resolve_source(&mut self, repo_root: PathBuf, repo: String) {
        if self.repo_root != repo_root || self.resolve_repo != repo {
            self.repo_root = repo_root;
            self.resolve_repo = repo;
            self.symbols = None; // re-scan lazily for the new source
            self.calls = None;
            self.metro_lines = None;
            self.metro_fuel_source.clear();
            self.navigable = None;
        }
    }

    /// Set the facett palette the pane paints with (C8).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// The configured workspace members (for the placeholder that names which
    /// members still need `nornir arch generate`). Set by the app on build/switch.
    pub fn set_members(&mut self, members: Vec<String>) {
        self.members = members;
    }

    /// Re-scope to a different workspace (the picker switched): drop the cached
    /// board so the next draw reloads from the new workspace.
    pub fn set_workspace(&mut self, members: Vec<String>) {
        self.members = members;
        self.loaded = false;
        self.last_fetch_at = None;
        self.graph = ArchGraph::default();
        self.members_with_data.clear();
        self.errors.clear();
        self.load_error = None;
        self.laid.clear();
        self.idx.clear();
        self.adj.clear();
        self.selected = None;
        self.traced.clear();
        self.coverage.clear();
        self.pan = Vec2::ZERO;
        self.zoom = 1.0;
        self.symbols = None;
        self.calls = None;
        self.metro_lines = None;
        self.metro_fuel_source.clear();
        self.last_open = None;
        self.navigable = None;
        // Task 2: a new workspace may legitimately need its own auto-generate kick.
        *self.deep_scan.lock().unwrap() = DeepScan::default();
    }

    /// Thin mode only: re-point the `Viz.Architecture` RPC at a new workspace.
    pub fn set_workspace_name(&mut self, workspace: String) {
        self.workspace = workspace;
    }

    /// Force a reload on the next draw (e.g. after a ⟳ Sync changed the warehouse).
    pub fn reload(&mut self) {
        self.loaded = false;
        self.last_fetch_at = None;
    }

    /// Load the merged board: local opens the warehouse + merges every member's
    /// latest board; remote reads the server's merge over `Viz.Architecture`.
    fn ensure_loaded(&mut self) {
        // AUTO-REFRESH (the sticky-`loaded` fix): the first load always runs;
        // while the board is EMPTY the shared `repo_pane` throttle re-loads —
        // eagerly when an `arch_generate`/populate job for this workspace is
        // active, else on the slow interval — so a background generate's board
        // appears WITHOUT a manual ↻. Once nodes exist it latches (no churn).
        let populate_active = super::repo_pane::populate_active_for(&self.workspace, "");
        if !super::repo_pane::should_refetch(
            self.loaded,
            self.last_fetch_at,
            self.graph.nodes.is_empty(),
            populate_active,
        ) {
            return;
        }
        self.loaded = true;
        self.last_fetch_at = Some(std::time::Instant::now());
        let source = match &self.src {
            Src::Local(p) => format!("local {}", p.display()),
            Src::Remote { endpoint, .. } => format!("remote {endpoint} (ws={})", self.workspace),
        };
        trace::emit_in(
            "architecture.load",
            &LoadIn {
                source,
                workspace: self.workspace.clone(),
            },
        );
        // The board and the metro fuel are the code room's (`Viz.Architecture`:
        // the kept classifier over the room's symbol / call rows). Thin asks the
        // server; fat asks the workspace's own code room in process — the same
        // reader, the same JSON. A reader that cannot answer (nothing consumed,
        // no lake) is `load_error` with its state — never an empty board that
        // reads as "nothing wired".
        let payload: Result<ArchPayload, String> = match &self.src {
            Src::Remote { endpoint, token } => super::remote::fetch_architecture(endpoint, token, &self.workspace).map_err(|e| format!("architecture board: {e:#}")),
            Src::Local(root) => super::remote::local_surface(root, &self.workspace, "Viz.Architecture", &serde_json::json!({ "workspace": self.workspace }))
                .map_err(|e| format!("architecture board: {e:#}"))
                .and_then(|v| serde_json::from_value(v).map_err(|e| format!("architecture board: the reader's answer is not a board: {e}"))),
        };
        match payload {
            Ok(p) => {
                self.load_error = None;
                self.members_with_data = p.members_with_data;
                self.errors = p.errors;
                self.coverage = p.coverage;
                // THIN-MODE METRO FUEL: in remote mode the server ships the merged
                // `symbol_facts` + `call_edges` it read from the WAREHOUSE (the fat
                // path scans the local checkout instead, so it leaves these empty).
                // When the payload carries them, seed the scan caches so the 🚇
                // Metro map builds real button→gRPC lines WITHOUT a checkout — the
                // core fix: thin mode reads the warehouse, it does NOT degrade to
                // click-handler-head stubs. Only overwrite when the server actually
                // supplied facts, so the fat path's own checkout scan is preserved.
                if !p.symbols.is_empty() {
                    self.symbols = Some(p.symbols);
                    self.calls = Some(p.calls);
                    // The reader's provenance tag (`resolved/scip`: rustc resolved
                    // every callee) styles the metro rails; an older server that
                    // ships "" reads as "unknown".
                    self.metro_fuel_source = p.metro_fuel_source;
                    self.metro_lines = None; // rebuild from the room's rows
                }
                self.access = p.access;
                self.set_graph(p.graph);
            }
            Err(e) => {
                self.load_error = Some(e);
                self.coverage.clear();
                self.set_graph(ArchGraph::default());
            }
        }
    }

    /// This node's test-matrix status (the green/red cross-check).
    fn cov_of(&self, node_id: &str) -> Cov {
        match self.coverage.get(node_id) {
            Some(v) => Cov::from_verdict(v),
            None => Cov::Unknown,
        }
    }

    /// Swap in a new graph + rebuild the layout/adjacency + reset navigation.
    fn set_graph(&mut self, graph: ArchGraph) {
        self.graph = graph;
        self.selected = None;
        self.traced.clear();
        self.pan = Vec2::ZERO;
        self.zoom = 1.0;
        self.navigable = None; // rebuild the click-through set for the new graph
        self.build_layout();
        self.render_dirty = true;
    }

    /// Layered layout (PCB-board style, reused from `arch::layers_of`'s model):
    /// columns by topological layer, tables pinned right; positions centred per
    /// column. Pure + deterministic, so the rendered board is stable.
    fn build_layout(&mut self) {
        self.laid.clear();
        self.idx.clear();
        self.adj.clear();
        let layers = layers_of(&self.graph);
        const COL_W: f32 = 230.0;
        const ROW_H: f32 = 64.0;
        let cols = layers.len().max(1) as f32;
        let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1) as f32;
        let total_w = COL_W * (cols - 1.0).max(0.0);
        let total_h = ROW_H * (rows - 1.0).max(0.0);
        for (ci, layer) in layers.iter().enumerate() {
            let layer_h = ROW_H * (layer.len().saturating_sub(1)) as f32;
            let y0 = -total_h / 2.0 + (total_h - layer_h) / 2.0;
            for (ri, &node_i) in layer.iter().enumerate() {
                let x = -total_w / 2.0 + ci as f32 * COL_W;
                let y = y0 + ri as f32 * ROW_H;
                let node = self.graph.nodes[node_i].clone();
                self.idx.insert(node.id.clone(), self.laid.len());
                self.laid.push(Laid {
                    node,
                    pos: Pos2::new(x, y),
                });
            }
        }
        // Forward adjacency over all edge kinds, in index space.
        for e in &self.graph.edges {
            if let (Some(&f), Some(&t)) = (self.idx.get(&e.from), self.idx.get(&e.to)) {
                self.adj.entry(f).or_default().push(t);
            }
        }
    }

    /// Build the SHARED [`super::graph_render::GraphModel`] from the laid-out
    /// board: each chip's kind fill, and a stroke that folds in the coverage ring
    /// (green tested / red untested / amber allowlisted) so the arch board keeps
    /// its keystone cross-check WITHOUT a decoration overlay — i.e. it is the
    /// "no decorations" caller of the shared `draw_graph`. Edges coloured by kind.
    fn graph_model(&self) -> super::graph_render::GraphModel {
        let theme = self.theme;
        let nodes = self
            .laid
            .iter()
            .map(|l| {
                let (fill, kind_stroke) = kind_color(&theme, l.node.kind);
                // Coverage ring rides in the node stroke (the shared renderer uses
                // the node stroke when no decoration ring is supplied).
                let stroke = cov_ring(self.cov_of(&l.node.id)).unwrap_or(kind_stroke);
                super::graph_render::GraphNode {
                    id: l.node.id.clone(),
                    label: l.node.label.clone(),
                    fill,
                    stroke,
                    pos: l.pos,
                }
            })
            .collect();
        let edges = self
            .graph
            .edges
            .iter()
            .map(|e| super::graph_render::GraphEdge {
                from: e.from.clone(),
                to: e.to.clone(),
                color: edge_color(&theme, e.kind),
                dashed: false,
                label: None,
            })
            .collect();
        super::graph_render::GraphModel { nodes, edges }
    }

    /// BFS the downstream-reachable set from a clicked node (the visual twin of
    /// `nornir arch trace` lifted to the laid-out board).
    fn trace_from(&mut self, seed: usize) {
        self.traced.clear();
        let mut q = VecDeque::new();
        self.traced.insert(seed);
        q.push_back(seed);
        while let Some(cur) = q.pop_front() {
            if let Some(outs) = self.adj.get(&cur) {
                for &nxt in outs {
                    if self.traced.insert(nxt) {
                        q.push_back(nxt);
                    }
                }
            }
        }
    }

    /// Lazily scan the configured repo's symbols (the click-to-code source of
    /// truth). Best-effort: in remote mode / with no checkout, leaves `symbols`
    /// an empty vec so resolution returns nothing (the printed `file:line` + the
    /// graph still work; only the inline preview is unavailable).
    fn ensure_symbols(&mut self) {
        if self.symbols.is_some() {
            return;
        }
        // The fuel rides the board's payload (`ensure_loaded`). Reaching here
        // means that payload carried none: the board could not be read, or the
        // reader left the fuel out (too large for one message). The tag says
        // which, so the metro never reads as "no lines".
        self.ensure_loaded();
        if self.symbols.is_some() {
            return;
        }
        self.symbols = Some(Vec::new());
        self.calls = Some(Vec::new());
        self.metro_fuel_source = if self.load_error.is_some() { "unavailable" } else { "none_shipped" }.to_string();
    }

    /// Build the 🚇 metro map (one line per registry button) from the cached
    /// scan — REUSES [`crate::arch::metro::build_metro_map`]. Lazy: built once
    /// per scan, dropped on a re-scope (workspace / resolve-source switch).
    fn ensure_metro(&mut self) {
        if self.metro_lines.is_some() {
            return;
        }
        self.ensure_symbols();
        let syms = self.symbols.as_deref().unwrap_or(&[]);
        let calls = self.calls.as_deref().unwrap_or(&[]);
        let lines = metro::build_metro_map(syms, calls);
        trace::emit_end(
            "architecture.metro.build",
            &serde_json::json!({
                "lines": lines.len(),
                "reached": lines.iter().filter(|l| l.reached).count(),
                "symbols": syms.len(),
                "calls": calls.len(),
            }),
        );
        self.metro_lines = Some(lines);
        self.render_dirty = true;
        // EMITTER (verify-without-looking): the metro's populated-or-empty state as
        // DATA, the instant it's built. A blank/stub metro is now a RED matrix row,
        // never a silent pass — RAGNARÖK.
        self.emit_metro_status();
    }

    /// **S5b — the facett feed.** Build a [`facett_graphview::MetroMap`] from THIS
    /// tab's already-loaded `symbol_facts` + `call_edges` (the same fuel the native
    /// 🚇 metro uses), overlaid with the workspace's latest `surface_coverage` (the
    /// LIT signal) and the `fn → table` warehouse-access edges (the TERMINUS), via
    /// the pure [`super::metro_feed::build_metro_map`] producer. The dedicated 🚇
    /// Metro tab hands the result to its [`facett_graphview::MetroView`].
    ///
    /// Lock-tolerant: any warehouse read error → empty coverage/access (every gating
    /// stop unlit — HONEST "not yet proven", never a crash). Remote/thin mode ships
    /// no coverage/access yet (S6b), so those lines render unlit skeletons.
    pub fn facett_metro_map(&mut self) -> facett_graphview::MetroMap {
        self.ensure_symbols();
        let syms = self.symbols.clone().unwrap_or_default();
        let calls = self.calls.clone().unwrap_or_default();

        // The terminus tables are the code room's: `(gRPC handler, nearest
        // table)` along CALLS + TOUCHES_TABLE, shipped with the board
        // (`ArchPayload::access`). A handler that reaches no table keeps its
        // placeholder terminus. The lit overlay (`surface_coverage`, written by
        // the autonom gate) is not read by this pane yet: every gating stop is
        // unlit — the honest "not yet proven".
        let coverage: Vec<_> = Vec::new();
        let access = self.access.clone();

        let map = super::metro_feed::build_metro_map(&syms, &calls, &coverage, &access);
        trace::emit_end(
            "architecture.metro.facett_feed",
            &serde_json::json!({
                "lines": map.lines.len(),
                "green": map.green_count(),
                "coverage_rows": coverage.len(),
                "access_edges": access.len(),
            }),
        );
        map
    }

    /// The 🚇 metro's POPULATED-STATE AGGREGATES, as DATA — so the metro's
    /// "is it real or a stub?" question is answered WITHOUT looking at pixels:
    ///   * `metro_edges`   — total stations *between* head and terminus summed over
    ///                       every line (the call-path edges actually drawn).
    ///   * `metro_nodes`   — total stations summed over every line (every fn drawn).
    ///   * `metro_emitter_nodes` — how many stations are MARKED emitters (the
    ///                       emit*/trace/functional_status sites the owner wants
    ///                       flagged), summed over every line.
    ///   * `metro_stub_lines` — lines that are STILL click-handler-head stubs (a
    ///                       single station, did not reach gRPC). MUST be 0 when the
    ///                       warehouse is populated; non-zero ⇒ the read path is broken.
    ///   * `reached_lines` / `line_count` — the reached-vs-total tally.
    fn metro_aggregates(&self) -> MetroAggregates {
        let lines = self.metro_lines.as_deref().unwrap_or(&[]);
        let mut nodes = 0usize;
        let mut edges = 0usize;
        let mut emitter_nodes = 0usize;
        let mut stub_lines = 0usize;
        let mut reached_lines = 0usize;
        for l in lines {
            nodes += l.stations.len();
            // edges = the rail segments between consecutive stations.
            edges += l.stations.len().saturating_sub(1);
            emitter_nodes += l.emitter_stations;
            if l.reached {
                reached_lines += 1;
            }
            // A STUB = a line whose gRPC handler IS known (the rpc→handler symbol
            // resolved, so the path CAN exist) yet the call graph did not connect it
            // to the head → it degraded to the click-handler head only (≤1 station).
            // That is precisely the "no call edges scanned" failure the warehouse
            // read fixes in thin mode. A button whose handler symbol is simply absent
            // from this fuel set (`grpc_terminus == None`) is NOT a stub — it has no
            // resolvable target, so it cannot be expected to reach. This makes
            // `metro_stub_lines == 0` the exact populated-state invariant: every
            // line that COULD reach DID.
            if l.grpc_terminus.is_some() && !l.reached && l.stations.len() <= 1 {
                stub_lines += 1;
            }
        }
        MetroAggregates {
            line_count: lines.len(),
            reached_lines,
            metro_nodes: nodes,
            metro_edges: edges,
            metro_emitter_nodes: emitter_nodes,
            metro_stub_lines: stub_lines,
        }
    }

    /// EMIT the metro's populated-state aggregates as a `functional_status` row +
    /// a trace event. EMIT-DOCTRINE: a metro draw is a pixel/render stage whose
    /// correctness CANNOT be read from a return value, so an in-fn emit (gated
    /// behind `testmatrix`) is the right tool — the RAGNARÖK test reads this row
    /// back. ok ⇔ the metro carries REAL call-path data: at least one edge drawn,
    /// at least one emitter node marked, and ZERO stub lines (empty/stub = RED).
    fn emit_metro_status(&self) {
        let a = self.metro_aggregates();
        trace::emit_end(
            "architecture.metro.status",
            &serde_json::json!({
                "metro_edges": a.metro_edges,
                "metro_nodes": a.metro_nodes,
                "metro_emitter_nodes": a.metro_emitter_nodes,
                "metro_stub_lines": a.metro_stub_lines,
                "reached_lines": a.reached_lines,
                "line_count": a.line_count,
            }),
        );
        #[cfg(feature = "testmatrix")]
        {
            let populated =
                a.metro_edges > 0 && a.metro_emitter_nodes > 0 && a.metro_stub_lines == 0;
            crate::selftest::emit(
                "viz/architecture (metro)",
                "metro_populated_not_stub",
                populated,
                &format!(
                    "metro_edges={} metro_nodes={} metro_emitter_nodes={} metro_stub_lines={} reached={}/{}",
                    a.metro_edges,
                    a.metro_nodes,
                    a.metro_emitter_nodes,
                    a.metro_stub_lines,
                    a.reached_lines,
                    a.line_count,
                ),
            );
        }
    }

    /// The currently-selected metro line (the highlighted button's path).
    fn selected_metro_line(&self) -> Option<&MetroLine> {
        self.metro_lines
            .as_ref()?
            .iter()
            .find(|l| l.button_id == self.metro_selected)
    }

    /// Click-to-code: resolve a clicked node's `label` (or a typed symbol) to its
    /// source `file:line` via the SHARED [`crate::arch::resolve_symbol`] (the same
    /// bridge `nornir arch open` uses), then (i) open it in the editor
    /// (`code --goto`, best-effort), and (ii) load the inline source-preview
    /// window. The resolution is recorded in `last_open` for the preview panel +
    /// `state_json` + the `architecture.open` trace. Returns whether a hit was
    /// found. `pub` so the robot/CLI-parity drive + tests can fire it directly.
    pub fn open_symbol(&mut self, label: &str, launch_editor: bool) -> bool {
        self.ensure_symbols();
        let syms = self.symbols.as_deref().unwrap_or(&[]);
        let hits = crate::arch::resolve_symbol(syms, label);
        trace::emit_in(
            "architecture.open",
            &serde_json::json!({ "query": label, "candidates": hits.len() }),
        );
        let mut res = OpenResult {
            query: label.to_string(),
            hits: hits.clone(),
            ..Default::default()
        };
        if let Some(best) = hits.first() {
            // (ii) inline preview window (±6 lines) — READ from the actual on-disk
            // clone. `locate_repo_root` walks the candidate git roots (the app's
            // source_root + the canonical `~/.nornir/workspaces/<ws>/git`) and every
            // configured member until it finds the file that really contains this
            // symbol, so the viewer shows real bytes in BOTH local and thin modes.
            let root = self.locate_repo_root(&best.file);
            let (mut start, mut lines) =
                crate::arch::source_window(root.as_deref(), &best.file, best.line, 6);
            // Thin mode with no local clone of this file: the server holds the
            // checkouts, so fall back to the `Mimir.SourceWindow` RPC (empty repo
            // ⇒ the server tries each member and returns the first that contains
            // the file). The tuple shape is identical to the local reader.
            if lines.is_empty() {
                if let Src::Remote { endpoint, token } = &self.src {
                    if let Ok((s, l)) = crate::viz::remote::fetch_source_window(
                        endpoint,
                        token,
                        &self.workspace,
                        "",
                        &best.file,
                        best.line,
                        6,
                    ) {
                        start = s;
                        lines = l;
                    }
                }
            }
            res.preview_start = start;
            res.preview = lines;
            // (i) best-effort editor launch (opens the resolved absolute path).
            if launch_editor {
                res.editor_opened = Some(
                    crate::arch::open_in_editor(root.as_deref(), &best.file, best.line)
                        .unwrap_or(false),
                );
            }
        }
        let found = !res.hits.is_empty();
        trace::emit_end(
            "architecture.open",
            &serde_json::json!({
                "query": label,
                "resolved": res.hits.first().map(|h| format!("{}:{}", h.file, h.line)),
                "editor_opened": res.editor_opened,
            }),
        );
        self.last_open = Some(res);
        self.render_dirty = true;
        found
    }

    /// N4 — locate the on-disk repo checkout that actually contains `file` (a
    /// repo-relative path from `symbol_facts`). Walks the candidate GIT roots — the
    /// app-supplied [`Self::source_root`] first, then the canonical server-clone dir
    /// `~/.nornir/workspaces/<ws>/git` — and, under each, every configured member via
    /// [`crate::config::repo_dir_resolved`] (case-tolerant), returning the first
    /// checkout whose `<root>/<file>` is a real file. This is what lets the source
    /// viewer read the REAL bytes in thin mode, where the warehouse is remote but the
    /// clones live locally. Returns `None` when the file isn't found on disk.
    fn locate_repo_root(&self, file: &str) -> Option<PathBuf> {
        if file.is_empty() {
            return None;
        }
        // 1. The configured first-member root (set directly in local mode).
        if !self.repo_root.as_os_str().is_empty() && self.repo_root.join(file).is_file() {
            return Some(self.repo_root.clone());
        }
        // Candidate GIT roots the member checkouts live under.
        let mut roots: Vec<PathBuf> = Vec::new();
        if !self.source_root.as_os_str().is_empty() {
            roots.push(self.source_root.clone());
        }
        if !self.workspace.is_empty() {
            // The canonical server-clone location (FIXQUEUE N4): the workspace's
            // `git/<repo>/…` under the nornir registry root.
            roots.push(
                crate::config::registry_root()
                    .join(&self.workspace)
                    .join("git"),
            );
        }
        // 2. Try every configured member (else the single resolve_repo) under each
        //    candidate git root; also try the git root itself joined, in case the
        //    recorded path is already repo-qualified.
        let members: Vec<&str> = if self.members.is_empty() {
            if self.resolve_repo.is_empty() {
                Vec::new()
            } else {
                vec![self.resolve_repo.as_str()]
            }
        } else {
            self.members.iter().map(String::as_str).collect()
        };
        for gr in &roots {
            for m in &members {
                let root = crate::config::repo_dir_resolved(gr, m);
                if root.join(file).is_file() {
                    return Some(root);
                }
            }
            if gr.join(file).is_file() {
                return Some(gr.clone());
            }
        }
        None
    }

    /// N4 — the click-through NAVIGABLE identifier set: the symbol names the loaded
    /// wiring knows about, so a token in the source preview can be linked to its
    /// definition (Wikipedia-style walk). Built from (a) the wiring graph's node
    /// labels (their last `::`/`.` leaf too), (b) the scanned `symbol_facts` item
    /// names, and (c) the `call_edges` callee idents. Lazily built + cached; the set
    /// is dropped when the graph or symbols change.
    ///
    /// TODO(full-resolution): this navigates identifiers KNOWN to the loaded wiring
    /// (graph nodes + scanned facts). A token that resolves to a symbol outside the
    /// currently-loaded members won't link yet — a full LSP/SCIP-grade cross-repo
    /// resolver would close that gap.
    fn navigable_symbols(&mut self) -> &BTreeSet<String> {
        if self.navigable.is_none() {
            let mut set: BTreeSet<String> = BTreeSet::new();
            // (a) wiring graph node labels + their leaf segment.
            for n in &self.graph.nodes {
                let leaf = n
                    .label
                    .rsplit(['.', ':'])
                    .find(|s| !s.is_empty())
                    .unwrap_or(&n.label);
                if leaf.len() >= 2 {
                    set.insert(leaf.to_string());
                }
            }
            // (b) scanned symbol item names + (c) call-edge callee idents.
            if let Some(syms) = &self.symbols {
                for s in syms {
                    let leaf = s.item_name.rsplit("::").next().unwrap_or(&s.item_name);
                    if leaf.len() >= 2 {
                        set.insert(leaf.to_string());
                    }
                }
            }
            if let Some(calls) = &self.calls {
                for c in calls {
                    if c.callee_ident.len() >= 2 {
                        set.insert(c.callee_ident.clone());
                    }
                }
            }
            self.navigable = Some(set);
        }
        self.navigable.as_ref().unwrap()
    }

    /// N2 — trigger a real `call_edges` recomputation for the selected workspace.
    /// THIN mode kicks a server-side workspace republish (`Workspaces.Fetch`
    /// background) that re-scans every member and PERSISTS `symbol_facts` +
    /// `call_edges` to the warehouse — the exact write path the monitor uses — then
    /// drops the load latch so the board/metro refetch the fresh edges. LOCAL/fat
    /// mode runs a live syn scan of the selected checkout on a background thread and
    /// seeds the result straight into the metro/callgraph caches. Idempotent while a
    /// scan is in flight. `pub` so a headless drive can fire it.
    pub fn trigger_deep_scan(&mut self) {
        {
            let g = self.deep_scan.lock().unwrap();
            if g.inflight {
                return;
            }
        }
        match &self.src {
            Src::Remote { endpoint, token } => {
                let (endpoint, token, workspace) =
                    (endpoint.clone(), token.clone(), self.workspace.clone());
                if workspace.is_empty() {
                    self.deep_scan.lock().unwrap().error =
                        Some("no workspace selected".to_string());
                    return;
                }
                {
                    let mut g = self.deep_scan.lock().unwrap();
                    g.inflight = true;
                    g.error = None;
                    g.note = None;
                }
                trace::emit_in(
                    "architecture.deepscan",
                    &serde_json::json!({ "mode": "remote", "workspace": workspace }),
                );
                let shared = self.deep_scan.clone();
                std::thread::spawn(move || {
                    let res = super::remote::populate_workspace(&endpoint, &token, &workspace);
                    let mut g = shared.lock().unwrap();
                    g.inflight = false;
                    match res {
                        Ok((fetched, _changed, errors, _snap)) => {
                            g.refetch = true;
                            g.note = Some(format!(
                                "{fetched} member(s) fetched{}; the board follows what the server's consumer has written to the code room",
                                if errors.is_empty() {
                                    String::new()
                                } else {
                                    format!(", {} warn", errors.len())
                                },
                            ));
                        }
                        Err(e) => g.error = Some(format!("{e:#}")),
                    }
                });
            }
            Src::Local(_) => {
                // Fat mode has nothing to scan from this pane: the board is read
                // from the workspace's code room, which the consumer writes
                // (`nornir workspace populate`). Re-read it.
                self.deep_scan.lock().unwrap().note =
                    Some("re-read the code room (it is written by the consumer: `nornir workspace populate`)".to_string());
                self.reload();
            }
        }
    }

    /// N2 — apply a completed background deep-scan on the UI thread: seed local-mode
    /// scan rows into the metro/callgraph caches, or drop the load latch after a
    /// thin-mode republish so the board/metro refetch the persisted `call_edges`.
    fn poll_deep_scan(&mut self) {
        let (result, refetch) = {
            let mut g = self.deep_scan.lock().unwrap();
            (g.result.take(), std::mem::take(&mut g.refetch))
        };
        if let Some((symbols, calls)) = result {
            self.symbols = Some(symbols);
            self.calls = Some(calls);
            self.metro_lines = None;
            self.metro_fuel_source = "syn/scan".to_string();
            self.navigable = None;
            self.render_dirty = true;
        }
        if refetch {
            // Fresh warehouse rows landed server-side → reload the board + metro.
            self.loaded = false;
            self.last_fetch_at = None;
            self.symbols = None;
            self.calls = None;
            self.metro_lines = None;
        }
    }

    /// Build the **Hållpunkter** (boundary landmark) graph: the ui / gRPC /
    /// emitter functions as LANDMARK nodes, classified by the coverage feature's
    /// [`crate::coverage::classify`] (REUSED, not reinvented), from the scanned
    /// symbols. Each landmark is a node kind-mapped to the board's layers
    /// (ui→Component, grpc→Grpc, emitter→CoreFn) so it paints with the same
    /// legend; edges are omitted (landmarks are anchors, not a call graph).
    fn boundary_landmarks(&self) -> Vec<(Boundary, ArchNode)> {
        let syms = self.symbols.as_deref().unwrap_or(&[]);
        let mut out: Vec<(Boundary, ArchNode)> = Vec::new();
        let mut seen: BTreeSet<String> = BTreeSet::new();
        for s in syms {
            // Build the fully-qualified-ish name the classifier expects.
            let fq = if s.module_path.is_empty() {
                s.item_name.clone()
            } else {
                format!("{}::{}", s.module_path, s.item_name)
            };
            let b = classify(&fq, &s.file);
            if !b.is_boundary() {
                continue;
            }
            let kind = match b {
                Boundary::Ui => NodeKind::Component,
                Boundary::Grpc => NodeKind::Grpc,
                Boundary::Emitter | Boundary::Core => NodeKind::CoreFn,
            };
            let id = format!("{}:{}", b.as_str(), fq);
            if seen.insert(id.clone()) {
                out.push((
                    b,
                    ArchNode {
                        id,
                        label: s.item_name.clone(),
                        kind,
                    },
                ));
            }
        }
        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.label.cmp(&b.1.label)));
        out
    }

    /// (ui, grpc, emitter) landmark tally for the legend + `state_json`.
    fn boundary_tally(&self) -> (usize, usize, usize) {
        let (mut u, mut g, mut e) = (0, 0, 0);
        for (b, _) in self.boundary_landmarks() {
            match b {
                Boundary::Ui => u += 1,
                Boundary::Grpc => g += 1,
                Boundary::Emitter => e += 1,
                Boundary::Core => {}
            }
        }
        (u, g, e)
    }

    /// The 🏛 Architecture CONTAINER: paints the sub-view selector strip, then
    /// dispatches to the active lens. Deps is drawn by the app (it needs the
    /// timeline) — `draw` returns early for it after the strip, so the app's
    /// `draw_dep_graph` paints the central area; the other three lenses are drawn
    /// here. `draw_deps_for` is never called — the app routes by `sub_view()`.
    pub fn draw(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        // N2 — apply any completed background deep-scan before drawing this frame.
        self.poll_deep_scan();
        // The sub-view selector strip — a chip per lens (Deps / Live / Wiring /
        // Hållpunkter), the active one highlighted. CONSOLIDATES deps under Arch.
        egui::Panel::top("arch_subview").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.label(RichText::new("🏛 Architecture").strong().color(theme.text));
                ui.separator();
                for sv in ArchSubView::ALL {
                    if ui.selectable_label(self.sub == sv, sv.label()).clicked() {
                        self.set_sub_view(sv);
                    }
                }
            });
        });

        match self.sub {
            // The app paints the dep graph / function call graph in the remaining
            // central area after this returns (those renderers live on the app —
            // Deps needs the timeline, CallGraph owns the warehouse-backed
            // `CallGraphState`) — we leave the space free for them.
            ArchSubView::Deps | ArchSubView::CallGraph => {}
            ArchSubView::Boundaries => self.draw_boundaries(ui),
            ArchSubView::Metro => self.draw_metro(ui),
            ArchSubView::Live | ArchSubView::Wiring => self.draw_board(ui),
        }
    }

    /// The 🏛 Wiring / 📡 Live system lens: the EPIC ARCH wiring board, drawn
    /// natively. Live additionally re-reads the warehouse on a timer + requests a
    /// repaint (repaint-on-change), mirroring bench_live/coverage_live.
    fn draw_board(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        // Live system: timer-driven reload + repaint (warehouse-driven).
        if self.sub == ArchSubView::Live {
            if self.live && self.last_live_reload.elapsed() >= LIVE_RELOAD_EVERY {
                self.loaded = false;
                self.last_live_reload = std::time::Instant::now();
            }
        }
        self.ensure_loaded();

        egui::Panel::top("arch_controls").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.heading(if self.sub == ArchSubView::Live {
                    "📡 Live system"
                } else {
                    "🏛 Wiring"
                });
                ui.separator();
                ui.label(format!(
                    "{} nodes · {} edges",
                    self.graph.nodes.len(),
                    self.graph.edges.len()
                ));
                ui.separator();
                if ui.button("↻ reload").clicked() {
                    self.loaded = false;
                    self.last_fetch_at = None;
                }
                // N4 — ⊙ fit: reframe the zoom so the whole graph fits the viewport.
                // The actual fit runs in the central panel (it needs the live
                // viewport size); here we just request it. Also recentres (pan 0).
                if ui
                    .button("⊙ fit")
                    .on_hover_text("zoom to frame the whole graph")
                    .clicked()
                {
                    self.fit_pending = true;
                    self.pan = Vec2::ZERO;
                    self.render_dirty = true;
                }
                if self.selected.is_some() && ui.button("✖ clear selection").clicked() {
                    self.selected = None;
                    self.traced.clear();
                    self.render_dirty = true;
                }
                // N2 — manual deep-scan trigger: recompute `call_edges` on demand.
                ui.separator();
                let scanning = {
                    let g = self.deep_scan.lock().unwrap();
                    g.inflight
                };
                if scanning {
                    ui.add(egui::Spinner::new());
                    ui.label("deep-scanning…");
                } else if ui
                    .button("🔬 Deep-scan this repo")
                    .on_hover_text(
                        "recompute call_edges now — thin mode republishes the workspace \
                         server-side (persists symbol_facts + call_edges); local mode \
                         scans the checkout",
                    )
                    .clicked()
                {
                    self.trigger_deep_scan();
                }
                {
                    let (err, note) = {
                        let g = self.deep_scan.lock().unwrap();
                        (g.error.clone(), g.note.clone())
                    };
                    if let Some(e) = err {
                        ui.colored_label(super::facett_theme::RED, format!("⚠ {e}"));
                    } else if !scanning {
                        if let Some(n) = note {
                            ui.colored_label(super::facett_theme::GREEN, format!("✓ {n}"));
                        }
                    }
                }
                if self.sub == ArchSubView::Live {
                    ui.separator();
                    ui.checkbox(&mut self.live, "📡 live")
                        .on_hover_text("re-read the warehouse + repaint on change");
                }
                if !self.members_with_data.is_empty() {
                    ui.separator();
                    ui.label(format!("members: {}", self.members_with_data.join(", ")));
                }
            });
            // The kind legend (chip FILL = layer), coloured exactly as the chips paint.
            ui.horizontal_wrapped(|ui| {
                for (kind, name) in [
                    (NodeKind::Component, "UI component"),
                    (NodeKind::Grpc, "gRPC"),
                    (NodeKind::Cli, "CLI command"),
                    (NodeKind::Backend, "backend"),
                    (NodeKind::Codec, "codec"),
                    (NodeKind::Sink, "sink"),
                    (NodeKind::CoreFn, "core fn"),
                    (NodeKind::Table, "warehouse table"),
                ] {
                    let (fill, _stroke) = kind_color(&theme, kind);
                    let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
                    ui.painter().rect_filled(rect, 2.0, fill);
                    ui.label(name);
                    ui.add_space(8.0);
                }
            });
            // The coverage legend + summary (chip RING = test-matrix cross-check):
            // does a test exist for this marker? This is the keystone of the tab.
            let (tested, untested, allowlisted, unknown) = self.cov_counts();
            ui.horizontal_wrapped(|ui| {
                ui.label("tests:");
                for (col, name, n) in [
                    (super::facett_theme::GREEN, "tested", tested),
                    (super::facett_theme::RED, "untested", untested),
                    (super::facett_theme::AMBER, "allowlisted", allowlisted),
                ] {
                    let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
                    ui.painter().rect_stroke(
                        rect,
                        2.0,
                        Stroke::new(2.0_f32, col),
                        egui::epaint::StrokeKind::Outside,
                    );
                    ui.label(format!("{name} {n}"));
                    ui.add_space(8.0);
                }
                if unknown > 0 {
                    ui.label(format!("· {unknown} not in surface_coverage"));
                }
                if tested + untested + allowlisted + unknown == self.graph.nodes.len()
                    && self.coverage.is_empty()
                {
                    ui.label("· (no coverage recorded — run `nornir test coverage`)");
                }
            });
        });

        // Edge-triggered render trace (LAW #6): emit what the board shows once per
        // data/selection change, not every frame.
        if self.render_dirty {
            trace::emit_end("architecture.render", &self.render_out());
            self.render_dirty = false;
        }

        // Click-to-code: the inline source-preview panel (sits below the board,
        // above the central canvas — egui needs side/bottom panels declared
        // before the CentralPanel). Shows the lines around the last-clicked
        // symbol's `file:line`, the editor-open result, and any extra candidates.
        self.draw_source_preview(ui);

        egui::CentralPanel::default().show_inside(ui, |ui| {
            if let Some(err) = &self.load_error {
                ui.colored_label(
                    super::facett_theme::RED,
                    format!("architecture load failed: {err}"),
                );
                return;
            }
            for e in &self.errors {
                ui.colored_label(super::facett_theme::AMBER, format!("⚠ {e}"));
            }
            if self.graph.nodes.is_empty() {
                self.draw_placeholder(ui);
                return;
            }

            // Build the SHARED graph model (the same `draw_graph` the 🧬 release
            // dashboard uses) + sync the canvas view from this tab's persisted
            // pan/zoom/selection (kept index-based for the rest of the tab).
            let model = self.graph_model();
            // N4 — ⊙ fit: reframe so the whole graph fits. `content_extent` is the
            // graph's true painted size in WORLD units (pre-zoom); pick the zoom that
            // makes it fit the live viewport (with a small margin), clamped to the
            // renderer's zoom range. Works for both the pan/zoom (Live) and the
            // scrolled (Wiring) canvases — a smaller zoom shrinks the scrolled canvas
            // until it needs no scrollbar.
            if self.fit_pending {
                let extent = super::graph_render::content_extent(&model);
                let avail = ui.available_size();
                if extent.x > 1.0 && extent.y > 1.0 {
                    let z = (avail.x / extent.x).min(avail.y / extent.y);
                    self.zoom = (z * 0.92).clamp(0.25, 4.0);
                }
                self.pan = Vec2::ZERO;
                self.fit_pending = false;
            }
            let mut view = super::graph_render::GraphView {
                pan: self.pan,
                zoom: self.zoom,
                selected: self.selected.map(|i| self.laid[i].node.id.clone()),
                dot_px: None,
            };
            // The arch board carries NO decoration overlay — its coverage ring
            // rides in the node's own stroke colour (`graph_model`). This is the
            // "no decorations" caller the shared widget is designed around.
            let deco = super::graph_render::Decorations::default();
            // 🏛 Wiring is the STATIC board — render it inside a ScrollArea whose
            // canvas is allocated at the graph's TRUE extent, so the scrollbar
            // handle scales to the real overflow and glides (smooth/kinetic).
            // 📡 Live stays on the pan/zoom canvas (it animates a warehouse feed;
            // drag-to-move suits a moving target better than a scrollbar).
            let resp = if self.sub == ArchSubView::Wiring {
                super::graph_render::draw_graph_scrolled(
                    ui,
                    &model,
                    &deco,
                    &mut view,
                    theme.bg,
                    theme.text,
                    theme.selection(),
                    theme.text_dim,
                )
            } else {
                super::graph_render::draw_graph(
                    ui,
                    &model,
                    &deco,
                    &mut view,
                    theme.bg,
                    theme.text,
                    theme.selection(),
                    theme.text_dim,
                )
            };
            // Mirror the (possibly mutated) view back onto the tab state.
            self.pan = view.pan;
            self.zoom = view.zoom;
            if let Some(id) = resp.clicked_node {
                if let Some(&i) = self.idx.get(&id) {
                    self.selected = Some(i);
                    self.trace_from(i);
                    // Click-to-code: resolve the clicked node → file:line, open in
                    // the editor (best-effort) + load the inline source preview.
                    // Tables have no symbol; skip them.
                    let node = &self.laid[i].node;
                    if node.kind != NodeKind::Table {
                        let label = node.label.clone();
                        self.open_symbol(&label, true);
                    }
                    self.render_dirty = true;
                }
            } else if resp.clicked_empty {
                self.selected = None;
                self.traced.clear();
                self.render_dirty = true;
            }
        });

        // Live system: keep repainting so a warehouse change animates in.
        if self.sub == ArchSubView::Live && self.live {
            ui.ctx().request_repaint_after(Duration::from_millis(600));
        }
    }

    /// The inline source-preview panel for the last click-to-code resolution: the
    /// resolved `file:line`, the editor-open status, the source window, and any
    /// extra candidates. Hidden when nothing has been clicked. (ii) of the
    /// click-to-code contract.
    fn draw_source_preview(&mut self, ui: &mut egui::Ui) {
        let Some(open) = self.last_open.clone() else {
            return;
        };
        let theme = self.theme;
        // N4 — CLICK-THROUGH: pre-compute which identifiers in this preview are
        // navigable (known to the loaded wiring) so each becomes a link that jumps
        // to its definition. Done BEFORE the panel closure so the `navigable_symbols`
        // borrow of `self` is released before the closure re-borrows `self` mutably.
        let current_symbol = open.hits.first().map(|h| h.symbol.clone());
        let present: std::collections::HashSet<String> = {
            let nav = self.navigable_symbols();
            let mut present = std::collections::HashSet::new();
            for line in &open.preview {
                for (is_ident, seg) in ident_runs(line) {
                    if is_ident && nav.contains(seg) {
                        present.insert(seg.to_string());
                    }
                }
            }
            present
        };
        // A symbol the click-through can navigate to, harvested from a link click.
        let mut to_open: Option<String> = None;
        egui::Panel::bottom("arch_source_preview")
            .resizable(true)
            .default_size(200.0)
            .show_inside(ui, |ui| {
                ui.horizontal(|ui| {
                    ui.label(RichText::new("📄 source").strong().color(theme.accent));
                    ui.label(RichText::new(&open.query).monospace().color(theme.text));
                    if let Some(best) = open.hits.first() {
                        ui.label(
                            RichText::new(format!("→ {}:{}", best.file, best.line))
                                .monospace()
                                .color(theme.text_dim),
                        );
                        match open.editor_opened {
                            Some(true) => {
                                ui.colored_label(super::facett_theme::GREEN, "✓ opened in editor");
                            }
                            Some(false) => {
                                ui.weak("editor unavailable — inline preview below");
                            }
                            None => {}
                        }
                    } else {
                        ui.colored_label(super::facett_theme::AMBER, "no symbol matched");
                    }
                    if ui.button("✖").on_hover_text("close preview").clicked() {
                        self.last_open = None;
                    }
                });
                if open.preview.is_empty() {
                    if !open.hits.is_empty() {
                        ui.colored_label(
                            super::facett_theme::AMBER,
                            format!(
                                "source file not found on disk for {} (looked under the \
                                 workspace clone) — showing metadata only",
                                open.hits.first().map(|h| h.file.as_str()).unwrap_or("?"),
                            ),
                        );
                    }
                    return;
                }
                ui.horizontal(|ui| {
                    ui.weak("click a highlighted symbol to jump to its definition");
                });
                ui.separator();
                ScrollArea::vertical()
                    .id_salt("arch_src")
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        let target = open.hits.first().map(|h| h.line).unwrap_or(0);
                        for (off, line) in open.preview.iter().enumerate() {
                            let n = open.preview_start + off as u32;
                            let is_target = n == target;
                            let base_col = if is_target { theme.accent } else { theme.text };
                            ui.horizontal(|ui| {
                                // Tight monospace layout: no inter-item spacing so the
                                // per-token labels/links read as one contiguous line.
                                ui.spacing_mut().item_spacing.x = 0.0;
                                ui.label(
                                    RichText::new(format!("{n:>5} "))
                                        .monospace()
                                        .color(theme.text_dim),
                                );
                                ui.label(
                                    RichText::new(if is_target { "▶ " } else { "  " })
                                        .monospace()
                                        .color(theme.accent),
                                );
                                for (is_ident, seg) in ident_runs(line) {
                                    // A navigable identifier (other than the one already
                                    // shown) becomes a Wikipedia-style link.
                                    let linkable = is_ident
                                        && present.contains(seg)
                                        && current_symbol.as_deref() != Some(seg);
                                    if linkable {
                                        if ui
                                            .link(RichText::new(seg).monospace().color(theme.point))
                                            .on_hover_text("jump to definition")
                                            .clicked()
                                        {
                                            to_open = Some(seg.to_string());
                                        }
                                    } else {
                                        ui.label(RichText::new(seg).monospace().color(base_col));
                                    }
                                }
                            });
                        }
                    });
            });
        // Fire the navigation AFTER the panel closure (it re-resolves the clicked
        // symbol → new file:line + preview, the Wikipedia-style walk).
        if let Some(sym) = to_open {
            self.open_symbol(&sym, false);
        }
    }

    /// The 📍 **Hållpunkter** (boundaries) lens: the ui / gRPC / emitter LANDMARK
    /// functions drawn as distinct landmark nodes (chips), grouped by boundary,
    /// classified with the coverage feature's [`crate::coverage::classify`]
    /// (REUSED). Each landmark is clickable → click-to-code (same as the board).
    fn draw_boundaries(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        self.ensure_symbols();
        let landmarks = self.boundary_landmarks();
        let (u, g, e) = self.boundary_tally();

        egui::Panel::top("arch_boundaries_head").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.heading("📍 Hållpunkter");
                ui.separator();
                ui.label(
                    "the hard-to-cover layer boundaries — the anchors a test matrix must reach",
                );
            });
            ui.horizontal_wrapped(|ui| {
                ui.colored_label(theme.accent, format!("ui {u}"));
                ui.add_space(8.0);
                ui.colored_label(super::facett_theme::AMBER, format!("gRPC {g}"));
                ui.add_space(8.0);
                ui.colored_label(theme.point, format!("emitter {e}"));
                // THIN MODE: landmarks are classified from the warehouse's
                // `symbol_facts` (read over Viz.Architecture) — no checkout needed.
                // Only warn when the warehouse yielded no symbols at all.
                if matches!(self.src, Src::Remote { .. })
                    && self.symbols.as_ref().map(|s| s.is_empty()).unwrap_or(true)
                {
                    ui.separator();
                    ui.colored_label(
                        super::facett_theme::AMBER,
                        "(warehouse has no symbol_facts — run a populate/deep scan)",
                    );
                }
            });
        });

        // The click-to-code preview panel works here too.
        self.draw_source_preview(ui);

        egui::CentralPanel::default().show_inside(ui, |ui| {
            if landmarks.is_empty() {
                ui.add_space(12.0);
                ui.weak(
                    "no boundary landmarks discovered yet — they are classified from the \
                     repo's scanned symbols (ui/draw/state_json under src/viz, gRPC handlers, \
                     emit*/trace points).",
                );
                return;
            }
            let mut to_open: Option<String> = None;
            ScrollArea::vertical()
                .id_salt("arch_landmarks")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    for (bnd, group_color, group_name) in [
                        (Boundary::Ui, theme.accent, "ui (impl Facet)"),
                        (Boundary::Grpc, super::facett_theme::AMBER, "gRPC handlers"),
                        (
                            Boundary::Emitter,
                            theme.point,
                            "emitter (state_json / emit* / $NORNIR_VIZ_TRACE)",
                        ),
                    ] {
                        let group: Vec<&ArchNode> = landmarks
                            .iter()
                            .filter(|(b, _)| *b == bnd)
                            .map(|(_, n)| n)
                            .collect();
                        if group.is_empty() {
                            continue;
                        }
                        ui.add_space(6.0);
                        ui.label(RichText::new(group_name).strong().color(group_color));
                        ui.horizontal_wrapped(|ui| {
                            for n in group {
                                let chip = egui::Button::new(
                                    RichText::new(&n.label)
                                        .monospace()
                                        .size(11.0)
                                        .color(theme.text),
                                )
                                .fill(group_color.linear_multiply(0.22))
                                .stroke(Stroke::new(1.2_f32, group_color));
                                if ui
                                    .add(chip)
                                    .on_hover_text("open source (click-to-code)")
                                    .clicked()
                                {
                                    to_open = Some(n.label.clone());
                                }
                            }
                        });
                    }
                });
            if let Some(label) = to_open {
                self.open_symbol(&label, true);
            }
        });
    }

    /// The 🚇 **Metro map** lens: per-button click→gRPC CALL PATHS, octolinear.
    ///
    /// Left strip = the line selector (one chip per registry button, the active
    /// one highlighted, coloured ✓ reached / ✗ stub). Central canvas = the
    /// selected button's line drawn left→right: the UI click-handler is the head
    /// terminus, the gRPC handler the tail terminus, and every fn between is a
    /// station. Boundary stations (ui / grpc / emitter) are larger labelled
    /// interchanges, classified by [`crate::coverage::classify`] (REUSED). Click a
    /// station → click-to-code (same `open_symbol` bridge as the board).
    fn draw_metro(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        self.ensure_metro();

        // Header: the line tally + selected line summary.
        let (total, reached) = self
            .metro_lines
            .as_ref()
            .map(|ls| (ls.len(), ls.iter().filter(|l| l.reached).count()))
            .unwrap_or((0, 0));
        egui::Panel::top("arch_metro_head").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.heading("🚇 Metro");
                ui.separator();
                ui.label("each UI button → the gRPC call path it fires · stations = functions · interchanges = layer boundaries");
            });
            ui.horizontal_wrapped(|ui| {
                ui.colored_label(super::facett_theme::GREEN, format!("✓ {reached} reach gRPC"));
                ui.add_space(8.0);
                ui.colored_label(super::facett_theme::RED, format!("✗ {} stub", total.saturating_sub(reached)));
                // THIN MODE: the metro is fuelled by the WAREHOUSE (call_edges +
                // symbol_facts read over Viz.Architecture), NOT a local checkout —
                // so "no checkout" is EXPECTED, not a degrade. Only warn when the
                // warehouse genuinely yielded no call edges (an empty/unpopulated
                // warehouse — the populate-diagnostic the user must act on).
                let calls = self.calls.as_ref().map(|c| c.len()).unwrap_or(0);
                if matches!(self.src, Src::Remote { .. }) {
                    ui.separator();
                    if calls == 0 {
                        ui.colored_label(
                            super::facett_theme::AMBER,
                            "(warehouse has no call_edges — run a deep scan/populate so the server persists them)",
                        );
                    } else {
                        ui.weak(format!("(warehouse: {calls} call edges)"));
                    }
                }
            });
            // The boundary-station legend (same colours as the line stations).
            ui.horizontal_wrapped(|ui| {
                ui.label("stations:");
                for (col, name) in [
                    (theme.accent, "ui (click-handler)"),
                    (super::facett_theme::AMBER, "gRPC (terminus)"),
                    (theme.point, "emitter"),
                    (theme.node_stroke, "core fn"),
                ] {
                    let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
                    ui.painter().circle_filled(rect.center(), 5.0, col);
                    ui.label(name);
                    ui.add_space(8.0);
                }
            });
            // ── SCIP-PROVENANCE LEGEND (the surfaced resolved-index overlay): a
            // SOLID accent rail = an edge RESOLVED by the SCIP index (it spans the
            // cross-crate bin→lib hop); a DASHED dim rail = the name-based SYN
            // fallback (an unresolved/heuristic edge scip closes). Counts come
            // from the same per-line tally `state_json` reports (LAW 6). The fuel
            // tag names which index answered so it's never a mystery.
            let (resolved_n, syn_n) = self.metro_provenance_counts();
            ui.horizontal_wrapped(|ui| {
                ui.label("provenance:");
                // resolved = solid accent swatch.
                let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
                ui.painter().line_segment(
                    [rect.left_center(), rect.right_center()],
                    Stroke::new(3.0_f32, theme.accent),
                );
                ui.label(format!("resolved (scip) {resolved_n}"));
                ui.add_space(10.0);
                // syn-fallback = dashed dim swatch.
                let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
                let y = rect.center().y;
                for k in 0..3 {
                    let x0 = rect.left() + k as f32 * 8.0;
                    ui.painter().line_segment(
                        [Pos2::new(x0, y), Pos2::new(x0 + 5.0, y)],
                        Stroke::new(3.0_f32, theme.text_dim),
                    );
                }
                ui.label(format!("syn-fallback {syn_n}"));
                ui.add_space(10.0);
                ui.weak(format!("· fuel: {}", self.metro_fuel()));
            });
        });

        // Left strip: the line selector (one chip per button).
        let mut pick: Option<String> = None;
        egui::Panel::left("arch_metro_lines")
            .resizable(true)
            .default_size(220.0)
            .show_inside(ui, |ui| {
                ui.add_space(4.0);
                ui.label(RichText::new("lines").strong().color(theme.text_dim));
                ScrollArea::vertical()
                    .id_salt("arch_metro_sel")
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        let lines = self.metro_lines.clone().unwrap_or_default();
                        for l in &lines {
                            let active = l.button_id == self.metro_selected;
                            let dot = if l.reached {
                                super::facett_theme::GREEN
                            } else {
                                super::facett_theme::RED
                            };
                            let line_col = line_color(&theme, &l.button_id);
                            ui.horizontal(|ui| {
                                let (rect, _) =
                                    ui.allocate_exact_size(Vec2::new(10.0, 10.0), Sense::hover());
                                ui.painter().circle_filled(rect.center(), 5.0, line_col);
                                let label = format!("{} · {}", l.button_id, l.rpc);
                                if ui
                                    .selectable_label(
                                        active,
                                        RichText::new(label).color(theme.text),
                                    )
                                    .clicked()
                                {
                                    pick = Some(l.button_id.clone());
                                }
                                ui.colored_label(dot, if l.reached { "✓" } else { "✗" });
                            });
                        }
                    });
            });
        if let Some(id) = pick {
            self.select_metro_button(&id);
        }

        // Click-to-code preview (shared with the board).
        self.draw_source_preview(ui);

        // Edge-triggered metro render trace (LAW #6).
        if self.render_dirty {
            trace::emit_end("architecture.metro.render", &self.metro_state());
            self.render_dirty = false;
        }

        // Central canvas: the selected line drawn octolinearly L→R. THIN mode
        // reads the warehouse, so "no checkout" is NOT a degrade — the only
        // legitimate empty state is an unpopulated warehouse (zero call edges).
        let is_remote = matches!(self.src, Src::Remote { .. });
        let scanned_calls = self.calls.as_ref().map(|c| c.len()).unwrap_or(0);
        egui::CentralPanel::default().show_inside(ui, |ui| {
            let Some(line) = self.selected_metro_line().cloned() else {
                // Explicit POPULATE-DIAGNOSTIC no-data state (never a blank pane):
                // name WHY it's empty + WHAT populates it (RAGNARÖK corollary).
                ui.add_space(24.0);
                ui.vertical_centered(|ui| {
                    ui.heading("No call path to draw yet");
                    ui.add_space(8.0);
                    if is_remote {
                        ui.label(
                            "The 🚇 Metro map traces each UI button to the gRPC handler it \
                             fires, over the warehouse's `call_edges` + `symbol_facts` (read \
                             from the server — no local checkout needed). The warehouse for this \
                             workspace has none recorded yet.",
                        );
                        ui.add_space(6.0);
                        ui.weak("Run a populate / deep scan so the server persists call_edges, then ↻ reload.");
                    } else {
                        ui.label(
                            "No metro lines were built — pick a workspace member whose source \
                             scan yields call edges, then reload.",
                        );
                    }
                });
                return;
            };
            let line_col = line_color(&theme, &line.button_id);
            // Zero call edges ⇒ every line is a head-only stub. In thin mode that
            // means the warehouse isn't populated; make it explicit + actionable so
            // the pane never reads as a silent failure (RAGNARÖK populate-diagnostic).
            if scanned_calls == 0 {
                ui.colored_label(
                    super::facett_theme::AMBER,
                    if is_remote {
                        "⚠ the warehouse has no call_edges for this workspace — lines show the click-handler head only (stubs). Run a populate/deep scan on the server."
                    } else {
                        "⚠ no call edges scanned for this source — lines show the click-handler head only (stubs)."
                    },
                );
                ui.add_space(4.0);
            }

            ui.horizontal_wrapped(|ui| {
                ui.label(RichText::new(format!("🚇 {}", line.button_id)).strong().color(line_col));
                ui.separator();
                ui.label(RichText::new(&line.rpc).monospace().color(theme.text));
                ui.separator();
                if line.reached {
                    ui.colored_label(super::facett_theme::GREEN, format!("✓ reaches {} · {} stations · {} emitters",
                        line.grpc_terminus.as_deref().unwrap_or("?"), line.stations.len(), line.emitter_stations));
                } else {
                    ui.colored_label(super::facett_theme::RED, format!("✗ does not reach the gRPC handler ({} stations)", line.stations.len()));
                }
                ui.separator();
                // The selected line's SCIP-provenance, in words.
                if self.line_is_resolved(&line) {
                    ui.colored_label(theme.accent, "● resolved (scip)");
                } else {
                    ui.colored_label(theme.text_dim, "┄ syn-fallback");
                }
            });
            ui.separator();

            let n = line.stations.len().max(1);
            let avail = ui.available_size();
            let (resp, painter) = ui.allocate_painter(avail, Sense::click());
            painter.rect_filled(resp.rect, 4.0, theme.bg);
            let top = resp.rect.left_top();
            // Octolinear: stations march down a single column at a fixed pitch;
            // the line is a vertical rail with 90° elbows into each labelled stop.
            let x_rail = top.x + 70.0;
            let pitch = ((resp.rect.height() - 60.0) / n as f32).clamp(40.0, 120.0);
            let mut centers: Vec<Pos2> = Vec::with_capacity(n);
            for i in 0..line.stations.len() {
                centers.push(Pos2::new(x_rail, top.y + 36.0 + i as f32 * pitch));
            }
            // The rail, STYLED BY SCIP-PROVENANCE: a RESOLVED line (the SCIP index
            // spanned the chain) draws a solid accent-tinted rail; a SYN-FALLBACK
            // line (name-based / unresolved bin→lib gap) draws a DASHED, dimmed
            // rail — so the resolved-vs-syn split is visible on the rail itself.
            let resolved = self.line_is_resolved(&line);
            let rail_col = if resolved { line_col } else { line_col.linear_multiply(0.45) };
            for w in centers.windows(2) {
                if resolved {
                    painter.line_segment([w[0], w[1]], Stroke::new(5.0_f32, rail_col));
                } else {
                    // Dashed: short dashes along the segment mark the syn fallback.
                    let dir = (w[1] - w[0]).normalized();
                    let len = (w[1] - w[0]).length();
                    let (dash, gap) = (9.0_f32, 6.0_f32);
                    let mut t = 0.0;
                    while t < len {
                        let a = w[0] + dir * t;
                        let b = w[0] + dir * (t + dash).min(len);
                        painter.line_segment([a, b], Stroke::new(4.0_f32, rail_col));
                        t += dash + gap;
                    }
                }
            }
            // Stations + labels. Click → open source.
            let mut to_open: Option<String> = None;
            for (i, st) in line.stations.iter().enumerate() {
                let c = centers[i];
                let (fill, r) = station_style(&theme, st.boundary);
                painter.circle_filled(c, r, fill);
                painter.circle_stroke(c, r, Stroke::new(2.0_f32, line_col));
                // Interchange (boundary) stations get a thick ring + a kind tag.
                let kind_tag = match st.boundary {
                    Boundary::Ui => " ⟵ ui click-handler",
                    Boundary::Grpc => " ⟵ gRPC handler",
                    Boundary::Emitter => " ⟲ emitter",
                    Boundary::Core => "",
                };
                let loc = if st.line > 0 { format!("  {}:{}", st.file, st.line) } else { String::new() };
                let label = format!("{}{}{}", st.fn_name, kind_tag, loc);
                let txt_col = if st.is_interchange() { theme.text } else { theme.text_dim };
                painter.text(
                    c + Vec2::new(r + 10.0, 0.0),
                    egui::Align2::LEFT_CENTER,
                    label,
                    FontId::proportional(if st.is_interchange() { 13.0 } else { 11.0 }),
                    txt_col,
                );
                // Hit-test the station for click-to-code.
                if resp.clicked() {
                    if let Some(p) = resp.interact_pointer_pos() {
                        if (p - c).length() <= r + 6.0 {
                            to_open = Some(st.fn_name.clone());
                        }
                    }
                }
            }
            if let Some(name) = to_open {
                self.open_symbol(&name, true);
            }
        });
    }

    /// Select a metro line by button id (selector click / robot drive). Emits the
    /// selection as a trace event so it's observable headlessly.
    fn select_metro_button(&mut self, id: &str) {
        if self.metro_selected != id {
            self.metro_selected = id.to_string();
            trace::emit_event(
                "architecture.metro.select",
                &serde_json::json!({ "button": id }),
            );
            self.render_dirty = true;
        }
    }

    /// The empty-board placeholder: WHY there is no board — the reader's refusal
    /// (nothing consumed, no lake …), or a room that holds no fn of this workspace.
    fn draw_placeholder(&self, ui: &mut egui::Ui) {
        ui.add_space(24.0);
        ui.vertical_centered(|ui| {
            ui.heading("Architecture wiring");
            ui.add_space(8.0);
            match &self.load_error {
                Some(e) => ui.label(e),
                None => ui.label("the code room holds no function of this workspace: an empty board is what it has, not a missing scan"),
            };
        });
    }

    /// Per-marker test-matrix tally: (tested, untested, allowlisted, unknown).
    /// The keystone numbers — green ⟺ every marker has a covering test.
    fn cov_counts(&self) -> (usize, usize, usize, usize) {
        let (mut t, mut u, mut a, mut k) = (0, 0, 0, 0);
        for n in &self.graph.nodes {
            match self.cov_of(&n.id) {
                Cov::Tested => t += 1,
                Cov::Untested => u += 1,
                Cov::Allowlisted => a += 1,
                Cov::Unknown => k += 1,
            }
        }
        (t, u, a, k)
    }

    fn render_out(&self) -> RenderOut {
        let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
        let (tested, untested, allowlisted, coverage_unknown) = self.cov_counts();
        RenderOut {
            node_count: self.graph.nodes.len(),
            edge_count: self.graph.edges.len(),
            components: count(NodeKind::Component),
            grpc: count(NodeKind::Grpc),
            cli: count(NodeKind::Cli),
            backends: count(NodeKind::Backend),
            codecs: count(NodeKind::Codec),
            sinks: count(NodeKind::Sink),
            core_fns: count(NodeKind::CoreFn),
            tables: count(NodeKind::Table),
            tested,
            untested,
            allowlisted,
            coverage_unknown,
            selected: self.selected.map(|i| self.laid[i].node.label.clone()),
        }
    }

    /// The 🚇 Metro map's slice of `state_json` (LAW #6) — the REAL structure of
    /// the button→gRPC call paths, as DATA: the selected button, every line's
    /// stations (fn + file:line + boundary kind), the gRPC terminus, the emitter
    /// stations, and whether the line reached its handler. A blank/stub path is
    /// visible here as `reached:false` + a 1-station line — the matrix FAILS on it.
    fn metro_state(&self) -> serde_json::Value {
        let lines = self.metro_lines.clone().unwrap_or_default();
        // The overall fuel provenance the `load_preferred` chokepoint reported.
        let fuel = self.metro_fuel();
        // PER-LINE SCIP-PROVENANCE (the surfaced overlay): a line resolved by the
        // SCIP index (it REACHED its handler over a `resolved/scip` fuel) is
        // "resolved/scip"; anything else is the name-based "syn" fallback (the
        // cross-crate bin→lib gap scip closes). Same source-of-truth the legend +
        // rail style read (`line_is_resolved`).
        let line_source = |l: &MetroLine| -> &'static str {
            if self.line_is_resolved(l) {
                "resolved/scip"
            } else {
                "syn"
            }
        };
        let line_json = |l: &MetroLine| {
            serde_json::json!({
                "button_id": l.button_id,
                "tab": l.tab,
                "rpc": l.rpc,
                "ui_handler": l.ui_handler,
                "grpc_terminus": l.grpc_terminus,
                "reached": l.reached,
                // The per-line provenance class the overlay styles by + reports.
                "source": line_source(l),
                "emitter_stations": l.emitter_stations,
                "station_count": l.stations.len(),
                "stations": l.stations.iter().map(|s| serde_json::json!({
                    "fn": s.fn_name,
                    "symbol": s.symbol,
                    "file": s.file,
                    "line": s.line,
                    "boundary": s.boundary.as_str(),
                    "interchange": s.is_interchange(),
                })).collect::<Vec<_>>(),
            })
        };
        // The resolved-vs-syn TALLY across the lines (the legend's counts).
        let resolved_lines = lines
            .iter()
            .filter(|l| line_source(l) == "resolved/scip")
            .count();
        let syn_lines = lines.len() - resolved_lines;
        let selected = self.selected_metro_line().map(line_json);
        // The button registry as DATA — the enumerable {tab,id,rpc} the matrix walks.
        let registry: Vec<serde_json::Value> = metro::button_registry()
            .iter()
            .map(|b| serde_json::json!({ "tab": b.tab, "id": b.id, "rpc": b.rpc, "ui_handler": b.ui_handler }))
            .collect();
        // The verify-without-looking aggregates — the EMITTER's numbers, also on
        // the state slice so a headless drive asserts populated-vs-stub as DATA.
        let agg = self.metro_aggregates();
        serde_json::json!({
            "selected_button": self.metro_selected,
            "line_count": lines.len(),
            "reached_count": lines.iter().filter(|l| l.reached).count(),
            "registry": registry,
            "selected": selected,
            "lines": lines.iter().map(line_json).collect::<Vec<_>>(),
            // ── EMITTER aggregates (RAGNARÖK): empty/stub metro = RED here ───────
            "metro_edges": agg.metro_edges,
            "metro_nodes": agg.metro_nodes,
            "metro_emitter_nodes": agg.metro_emitter_nodes,
            "metro_stub_lines": agg.metro_stub_lines,
            "reached_lines": agg.reached_lines,
            // The source of the metro fuel: `warehouse` in thin mode (read over
            // Viz.Architecture), `checkout` in fat mode (local scan). So a drive can
            // assert thin mode is actually warehouse-backed, not stub-degraded.
            "fuel_source": match self.src {
                Src::Remote { .. } => "warehouse",
                Src::Local(_) => "checkout",
            },
            // ── SCIP-PROVENANCE OVERLAY (LAW 6): which INDEX resolved the call
            // graph these lines are built from — the `query::load_preferred` tag
            // ("resolved/scip" the SCIP index answered, "syn"/"syn/scan" the
            // name-based fallback, "warehouse" thin facts). The per-line `source`
            // above is derived from this; these counts feed the legend so a reader
            // SEES resolved-vs-syn-fallback without inspecting pixels.
            "fuel_provenance": fuel,
            "resolved_lines": resolved_lines,
            "syn_lines": syn_lines,
        })
    }

    /// The metro fuel provenance tag (the `query::load_preferred` source) — empty
    /// until `ensure_symbols` runs, so default to the honest "unknown".
    fn metro_fuel(&self) -> &str {
        if self.metro_fuel_source.is_empty() {
            "unknown"
        } else {
            self.metro_fuel_source.as_str()
        }
    }

    /// Is THIS line's interior resolved by the SCIP index? A line that REACHED its
    /// gRPC handler over a `resolved/scip` fuel is resolved (solid accent rail);
    /// anything else — an unreached bin→lib skeleton, or a `syn`/`syn/scan`/
    /// `warehouse` fuel — is the name-based SYN fallback (dashed dim rail). This is
    /// the single source-of-truth the legend, the rail style, and `state_json`'s
    /// per-line `source` all read.
    fn line_is_resolved(&self, line: &MetroLine) -> bool {
        line.reached && self.metro_fuel() == "resolved/scip"
    }

    /// (resolved, syn-fallback) line counts for the provenance legend.
    fn metro_provenance_counts(&self) -> (usize, usize) {
        let lines = self.metro_lines.as_deref().unwrap_or(&[]);
        let resolved = lines.iter().filter(|l| self.line_is_resolved(l)).count();
        (resolved, lines.len() - resolved)
    }

    /// The 🚇 Metro slice as standalone JSON. The full Metro state is also folded
    /// into `state_json()["metro"]`; this accessor exposes it on its own for the
    /// app/matrix. `pub` for the app + the matrix.
    pub fn metro_state_json(&self) -> serde_json::Value {
        self.metro_state()
    }

    /// The 🏛 Architecture tab's slice of `state_json` (LAW #6) — the exact board
    /// it renders: counts per kind, edges, which members contributed, the selected
    /// node + its downstream-traced labels, and the active palette.
    pub fn state_json(&self) -> serde_json::Value {
        let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
        // N2 deep-scan button state (computed here — a locked read can't sit inline
        // in the `json!` macro).
        let deep_scan_json = {
            let g = self.deep_scan.lock().unwrap();
            serde_json::json!({
                "inflight": g.inflight,
                "note": g.note,
                "error": g.error,
            })
        };
        let kinds: Vec<serde_json::Value> = self
            .graph
            .nodes
            .iter()
            .map(|n| {
                serde_json::json!({
                    "id": n.id,
                    "label": n.label,
                    "kind": n.kind.as_str(),
                    // The canonical layer (ui/grpc/cli/core/table) — AUT8-GAP-LAYER,
                    // derived from kind so the matrix groups by the same strata.
                    "layer": n.kind.layer(),
                    // The per-marker cross-check: is there a test for it?
                    "cov": self.cov_of(&n.id).label(),
                })
            })
            .collect();
        let (tested, untested, allowlisted, coverage_unknown) = self.cov_counts();
        let edges: Vec<serde_json::Value> = self
            .graph
            .edges
            .iter()
            .map(|e| serde_json::json!({ "from": e.from, "to": e.to, "kind": e.kind.as_str() }))
            .collect();
        let traced: Vec<String> = self
            .traced
            .iter()
            .map(|&i| self.laid[i].node.label.clone())
            .collect();
        // 📍 Hållpunkter: the boundary landmark tally + the landmark labels by
        // boundary, classified with the coverage feature's `classify` (reused).
        let (bnd_ui, bnd_grpc, bnd_emitter) = self.boundary_tally();
        let landmark_list: Vec<serde_json::Value> = self
            .boundary_landmarks()
            .iter()
            .map(|(b, n)| serde_json::json!({ "boundary": b.as_str(), "label": n.label, "id": n.id }))
            .collect();
        serde_json::json!({
            "source": match &self.src { Src::Local(_) => "local", Src::Remote { .. } => "remote" },
            "workspace": self.workspace,
            "node_count": self.graph.nodes.len(),
            "edge_count": self.graph.edges.len(),
            "components": count(NodeKind::Component),
            "grpc": count(NodeKind::Grpc),
            "cli": count(NodeKind::Cli),
            "backends": count(NodeKind::Backend),
            "codecs": count(NodeKind::Codec),
            "sinks": count(NodeKind::Sink),
            "core_fns": count(NodeKind::CoreFn),
            "tables": count(NodeKind::Table),
            // The keystone cross-check, as readable data: every marker tallied
            // against the test matrix. green ⟺ untested == 0.
            "coverage": {
                "tested": tested,
                "untested": untested,
                "allowlisted": allowlisted,
                "unknown": coverage_unknown,
                "has_data": !self.coverage.is_empty(),
            },
            "nodes": kinds,
            "edges": edges,
            "members_with_data": self.members_with_data,
            "members": self.members,
            "errors": self.errors,
            "load_error": self.load_error,
            "selected": self.selected.map(|i| self.laid[i].node.label.clone()),
            "traced_downstream": traced,
            "empty": self.graph.nodes.is_empty(),
            // LAW 2 empty-state: "populated" / "in_route" (a job is acting on this
            // workspace) / "not_scanned" — the reason a blank board shows, asserted
            // headlessly so an empty arch board can never silently pass.
            "empty_state": super::repo_pane::classify_empty(
                self.graph.nodes.is_empty(), &self.workspace, "",
            ).id(),
            "palette": self.theme.name,
            // ── CONTAINER: the active sub-view + the full sub-view list, so the
            // headless matrix mechanically enumerates every lens (LAW #6).
            "sub_view": self.sub.id(),
            "sub_views": ArchSubView::ALL.iter().map(|v| v.id()).collect::<Vec<_>>(),
            "live": self.live,
            // ── 🏛 Wiring SCROLL EXTENT (validate-as-data, task #23): the TRUE
            // painted bounds of the board in world units, so the ScrollArea's
            // canvas — and thus the scrollbar handle — scales to the real graph,
            // not to the viewport. `scrollable` is true for the static Wiring lens
            // (the lens that wraps the board in a ScrollArea); Live pans instead.
            // A headless drive asserts this is non-zero + grows with the graph,
            // so the "wrong scale" bug can't silently return.
            "board": {
                "content_extent": {
                    "w": super::graph_render::content_extent(&self.graph_model()).x,
                    "h": super::graph_render::content_extent(&self.graph_model()).y,
                },
                "scrollable": self.sub == ArchSubView::Wiring,
                "smooth_scroll": self.sub == ArchSubView::Wiring,
            },
            // ── 📍 Hållpunkter: the ui/grpc/emitter LANDMARK tally (the boundary
            // anchors), classified by the coverage feature's `classify` (reused).
            "boundaries": {
                "ui": bnd_ui,
                "grpc": bnd_grpc,
                "emitter": bnd_emitter,
                "total": bnd_ui + bnd_grpc + bnd_emitter,
                "landmarks": landmark_list,
            },
            // ── 🚇 Metro map: the button→gRPC CALL PATHS as DATA — selected
            // button, every line's stations (fn + file:line + boundary kind), the
            // gRPC terminus + emitter stations + reached flag. This is the slice a
            // robot drive asserts is NON-EMPTY and reaches the RPC (task proof).
            "metro": self.metro_state(),
            // ── click-to-code: the last-clicked symbol → file:line + candidates,
            // the editor-open result, and the inline preview line range — so a
            // robot test asserts the click-to-code WITHOUT pixels (task #3).
            "last_open": self.last_open.as_ref().map(|o| serde_json::json!({
                "query": o.query,
                "resolved": o.hits.first().map(|h| format!("{}:{}", h.file, h.line)),
                "file": o.hits.first().map(|h| h.file.clone()),
                "line": o.hits.first().map(|h| h.line),
                "symbol": o.hits.first().map(|h| h.symbol.clone()),
                "candidates": o.hits.len(),
                "editor_opened": o.editor_opened,
                "preview_lines": o.preview.len(),
                "preview_start": o.preview_start,
            })),
            // ── N2 deep-scan button state (LAW 6): inflight / last note / error,
            // so a headless drive asserts the manual call_edges trigger fired.
            "deep_scan": deep_scan_json,
        })
    }

    // ── test-only injectors (no warehouse / no server) ───────────────────────

    /// Inject a merged board directly (as the warehouse merge / RPC would deliver)
    /// + the configured members, so the inject-assert harness can read
    /// `state_json()` back without a warehouse or server. Returns nothing; after
    /// this the board renders the injected graph.
    #[doc(hidden)]
    pub fn inject_for_test(&mut self, graph: ArchGraph, members_with_data: Vec<String>) {
        self.loaded = true;
        self.load_error = None;
        self.members_with_data = members_with_data;
        self.errors = Vec::new();
        self.set_graph(graph);
    }

    /// Inject coverage verdicts (node id → `covered`/`missing`/`allowlisted`) as
    /// the warehouse cross-check would deliver, so a test can assert the green/red
    /// tally via `state_json()` without a warehouse.
    #[doc(hidden)]
    pub fn inject_coverage_for_test(
        &mut self,
        coverage: std::collections::BTreeMap<String, String>,
    ) {
        self.coverage = coverage;
        self.render_dirty = true;
    }

    /// Inject scanned symbols directly (no warehouse / no checkout) so a test can
    /// drive click-to-code resolution + the boundary-landmark classifier via
    /// `state_json()` / `open_symbol`. Mirrors the other inject seams.
    #[doc(hidden)]
    pub fn inject_symbols_for_test(&mut self, symbols: Vec<crate::knowledge::symbols::SymbolRow>) {
        self.symbols = Some(symbols);
        self.render_dirty = true;
    }

    /// Inject a full [`ArchPayload`] exactly as the thin `Viz.Architecture` RPC
    /// would deliver it — including the WAREHOUSE-READ `symbols` + `calls` the
    /// server ships for the 🚇 Metro map. Drives the SAME seeding `ensure_loaded`'s
    /// remote branch runs, so a test proves the thin-mode metro builds REAL lines
    /// from warehouse facts (not checkout stubs) without a running server. Only
    /// meaningful on a `remote()` state (the thin path). Returns nothing; after it,
    /// `build_metro_for_test` builds lines from the payload's call edges.
    #[doc(hidden)]
    pub fn inject_remote_payload_for_test(&mut self, payload: ArchPayload) {
        self.loaded = true;
        self.load_error = None;
        self.members_with_data = payload.members_with_data;
        self.errors = payload.errors;
        self.coverage = payload.coverage;
        if !payload.symbols.is_empty() {
            self.symbols = Some(payload.symbols);
            self.calls = Some(payload.calls);
            self.metro_lines = None;
            // Mirror production (`ensure_loaded`): adopt the server's
            // `load_preferred` provenance tag, defaulting to "warehouse" only when
            // the payload shipped none (older server / empty fuel).
            self.metro_fuel_source = if payload.metro_fuel_source.is_empty() {
                "warehouse".to_string()
            } else {
                payload.metro_fuel_source
            };
        }
        self.access = payload.access;
        self.set_graph(payload.graph);
    }

    /// Inject scanned symbols + call edges directly (no warehouse / no checkout)
    /// so a test can build the 🚇 metro map + assert its `state_json` without a
    /// scan. Mirrors `inject_symbols_for_test` but also seeds the calls so the
    /// button→gRPC paths are real.
    #[doc(hidden)]
    pub fn inject_scan_for_test(
        &mut self,
        symbols: Vec<crate::knowledge::symbols::SymbolRow>,
        calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
    ) {
        self.symbols = Some(symbols);
        self.calls = Some(calls);
        self.metro_lines = None;
        self.render_dirty = true;
    }

    /// Inject scanned facts WITH an explicit SCIP-provenance fuel source — the
    /// same tag `query::load_preferred` returns (`"resolved/scip"` vs `"syn"`).
    /// Lets the inject-assert harness prove the metro's per-edge/per-line
    /// provenance reaches `state_json` (LAW 6) + the rail styling without a
    /// `--features scip` warehouse. Mirrors `inject_scan_for_test`.
    #[doc(hidden)]
    pub fn inject_scan_with_source_for_test(
        &mut self,
        symbols: Vec<crate::knowledge::symbols::SymbolRow>,
        calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
        source: &str,
    ) {
        self.symbols = Some(symbols);
        self.calls = Some(calls);
        self.metro_fuel_source = source.to_string();
        self.metro_lines = None;
        self.render_dirty = true;
    }

    /// Build the 🚇 metro map from the (injected or scanned) facts, so a test /
    /// robot can read `state_json["architecture"]["metro"]` back without a draw.
    /// Parity with switching to the Metro lens.
    #[doc(hidden)]
    pub fn build_metro_for_test(&mut self) {
        self.ensure_metro();
    }

    /// Select a metro line by button id from a test (parity with a selector click).
    #[doc(hidden)]
    pub fn select_metro_button_for_test(&mut self, id: &str) {
        self.select_metro_button(id);
    }

    /// Set the active sub-view from a test (parity with a selector click).
    #[doc(hidden)]
    pub fn set_sub_view_by_id_for_test(&mut self, id: &str) -> bool {
        match ArchSubView::from_id(id) {
            Some(sv) => {
                self.set_sub_view(sv);
                true
            }
            None => false,
        }
    }

    /// Drive a click on the node with `label` (parity with a pointer click), so a
    /// test can assert the downstream-trace highlight via `state_json()`.
    #[doc(hidden)]
    pub fn select_by_label_for_test(&mut self, label: &str) -> bool {
        if let Some(i) = self.laid.iter().position(|l| l.node.label == label) {
            self.selected = Some(i);
            self.trace_from(i);
            self.render_dirty = true;
            true
        } else {
            false
        }
    }
}

/// N4 — split a source line into alternating (is_identifier, slice) runs so the
/// click-through preview can render identifier tokens as links while keeping the
/// exact spacing/indentation of the non-identifier runs. An "identifier" run is a
/// maximal `[A-Za-z0-9_]+` span (Rust ident chars); everything else (whitespace,
/// punctuation) is a non-identifier run. The concatenation of all slices equals
/// the input verbatim.
fn ident_runs(line: &str) -> Vec<(bool, &str)> {
    let is_ident = |c: char| c.is_alphanumeric() || c == '_';
    let mut out = Vec::new();
    let mut start = 0usize;
    let mut cur: Option<bool> = None;
    for (i, c) in line.char_indices() {
        let k = is_ident(c);
        match cur {
            Some(prev) if prev == k => {}
            Some(prev) => {
                out.push((prev, &line[start..i]));
                start = i;
                cur = Some(k);
            }
            None => cur = Some(k),
        }
    }
    if let Some(prev) = cur {
        out.push((prev, &line[start..]));
    }
    out
}

/// Chip (fill, stroke) per node kind, on the active facett palette. Each kind
/// gets a distinct semantic tint so the four layers read apart at a glance: UI
/// components on the palette accent, gRPC on amber, core-fns on the neutral
/// node-fill, warehouse tables on the palette point colour (the warehouse rail).
fn kind_color(theme: &Theme, kind: NodeKind) -> (Color32, Color32) {
    match kind {
        NodeKind::Component => (theme.accent.linear_multiply(0.30), theme.accent),
        NodeKind::Grpc => (
            super::facett_theme::AMBER.linear_multiply(0.30),
            super::facett_theme::AMBER,
        ),
        // CLI verbs on the palette edge colour — the third entrypoint marker
        // layer beside UI (accent) and gRPC (amber); kept off green/red so the
        // chip FILL never clashes with the coverage RING.
        NodeKind::Cli => (theme.edge.linear_multiply(0.30), theme.edge),
        NodeKind::CoreFn => (theme.node_fill, theme.node_stroke),
        NodeKind::Table => (theme.point.linear_multiply(0.30), theme.point),
        // The three marker layers beyond UI — violet (backend), cyan-teal (codec
        // engine), magenta (sink egress). Fixed hues (not theme fields) so they
        // read apart from accent/amber/edge/point, and kept off green/red so the
        // chip FILL never clashes with the coverage RING (same rule as CLI).
        NodeKind::Backend => {
            let c = Color32::from_rgb(154, 108, 208);
            (c.linear_multiply(0.30), c)
        }
        NodeKind::Codec => {
            let c = Color32::from_rgb(42, 192, 196);
            (c.linear_multiply(0.30), c)
        }
        NodeKind::Sink => {
            let c = Color32::from_rgb(208, 74, 154);
            (c.linear_multiply(0.30), c)
        }
    }
}

/// A stable per-line colour for the 🚇 metro map — a small deterministic palette
/// keyed by the button id so each line reads apart (subway-map house style).
fn line_color(theme: &Theme, button_id: &str) -> Color32 {
    // A fixed metro-line palette (octolinear maps use ~8 distinct line colours).
    const LINES: [Color32; 6] = [
        Color32::from_rgb(0xE3, 0x2D, 0x2D), // red
        Color32::from_rgb(0x2D, 0x7D, 0xE3), // blue
        Color32::from_rgb(0x2D, 0xB3, 0x6B), // green
        Color32::from_rgb(0xE3, 0x9A, 0x2D), // orange
        Color32::from_rgb(0x9A, 0x4D, 0xE3), // purple
        Color32::from_rgb(0x2D, 0xC4, 0xC4), // teal
    ];
    let h = button_id
        .bytes()
        .fold(0u32, |a, b| a.wrapping_mul(31).wrapping_add(b as u32));
    let _ = theme; // palette-neutral lines so they stay distinct on any theme.
    LINES[(h as usize) % LINES.len()]
}

/// Station (fill, radius) per boundary: ui click-handler on the accent, gRPC
/// terminus on amber, emitter stops on the point colour, core fns small + neutral.
fn station_style(theme: &Theme, boundary: Boundary) -> (Color32, f32) {
    match boundary {
        Boundary::Ui => (theme.accent, 10.0),
        Boundary::Grpc => (super::facett_theme::AMBER, 10.0),
        Boundary::Emitter => (theme.point, 9.0),
        Boundary::Core => (theme.node_stroke, 6.0),
    }
}

/// Edge colour per relation kind: calls on the palette accent, reads/writes on
/// amber (the warehouse access traces).
fn edge_color(theme: &Theme, kind: ArchEdgeKind) -> Color32 {
    match kind {
        ArchEdgeKind::Calls => theme.edge,
        ArchEdgeKind::Reads | ArchEdgeKind::Writes => super::facett_theme::AMBER,
    }
}

/// The coverage ring colour for a marker's test-matrix status: green = a test
/// exists, red = none, amber = allowlisted gap, `None` = unknown (keep the kind
/// stroke). Uses the facett status palette — no ad-hoc chrome.
fn cov_ring(cov: Cov) -> Option<Color32> {
    match cov {
        Cov::Tested => Some(super::facett_theme::GREEN),
        Cov::Untested => Some(super::facett_theme::RED),
        Cov::Allowlisted => Some(super::facett_theme::AMBER),
        Cov::Unknown => None,
    }
}

/// Layered BFS columns (verbatim model of `arch::layers_of`, reimplemented here
/// against the laid graph so the native render columns match the SVG's): layer 0
/// = sources (no incoming edge), tables pinned to the rightmost column (the
/// warehouse ground rail). Deterministic, cycle-safe.
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
    let n = graph.nodes.len();
    let idx: HashMap<&str, usize> = graph
        .nodes
        .iter()
        .enumerate()
        .map(|(i, nd)| (nd.id.as_str(), i))
        .collect();
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    let mut indeg: Vec<usize> = vec![0; n];
    for e in &graph.edges {
        if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            if f != t {
                adj[f].push(t);
                indeg[t] += 1;
            }
        }
    }
    let mut layer_of = vec![0usize; n];
    let mut remaining: BTreeSet<usize> = (0..n).collect();
    let mut level = 0usize;
    while !remaining.is_empty() {
        let ready: Vec<usize> = remaining
            .iter()
            .copied()
            .filter(|&i| indeg[i] == 0)
            .collect();
        if ready.is_empty() {
            for &i in &remaining {
                layer_of[i] = level;
            }
            break;
        }
        for &i in &ready {
            layer_of[i] = level;
            remaining.remove(&i);
        }
        for &i in &ready {
            for &j in &adj[i] {
                if indeg[j] > 0 {
                    indeg[j] -= 1;
                }
            }
        }
        level += 1;
    }
    // Pin tables to the rightmost column (the warehouse ground rail).
    let mut max_level = *layer_of.iter().max().unwrap_or(&0);
    let has_table = graph.nodes.iter().any(|nd| nd.kind == NodeKind::Table);
    if has_table {
        max_level = max_level.max(1);
        for (i, nd) in graph.nodes.iter().enumerate() {
            if nd.kind == NodeKind::Table {
                layer_of[i] = max_level;
            }
        }
    }
    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| graph.nodes[a].label.cmp(&graph.nodes[b].label));
    for i in order {
        layers[layer_of[i]].push(i);
    }
    layers
}

#[cfg(test)]
mod tests {
    use super::*;

    fn board() -> ArchGraph {
        // TestTab -reads-> test_results ; Viz.Architecture -writes-> release_lineage
        // ; TestTab -calls-> nornir::viz (a core fn).
        ArchGraph {
            nodes: vec![
                ArchNode {
                    id: "component:TestTab".into(),
                    label: "TestTab".into(),
                    kind: NodeKind::Component,
                },
                ArchNode {
                    id: "grpc:Viz.Architecture".into(),
                    label: "Viz.Architecture".into(),
                    kind: NodeKind::Grpc,
                },
                ArchNode {
                    id: "corefn:nornir::viz".into(),
                    label: "nornir::viz".into(),
                    kind: NodeKind::CoreFn,
                },
                ArchNode {
                    id: "table:test_results".into(),
                    label: "test_results".into(),
                    kind: NodeKind::Table,
                },
                ArchNode {
                    id: "table:release_lineage".into(),
                    label: "release_lineage".into(),
                    kind: NodeKind::Table,
                },
            ],
            edges: vec![
                ArchEdge {
                    from: "component:TestTab".into(),
                    to: "table:test_results".into(),
                    kind: ArchEdgeKind::Reads,
                },
                ArchEdge {
                    from: "component:TestTab".into(),
                    to: "corefn:nornir::viz".into(),
                    kind: ArchEdgeKind::Calls,
                },
                ArchEdge {
                    from: "grpc:Viz.Architecture".into(),
                    to: "table:release_lineage".into(),
                    kind: ArchEdgeKind::Writes,
                },
            ],
        }
    }

    #[test]
    fn injected_board_renders_counts_in_state_json() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.set_members(vec!["nornir".into()]);
        st.inject_for_test(board(), vec!["nornir".into()]);
        let js = st.state_json();
        assert_eq!(js["node_count"], 5);
        assert_eq!(js["edge_count"], 3);
        assert_eq!(js["components"], 1);
        assert_eq!(js["grpc"], 1);
        assert_eq!(js["core_fns"], 1);
        assert_eq!(js["tables"], 2);
        assert_eq!(js["empty"], false);
        assert_eq!(js["members_with_data"][0], "nornir");
        assert_eq!(js["source"], "local");
    }

    #[test]
    fn wiring_board_reports_true_scroll_extent_as_data() {
        // Task #23 (validate-as-data): the static 🏛 Wiring lens wraps the board in
        // a ScrollArea whose canvas is the graph's TRUE extent — so the scrollbar
        // handle scales to the real overflow. Assert the extent surfaces non-zero
        // and the lens flags itself scrollable + smooth-scrolling. Live, by
        // contrast, pans (no scrollbar), so it must NOT flag scrollable.
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);

        st.set_sub_view(ArchSubView::Wiring);
        let js = st.state_json();
        let (w, h) = (
            js["board"]["content_extent"]["w"].as_f64().unwrap(),
            js["board"]["content_extent"]["h"].as_f64().unwrap(),
        );
        // The 5-node layered board spans multiple columns/rows: both dims clear one
        // chip's worth — the canvas is the graph, not the viewport (the old bug).
        assert!(
            w > super::super::graph_render::BOX_W as f64,
            "wiring extent width > one chip: {w}"
        );
        assert!(
            h > super::super::graph_render::BOX_H as f64,
            "wiring extent height > one chip: {h}"
        );
        assert_eq!(
            js["board"]["scrollable"], true,
            "Wiring wraps the board in a ScrollArea"
        );
        assert_eq!(
            js["board"]["smooth_scroll"], true,
            "Wiring scrolls smoothly/kinetically"
        );

        // Live shares the renderer but pans — it is NOT scrollable.
        st.set_sub_view(ArchSubView::Live);
        let live = st.state_json();
        assert_eq!(
            live["board"]["scrollable"], false,
            "Live pans, no scrollbar"
        );
    }

    #[test]
    fn empty_board_shows_placeholder_not_error() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.set_members(vec!["nornir".into()]);
        st.inject_for_test(ArchGraph::default(), vec![]);
        let js = st.state_json();
        assert_eq!(js["empty"], true);
        assert_eq!(js["node_count"], 0);
        assert!(js["load_error"].is_null(), "empty board is not an error");
    }

    #[test]
    fn click_lights_downstream_trace() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);
        // Click TestTab: downstream = {TestTab, test_results, nornir::viz}.
        assert!(st.select_by_label_for_test("TestTab"));
        let js = st.state_json();
        assert_eq!(js["selected"], "TestTab");
        let traced: BTreeSet<String> = js["traced_downstream"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(traced.contains("TestTab"));
        assert!(traced.contains("test_results"));
        assert!(traced.contains("nornir::viz"));
        // release_lineage is NOT reachable from TestTab (it's Viz.Architecture's).
        assert!(
            !traced.contains("release_lineage"),
            "unreachable node not lit: {traced:?}"
        );
    }

    #[test]
    fn layers_pin_tables_right_and_sources_left() {
        let g = board();
        let layers = layers_of(&g);
        // Sources (TestTab, Viz.Architecture — no incoming edge) in layer 0.
        let l0: BTreeSet<&str> = layers[0]
            .iter()
            .map(|&i| g.nodes[i].label.as_str())
            .collect();
        assert!(l0.contains("TestTab"));
        assert!(l0.contains("Viz.Architecture"));
        // Every table is pinned to the LAST layer (the warehouse ground rail), and
        // no table appears in any earlier column.
        let last = layers.len() - 1;
        for (li, layer) in layers.iter().enumerate() {
            for &i in layer {
                if g.nodes[i].kind == NodeKind::Table {
                    assert_eq!(
                        li, last,
                        "table `{}` not on the right rail",
                        g.nodes[i].label
                    );
                }
            }
        }
        // The last layer actually carries the tables.
        assert!(
            layers[last]
                .iter()
                .any(|&i| g.nodes[i].kind == NodeKind::Table),
            "the rightmost column carries the warehouse tables"
        );
    }

    #[test]
    fn coverage_cross_check_tallies_tested_vs_untested() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);
        // TestTab has a test; Viz.Architecture has none; test_results is allowlisted.
        let mut cov = std::collections::BTreeMap::new();
        cov.insert("component:TestTab".to_string(), "covered".to_string());
        cov.insert("grpc:Viz.Architecture".to_string(), "missing".to_string());
        cov.insert("table:test_results".to_string(), "allowlisted".to_string());
        st.inject_coverage_for_test(cov);
        let js = st.state_json();
        // The keystone tally: green ⟺ untested == 0.
        assert_eq!(js["coverage"]["tested"], 1);
        assert_eq!(js["coverage"]["untested"], 1);
        assert_eq!(js["coverage"]["allowlisted"], 1);
        // 5 nodes, 3 carry a verdict → 2 unmatched (never a false green).
        assert_eq!(js["coverage"]["unknown"], 2);
        assert_eq!(js["coverage"]["has_data"], true);
        // The per-marker verdict surfaces in the nodes array.
        let nodes = js["nodes"].as_array().unwrap();
        let find = |id: &str| {
            nodes.iter().find(|n| n["id"] == id).unwrap()["cov"]
                .as_str()
                .unwrap()
                .to_string()
        };
        assert_eq!(find("component:TestTab"), "tested");
        assert_eq!(find("grpc:Viz.Architecture"), "untested");
        assert_eq!(find("table:test_results"), "allowlisted");
        assert_eq!(find("corefn:nornir::viz"), "unknown");
    }

    #[test]
    fn no_coverage_data_is_all_unknown_not_false_green() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);
        let js = st.state_json();
        assert_eq!(js["coverage"]["has_data"], false);
        assert_eq!(js["coverage"]["tested"], 0);
        assert_eq!(
            js["coverage"]["unknown"], 5,
            "no data → all unknown, none green"
        );
    }

    #[test]
    fn palette_switch_reaches_pane() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);
        assert_eq!(st.state_json()["palette"], "default");
        st.set_palette(Theme::cyberpunk_neon());
        assert_eq!(st.state_json()["palette"], "cyberpunk-neon");
    }

    /// N3 — the Architecture tab OPENS on the 🕸 Call Graph lens (the 3-D rotating
    /// node cloud), with the 2-D lenses reachable via the selector.
    #[test]
    fn architecture_defaults_to_3d_call_graph_lens() {
        let st = ArchTabState::local(PathBuf::from("/nonexistent"));
        assert_eq!(st.state_json()["sub_view"], "CallGraph");
        // Every other lens (the 2-D toggle targets) is still enumerated.
        let subs: Vec<String> = st.state_json()["sub_views"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(subs.contains(&"Wiring".to_string()));
        assert!(subs.contains(&"Metro".to_string()));
    }

    fn sym(item_name: &str, file: &str, line: u32) -> crate::knowledge::symbols::SymbolRow {
        crate::knowledge::symbols::SymbolRow {
            crate_name: "myrepo".into(),
            module_path: String::new(),
            item_kind: "fn".into(),
            item_name: item_name.into(),
            visibility: "pub".into(),
            file: file.into(),
            line,
            doc_lines: 0,
            signature: None,
        }
    }

    /// N4 — the source viewer READS the real file bytes from the on-disk clone
    /// (resolved via `repo_dir_resolved(source_root, <member>)`) and the click-
    /// through re-resolves a second symbol to its own definition line.
    #[test]
    fn source_viewer_reads_real_file_and_click_through_navigates() {
        use std::io::Write;
        let tmp =
            std::env::temp_dir().join(format!("nornir-archsrc-{}-{}", std::process::id(), line!()));
        let src = tmp.join("git").join("myrepo").join("src");
        std::fs::create_dir_all(&src).unwrap();
        let mut f = std::fs::File::create(src.join("foo.rs")).unwrap();
        // 1: fn alpha  2: fn foo {  3:   beta();  4: }  5: fn beta {}
        writeln!(
            f,
            "fn alpha() {{}}\nfn foo() {{\n    beta();\n}}\nfn beta() {{}}"
        )
        .unwrap();

        let mut st = ArchTabState::local(PathBuf::from("/nonexistent-wh"));
        st.set_members(vec!["myrepo".into()]);
        st.set_source_root(tmp.join("git"));
        st.inject_symbols_for_test(vec![
            sym("foo", "src/foo.rs", 2),
            sym("beta", "src/foo.rs", 5),
        ]);

        // Open `foo` → reads real lines around line 2 from disk.
        assert!(st.open_symbol("foo", false), "resolved foo");
        let js = st.state_json();
        assert_eq!(js["last_open"]["file"], "src/foo.rs");
        assert_eq!(js["last_open"]["line"], 2);
        assert!(
            js["last_open"]["preview_lines"].as_u64().unwrap() > 0,
            "the viewer read REAL bytes from the on-disk clone, not a stub"
        );
        // `beta` is a navigable identifier (a known symbol) present in foo's body.
        assert!(st.navigable_symbols().contains("beta"));
        // Click-through: navigate to `beta` → its own definition line (5).
        assert!(st.open_symbol("beta", false), "navigated to beta");
        assert_eq!(st.state_json()["last_open"]["line"], 5);

        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// N2 — the deep-scan trigger is real: its state surfaces in `state_json`. A
    /// fat pane has nothing to scan (the consumer writes the code room): the
    /// trigger says so and drops the load latch, so the board is read again —
    /// never a dead no-op.
    #[test]
    fn deep_scan_trigger_surfaces_state() {
        let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
        st.inject_for_test(board(), vec!["nornir".into()]);
        assert_eq!(st.state_json()["deep_scan"]["inflight"], false);
        assert!(st.loaded);
        st.trigger_deep_scan();
        let js = st.state_json();
        assert_eq!(js["deep_scan"]["inflight"], false);
        assert!(js["deep_scan"]["note"].as_str().is_some_and(|n| n.contains("code room")), "{}", js["deep_scan"]);
        assert!(!st.loaded, "the next draw reads the code room again");
    }

    /// Fat mode end to end: the pane reads its workspace's code room through
    /// the reader — the board, who contributed, and the metro fuel — and a
    /// workspace nothing was consumed into is `load_error` by state, never an
    /// empty board.
    #[cfg(feature = "code-room")]
    #[test]
    fn a_fat_pane_reads_the_board_and_the_metro_fuel_from_the_code_room() {
        use crate::code_room::reader::fixture;
        let mut st = ArchTabState::local(fixture::shop().builds.clone());
        st.ensure_loaded();
        let js = st.state_json();
        assert!(js["load_error"].is_null(), "{js}");
        assert_eq!(js["node_count"], 5, "shop, shop::cart, shop::pay, stripe::Client, web");
        assert_eq!(st.members_with_data, vec!["shop".to_string(), "web".to_string()]);
        assert_eq!((st.symbols.as_ref().map(Vec::len), st.calls.as_ref().map(Vec::len)), (Some(16), Some(14)), "the fuel rides the payload in fat mode too");
        assert_eq!(st.metro_fuel(), "resolved/scip");
        // `shop` has no handler behind nornir's buttons: every line is built from the
        // room's rows and none reaches — unlit skeletons, never "no fuel"
        st.ensure_metro();
        let lines = st.metro_lines.as_deref().unwrap();
        assert!(!lines.is_empty() && lines.iter().all(|l| !l.reached), "{} line(s)", lines.len());

        let mut st = ArchTabState::local(fixture::unconsumed().builds.clone());
        st.ensure_loaded();
        let js = st.state_json();
        assert!(js["load_error"].as_str().is_some_and(|e| e.contains("[not_consumed]")), "{js}");
        assert_eq!(js["node_count"], 0);
        st.ensure_symbols();
        assert_eq!(st.metro_fuel(), "unavailable", "no fuel because the board could not be read — not `no lines`");
    }
}