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

nordisk/facett

facett-coderoom-view/src/lib.rs

//! # facett-coderoom-view — the shared **world-coderoom** pane
//!
//! The ONE robot-testable egui [`Facet`](facett_core::Facet) that renders the
//! constellation's **world coderoom** — every `crate@version` the coderoom
//! warehouse holds (with its `symbol_facts` / `call_edges`) plus the
//! `semver_diffs` **compat edges** between a producer and the callers that
//! reference it — as an interactive **dependency node-graph**, so every suite
//! surface that wants to show "which crate breaks whom" (nornir's `coderoom`
//! deck · dwarves' release view) mounts **one pane, not a per-app twin** (LAW #5
//! — DRY). It renders:
//!
//! 1. **Dependency graph** — crates as node chips laid out in toposort columns
//!    (deps right, consumers left), labelled + sized by crate.
//! 2. **Compat edges** — each `semver_diff` edge coloured **green = compatible**
//!    / **red = breaking**, its `suggested_bump` on the arrow label.
//! 3. **What-breaks mode** — given a selected `crate@Z`, the world callers that
//!    reference a **removed** symbol (the impact set) are **highlighted** (a red
//!    ring + a ✗ badge, kept bright) while everything off the impact path is
//!    **dimmed**.
//! 4. **Summary line** — `N crates · M breaking edges · K impacted callers`.
//!
//! ## Layering — the pane is WAREHOUSE-AGNOSTIC (important)
//! facett carries **no** nornir / dwarves / warehouse dependency. The pane is
//! **presentational + host-driven**: the HOST implements the [`CoderoomDataSeam`]
//! trait (feeding the crate nodes + compat edges + the what-breaks result from
//! the dedicated coderoom warehouse), and [`CoderoomPane`] only ever **renders
//! the rows the seam surfaces**. This mirrors how the Release tab takes a
//! `ReleaseConvergence` in and the binary pane takes a `BinaryDataSeam` in — the
//! pane never reaches for a backend (a ONE-WAY adapter: rows flow in, nothing
//! leaks back).
//!
//! ## Reuse — no twinned graph layout
//! The dependency graph is drawn with **`facett-graphview`**: the crate DAG is
//! laid into columns by [`facett_graphview::DepGraphLayout`] (the shared,
//! egui-free toposort layout) and painted by
//! [`facett_graphview::DecoratedGraphView`] (per-edge colour, node rings/badges,
//! pan/zoom, click-select, `state_json`). This pane contributes only the
//! **colour + what-breaks policy** on top of that shared renderer — it invents no
//! layout of its own.
//!
//! A network-free [`StubCoderoomData`] drives the shipped demo + the robot test.

use std::collections::{BTreeSet, HashMap};

use facett_core::look::oklch::Oklch;
use facett_graphview::depgraph_layout::{DepEdge as LayoutEdge, DepGraphLayout};
use facett_graphview::model::{Decorations, GraphEdge, GraphModel, GraphNode, Pos};
use facett_graphview::DecoratedGraphView;
use serde::{Deserialize, Serialize};

pub use facett_core::Facet;
// Re-exported so a host/robot speaks the pane's colour + decoration types without
// depending on facett-graphview directly.
pub use facett_graphview::model::{Color, NodeDecoration};

// ─────────────────────────────────────────────────────────────────────────────
// Data rows — the neutral shapes the HOST feeds over the seam. These mirror the
// coderoom warehouse's tables (crates@versions with symbol_facts, and the
// semver_diffs compat edges) but carry NO warehouse type dependency.
// ─────────────────────────────────────────────────────────────────────────────

/// One `crate@version` node in the world coderoom — a crate at a specific
/// version, plus its symbol count (the coderoom warehouse's `symbol_facts`
/// cardinality) which sizes/annotates the node.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateNode {
    /// Crate name (e.g. `"a"`, `"nornir"`).
    pub name: String,
    /// The version this node pins (e.g. `"0.2.0"`).
    pub version: String,
    /// How many public symbols this version exposes (`symbol_facts` count) — the
    /// node's size/importance weight.
    pub symbols: u64,
}

impl CrateNode {
    /// A `name`/`version`/`symbols` node row.
    pub fn new(name: impl Into<String>, version: impl Into<String>, symbols: u64) -> Self {
        Self { name: name.into(), version: version.into(), symbols }
    }
    /// The stable node id — `"{name}@{version}"`. Edges + the what-breaks impact
    /// set key on this.
    pub fn id(&self) -> String {
        format!("{}@{}", self.name, self.version)
    }
}

/// The semver verdict on a compat edge (a `semver_diffs` row): does the
/// producer's change break the consumer that references it?
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Compat {
    /// Non-breaking — the consumer still compiles/links against the new version.
    Compatible,
    /// Breaking — the producer removed/changed a symbol the consumer references.
    Breaking,
}

impl Compat {
    /// `true` for [`Compat::Breaking`].
    pub fn is_breaking(self) -> bool {
        matches!(self, Compat::Breaking)
    }
    /// A short, stable label for the JSON / arrow.
    pub fn label(self) -> &'static str {
        match self {
            Compat::Compatible => "compatible",
            Compat::Breaking => "breaking",
        }
    }
}

/// One `semver_diffs` **compat edge**: the consumer `from` references the
/// producer `to`; [`compat`](Self::compat) is the verdict for the producer's
/// pending version change, [`suggested_bump`](Self::suggested_bump) the semver
/// bump that change warrants, and [`via`](Self::via) the referenced symbols
/// justifying the edge.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompatEdge {
    /// The consumer crate id (`name@version`) — it depends on / references `to`.
    pub from: String,
    /// The producer crate id (`name@version`) being changed.
    pub to: String,
    /// The compatibility verdict (green/red).
    pub compat: Compat,
    /// The semver bump the producer's change warrants (`"major"`, `"minor"`, …).
    pub suggested_bump: Option<String>,
    /// The referenced symbols justifying the edge (empty is fine).
    pub via: Vec<String>,
}

impl CompatEdge {
    /// A breaking `from → to` edge (with the referenced symbols + suggested bump).
    pub fn breaking(from: impl Into<String>, to: impl Into<String>, bump: impl Into<String>, via: &[&str]) -> Self {
        Self {
            from: from.into(),
            to: to.into(),
            compat: Compat::Breaking,
            suggested_bump: Some(bump.into()),
            via: via.iter().map(|s| s.to_string()).collect(),
        }
    }
    /// A compatible `from → to` edge.
    pub fn compatible(from: impl Into<String>, to: impl Into<String>, bump: impl Into<String>, via: &[&str]) -> Self {
        Self {
            from: from.into(),
            to: to.into(),
            compat: Compat::Compatible,
            suggested_bump: Some(bump.into()),
            via: via.iter().map(|s| s.to_string()).collect(),
        }
    }
}

/// One breaking caller in a what-breaks result: the crate `caller` that references
/// a `symbol` the analysed target removed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Impact {
    /// The breaking caller's crate id (`name@version`).
    pub caller: String,
    /// The removed symbol the caller referenced.
    pub symbol: String,
}

impl Impact {
    /// A `caller`/`symbol` impact row.
    pub fn new(caller: impl Into<String>, symbol: impl Into<String>) -> Self {
        Self { caller: caller.into(), symbol: symbol.into() }
    }
}

/// A **what-breaks** result (the coderoom `what-breaks crate@Z` query): the world
/// callers that break because the target `crate@version` removed one or more
/// symbols they reference.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WhatBreaks {
    /// The analysed target crate id (`name@version`).
    pub target: String,
    /// The symbols the target removed at this version.
    pub removed_symbols: Vec<String>,
    /// The world callers that break (they reference a removed symbol) — the impact
    /// set the pane highlights.
    pub impacted: Vec<Impact>,
}

impl WhatBreaks {
    /// The impacted caller ids, deduplicated.
    pub fn impacted_ids(&self) -> BTreeSet<String> {
        self.impacted.iter().map(|i| i.caller.clone()).collect()
    }
}

/// The per-crate **symbol churn** for one crate that survived across the two
/// snapshots — the *counts* of symbols added / removed / signature-changed. facett
/// carries only the counts the pane renders (not the symbol names): the host maps
/// them from nornir's `crates_changed[].symbols_{added,removed,changed}` arrays.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateDelta {
    /// The crate id (`name@version`) whose public surface changed.
    pub crate_id: String,
    /// Symbols present in B but not A.
    pub symbols_added: u64,
    /// Symbols present in A but not B.
    pub symbols_removed: u64,
    /// Surviving symbols whose signature set changed.
    pub symbols_changed: u64,
}

impl CrateDelta {
    /// A `crate_id` delta with its `added`/`removed`/`changed` symbol counts.
    pub fn new(crate_id: impl Into<String>, added: u64, removed: u64, changed: u64) -> Self {
        Self { crate_id: crate_id.into(), symbols_added: added, symbols_removed: removed, symbols_changed: changed }
    }
    /// The total symbol churn (`added + removed + changed`).
    pub fn total(&self) -> u64 {
        self.symbols_added + self.symbols_removed + self.symbols_changed
    }
}

/// A **snapshot diff** — the corpus-wide "what changed" between two coderoom
/// snapshots `A → B` (nornir's `coderoom snapshot-diff <A> <B>`): the crates added
/// to / removed from the corpus, and the per-crate symbol deltas of the crates that
/// survived but whose surface churned. HOST-fed; the pane only renders it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoderoomSnapshotDiff {
    /// The "before" snapshot label (`snapshot_id`).
    pub snapshot_a: String,
    /// The "after" snapshot label (`snapshot_id`).
    pub snapshot_b: String,
    /// Crate ids (`name@version`) present in B but not A.
    pub crates_added: Vec<String>,
    /// Crate ids (`name@version`) present in A but not B.
    pub crates_removed: Vec<String>,
    /// Surviving crates whose symbol surface changed, with the per-crate counts.
    pub crates_changed: Vec<CrateDelta>,
}

impl CoderoomSnapshotDiff {
    /// `(added, removed, changed)` — the crate-level counts the summary line paints.
    pub fn counts(&self) -> (usize, usize, usize) {
        (self.crates_added.len(), self.crates_removed.len(), self.crates_changed.len())
    }
    /// `true` when nothing changed between the two snapshots.
    pub fn is_empty(&self) -> bool {
        self.crates_added.is_empty() && self.crates_removed.is_empty() && self.crates_changed.is_empty()
    }
}

/// What JOINS two crates, with NO verdict: how many dependency edges, item-level
/// calls (and their call sites) and type uses the host's graph holds from `from` to
/// `to`. Drawn in a neutral colour with its counts on the arrow — never green or
/// red, because a count is not a compatibility verdict.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateLink {
    /// The crate id (`name@version`) the edges leave.
    pub from: String,
    /// The crate id (`name@version`) they arrive at.
    pub to: String,
    /// Dependency edges (`from` depends on `to`).
    pub depends_on: u64,
    /// Item → item call edges.
    pub calls: u64,
    /// The call sites carrying those call edges.
    pub call_sites: u64,
    /// Item → type use edges.
    pub uses_type: u64,
}

impl CrateLink {
    /// The arrow label: only the counts that are not zero (`dep · 12 calls`).
    pub fn label(&self) -> String {
        let mut parts: Vec<String> = Vec::new();
        if self.depends_on > 0 {
            parts.push("dep".to_string());
        }
        if self.calls > 0 {
            parts.push(format!("{} calls", self.calls));
        }
        if self.uses_type > 0 {
            parts.push(format!("{} types", self.uses_type));
        }
        parts.join(" · ")
    }
}

/// What ONE layer of the pane rests on, in the host's words: the layer's name
/// ([`LAYER_GRAPH`], [`LAYER_VERDICTS`], [`LAYER_DIFF`]), the answer's state by name
/// (`complete`, `partial`, `not_wired`, `not_consumed` …), whether that state is a
/// MEASUREMENT, and the evidence text. The pane draws it as a state chip with the
/// evidence beside it, and — this is the point — draws NO number for a layer that
/// was not measured: zero breaking edges from a layer that never judged anything is
/// not a finding.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayerState {
    /// Which layer: one of the `LAYER_*` names.
    pub layer: String,
    /// The answer's state, by its own name.
    pub state: String,
    /// The state is a measurement (the layer's numbers may be drawn).
    pub measured: bool,
    /// What the answer rests on / why there is none.
    pub evidence: String,
}

impl LayerState {
    /// A layer's state row.
    pub fn new(layer: impl Into<String>, state: impl Into<String>, measured: bool, evidence: impl Into<String>) -> Self {
        Self { layer: layer.into(), state: state.into(), measured, evidence: evidence.into() }
    }
}

/// The layer of crates and what joins them ([`CoderoomDataSeam::crates`] /
/// [`links`](CoderoomDataSeam::links)).
pub const LAYER_GRAPH: &str = "crate graph";
/// The layer of compatibility verdicts ([`compat_edges`](CoderoomDataSeam::compat_edges)
/// / [`what_breaks`](CoderoomDataSeam::what_breaks)).
pub const LAYER_VERDICTS: &str = "compat verdicts";
/// The layer of the snapshot diff ([`snapshot_diff`](CoderoomDataSeam::snapshot_diff)).
pub const LAYER_DIFF: &str = "snapshot diff";

/// A neutral join / an unjudged edge — a grey that reads as "no verdict".
pub const NEUTRAL: Color = Color::rgb(0x8a, 0x93, 0x9e);

// ─────────────────────────────────────────────────────────────────────────────
// The seam — implemented by the HOST (nornir/dwarves), which feeds rows from the
// dedicated coderoom warehouse. No method reaches for a backend from the pane.
// ─────────────────────────────────────────────────────────────────────────────

/// The world-coderoom **data seam** the [`CoderoomPane`] renders — implemented by
/// the **HOST** (nornir/dwarves), which feeds the rows from the coderoom
/// warehouse's `crates@versions` (+ `symbol_facts`) and its `semver_diffs` compat
/// edges. The pane never computes any of these; it only paints whatever the seam
/// returns.
pub trait CoderoomDataSeam {
    /// An honest banner for *where* the coderoom came from, e.g.
    /// `"nornir coderoom warehouse"` or `"stub (demo)"`.
    fn source(&self) -> &str {
        "coderoom"
    }

    /// Every `crate@version` node in the world coderoom.
    fn crates(&self) -> Vec<CrateNode>;

    /// The `semver_diffs` compat edges (consumer → producer, with a verdict).
    fn compat_edges(&self) -> Vec<CompatEdge>;

    /// The **what-breaks** result for a `crate@version` `target` — the world
    /// callers referencing a symbol the target removed. `None` ⇒ the target is
    /// unknown or nothing breaks (no impact set to highlight).
    fn what_breaks(&self, target: &str) -> Option<WhatBreaks>;

    /// The **snapshot diff** — the corpus-wide "what changed" between two coderoom
    /// snapshots (nornir's `coderoom snapshot-diff <A> <B>`). Default `None`: a host
    /// that has no two-snapshot history simply doesn't offer the what-changed mode.
    fn snapshot_diff(&self) -> Option<CoderoomSnapshotDiff> {
        None
    }

    /// What joins the crates, with no verdict ([`CrateLink`]): dependency, call and
    /// type-use counts. Default none — a host that only has verdict edges feeds
    /// [`compat_edges`](Self::compat_edges).
    fn links(&self) -> Vec<CrateLink> {
        Vec::new()
    }

    /// What each layer rests on ([`LayerState`]). Default none: a host that says
    /// nothing about its layers is drawn as before. A host that names
    /// [`LAYER_VERDICTS`] as NOT measured gets no breaking / compatible count, no
    /// red or green edge and no impact set drawn — only the state and its evidence.
    fn layers(&self) -> Vec<LayerState> {
        Vec::new()
    }

    /// Lines printed under the `via …` banner: WHICH graph this is (workspace,
    /// commits, node and edge counts), so a person can compare with the source.
    fn identity(&self) -> Vec<String> {
        Vec::new()
    }
}

/// Which view the [`CoderoomPane`] is showing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoderoomMode {
    /// The dependency node-graph + what-breaks overlay (the default).
    Graph,
    /// The snapshot-diff "what changed" list (added / removed / changed crates).
    WhatChanged,
}

// ─────────────────────────────────────────────────────────────────────────────
// Colours — green = compatible, red = breaking, amber = the analysed target.
// Node hues reuse facett-core's OKLCH authoring space (no twinned colour maths).
// ─────────────────────────────────────────────────────────────────────────────

/// A compatible edge / OK verdict — a cool green.
pub const GREEN: Color = Color::rgb(0x3f, 0xa6, 0x60);
/// A breaking edge / removed-symbol verdict — a warm red.
pub const RED: Color = Color::rgb(0xd9, 0x53, 0x4f);
/// The analysed what-breaks target ring — an amber.
pub const AMBER: Color = Color::rgb(0xf0, 0xa8, 0x30);

/// A stable fill colour for a crate name — reuses [`Oklch`] (no twinned colour
/// maths): a golden-ratio-ish hue hash at a fixed lightness/chroma so a crate
/// always paints the same hue and adjacent chips read apart.
pub fn crate_color(name: &str) -> Color {
    // FNV-1a over the bytes → a stable hue in [0, 360).
    let mut h: u64 = 0xcbf29ce484222325;
    for b in name.bytes() {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    let hue = (h % 360) as f32;
    Color::from(Oklch::new(0.62, 0.13, hue).to_color32())
}

/// The hex string (`#rrggbb`) for a [`Color`] — the render-truth the robot test
/// asserts an edge painted (red for breaking, green for compatible).
fn hex(c: Color) -> String {
    format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
}

// Layout world-space spacing between the toposort columns/rows.
const COL_SPACING: f32 = 240.0;
const ROW_SPACING: f32 = 88.0;

// ─────────────────────────────────────────────────────────────────────────────
// The pane.
// ─────────────────────────────────────────────────────────────────────────────

/// The shared **world-coderoom pane** — a [`Facet`](facett_core::Facet) the host
/// mounts, driving its host-supplied [`CoderoomDataSeam`]. Presentational: it
/// caches a snapshot of the seam's rows, lays them into a dependency graph via the
/// REUSED [`DecoratedGraphView`], and paints the compat colours + the what-breaks
/// highlight. [`refresh`](Self::refresh) re-pulls from the seam;
/// [`select_target`](Self::select_target) drives the what-breaks mode.
pub struct CoderoomPane {
    seam: Box<dyn CoderoomDataSeam>,
    title: String,
    // Cached snapshot of the seam's rows (re-pulled on `refresh`).
    crates: Vec<CrateNode>,
    edges: Vec<CompatEdge>,
    // The neutral joins (dependency / call / type-use counts), the layer states
    // and the identity lines — re-pulled with the rest on `refresh`.
    links: Vec<CrateLink>,
    layers: Vec<LayerState>,
    identity: Vec<String>,
    // The what-breaks target (`crate@version`) + its impact set, when in that mode.
    target: Option<String>,
    what_breaks: Option<WhatBreaks>,
    // The snapshot-diff "what changed" payload, when the host feeds one.
    diff: Option<CoderoomSnapshotDiff>,
    // Which view (dependency graph vs. snapshot diff) is showing.
    mode: CoderoomMode,
    // The REUSED graph viz, rebuilt from the scene whenever the mode changes.
    graph: DecoratedGraphView,
    // The host opted the viz into the shared GPU cloud (`with_gpu`): above
    // `cloud::RESIDENT_ABOVE` crates it paints on the RESIDENT lane.
    gpu: bool,
    // `self.edges` that are breaking — counted per refresh, not per frame.
    breaking: usize,
    // The laid-out world positions — computed per refresh (the layout is a function of
    // the crates + edges only), reused by every what-breaks rebuild.
    pos: HashMap<String, Pos>,
}

impl CoderoomPane {
    /// Build the pane over a host-supplied [`CoderoomDataSeam`], pulling an initial
    /// snapshot of its rows and laying out the dependency graph.
    pub fn new(seam: Box<dyn CoderoomDataSeam>) -> Self {
        let mut pane = Self {
            seam,
            title: "World coderoom".to_string(),
            crates: Vec::new(),
            edges: Vec::new(),
            links: Vec::new(),
            layers: Vec::new(),
            identity: Vec::new(),
            target: None,
            what_breaks: None,
            diff: None,
            mode: CoderoomMode::Graph,
            graph: DecoratedGraphView::new("coderoom deps"),
            gpu: false,
            breaking: 0,
            pos: HashMap::new(),
        };
        pane.refresh();
        pane
    }

    /// Opt the reused [`DecoratedGraphView`] into the shared instanced GPU cloud
    /// ([`DecoratedGraphView::with_gpu`]). Above `cloud::RESIDENT_ABOVE` crates the
    /// world paints on the RESIDENT lane: uploaded once per scene (a refresh, a new
    /// what-breaks target), the camera alone per frame; rings, badges and on-screen
    /// labels stay overlays. Without an installed renderer the CPU painter draws.
    #[must_use]
    /// The graph canvas's rect on the last paint — what a robot reads to check the graph
    /// gets the bulk of the tab.
    pub fn graph_rect(&self) -> Option<egui::Rect> {
        self.graph.last_paint_rect()
    }

    pub fn with_gpu(mut self, on: bool) -> Self {
        self.gpu = on;
        self.rebuild_graph();
        self
    }

    /// Override the pane heading (default `"World coderoom"`).
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// Re-pull the whole snapshot from the seam (call when the host has fresh
    /// warehouse rows) and re-lay the graph.
    pub fn refresh(&mut self) {
        self.crates = self.seam.crates();
        self.layers = self.seam.layers();
        self.identity = self.seam.identity();
        self.links = self.seam.links();
        // A verdict layer that was NOT measured has no edges to draw: whatever the
        // seam hands over, a red or green arrow would be a verdict nobody reached.
        self.edges = if self.verdicts_measured() { self.seam.compat_edges() } else { Vec::new() };
        self.breaking = self.edges.iter().filter(|e| e.compat.is_breaking()).count();
        self.pos = self.layout_positions();
        self.diff = self.seam.snapshot_diff();
        // The host stopped feeding a diff ⇒ don't strand the pane in a mode it can
        // no longer render.
        if self.diff.is_none() {
            self.mode = CoderoomMode::Graph;
        }
        self.reload_what_breaks();
        self.rebuild_graph();
    }

    /// Enter (or leave, with `None`) **what-breaks** mode for a `crate@version`
    /// target: the seam is asked which world callers break, and the graph is
    /// rebuilt with the impact set highlighted + everything else dimmed.
    pub fn select_target(&mut self, target: Option<String>) {
        self.target = target;
        self.reload_what_breaks();
        self.rebuild_graph();
    }

    /// The current what-breaks target (`crate@version`), if any.
    pub fn target(&self) -> Option<&str> {
        self.target.as_deref()
    }

    /// The view the pane is showing ([`CoderoomMode::Graph`] by default).
    pub fn mode(&self) -> CoderoomMode {
        self.mode
    }

    /// Switch the view. Selecting [`CoderoomMode::WhatChanged`] is a no-op when the
    /// host feeds no snapshot diff (there is nothing to render).
    pub fn set_mode(&mut self, mode: CoderoomMode) {
        if mode == CoderoomMode::WhatChanged && self.diff.is_none() {
            return;
        }
        self.mode = mode;
    }

    /// The host-fed snapshot diff, if any (`None` ⇒ no what-changed mode offered).
    pub fn snapshot_diff(&self) -> Option<&CoderoomSnapshotDiff> {
        self.diff.as_ref()
    }

    /// `(added, removed, changed)` crate counts for the what-changed summary line
    /// (all zero when the host feeds no diff).
    pub fn diff_counts(&self) -> (usize, usize, usize) {
        self.diff.as_ref().map(CoderoomSnapshotDiff::counts).unwrap_or((0, 0, 0))
    }

    /// The impacted caller ids for the current target (empty when not in
    /// what-breaks mode).
    pub fn impacted(&self) -> BTreeSet<String> {
        self.what_breaks.as_ref().map(WhatBreaks::impacted_ids).unwrap_or_default()
    }

    /// The ring/badge decorations the pane handed the REUSED viz (read-only) — the
    /// what-breaks highlight as data, so a host/robot asserts the impacted rings
    /// without a screenshot.
    pub fn graph_decorations(&self) -> &HashMap<String, NodeDecoration> {
        &self.graph.state().decorations.nodes
    }

    /// The reused graph viz (read-only) — its lane readouts (`legibility()`), camera
    /// and model, for a host that publishes a summary instead of the full state.
    pub fn graph(&self) -> &DecoratedGraphView {
        &self.graph
    }

    /// `(crates, breaking_edges, impacted_callers)` — the summary line's numbers.
    pub fn summary(&self) -> (usize, usize, usize) {
        (self.crates.len(), self.breaking, self.what_breaks.as_ref().map_or(0, |w| w.impacted_ids().len()))
    }

    fn reload_what_breaks(&mut self) {
        // no impact set from a verdict layer that was not measured
        self.what_breaks = if self.verdicts_measured() { self.target.as_deref().and_then(|t| self.seam.what_breaks(t)) } else { None };
    }

    /// The state the host gave for `layer`, if it gave one.
    pub fn layer(&self, layer: &str) -> Option<&LayerState> {
        self.layers.iter().find(|l| l.layer == layer)
    }

    /// May the verdict layer's numbers be drawn? Yes when the host says it was
    /// measured — and when the host says nothing about its layers at all (the
    /// contract before layers existed).
    pub fn verdicts_measured(&self) -> bool {
        self.layer(LAYER_VERDICTS).is_none_or(|l| l.measured)
    }

    /// The neutral joins the host fed (dependency / call / type-use counts).
    pub fn links(&self) -> &[CrateLink] {
        &self.links
    }

    /// Lay the crate DAG into world positions using the REUSED, egui-free toposort
    /// layout, centred around the origin so the default camera frames it.
    fn layout_positions(&self) -> HashMap<String, Pos> {
        let repos: Vec<String> = self.crates.iter().map(CrateNode::id).collect();
        let layout_edges: Vec<LayoutEdge> = self
            .edges
            .iter()
            .map(|e| {
                let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
                LayoutEdge::new(e.from.clone(), e.to.clone(), &via)
            })
            // the neutral joins shape the layout too (a dependency is a dependency)
            .chain(self.links.iter().map(|l| LayoutEdge::new(l.from.clone(), l.to.clone(), &[])))
            .collect();
        let lay = DepGraphLayout::build(&repos, &layout_edges, false, &BTreeSet::new());

        // Rows-per-column so each column can be centred vertically.
        let ncols = lay.nodes.iter().map(|n| n.col).max().unwrap_or(0);
        let mut rows_in_col: HashMap<usize, usize> = HashMap::new();
        for n in &lay.nodes {
            *rows_in_col.entry(n.col).or_insert(0) += 1;
        }
        let mut out = HashMap::new();
        for n in &lay.nodes {
            let count = *rows_in_col.get(&n.col).unwrap_or(&1);
            let x = (n.col as f32 - ncols as f32 / 2.0) * COL_SPACING;
            let y = (n.row as f32 - (count as f32 - 1.0) / 2.0) * ROW_SPACING;
            out.insert(n.repo.clone(), Pos::new(x, y));
        }
        out
    }

    /// Build the [`GraphModel`] + [`Decorations`] fed to the reused viz. This is
    /// the whole colour + what-breaks POLICY: node fills by crate (dimmed off the
    /// impact path), edges green/red by verdict, a red ring + ✗ badge on each
    /// impacted caller, an amber ring on the analysed target.
    fn build_scene(&self) -> (GraphModel, Decorations) {
        let pos = &self.pos;
        let impacted = self.impacted();
        let has_target = self.target.is_some();
        let target = self.target.clone();
        let max_sym = self.crates.iter().map(|c| c.symbols).max().unwrap_or(1).max(1) as f32;

        let mut deco_nodes: HashMap<String, NodeDecoration> = HashMap::new();
        let mut nodes: Vec<GraphNode> = Vec::with_capacity(self.crates.len());
        for c in &self.crates {
            let id = c.id();
            let is_target = target.as_deref() == Some(id.as_str());
            let is_impacted = impacted.contains(&id);
            let base = crate_color(&c.name);
            // Dim everything off the impact path once a target is chosen.
            let fill = if has_target && !is_target && !is_impacted { base.dim(0.30) } else { base };
            let stroke = if has_target && !is_target && !is_impacted { Color::GRAY.dim(0.5) } else { Color::WHITE };
            nodes.push(GraphNode {
                id: id.clone(),
                label: format!("{}@{} · {} sym", c.name, c.version, c.symbols),
                fill,
                stroke,
                pos: *pos.get(&id).unwrap_or(&Pos::new(0.0, 0.0)),
            });

            // Size-by-crate: a marker-radius multiplier the L0/GPU raster path reads
            // (the egui reference chip is a fixed size; the intent is carried here +
            // in state_json). Range ~[0.6, 1.6].
            let scale = 0.6 + (c.symbols as f32 / max_sym);
            let deco = deco_nodes.entry(id.clone()).or_default();
            deco.scale = Some(scale.min(1.6));

            if has_target {
                if is_impacted {
                    deco.ring = Some(RED);
                    deco.badge = Some("✗".into());
                    deco.badge_color = Some(RED);
                } else if is_target {
                    deco.ring = Some(AMBER);
                    deco.badge = Some("Z".into());
                    deco.badge_color = Some(AMBER);
                }
            }
        }

        let mut edges: Vec<GraphEdge> = self
            .edges
            .iter()
            .map(|e| {
                let mut color = if e.compat.is_breaking() { RED } else { GREEN };
                // Off the impact path, fade the edge too.
                if has_target {
                    let on_path = impacted.contains(&e.from)
                        || impacted.contains(&e.to)
                        || target.as_deref() == Some(e.from.as_str())
                        || target.as_deref() == Some(e.to.as_str());
                    if !on_path {
                        color = color.dim(0.30);
                    }
                }
                GraphEdge {
                    from: e.from.clone(),
                    to: e.to.clone(),
                    color,
                    dashed: false,
                    label: e.suggested_bump.clone(),
                }
            })
            .collect();
        // The neutral joins: grey, dashed when nothing but a dependency joins the
        // two, the counts on the arrow. Never a verdict colour.
        edges.extend(self.links.iter().map(|l| GraphEdge {
            from: l.from.clone(),
            to: l.to.clone(),
            color: if has_target { NEUTRAL.dim(0.5) } else { NEUTRAL },
            dashed: l.calls == 0 && l.uses_type == 0,
            label: Some(l.label()).filter(|t| !t.is_empty()),
        }));

        (GraphModel { nodes, edges }, Decorations { nodes: deco_nodes, edges: Vec::new() })
    }

    /// Rebuild the embedded [`DecoratedGraphView`] from the current scene. The
    /// reused viz's OWN selection is left cleared: the highlight/dim is baked into
    /// the model colours + ring decorations, so the seam's impact set (not the
    /// graph topology) is authoritative.
    ///
    /// The camera carries over (a new target is a new scene of the SAME graph — the
    /// user's pan/zoom must not jump back to zoom 1 on every click).
    fn rebuild_graph(&mut self) {
        let (model, decorations) = self.build_scene();
        let (zoom, px, py) = {
            let s = self.graph.state();
            (s.zoom, s.pan_x, s.pan_y)
        };
        self.graph =
            DecoratedGraphView::new("coderoom deps").with_model(model).with_decorations(decorations).with_gpu(self.gpu);
        self.graph.set_camera(zoom, px, py);
    }

    /// **Frame the whole world in a `viewport`-sized pane** — the reused viz's
    /// [`DecoratedGraphView::fit_model`]. A host that hands the pane a big seam calls it
    /// once; the camera then survives every what-breaks rebuild.
    pub fn fit_graph(&mut self, viewport: egui::Vec2, margin: f32) {
        self.graph.fit_model(viewport, margin);
    }

    /// Render the snapshot-diff **what-changed** list: a `N added · M removed · K
    /// changed` summary, then the added crates (green `+`), the removed crates (red
    /// `−`), and the changed crates with their per-crate symbol add/remove/change
    /// counts (amber `~`). All real widgets so every row reaches the a11y tree.
    fn ui_what_changed(&mut self, ui: &mut egui::Ui) {
        let green32 = egui::Color32::from_rgb(GREEN.r, GREEN.g, GREEN.b);
        let red32 = egui::Color32::from_rgb(RED.r, RED.g, RED.b);
        let amber32 = egui::Color32::from_rgb(AMBER.r, AMBER.g, AMBER.b);

        let Some(diff) = &self.diff else {
            ui.weak("no snapshot diff available");
            return;
        };

        let (added, removed, changed) = diff.counts();
        ui.label(format!("{added} added · {removed} removed · {changed} changed"));
        ui.weak(format!("{} → {}", diff.snapshot_a, diff.snapshot_b));
        ui.separator();

        if !diff.crates_added.is_empty() {
            ui.strong("Added");
            for c in &diff.crates_added {
                ui.colored_label(green32, format!("+ {c}"));
            }
        }
        if !diff.crates_removed.is_empty() {
            ui.strong("Removed");
            for c in &diff.crates_removed {
                ui.colored_label(red32, format!("− {c}"));
            }
        }
        if !diff.crates_changed.is_empty() {
            ui.strong("Changed");
            for c in &diff.crates_changed {
                ui.colored_label(
                    amber32,
                    format!("~ {} +{} −{} ~{}", c.crate_id, c.symbols_added, c.symbols_removed, c.symbols_changed),
                );
            }
        }
        if diff.is_empty() {
            ui.weak("no crates changed between the two snapshots");
        }
    }
}

impl Facet for CoderoomPane {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // The header (title, identity, layer states, the summary) in a top region with a
        // drag bar, the graph below with ALL the remaining height (facett_core::
        // graph_split, remembered per viewer) — it used to get only what the header
        // left: "only the lower part of the window, and no drag bar".
        facett_core::graph_split::header_above(ui, "coderoom", |ui| {
            ui.heading(&self.title);
            ui.horizontal(|ui| {
                ui.weak(format!("via {}", self.seam.source()));
            });
            // WHICH graph this is — the host's own lines (workspace, commits, counts).
            for line in &self.identity {
                ui.weak(line);
            }
            // What each layer rests on: the state by name, then the evidence.
            for l in &self.layers {
                ui.horizontal_wrapped(|ui| {
                    let (r, g, b) = if l.measured { (0x00, 0x69, 0x5c) } else { (0xc6, 0x28, 0x28) };
                    ui.label(
                        egui::RichText::new(format!(" {} ", l.state.to_uppercase().replace('_', " ")))
                            .small()
                            .strong()
                            .color(egui::Color32::WHITE)
                            .background_color(egui::Color32::from_rgb(r, g, b)),
                    );
                    ui.strong(&l.layer);
                    ui.label(egui::RichText::new(&l.evidence).small());
                });
            }

            // Mode toggle — the graph/what-breaks view vs. the snapshot-diff "what
            // changed" list. The what-changed toggle only appears when the host feeds a
            // diff (so a diff-less host shows just the graph).
            if self.diff.is_some() {
                ui.horizontal(|ui| {
                    ui.selectable_value(&mut self.mode, CoderoomMode::Graph, "Graph");
                    ui.selectable_value(&mut self.mode, CoderoomMode::WhatChanged, "What changed");
                });
            }

            // Summary line — real widgets so the numbers reach the a11y tree.
            let (n, m, k) = self.summary();
            let red32 = egui::Color32::from_rgb(RED.r, RED.g, RED.b);
            let amber32 = egui::Color32::from_rgb(AMBER.r, AMBER.g, AMBER.b);
            let judged = self.verdicts_measured();
            ui.horizontal_wrapped(|ui| {
                ui.label(format!("{n} crates"));
                if !self.links.is_empty() {
                    ui.label(format!("{} joins", self.links.len()));
                }
                if judged {
                    ui.colored_label(red32, format!("{m} breaking edges"));
                } else {
                    // not "0 breaking edges": nothing judged them
                    ui.weak("no compat verdict");
                }
                if let Some(t) = &self.target {
                    if judged {
                        ui.colored_label(amber32, format!("{k} impacted callers"));
                        ui.weak(format!("what-breaks: {t}"));
                    } else {
                        ui.weak(format!("selected: {t} — what it breaks is not judged"));
                    }
                }
            });
        });

        match self.mode {
            CoderoomMode::Graph => {
                // The REUSED dependency-graph viz. Detect a node-box click (it toggles
                // the viz's own selection) and adopt it as the what-breaks target.
                let before = self.graph.state().selected.clone();
                Facet::ui(&mut self.graph, ui);
                let after = self.graph.state().selected.clone();
                if after != before {
                    // Re-clicking the current target clears what-breaks mode.
                    let next = match after {
                        Some(id) if self.target.as_deref() == Some(id.as_str()) => None,
                        other => other,
                    };
                    self.select_target(next);
                }
            }
            CoderoomMode::WhatChanged => self.ui_what_changed(ui),
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let (n, m, k) = self.summary();
        let impacted = self.impacted();
        let has_target = self.target.is_some();

        let crates: Vec<serde_json::Value> = self
            .crates
            .iter()
            .map(|c| {
                let id = c.id();
                serde_json::json!({
                    "id": id,
                    "name": c.name,
                    "version": c.version,
                    "symbols": c.symbols,
                    "impacted": impacted.contains(&id),
                    "target": self.target.as_deref() == Some(id.as_str()),
                })
            })
            .collect();

        let edges: Vec<serde_json::Value> = self
            .edges
            .iter()
            .map(|e| {
                let breaking = e.compat.is_breaking();
                let color = if breaking { RED } else { GREEN };
                serde_json::json!({
                    "from": e.from,
                    "to": e.to,
                    "compat": e.compat.label(),
                    "breaking": breaking,
                    "color": hex(color),
                    "suggested_bump": e.suggested_bump,
                    "via": e.via,
                })
            })
            .collect();

        let judged = self.verdicts_measured();
        let summary = match (judged, has_target) {
            (true, true) => format!("{n} crates · {m} breaking edges · {k} impacted callers"),
            (true, false) => format!("{n} crates · {m} breaking edges"),
            (false, _) => format!("{n} crates · {} joins · no compat verdict", self.links.len()),
        };

        let (da, dr, dc) = self.diff_counts();
        let mode = match self.mode {
            CoderoomMode::Graph => "graph",
            CoderoomMode::WhatChanged => "what_changed",
        };

        serde_json::json!({
            "kind": "coderoom",
            "source": self.seam.source(),
            "mode": mode,
            // The snapshot-diff "what changed" payload (null when the host feeds none).
            "snapshot_diff": self.diff,
            "diff_added": da,
            "diff_removed": dr,
            "diff_changed": dc,
            "diff_summary": self.diff.as_ref().map(|_| format!("{da} added · {dr} removed · {dc} changed")),
            "crate_count": n,
            "edge_count": self.edges.len(),
            // null, not 0, when no verdict layer was measured: a zero would read as
            // "judged, and nothing breaks"
            "breaking_edges": judged.then_some(m),
            "compatible_edges": judged.then_some(self.edges.len() - m),
            "verdicts_measured": judged,
            // the neutral joins (dependency / call / type-use counts) and their totals
            "link_count": self.links.len(),
            "links": self.links,
            "depends_on_edges": self.links.iter().map(|l| l.depends_on).sum::<u64>(),
            "call_edges": self.links.iter().map(|l| l.calls).sum::<u64>(),
            "call_sites": self.links.iter().map(|l| l.call_sites).sum::<u64>(),
            "layers": self.layers,
            "identity": self.identity,
            "target": self.target,
            "impacted": impacted.iter().cloned().collect::<Vec<_>>(),
            "impacted_count": k,
            "removed_symbols": self.what_breaks.as_ref().map(|w| w.removed_symbols.clone()).unwrap_or_default(),
            "summary": summary,
            "crates": crates,
            "edges": edges,
            // Proof the REUSED viz actually received the laid-out scene.
            "graph_nodes": self.graph.state().model.nodes.len(),
            "graph_edges": self.graph.state().model.edges.len(),
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if v.get("refresh").and_then(|t| t.as_bool()) == Some(true) {
                self.refresh();
            }
            if v.get("clear").and_then(|t| t.as_bool()) == Some(true) {
                self.select_target(None);
            }
            if let Some(t) = v.get("target").and_then(|t| t.as_str()) {
                self.select_target(Some(t.to_string()));
            }
            if let Some(m) = v.get("mode").and_then(|t| t.as_str()) {
                match m {
                    "graph" => self.set_mode(CoderoomMode::Graph),
                    "what_changed" => self.set_mode(CoderoomMode::WhatChanged),
                    _ => {}
                }
            }
        }
    }

    fn kind(&self) -> &'static str {
        "coderoom"
    }
}

// ── testmatrix emit shim (real row emitter with `--features testmatrix`, a
//    compiled-out no-op otherwise — the facett-binview idiom) ────────────────
/// Emit one functional-status row (no-op unless built `--features testmatrix`).
#[cfg(feature = "testmatrix")]
pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// No-op shape compiled when `testmatrix` is OFF (the release path).
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}

// ─────────────────────────────────────────────────────────────────────────────
// A network-free stub seam for the demo + the robot test.
// ─────────────────────────────────────────────────────────────────────────────

/// A **stub** [`CoderoomDataSeam`] with a deterministic small world: crate `a`
/// (at `a@0.2.0`) is referenced by callers `b`, `c`, `d`, `e`. At `0.2.0`, `a`
/// **drops `foo`** — so `b`, `c`, `d` (which reference `foo`) **break** (red
/// edges), while `e` references the still-present `bar` and stays **compatible**
/// (a green edge). The what-breaks result for `a@0.2.0` is exactly the three
/// callers `{b, c, d}` — NOT `e`, proving the impact set is the seam's authority,
/// not merely "every dependent".
pub struct StubCoderoomData;

const STUB_A: &str = "a@0.2.0";
const STUB_B: &str = "b@1.0.0";
const STUB_C: &str = "c@1.1.0";
const STUB_D: &str = "d@0.9.0";
const STUB_E: &str = "e@2.0.0";
// A crate NOT in the current corpus — the snapshot-diff's "added" row.
const STUB_F: &str = "f@0.1.0";

impl CoderoomDataSeam for StubCoderoomData {
    fn source(&self) -> &str {
        "stub (demo)"
    }

    fn crates(&self) -> Vec<CrateNode> {
        vec![
            CrateNode::new("a", "0.2.0", 12),
            CrateNode::new("b", "1.0.0", 8),
            CrateNode::new("c", "1.1.0", 6),
            CrateNode::new("d", "0.9.0", 5),
            CrateNode::new("e", "2.0.0", 7),
        ]
    }

    fn compat_edges(&self) -> Vec<CompatEdge> {
        vec![
            CompatEdge::breaking(STUB_B, STUB_A, "major", &["foo"]),
            CompatEdge::breaking(STUB_C, STUB_A, "major", &["foo"]),
            CompatEdge::breaking(STUB_D, STUB_A, "major", &["foo"]),
            CompatEdge::compatible(STUB_E, STUB_A, "minor", &["bar"]),
        ]
    }

    fn what_breaks(&self, target: &str) -> Option<WhatBreaks> {
        if target != STUB_A {
            return None;
        }
        Some(WhatBreaks {
            target: STUB_A.to_string(),
            removed_symbols: vec!["foo".to_string()],
            impacted: vec![
                Impact::new(STUB_B, "foo"),
                Impact::new(STUB_C, "foo"),
                Impact::new(STUB_D, "foo"),
            ],
        })
    }

    /// A deterministic snapshot diff `snap-A → snap-B`: crate `f@0.1.0` was **added**
    /// to the corpus, `d@0.9.0` was **removed**, and the producer `a@0.2.0` survived
    /// but **churned** (2 symbols added, 1 removed — `foo` — and 3 signatures
    /// changed) — so the what-changed mode always demos one of each row.
    fn snapshot_diff(&self) -> Option<CoderoomSnapshotDiff> {
        Some(CoderoomSnapshotDiff {
            snapshot_a: "snap-A".to_string(),
            snapshot_b: "snap-B".to_string(),
            crates_added: vec![STUB_F.to_string()],
            crates_removed: vec![STUB_D.to_string()],
            crates_changed: vec![CrateDelta::new(STUB_A, 2, 1, 3)],
        })
    }
}

/// The stub target (`a@0.2.0`) — the crate that dropped `foo`. Exposed so the
/// demo + the robot test drive the what-breaks mode without hard-coding the id.
pub const STUB_TARGET: &str = STUB_A;

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

    #[test]
    fn stub_snapshot_reads_through_state_json() {
        let pane = CoderoomPane::new(Box::new(StubCoderoomData));
        let s = pane.state_json();
        assert_eq!(s["kind"], "coderoom");
        assert_eq!(s["crate_count"], 5);
        assert_eq!(s["edge_count"], 4);
        assert_eq!(s["breaking_edges"], 3);
        assert_eq!(s["compatible_edges"], 1);
        // The reused viz received all 5 nodes + 4 edges.
        assert_eq!(s["graph_nodes"], 5);
        assert_eq!(s["graph_edges"], 4);

        // Breaking edges paint red, the compatible one green.
        let edges = s["edges"].as_array().unwrap();
        let breaking: Vec<_> = edges.iter().filter(|e| e["breaking"] == true).collect();
        assert_eq!(breaking.len(), 3);
        assert!(breaking.iter().all(|e| e["color"] == hex(RED)));
        let compat: Vec<_> = edges.iter().filter(|e| e["breaking"] == false).collect();
        assert_eq!(compat.len(), 1);
        assert_eq!(compat[0]["color"], hex(GREEN));

        // No target chosen yet ⇒ no impact set.
        assert!(s["target"].is_null());
        assert_eq!(s["impacted_count"], 0);
    }

    #[test]
    fn what_breaks_highlights_exactly_the_three_callers() {
        let mut pane = CoderoomPane::new(Box::new(StubCoderoomData));
        pane.select_target(Some(STUB_TARGET.to_string()));
        let s = pane.state_json();
        assert_eq!(s["target"], STUB_TARGET);
        assert_eq!(s["impacted_count"], 3);
        let impacted: BTreeSet<String> =
            s["impacted"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
        assert_eq!(impacted, [STUB_B, STUB_C, STUB_D].iter().map(|s| s.to_string()).collect());
        // e depends on a but does NOT break — the impact set is the seam's authority.
        assert!(!impacted.contains(STUB_E));
        assert_eq!(s["removed_symbols"].as_array().unwrap().len(), 1);
        assert_eq!(s["summary"], "5 crates · 3 breaking edges · 3 impacted callers");
    }

    #[test]
    fn impacted_callers_get_a_red_ring_in_the_reused_viz() {
        let mut pane = CoderoomPane::new(Box::new(StubCoderoomData));
        pane.select_target(Some(STUB_TARGET.to_string()));
        // The scene handed to the reused viz rings the 3 impacted callers (red) +
        // the target (amber) = 4 rings; the untouched `e` gets none.
        let decos = &pane.graph.state().decorations.nodes;
        let ring_of = |id: &str| decos.get(id).and_then(|d| d.ring);
        assert_eq!(ring_of(STUB_B), Some(RED));
        assert_eq!(ring_of(STUB_C), Some(RED));
        assert_eq!(ring_of(STUB_D), Some(RED));
        assert_eq!(ring_of(STUB_A), Some(AMBER));
        assert_eq!(ring_of(STUB_E), None);
    }

    #[test]
    fn layout_places_the_producer_right_of_its_consumers() {
        let pane = CoderoomPane::new(Box::new(StubCoderoomData));
        let pos = pane.layout_positions();
        let a_x = pos.get(STUB_A).unwrap().x;
        // Every consumer sits strictly to the LEFT of the producer `a` (deps right).
        for caller in [STUB_B, STUB_C, STUB_D, STUB_E] {
            assert!(pos.get(caller).unwrap().x < a_x, "{caller} is left of the producer");
        }
    }

    #[test]
    fn empty_seam_does_not_panic() {
        struct Empty;
        impl CoderoomDataSeam for Empty {
            fn crates(&self) -> Vec<CrateNode> {
                Vec::new()
            }
            fn compat_edges(&self) -> Vec<CompatEdge> {
                Vec::new()
            }
            fn what_breaks(&self, _target: &str) -> Option<WhatBreaks> {
                None
            }
        }
        let pane = CoderoomPane::new(Box::new(Empty));
        let s = pane.state_json();
        assert_eq!(s["crate_count"], 0);
        assert_eq!(s["graph_nodes"], 0);
        assert_eq!(pane.summary(), (0, 0, 0));
    }

    #[test]
    fn reselecting_the_target_clears_what_breaks_mode() {
        let mut pane = CoderoomPane::new(Box::new(StubCoderoomData));
        pane.select_target(Some(STUB_TARGET.to_string()));
        assert_eq!(pane.target(), Some(STUB_TARGET));
        pane.select_target(None);
        assert!(pane.target().is_none());
        assert_eq!(pane.impacted().len(), 0);
    }

    #[test]
    fn stub_snapshot_diff_reads_through_state_json() {
        let pane = CoderoomPane::new(Box::new(StubCoderoomData));
        // Default mode is the graph; the diff payload is still surfaced.
        assert_eq!(pane.mode(), CoderoomMode::Graph);
        let s = pane.state_json();
        assert_eq!(s["mode"], "graph");
        assert_eq!(s["diff_added"], 1);
        assert_eq!(s["diff_removed"], 1);
        assert_eq!(s["diff_changed"], 1);
        assert_eq!(s["diff_summary"], "1 added · 1 removed · 1 changed");

        let d = &s["snapshot_diff"];
        assert_eq!(d["crates_added"][0], "f@0.1.0");
        assert_eq!(d["crates_removed"][0], "d@0.9.0");
        assert_eq!(d["crates_changed"][0]["crate_id"], "a@0.2.0");
        assert_eq!(d["crates_changed"][0]["symbols_added"], 2);
        assert_eq!(d["crates_changed"][0]["symbols_removed"], 1);
        assert_eq!(d["crates_changed"][0]["symbols_changed"], 3);
    }

    #[test]
    fn set_mode_toggles_what_changed_and_state_json_follows() {
        let mut pane = CoderoomPane::new(Box::new(StubCoderoomData));
        pane.set_mode(CoderoomMode::WhatChanged);
        assert_eq!(pane.mode(), CoderoomMode::WhatChanged);
        assert_eq!(pane.state_json()["mode"], "what_changed");
        // Toggling back to the graph works.
        pane.set_mode(CoderoomMode::Graph);
        assert_eq!(pane.mode(), CoderoomMode::Graph);
        assert_eq!(pane.diff_counts(), (1, 1, 1));
    }

    #[test]
    fn what_changed_mode_is_refused_without_a_diff() {
        struct NoDiff;
        impl CoderoomDataSeam for NoDiff {
            fn crates(&self) -> Vec<CrateNode> {
                vec![CrateNode::new("a", "0.1.0", 1)]
            }
            fn compat_edges(&self) -> Vec<CompatEdge> {
                Vec::new()
            }
            fn what_breaks(&self, _target: &str) -> Option<WhatBreaks> {
                None
            }
            // snapshot_diff defaults to None.
        }
        let mut pane = CoderoomPane::new(Box::new(NoDiff));
        assert!(pane.snapshot_diff().is_none());
        // The mode cannot be entered with no diff to render.
        pane.set_mode(CoderoomMode::WhatChanged);
        assert_eq!(pane.mode(), CoderoomMode::Graph);
        assert!(pane.state_json()["snapshot_diff"].is_null());
        assert_eq!(pane.diff_counts(), (0, 0, 0));
    }

    /// A graph with joins and NO verdict layer: the host says the verdicts were not
    /// measured, hands neutral joins — and, wrongly, verdict edges and an impact set.
    struct Unjudged;
    impl CoderoomDataSeam for Unjudged {
        fn source(&self) -> &str {
            "a host graph"
        }
        fn crates(&self) -> Vec<CrateNode> {
            vec![CrateNode::new("app", "1.0.0", 40), CrateNode::new("lib", "0.3.0", 12), CrateNode::new("dep", "2.1.0", 0)]
        }
        // a verdict edge the pane must NOT draw while the layer is unmeasured
        fn compat_edges(&self) -> Vec<CompatEdge> {
            vec![CompatEdge::breaking("app@1.0.0", "lib@0.3.0", "major", &["gone"])]
        }
        fn what_breaks(&self, _target: &str) -> Option<WhatBreaks> {
            Some(WhatBreaks { target: "lib@0.3.0".into(), removed_symbols: vec!["gone".into()], impacted: vec![Impact::new("app@1.0.0", "gone")] })
        }
        fn links(&self) -> Vec<CrateLink> {
            vec![
                CrateLink { from: "app@1.0.0".into(), to: "lib@0.3.0".into(), depends_on: 1, calls: 7, call_sites: 19, uses_type: 2 },
                CrateLink { from: "lib@0.3.0".into(), to: "dep@2.1.0".into(), depends_on: 1, ..Default::default() },
            ]
        }
        fn layers(&self) -> Vec<LayerState> {
            vec![
                LayerState::new(LAYER_GRAPH, "complete", true, ""),
                LayerState::new(LAYER_VERDICTS, "not_wired", false, "no producer writes compat verdicts"),
            ]
        }
        fn identity(&self) -> Vec<String> {
            vec!["workspace w · app@3 (0123456789) · 52 nodes · 9 edges".into()]
        }
    }

    /// The joins are drawn with their counts and no verdict colour; the unmeasured
    /// verdict layer draws its state and NO number — not "0 breaking edges", and not
    /// the red edge / impact set the seam handed over anyway.
    #[test]
    fn an_unmeasured_verdict_layer_draws_its_state_and_no_verdict() {
        let mut pane = CoderoomPane::new(Box::new(Unjudged));
        pane.select_target(Some("lib@0.3.0".to_string()));
        let s = pane.state_json();
        assert_eq!((s["crate_count"].as_u64(), s["link_count"].as_u64()), (Some(3), Some(2)));
        assert_eq!((s["depends_on_edges"].as_u64(), s["call_edges"].as_u64(), s["call_sites"].as_u64()), (Some(2), Some(7), Some(19)));
        assert_eq!(s["links"][0]["calls"], 7);
        // the verdict layer: a state, no number
        assert_eq!(s["verdicts_measured"], false);
        assert!(s["breaking_edges"].is_null() && s["compatible_edges"].is_null(), "no count from an unmeasured layer: {s}");
        assert_eq!((s["edge_count"].as_u64(), s["impacted_count"].as_u64()), (Some(0), Some(0)));
        assert!(s["removed_symbols"].as_array().unwrap().is_empty());
        assert_eq!(s["summary"], "3 crates · 2 joins · no compat verdict");
        assert_eq!(s["layers"][1]["state"], "not_wired");
        assert_eq!(s["identity"][0], "workspace w · app@3 (0123456789) · 52 nodes · 9 edges");
        // the reused viz got the two joins, neutral — never red or green
        let model = &pane.graph().state().model;
        assert_eq!((model.nodes.len(), model.edges.len()), (3, 2));
        assert!(model.edges.iter().all(|e| e.color != RED && e.color != GREEN));
        assert_eq!(model.edges[0].label.as_deref(), Some("dep · 7 calls · 2 types"));
        assert!(model.edges[1].dashed && !model.edges[0].dashed, "a join that is only a dependency is dashed");
        // no ring on the crate the seam called impacted
        assert!(pane.graph_decorations().get("app@1.0.0").is_none_or(|d| d.ring.is_none()));
    }

    /// A host that names no layers is drawn as before (the stub's verdicts count).
    #[test]
    fn a_host_without_layer_states_keeps_its_verdicts() {
        let pane = CoderoomPane::new(Box::new(StubCoderoomData));
        let s = pane.state_json();
        assert_eq!((s["verdicts_measured"].as_bool(), s["breaking_edges"].as_u64(), s["link_count"].as_u64()), (Some(true), Some(3), Some(0)));
    }
}