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

nordisk/nornir

src/viz/holger_tab.rs

//! 📦 Holger tab — the **built-in (married) holger registry** pane.
//!
//! nornir LINKS `holger-server-lib` IN-PROCESS (the `embed-holger` feature,
//! default on — see [`crate::holger_embed`]). The dev pair it embeds is a
//! crates.io-mirror (`/cache`) + a writable release-rehearsal registry
//! (`/sparring`), both backed by znippy archives under
//! `<warehouse>/holger-cache` (the `nornir cache up` data path).
//!
//! This pane reads that on-disk registry through holger's **functional
//! (in-process) API**, NOT gRPC/HTTP: it constructs the dev-pair `Holger`,
//! `instantiate_backends`, then enumerates each repo's backend with
//! `list(None, limit)` ([`crate::holger_embed::read_registry`]). NO server is
//! started just to read.
//!
//! The optional **"bring up /cache + /sparring"** button is the in-process
//! [`EmbeddedHolger::start`](crate::holger_embed::EmbeddedHolger) — the
//! `nornir cache up` action — run on a worker thread so the egui paint thread
//! never blocks, with a `wait_ready` readiness probe.
//!
//! Gated behind `embed-holger`; this module is only compiled when that feature
//! is on (see `viz/mod.rs`), and the whole 📦 pane degrades to a short note when
//! the feature is off.

use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use eframe::egui::{self, Color32, Pos2, Sense, Stroke, vec2};

use crate::holger_embed::{self, EmbeddedHolger, RepoSummary};

use super::facett_theme::{AMBER, GREEN, RED, Theme};

/// The 📦 Holger tab's two lenses onto the root registry (N6). The default is the
/// spinning 3-D graph; the table is the classic grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HolgerView {
    /// A rotating, clickable 3-D node cloud of the root repos (default).
    Graph,
    /// The classic per-repo table.
    Table,
}

impl HolgerView {
    fn label(self) -> &'static str {
        match self {
            HolgerView::Graph => "Graph (3D)",
            HolgerView::Table => "Table",
        }
    }
    fn id(self) -> &'static str {
        match self {
            HolgerView::Graph => "graph3d",
            HolgerView::Table => "table",
        }
    }
    const ALL: [HolgerView; 2] = [HolgerView::Graph, HolgerView::Table];
}

/// How many artifact entries to enumerate per repo when summarising (counts +
/// the recent list). The dev-pair archives are small; this is plenty.
const LIST_LIMIT: usize = 100_000;

/// The async "bring up" state machine. The button spawns a worker that starts
/// the embedded dev pair + probes readiness; the slot carries the outcome back.
type BringUpSlot = Arc<Mutex<Option<Result<(), String>>>>;

/// The async registry read. `read_registry` opens znippy archives, so it runs
/// off the paint thread too.
type ReadSlot = Arc<Mutex<Option<Result<Vec<RepoSummary>, String>>>>;

/// What `read_registry` is run against: the real on-disk dev-pair dir (the
/// `cache up` data path), or — in tests — an injected, pre-built summary so the
/// pane can be asserted WITHOUT a live server or a real archive on disk.
enum Source {
    /// `(data_dir, grpc_addr, http_addr)` — the dev-pair config the read uses.
    Disk {
        data_dir: PathBuf,
        grpc: String,
        http: String,
    },
    /// Thin/remote client: there is no local holger to read, so fetch the
    /// registry summary from a running `nornir-server` over `Viz.HolgerRegistry`
    /// (the server runs `read_registry` on its own data dir). Carries the
    /// endpoint/token/workspace the gRPC call needs.
    Remote {
        endpoint: String,
        token: String,
        workspace: String,
    },
    /// Test seam: a fixed set of repo summaries.
    Inject(Vec<RepoSummary>),
}

pub struct HolgerTab {
    source: Source,
    /// The grpc/http the **bring-up** button binds (defaults match `cache up`).
    grpc: String,
    http: String,
    /// Loaded repo summaries (`None` until the first read completes).
    repos: Option<Result<Vec<RepoSummary>, String>>,
    /// In-flight background read (slot + when it started).
    reading: Option<(ReadSlot, Instant)>,
    loaded: bool,
    /// The live embedded dev pair, once the bring-up button started it. Held so
    /// the servers keep running; dropping it stops them.
    embedded: Option<EmbeddedHolger>,
    /// In-flight bring-up (slot + start). The worker returns the readiness
    /// result; on success the next read re-enumerates the now-warm registry.
    bringing_up: Option<(BringUpSlot, Instant)>,
    /// Where the bring-up worker parks the live [`EmbeddedHolger`] handle it
    /// built (its tokio runtime owns the server threads). `poll_bring_up`
    /// adopts it into `embedded` so the servers outlive the worker.
    bring_up_handle_slot: Option<Arc<Mutex<Option<EmbeddedHolger>>>>,
    /// Last bring-up outcome string for the pane + `state_json`.
    bring_up_status: Option<Result<String, String>>,
    theme: Theme,
    /// The active lens (N6): the 3-D root-repo graph (default) or the table.
    view: HolgerView,
    /// The spinning, clickable 3-D graph of root repos (built from `repos`).
    graph: RepoGraph3D,
}

impl HolgerTab {
    /// The live pane: read the dev-pair registry under `data_dir` (the
    /// `nornir cache up` data path = `<warehouse>/holger-cache`).
    pub fn disk(data_dir: PathBuf, grpc: String, http: String) -> Self {
        Self::with(
            Source::Disk {
                data_dir,
                grpc: grpc.clone(),
                http: http.clone(),
            },
            grpc,
            http,
        )
    }

    /// Thin/remote client: fetch the registry summary from a running
    /// `nornir-server` over `Viz.HolgerRegistry` (the server reads its own
    /// holger data dir). No local holger is touched — the pane lists exactly the
    /// repos the server serves (`crates-io` / `cache` / `sparring`). The bring-up
    /// button is disabled (nothing local to start).
    pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
        Self::with(
            Source::Remote {
                endpoint,
                token,
                workspace,
            },
            "127.0.0.1:18443".into(),
            "127.0.0.1:18444".into(),
        )
    }

    /// Test seam: inject a fixed set of repo summaries so `state_json` / `draw`
    /// can be asserted without a live server or an on-disk archive.
    pub fn inject(repos: Vec<RepoSummary>) -> Self {
        Self::with(
            Source::Inject(repos),
            "127.0.0.1:18443".into(),
            "127.0.0.1:18444".into(),
        )
    }

    fn with(source: Source, grpc: String, http: String) -> Self {
        Self {
            source,
            grpc,
            http,
            repos: None,
            reading: None,
            loaded: false,
            embedded: None,
            bringing_up: None,
            bring_up_handle_slot: None,
            bring_up_status: None,
            theme: Theme::default(),
            view: HolgerView::Graph,
            graph: RepoGraph3D::default(),
        }
    }

    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// The data dir the pane reads/brings up (the `cache up` path), the server
    /// endpoint it fetches from (remote), or `<injected>` (test seam).
    fn data_dir_label(&self) -> String {
        match &self.source {
            Source::Disk { data_dir, .. } => data_dir.display().to_string(),
            Source::Remote { endpoint, .. } => format!("server {endpoint}"),
            Source::Inject(_) => "<injected>".to_string(),
        }
    }

    /// Whether the embedded dev pair is currently brought up in-process by THIS
    /// pane (the bring-up button succeeded and the handle is held).
    fn is_up(&self) -> bool {
        self.embedded.is_some()
    }

    /// Kick off a background registry read (the functional API, no server). Runs
    /// off the paint thread because opening znippy archives touches disk.
    fn start_read(&mut self) {
        self.loaded = true;
        match &self.source {
            Source::Inject(repos) => {
                // No I/O — resolve immediately so the test seam is synchronous.
                self.repos = Some(Ok(repos.clone()));
            }
            Source::Disk {
                data_dir,
                grpc,
                http,
            } => {
                let slot: ReadSlot = Arc::new(Mutex::new(None));
                let sink = slot.clone();
                let (data_dir, grpc, http) = (data_dir.clone(), grpc.clone(), http.clone());
                super::trace::emit_in(
                    "holger.read_registry",
                    &serde_json::json!({ "data_dir": data_dir.display().to_string() }),
                );
                std::thread::spawn(move || {
                    let res = holger_embed::read_registry(&data_dir, &grpc, &http, LIST_LIMIT)
                        .map_err(|e| format!("{e:#}"));
                    *sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
                });
                self.reading = Some((slot, Instant::now()));
            }
            Source::Remote {
                endpoint,
                token,
                workspace,
            } => {
                // Thin mode: fetch the server's registry over Viz.HolgerRegistry
                // off the paint thread (it's a blocking gRPC round-trip).
                let slot: ReadSlot = Arc::new(Mutex::new(None));
                let sink = slot.clone();
                let (endpoint, token, workspace) =
                    (endpoint.clone(), token.clone(), workspace.clone());
                super::trace::emit_in(
                    "holger.fetch_registry",
                    &serde_json::json!({ "endpoint": endpoint, "workspace": workspace }),
                );
                std::thread::spawn(move || {
                    let res = super::remote::fetch_holger_registry(&endpoint, &token, &workspace)
                        .map_err(|e| format!("{e:#}"));
                    *sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
                });
                self.reading = Some((slot, Instant::now()));
            }
        }
    }

    /// Drain a finished background read into `self.repos`.
    fn poll_read(&mut self) {
        if let Some((slot, _)) = &self.reading {
            let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
            if let Some(res) = done {
                self.repos = Some(res);
                self.reading = None;
            }
        }
    }

    /// Start the embedded dev pair (`/cache` + `/sparring`) IN-PROCESS on a
    /// worker thread — the same `EmbeddedHolger::start` the `nornir cache up`
    /// CLI runs — then probe `wait_ready`. The egui thread never blocks; the
    /// outcome lands in `bring_up_status` and triggers a re-read.
    fn start_bring_up(&mut self) {
        if self.bringing_up.is_some() || self.is_up() {
            return;
        }
        let Source::Disk { data_dir, .. } = &self.source else {
            // Nothing to bring up against an injected fixture.
            self.bring_up_status = Some(Err("no on-disk registry (injected fixture)".into()));
            return;
        };
        let data_dir = data_dir.clone();
        let (grpc, http) = (self.grpc.clone(), self.http.clone());
        let slot: BringUpSlot = Arc::new(Mutex::new(None));
        let sink = slot.clone();
        super::trace::emit_in(
            "holger.bring_up",
            &serde_json::json!({
                "data_dir": data_dir.display().to_string(),
                "grpc": grpc, "http": http,
            }),
        );
        // The worker BUILDS the EmbeddedHolger (which owns its own tokio runtime
        // + the server threads) and probes readiness. It parks the live handle
        // in a shared slot so the UI thread can ADOPT it into `self.embedded`
        // (keeping the servers alive), and reports the readiness result via the
        // bring-up slot.
        let handle_slot: Arc<Mutex<Option<EmbeddedHolger>>> = Arc::new(Mutex::new(None));
        let handle_sink = handle_slot.clone();
        std::thread::spawn(
            move || match EmbeddedHolger::start(&data_dir, &grpc, &http) {
                Ok(h) => {
                    let ready = h.wait_ready(Duration::from_secs(20));
                    *handle_sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(h);
                    *sink.lock().unwrap_or_else(|p| p.into_inner()) =
                        Some(ready.map_err(|e| format!("{e:#}")));
                }
                Err(e) => {
                    *sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(Err(format!("{e:#}")));
                }
            },
        );
        self.bring_up_handle_slot = Some(handle_slot);
        self.bringing_up = Some((slot, Instant::now()));
    }

    /// Drain a finished bring-up: adopt the live handle (so the servers stay up)
    /// and record the readiness outcome; on success, re-read the registry.
    fn poll_bring_up(&mut self) {
        let Some((slot, _)) = &self.bringing_up else {
            return;
        };
        let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
        if let Some(res) = done {
            // Adopt the handle the worker built (keeps the runtime/servers alive).
            if let Some(hs) = self.bring_up_handle_slot.take() {
                if let Some(h) = hs.lock().unwrap_or_else(|p| p.into_inner()).take() {
                    self.embedded = Some(h);
                }
            }
            self.bringing_up = None;
            match res {
                Ok(()) => {
                    nornir_testmatrix::functional_status(
                        "nornir/viz/holger",
                        "bring_up_dev_pair",
                        true,
                        &format!("/cache + /sparring up at {}", self.http),
                    );
                    self.bring_up_status =
                        Some(Ok(format!("/cache + /sparring up at {}", self.http)));
                    // Re-enumerate now that the registry is warm.
                    self.loaded = false;
                }
                Err(e) => {
                    nornir_testmatrix::functional_status(
                        "nornir/viz/holger",
                        "bring_up_dev_pair",
                        false,
                        &e,
                    );
                    self.bring_up_status = Some(Err(e));
                }
            }
        }
    }

    /// 📦 Holger tab's slice of `state_json` (LAW #6): is the embedded holger
    /// reachable + which data dir, the repositories with per-repo crate/artifact
    /// counts + a few recent ids, and the bring-up state.
    pub fn state_json(&self) -> serde_json::Value {
        let repos = match &self.repos {
            None => serde_json::Value::Null,
            Some(Err(e)) => serde_json::json!({ "error": e }),
            Some(Ok(rs)) => serde_json::json!(
                rs.iter()
                    .map(|r| serde_json::json!({
                        "name": r.name,
                        "repo_type": r.repo_type,
                        "writable": r.writable,
                        "crate_count": r.crate_count,
                        "artifact_count": r.artifact_count,
                        "recent": r.recent,
                    }))
                    .collect::<Vec<_>>()
            ),
        };
        let repo_names: Vec<String> = match &self.repos {
            Some(Ok(rs)) => rs.iter().map(|r| r.name.clone()).collect(),
            _ => Vec::new(),
        };
        // The crates.io-mirror `/cache` + the `/sparring` rehearsal registry are
        // the two writable repos; surface their crate counts at the top level so
        // the headless matrix reads them without walking the repo list.
        let count_for = |name: &str| -> Option<usize> {
            self.repos
                .as_ref()
                .and_then(|r| r.as_ref().ok())
                .and_then(|rs| rs.iter().find(|x| x.name == name).map(|x| x.crate_count))
        };
        let bring_up = match &self.bring_up_status {
            None => serde_json::Value::Null,
            Some(Ok(s)) => serde_json::json!({ "ok": true, "detail": s }),
            Some(Err(e)) => serde_json::json!({ "ok": false, "error": e }),
        };
        serde_json::json!({
            // `embed-holger` is what gates this whole pane; when it's on (this
            // module is compiled) the embedded holger is in-process reachable.
            "embedded": true,
            "data_dir": self.data_dir_label(),
            // Whether THIS pane has the dev pair brought up in-process right now.
            "up": self.is_up(),
            "bringing_up": self.bringing_up.is_some(),
            "loaded": self.loaded,
            "repositories": repo_names,
            "repo_count": match &self.repos { Some(Ok(rs)) => rs.len(), _ => 0 },
            "cache_crates": count_for("cache"),
            "sparring_crates": count_for("sparring"),
            "repos": repos,
            "bring_up": bring_up,
            "grpc": self.grpc,
            "http": self.http,
            "palette": self.theme.name,
            // N6: which lens is active + the 3-D root-repo graph's observable shape.
            "view": self.view.id(),
            "graph": self.graph.state_json(),
        })
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        self.poll_read();
        self.poll_bring_up();
        if !self.loaded && self.reading.is_none() {
            self.start_read();
        }

        let theme = self.theme;
        ui.horizontal(|ui| {
            ui.heading("📦 Holger registry");
            if ui.button("⟳ Reload").clicked() {
                self.loaded = false;
            }
            // The "bring up /cache + /sparring" button = the in-process
            // `nornir cache up` action. Disabled while up or while a bring-up is
            // in flight; the worker keeps the paint thread responsive.
            let busy = self.bringing_up.is_some();
            let up = self.is_up();
            let can_bring_up = matches!(self.source, Source::Disk { .. }) && !up && !busy;
            if ui
                .add_enabled(
                    can_bring_up,
                    egui::Button::new("▶ bring up /cache + /sparring"),
                )
                .clicked()
            {
                self.start_bring_up();
            }
            if up {
                ui.colored_label(GREEN, "● up");
            } else if busy {
                ui.spinner();
                ui.colored_label(AMBER, "starting…");
            } else {
                ui.colored_label(theme.text_dim, "○ down");
            }
        });
        ui.label(
            "the married, IN-PROCESS holger dev pair — a crates.io mirror (/cache) + a \
             writable release-rehearsal registry (/sparring). Read here via holger's \
             functional API (no gRPC/HTTP), the same backends `nornir cache up` serves.",
        );
        ui.label(format!("data dir: {}", self.data_dir_label()));
        if let Some(res) = &self.bring_up_status {
            match res {
                Ok(s) => ui.colored_label(GREEN, format!("✔ {s}")),
                Err(e) => ui.colored_label(RED, format!("✘ bring-up: {e}")),
            };
        }
        ui.separator();

        match &self.repos {
            None => {
                ui.spinner();
                ui.label("reading the embedded registry…");
            }
            Some(Err(e)) => {
                ui.colored_label(RED, format!("failed to read registry: {e}"));
            }
            Some(Ok(repos)) => {
                if repos.is_empty() {
                    ui.colored_label(theme.text_dim, "no repositories configured.");
                    return;
                }
                // N6: keep the 3-D graph in sync with the loaded repo set (rebuilt
                // only when the repo names change).
                self.graph.sync(repos, &theme);

                // Lens selector (Graph 3D ⇄ Table).
                ui.horizontal(|ui| {
                    for v in HolgerView::ALL {
                        ui.selectable_value(&mut self.view, v, v.label());
                    }
                });
                ui.separator();

                match self.view {
                    HolgerView::Graph => self.graph.ui(ui, repos, &theme),
                    HolgerView::Table => Self::draw_table(ui, repos, &theme),
                }
            }
        }
    }

    /// The classic per-repo table (the `Table` lens).
    fn draw_table(ui: &mut egui::Ui, repos: &[RepoSummary], theme: &Theme) {
        egui::Grid::new("holger_repos_grid")
            .striped(true)
            .num_columns(4)
            .spacing([18.0, 4.0])
            .show(ui, |ui| {
                ui.strong("repository");
                ui.strong("type");
                ui.strong("crates");
                ui.strong("versions");
                ui.end_row();
                for r in repos {
                    ui.horizontal(|ui| {
                        ui.label(format!("/{}", r.name));
                        if r.writable {
                            ui.colored_label(theme.accent, "rw");
                        } else {
                            ui.colored_label(theme.text_dim, "ro");
                        }
                    });
                    ui.label(&r.repo_type);
                    ui.label(r.crate_count.to_string());
                    ui.label(r.artifact_count.to_string());
                    ui.end_row();
                }
            });
        ui.add_space(8.0);
        // A few recent /sparring publishes (the rehearsal registry) — the
        // most interesting "what did the last rehearsal push?" view.
        if let Some(sparring) = repos.iter().find(|r| r.name == "sparring") {
            ui.strong("recent /sparring publishes");
            if sparring.recent.is_empty() {
                ui.colored_label(theme.text_dim, "none yet — run a release rehearsal.");
            } else {
                for id in &sparring.recent {
                    ui.label(format!("  • {id}"));
                }
            }
        }
    }
}

/// A spinning, clickable **3-D graph of holger's root repos** (N6): a central
/// `holger` hub node with one satellite node per repo — sized by crate count,
/// coloured by writable (rw = accent, ro = dim) — laid out by the shared
/// [`facett_graph3d::Graph3D`] placement, orbiting on its own and grabbable by
/// drag. Clicking a repo node selects it and reveals its detail below the cloud.
struct RepoGraph3D {
    /// Node labels; index 0 is the `holger` hub, 1.. are repos in `built_for` order.
    labels: Vec<String>,
    colors: Vec<Color32>,
    /// Per-node paint radius scale (hub biggest, repos by crate count).
    sizes: Vec<f32>,
    positions: Vec<[f32; 3]>,
    edges: Vec<(usize, usize)>,
    yaw: f32,
    spin: bool,
    /// Selected node index (into `labels`), or `None`. 0 = hub.
    selected: Option<usize>,
    /// The repo names the current layout was built for (rebuild only on change).
    built_for: Vec<String>,
}

impl Default for RepoGraph3D {
    fn default() -> Self {
        Self {
            labels: Vec::new(),
            colors: Vec::new(),
            sizes: Vec::new(),
            positions: Vec::new(),
            edges: Vec::new(),
            yaw: 0.6,
            spin: true,
            selected: None,
            built_for: Vec::new(),
        }
    }
}

impl RepoGraph3D {
    /// Rebuild the hub-and-spoke cloud iff the repo name set changed.
    fn sync(&mut self, repos: &[RepoSummary], theme: &Theme) {
        let names: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
        if names == self.built_for {
            return;
        }
        self.built_for = names;
        self.selected = None;

        // Node 0 = the `holger` hub; nodes 1.. = repos.
        let max_crates = repos
            .iter()
            .map(|r| r.crate_count)
            .max()
            .unwrap_or(0)
            .max(1) as f32;
        self.labels = std::iter::once("holger".to_string())
            .chain(repos.iter().map(|r| r.name.clone()))
            .collect();
        self.colors = std::iter::once(theme.accent)
            .chain(repos.iter().map(|r| {
                if r.writable {
                    theme.accent
                } else {
                    theme.text_dim
                }
            }))
            .collect();
        self.sizes = std::iter::once(1.6_f32)
            .chain(
                repos
                    .iter()
                    .map(|r| 0.6 + (r.crate_count as f32 / max_crates)),
            )
            .collect();
        // Hub → each repo (spoke edges).
        self.edges = (1..=repos.len()).map(|i| (0, i)).collect();
        self.layout_positions();
    }

    /// (Re)place nodes via the shared facett 3-D force layout (hub pulls its spokes
    /// inward), copying positions back onto our coloured/sized nodes.
    fn layout_positions(&mut self) {
        let nodes: Vec<(String, Color32)> = self
            .labels
            .iter()
            .cloned()
            .zip(self.colors.iter().copied())
            .collect();
        let g = facett_graph3d::Graph3D::new(
            nodes,
            self.edges.clone(),
            facett_graph3d::Layout3D::Force,
        );
        self.positions = g.nodes.iter().map(|n| n.pos).collect();
    }

    fn project(&self, p: [f32; 3], center: Pos2, scale: f32) -> (Pos2, f32) {
        let (s, c) = self.yaw.sin_cos();
        let x = p[0] * c + p[2] * s;
        let z = -p[0] * s + p[2] * c;
        (center + vec2(x, -p[1]) * scale, z)
    }

    /// Paint the spinning cloud + handle click-to-select; render the selected
    /// repo's detail line below.
    fn ui(&mut self, ui: &mut egui::Ui, repos: &[RepoSummary], theme: &Theme) {
        if self.labels.is_empty() {
            ui.colored_label(theme.text_dim, "no repositories to graph.");
            return;
        }
        ui.horizontal(|ui| {
            ui.label(format!(
                "{} root repo(s) — drag to spin, click a node to inspect.",
                repos.len()
            ));
            if ui.selectable_label(self.spin, "⟳ spin").clicked() {
                self.spin = !self.spin;
            }
        });

        let want = vec2(
            ui.available_width(),
            (ui.available_height() - 40.0).max(220.0),
        );
        let (rect, resp) = ui.allocate_exact_size(want, Sense::click_and_drag());
        if self.spin && !resp.dragged() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            self.yaw += dt * 0.4;
            ui.ctx().request_repaint();
        }
        if resp.dragged() {
            self.yaw += resp.drag_delta().x * 0.01;
        }

        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, theme.bg);
        let center = rect.center();
        let scale = rect.size().min_elem() * 0.36;
        let proj: Vec<(Pos2, f32)> = self
            .positions
            .iter()
            .map(|p| self.project(*p, center, scale))
            .collect();

        // Click hit-test: nearest node within its paint radius wins.
        if resp.clicked() {
            if let Some(pos) = resp.interact_pointer_pos() {
                let mut best: Option<(usize, f32)> = None;
                for (i, (p, z)) in proj.iter().enumerate() {
                    let r = (6.0 + (z + 1.0) * 3.0) * self.sizes[i];
                    let d = p.distance(pos);
                    if d <= r.max(8.0) && best.map(|(_, bd)| d < bd).unwrap_or(true) {
                        best = Some((i, d));
                    }
                }
                self.selected = best.map(|(i, _)| i);
            }
        }

        // Edges, faded by mean depth.
        for &(a, b) in &self.edges {
            if a < proj.len() && b < proj.len() {
                let depth = (proj[a].1 + proj[b].1) * 0.5;
                let alpha = (120.0 + depth * 100.0).clamp(30.0, 220.0);
                painter.line_segment(
                    [proj[a].0, proj[b].0],
                    Stroke::new(0.8_f32, theme.edge.gamma_multiply(alpha / 255.0)),
                );
            }
        }
        // Nodes back-to-front (nearer = bigger + brighter); label each.
        let mut order: Vec<usize> = (0..self.labels.len()).collect();
        order.sort_by(|&i, &j| {
            proj[i]
                .1
                .partial_cmp(&proj[j].1)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        for i in order {
            let (p, z) = proj[i];
            let base_r = 6.0 + (z + 1.0) * 3.0;
            let r = base_r * self.sizes[i];
            let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.35, 1.0);
            let mut col = self.colors[i].gamma_multiply(bright);
            if self.selected == Some(i) {
                col = theme.accent;
                painter.circle_stroke(p, r + 3.0, Stroke::new(2.0_f32, theme.accent));
            }
            painter.circle_filled(p, r, col);
            painter.text(
                p + vec2(r + 3.0, -r),
                egui::Align2::LEFT_BOTTOM,
                &self.labels[i],
                egui::FontId::proportional(11.0),
                theme.text.gamma_multiply(bright),
            );
        }

        // Selected repo detail (hub selection just re-states the registry).
        ui.add_space(4.0);
        match self.selected {
            Some(0) | None => {
                ui.colored_label(
                    theme.text_dim,
                    "the embedded holger registry hub — click a repo node.",
                );
            }
            Some(i) => {
                if let Some(r) = repos.get(i - 1) {
                    ui.horizontal_wrapped(|ui| {
                        ui.strong(format!("/{}", r.name));
                        ui.colored_label(
                            if r.writable {
                                theme.accent
                            } else {
                                theme.text_dim
                            },
                            if r.writable { "rw" } else { "ro" },
                        );
                        ui.label(format!(
                            "· {} · {} crate(s) · {} version(s)",
                            r.repo_type, r.crate_count, r.artifact_count
                        ));
                    });
                    if !r.recent.is_empty() {
                        ui.label(format!("recent: {}", r.recent.join(", ")));
                    }
                }
            }
        }
    }

    /// Robot-observable shape — node/edge counts, spin/yaw, and the selection.
    fn state_json(&self) -> serde_json::Value {
        let finite = self
            .positions
            .iter()
            .filter(|p| p.iter().all(|c| c.is_finite()))
            .count();
        serde_json::json!({
            "nodes": self.labels.len(),
            "edges": self.edges.len(),
            "finite_positions": finite,
            "spin": self.spin,
            "yaw": self.yaw,
            "selected": self.selected.and_then(|i| self.labels.get(i)).cloned(),
            "repos": self.built_for,
        })
    }
}