src/app.rs
//! egui presentation for the holger UI, built from [facett] components.
//!
//! One [`UiData`] drives everything; each tab issues at most one blocking core
//! call per click (the nornir-viz idiom). The repo list is rendered with a
//! `facett::table::Table` facet; the rest is plain themed egui. Selection +
//! every visible value live in [`UiData`]'s views, which expose `state_json()`
//! — so the same screen can be asserted headlessly.
//!
//! ═══════════════════════════════════════════════════════════════════════════
//! **RULE: DO NOT ADD ANOTHER `#[cfg(feature = "mini")]` AT A USE SITE.**
//! ═══════════════════════════════════════════════════════════════════════════
//!
//! There are already 76 of them across this file (47), `app/scene.rs` (23) and
//! `app/command.rs` (6), and they went 44 → 76 in a single commit. Every new
//! pane forces a decision at every one of those sites, and the compiler says
//! NOTHING when you miss one — you find out because the pane appears in
//! `holger-server-ui-mikro`.
//!
//! **The two axes are separate, and the mini tier used to have only one of
//! them.** `server/ui/Cargo.toml` said `mini = ["gui"]` — it enabled `gui` and
//! removed NOTHING. Same `eframe`, `egui`, `server-lib`, `modgunn` (→ `skade`),
//! `world-connector`, `nornir-auth-client/online`. The binaries measured on
//! 2026-07-22 (debug), under the bin names of the day: `holger-ui`
//! 1 048 139 472 B, `holger-ui-mikro`
//! 1 050 137 592 B — the "lean" tier was 2 MB **bigger**. It trimmed the surface
//! and linked the identical closure.
//!
//! ✅ **Fixed 2026-07-23.** Every heavy dependency is optional now and hangs off
//! the ONE pane that needs it (`about-card` · `signin` · `helix` · `tools` ·
//! `bench` · `warehouse-scan` · `demo`), and each tier feature selects only what
//! it can drive. Measured, release:
//!
//! ```text
//! gui-embedded holger-server-ui-embedded 64 732 640 B 608 crates (the opt-in fat one)
//! gui holger-server-ui 31 603 160 B 534 crates
//! gui-mini holger-server-ui-mini 20 685 888 B 493 crates
//! gui-mikro holger-server-ui-mikro 20 488 072 B 492 crates
//! ```
//!
//! Two tests keep it that way, and neither reads a manifest:
//! `tests/tier_ladder.rs` fails if a tier is not strictly leaner than the one
//! above it, and `tests/thin_client.rs` fails if a shipped tier links a server
//! entry point or the storage engine.
//!
//! **What actually removes code:** `#[cfg]` on the leaf PLUS the dependency
//! behind an optional feature. Rust compiles whole functions; no runtime filter
//! ever drops a byte.
//!
//! **So when a pane must not exist in a tier, do all four — not just the cfg:**
//!
//! 1. Give it its OWN feature in `server/ui/Cargo.toml` (`signin`, `tools`,
//! `helix`, …) and hang its deps off that feature, not off `gui`.
//! 2. Gate the leaf — its render fn and its module — with
//! `#[cfg(feature = "<that feature>")]`. This is the only step that shrinks
//! the binary. Pane bodies that the read-only tiers drop gate on the SURFACE
//! marker (`#[cfg(not(feature = "mini"))]`); the tier features are what make
//! that sound, and `lib.rs` carries a `compile_error!` if no tier is picked.
//! 3. Name that feature on the pane's declaration in `app/scene.rs`, so the
//! scene, the View menu and the Ctrl-K palette drop it from ONE place —
//! the way [`TAB_ORDER`] already does for the tab strip.
//! 4. Add the feature to that variant's `features` list in
//! `.nornir/build-variants.toml`. The build-thing variant is the tier's
//! single declaration; if it and this file disagree, the build-thing is
//! lying about what it ships.
//!
//! **And keep it THIN.** Every shipped UI tier links no server entry point and no
//! storage engine — a client that embeds its own server shows its own in-process
//! world instead of the server's and becomes a second writer on the warehouse
//! lock. If a pane needs `LocalHolger`, an Iceberg catalog or an in-process
//! gateway, it belongs behind `gui-embedded`, the build that is asked for by name.
//!
//! [`TAB_ORDER`] is the one place that already does this right: a single
//! constant swaps the full tab strip for the tier's, and the scene / View menu /
//! Ctrl-K palette all derive from it. Copy THAT pattern.
use eframe::egui;
use egui::{RichText, Ui};
use facett::effects::IceDrip; // Skade-Vinter ice-drip winter decor (icicles + melt-droplets + frost)
// The 3D graph primitives. `Graph3D` + `RabbitLogo3D` are shared (the splash rabbit,
// the Lineage fill); the layout/decor policy knobs belong to the full tier's Graph tab.
#[cfg(not(feature = "mini"))]
use facett::graph3d::{DecorPolicy, Layout3D};
#[cfg(not(feature = "mini"))]
use facett::graph3d::Graph3D;
use facett::graph3d::RabbitLogo3D;
use facett::look::Theme as LookTheme; // the unified facett look & feel (one preset/frame)
use facett::menu::CommandPalette; // the facett overlay menu system — the Ctrl-K command palette
use facett::table::Table;
use facett::Facet;
// The SHARED declarative pane/scene host (facett_app::scene) — holger's tabbed shell is a
// `Scene` (Tabs root, one foldable `Slot` per tab, plus a grouped menubar) rendered through
// facett's OWN TabView + pane engine; `Device::from_width` drives the responsive canvas-first
// control fold. The builder / `SceneHost` / render bridge live in the `scene` child module
// below, so only the types the app struct itself names are imported here.
use facett_app::scene::{Device, NavState, Scene};
// The SHARED device-code sign-in pane (facett_auth_ui) mounted on the Sign-in tab, driven
// by holger's own `HolgerLoginSeam` over nornir-auth-client (LAW #5 — one pane, no twin).
// FULL TIER ONLY: the Sign-in tab is the one pane that links an outbound HTTP client
// (`nornir-auth-client/online` → reqwest), so the read-only tiers do not compile it.
#[cfg(not(feature = "mini"))]
use crate::login_seam::HolgerLoginSeam;
// `LoginPane`'s `ui`/`state_json` are `facett_core::Facet` methods; that trait is already
// in scope in this module (the scene/table facets use it), so no extra trait import here.
#[cfg(not(feature = "mini"))]
use facett_auth_ui::LoginPane;
// The TIME-HELIX DAG view — FULL TIER ONLY (the `helix` feature owns `facett-helix`).
#[cfg(not(feature = "mini"))]
use facett_helix::{EventKind, HelixView, Kind, PlanDag, Status};
use serde_json::{json, Value};
#[cfg(not(feature = "mini"))]
use std::collections::HashMap;
#[cfg(not(feature = "mini"))]
use std::collections::VecDeque;
use traits::ArtifactId;
#[cfg(not(feature = "mini"))]
use crate::data::{LineageEvent, LineageNode, NodeKind};
use crate::data::UiData;
/// Command palette + menu dispatch (build_commands / dispatch_command / run_command /
/// apply_repo_row_nav + the id→enum maps) — a child module so it can reach
/// `HolgerUiApp`'s private state while keeping `app` focused on rendering.
mod command;
/// The dependency-graph view types (GraphNodeKind palette, repo→kind classifier,
/// the Móðguðr verdict-graph renderer) — a child module; the `Graph3D`-building
/// methods (`build_graph` / `graph_tab`) stay here and use these.
/// FULL TIER ONLY — the Graph tab, the Móðguðr verdict graph and the Lineage
/// palette are all full-tier surfaces, so the whole module goes with them.
#[cfg(not(feature = "mini"))]
mod graph;
#[cfg(not(feature = "mini"))]
use graph::{render_security_graph, repo_node_kind, GraphNodeKind};
/// The declarative UI-scene layer (`holger_scene` builder + `SceneHost` impl +
/// `render_scene` / `scene_state_json`) — the whole shell (menubar + tabbed pane
/// tree) expressed as a `facett_app::scene::Scene` and rendered through facett-app's
/// own primitives, DRIVING the existing tab renderers. A child module so it reaches
/// `HolgerUiApp`'s private tab methods while keeping `app` focused on the widgets.
mod scene;
/// The holger-server-ui version, shown on the startup splash + in `state_json` (so the
/// robot can assert a version string is presented). Sourced from the crate's
/// `Cargo.toml` at compile time.
pub const HOLGER_UI_VERSION: &str = env!("CARGO_PKG_VERSION");
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep in a release build). Each Tools pane's run button emits one so
/// `nornir test --features testmatrix` SEES each operator surface fire. Mirrors
/// the reference emitter in `korp-collectors/src/lib.rs`.
#[inline]
#[cfg_attr(feature = "mini", allow(dead_code))]
pub(crate) fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
/// The recovered holger+znippy splash logo (low-poly steel Norse warrior resting
/// on a sword + a round shield bearing the znippy lightning-rabbit). Embedded at
/// compile time via `include_bytes!` so it ships INSIDE the binary — no runtime
/// file dependency (this is an airgap product). Decoded once on the first splash
/// frame into an egui texture (see [`HolgerUiApp::splash_logo_texture`]).
#[cfg(feature = "about-card")]
const HOLGER_LOGO_PNG: &[u8] = include_bytes!("../assets/holger-znippy-logo.png");
/// holger-server-ui's look roster. facett ships OS-specific presets (Windows + macOS,
/// each light+dark) but they are visually interchangeable here, so holger
/// collapses them to just **dark** and **light**, plus facett's non-OS `device`
/// look. Each entry is `(switcher label, facett Theme constructor)`; the index
/// into this slice is the persisted `look_preset`.
pub const LOOKS: &[(&str, fn() -> LookTheme)] = &[
("dark", LookTheme::windows_dark),
("light", LookTheme::windows_light),
("device", LookTheme::device),
];
/// The switcher roster as plain labels (`dark`, `light`, `device`).
/// The ⬇ save suggestion — the user's download dir (Hämtningar/Downloads,
/// falling back to $HOME) + `name-version`. A plain suggestion; the box is editable.
/// Present in EVERY tier: the download / SBOM / property read-tier extras are part of
/// the operator (full `gui`) UI too, not just the mini read-only client.
fn default_save_path(a: &crate::data::ArtifactView) -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let dir = [format!("{home}/Hämtningar"), format!("{home}/Downloads")]
.into_iter()
.find(|d| std::path::Path::new(d).is_dir())
.unwrap_or(home);
let file = if a.version.is_empty() {
a.name.clone()
} else {
format!("{}-{}", a.name, a.version)
};
format!("{dir}/{file}")
}
pub fn look_names() -> Vec<String> {
LOOKS.iter().map(|(n, _)| n.to_string()).collect()
}
/// The default look for holger-server-ui: `dark`. facett's `from_os` resolves every OS
/// to its dark variant, so the collapsed roster's first-launch default is dark.
/// Pure → unit-testable with no window.
pub fn default_look_preset() -> usize {
LOOKS.iter().position(|(n, _)| *n == "dark").unwrap_or(0)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Tab {
Status,
Repos,
Browse,
Archive,
Artifact,
Graph,
Helix,
Lineage,
Tools,
Upload,
/// **Search** — cross-repo artifact discovery (`SearchService.Search`), the
/// read-only search the mini tier exposes as its sixth view.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Search,
/// **Sign in** — the operator OIDC device-code SSO surface. Mounts the ONE shared
/// `facett_auth_ui::LoginPane` driven by holger's [`crate::login_seam::HolgerLoginSeam`]
/// (LAW #5 — the same sign-in korp/dwarves mount, never a per-app twin).
SignIn,
/// **Settings** — the preferences pane, and the ONE home of holger's LOOK & FEEL.
///
/// The look-preset switcher used to sit on the root chrome bar AND in a root
/// menu-bar "Look" submenu; appearance is a preference, not navigation, so it
/// moved here. This mirrors dwarves-demo's `settings` tab (the 🎛 glyph and the
/// same split of responsibilities: root menu = navigation + actions, Settings =
/// appearance + preferences), so an operator moving between the suite's apps
/// finds the look control in the same place in each.
///
/// Present on EVERY tier — the read-only mini/mikro artifacts carry no chrome
/// control group, so before this pane existed their only look control was the
/// root "Look" menu. Removing that menu without giving them Settings would have
/// deleted the control outright.
Settings,
}
/// The unique name of the shared-scene ROOT pane — a **draggable horizontal
/// master/detail [`Split`](facett_app::scene::Layout::Split)**: the registry rail
/// (master) beside the tabbed workspace (detail). The `Router` reports this id as the
/// scene's route, and a robot drags its divider via the render's `separators` rects.
///
/// It used to be the `Tabs` pane itself — a flat shell with no divider to grab. The
/// tabs moved down to [`WORKSPACE_PANE`]; this name stays put so every existing
/// route/deep-link that addressed `holger-shell` still resolves to the shell.
pub(crate) const SHELL_PANE: &str = "holger-shell";
/// The **registry rail** — the master side of holger's master/detail shell, and a
/// foldable DRAWER (it auto-folds crossing into a phone-class viewport and restores
/// above it). Itself a draggable VERTICAL split of the repository roster over the
/// selected repository's cross-tab context.
pub(crate) const RAIL_PANE: &str = "holger-rail";
/// The rail's top half: the repository ROSTER (filter + selectable list). Picking a
/// row here is the SAME `select_repo` every detail tab reads — this is the one master
/// selection, not a rail-local copy.
pub(crate) const RAIL_ROSTER_PANE: &str = "rail_roster_pane";
/// The rail's bottom half: what the shell knows about the SELECTED repository across
/// the whole workspace (loaded listing, archive, upload gate) — the detail the Repos
/// table (a row per repo) cannot show.
pub(crate) const RAIL_SELECTION_PANE: &str = "rail_selection_pane";
/// The unique name of the tabbed WORKSPACE pane (the detail side): the `Tabs` layout
/// hosting holger's operator tabs. The [`NavState`] addresses the active tab by this id.
pub(crate) const WORKSPACE_PANE: &str = "holger-workspace";
/// The tab strip order — the single source of truth mapping the [`Tab`] enum to its
/// scene-slot index, slot id, and header label. The shared [`Scene`] builds one foldable
/// `Slot` per entry in this order; [`NavState::active_tab`] indexes into it.
///
/// [`Tab::Settings`] is LAST on every tier: appending it leaves every existing tab's
/// strip index (and therefore every persisted `NavState`, deep-link and robot
/// `active_tab` assertion) exactly where it was.
#[cfg(not(feature = "mini"))]
const TAB_ORDER: [Tab; 12] = [
Tab::Status,
Tab::Repos,
Tab::Browse,
Tab::Archive,
Tab::Artifact,
Tab::Graph,
Tab::Helix,
Tab::Lineage,
Tab::Tools,
Tab::Upload,
Tab::SignIn,
Tab::Settings,
];
/// The **mini** tier's tab strip. `holger-server-ui-mikro` (bin `required-features =
/// ["mini"]`) is the read-only artifact for the mini SERVER (`server-lib/mini`:
/// no federation, no `online`), whose gRPC surface is exactly five read ops —
/// list_repositories / list_artifacts / list_archive_files / archive_info /
/// health. So the mini UI carries ONLY the tabs those back: no Upload (write),
/// no Tools (holger-ds/migrate/scan/deploy/airgap — federation & host surface),
/// no Sign in (OIDC needs `online`), no Graph/Helix/Lineage (scan- / proxy-cache-
/// fed). Since the scene, View menu, and Ctrl-K palette all derive from
/// `TAB_ORDER`, shrinking it here strips all three at once.
/// Settings rides along on the read-only tiers too: it is a LOCAL preference pane
/// (the look & feel), it calls no gRPC op, and without it the mini artifact would
/// have no look control at all once the root "Look" menu was retired.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
const TAB_ORDER: [Tab; 7] = [
Tab::Status,
Tab::Repos,
Tab::Browse,
Tab::Archive,
Tab::Artifact,
Tab::Search,
Tab::Settings,
];
/// The **mikro** tier's tab strip — a STRICT SUBSET of the mini one, and the bottom
/// rung of the ladder. `holger-server-ui-mikro` (bin `required-features = ["gui-mikro"]`)
/// keeps the three ops that answer the only three questions an operator has against
/// an airgapped registry they cannot write to:
///
/// * **Status** — is it up, what version, how long (`AdminService.Health`).
/// * **Repos** — what does it carry (`RepositoryService.ListRepositories`).
/// * **Artifact** — is THIS exact coordinate on it, and how big
/// (`RepositoryService.GetArtifact` — a point lookup, no paging).
///
/// It drops the three PAGING/SCANNING surfaces the mini tier keeps — Browse
/// (`ListArtifacts`), Archive (`ListArchiveFiles` + `ArchiveInfo`) and Search
/// (`SearchService.Search`) — because each is a list-and-filter view whose whole
/// job is discovery, and the mikro tier's premise is that the caller already knows
/// the coordinate. That is a surface decision made from the gRPC surface, not from
/// taste: every op the mikro tier keeps is one round trip with no cursor.
///
/// Plus **Settings** — a local preference pane over the look & feel, no gRPC op
/// behind it. It is the only home the look control has once the root "Look" menu is
/// gone, and dropping it would have deleted the control from this tier rather than
/// moving it.
#[cfg(feature = "mikro")]
const TAB_ORDER: [Tab; 4] = [Tab::Status, Tab::Repos, Tab::Artifact, Tab::Settings];
impl Tab {
/// The stable scene-slot id (also the `state_json.tab` string) for this tab.
pub(crate) fn slot_id(self) -> &'static str {
match self {
Tab::Status => "status",
Tab::Repos => "repos",
Tab::Browse => "browse",
Tab::Archive => "archive",
Tab::Artifact => "artifact",
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => "search",
Tab::Graph => "graph",
Tab::Helix => "helix",
Tab::Lineage => "lineage",
Tab::Tools => "tools",
Tab::Upload => "upload",
Tab::SignIn => "signin",
Tab::Settings => "settings",
}
}
/// The human header label shown on the scene's TabView button (the AccessKit label
/// a robot clicks). Stable — asserted by the robot suite.
pub(crate) fn label(self) -> &'static str {
match self {
Tab::Status => "Status",
Tab::Repos => "Repos",
Tab::Browse => "Browse",
Tab::Archive => "Archive",
Tab::Artifact => "Artifact",
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => "Search",
Tab::Graph => "Graph",
Tab::Helix => "Helix",
Tab::Lineage => "Lineage",
Tab::Tools => "Tools",
Tab::Upload => "Upload",
Tab::SignIn => "Sign in",
Tab::Settings => "Settings",
}
}
/// This tab's index into [`TAB_ORDER`] (the scene's active-tab child index).
pub(crate) fn index(self) -> usize {
TAB_ORDER.iter().position(|t| *t == self).unwrap_or(0)
}
/// The tab at scene-slot index `i` (clamped to [`Tab::Status`]).
pub(crate) fn from_index(i: usize) -> Tab {
TAB_ORDER.get(i).copied().unwrap_or(Tab::Status)
}
/// A one-paragraph plain-language explanation of what this tab is for — shown
/// behind the small ⓘ button at the top of each tab body. Mini-only (the affordance
/// that reads it, `scene::tab_info`, is gated to the mini tier).
#[cfg(feature = "mini")]
pub(crate) fn help_text(self) -> &'static str {
match self {
Tab::Status => "The registry's health at a glance: is it reachable, what version \
is it, how long has it been up. Read live over gRPC (AdminService.Health); \
nothing here changes the server.",
Tab::Repos => "Every repository the server exposes, one row each, with its \
ecosystem (cargo · pip · maven · npm · …) and whether it is writable. Pick a \
row here; it is the selection the Browse, Archive and Artifact tabs read.",
Tab::Browse => "The artifacts inside the selected repository: name, version and \
size, paged. A read-only listing (RepositoryService.ListArtifacts); this is \
where you see what the registry is actually serving.",
Tab::Archive => "The files packed inside the selected repository's immutable, \
content-addressed znippy archive, plus its totals (file count · uncompressed \
bytes · archive path). The on-disk truth behind the listing.",
Tab::Artifact => "Look one artifact up by its coordinate (namespace · name · \
version): whether it exists on the server and how big it is. A read-only \
fetch; the quickest 'is this exact version here?' check.",
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => "Find artifacts across every repository at once by name \
substring (SearchService.Search): each hit shows which repo carries it, \
the version and the size. A read-only aggregate over the same listings \
Browse pages through.",
// Full-tier tabs (never shown in the mini artifact, but the match is exhaustive):
Tab::Graph => "The dependency/link graph across scanned artifacts.",
Tab::Helix => "The time-helix DAG view of registry history.",
Tab::Lineage => "The live proxy-cache-fill graph (a federation view).",
Tab::Tools => "Operator functions (delivery bench, migrate, scan, deploy, airgap, demo).",
Tab::Upload => "Push an artifact into a writable repository.",
Tab::SignIn => "The OIDC device-code operator login.",
Tab::Settings => "Preferences — the LOOK & FEEL of this window. Pick a theme \
preset and every view re-themes at once (tables, graphs, chrome). A local \
choice: it changes nothing on the server and sends no request.",
}
}
}
/// How the Lineage tab renders the live cache-fill graph: the rotating spatial
/// FILL (facett-graph3d) or its temporal companion (facett-helix).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg(not(feature = "mini"))]
pub(crate) enum LineageMode {
/// The primary rotating 3D fill — nodes pop in and the graph grows as the
/// cache fills (facett-graph3d).
Graph3d,
/// The time companion — each arrival wound onto a per-ecosystem coil
/// (facett-helix). FULL TIER ONLY — the read-only tiers link no facett-helix.
#[cfg(not(feature = "mini"))]
Helix,
}
/// The four Tools-menu functions (a mirror of the holger CLI tools).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ToolsFn {
HolgerDs,
Migrate,
Security,
Deploy,
Airgap,
/// Tool #6 — the shared-framework **demo appliance**: start a demo registry
/// container THROUGH the shared job manager `jera` (a durable `BOOT_CONTAINER`
/// job), mirroring korp's Tools-menu container demos. Read-path-safe to declare +
/// dispatch; the LIVE boot needs the `containers` feature.
Demo,
}
/// The **structured outcome of the last upload attempt** — the write's success or
/// failure as READABLE DATA, not just the transient `notice` label. LAW #6: the
/// real-write outcome (`put_artifact`) is the ONLY human-facing result of the Upload
/// tab, so it is emitted field-by-field (`ok` / `bytes` / `stage` / `error`) and a
/// robot asserts the VALUE, never the pixels or a substring of a free-text string.
///
/// `attempted == false` is the clean-slate state (no upload run yet); `ok` is `None`
/// until an attempt lands, then `Some(true)` on a stored write or `Some(false)` on a
/// failure whose `stage` (`"read"` = the local file read, `"put"` = the server write)
/// and `error` are both populated (LAW #2 — the failure path is an asserted value).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UploadOutcome {
/// `true` once the operator has clicked upload at least once.
pub attempted: bool,
/// `Some(true)` = the artifact was written; `Some(false)` = it failed; `None`
/// before any attempt.
pub ok: Option<bool>,
/// Bytes written on success (the length of the file the server stored).
pub bytes: Option<u64>,
/// The repository the write targeted.
pub repo: Option<String>,
/// The failing stage on `ok == Some(false)`: `"read"` (the local file read) or
/// `"put"` (the server write). `None` on success.
pub stage: Option<&'static str>,
/// The error string on failure (the same text folded into `notice`), `None` on
/// success — so an operator (and a robot) reads WHY without parsing the notice.
pub error: Option<String>,
}
impl UploadOutcome {
/// The outcome as observable data (LAW #6): every field a robot asserts.
pub fn state_json(&self) -> Value {
json!({
"attempted": self.attempted,
"ok": self.ok,
"bytes": self.bytes,
"repo": self.repo,
"stage": self.stage,
"error": self.error,
})
}
}
/// The holger UI application: a [`UiData`] handle + the tab's input fields.
pub struct HolgerUiApp {
data: UiData,
tab: Tab,
repo: String,
namespace: String,
name: String,
version: String,
#[cfg(not(feature = "mini"))]
upload_path: String,
#[cfg(not(feature = "mini"))]
notice: Option<String>,
/// The structured outcome of the last upload (the write's success/failure as
/// DATA — see [`UploadOutcome`]). Paired with `notice` (the human label).
#[cfg(not(feature = "mini"))]
upload_outcome: UploadOutcome,
/// Demo: static **Skade Vinter** showcase mode (the icy winter scene — replaces
/// the old red "silent running" theme). Read-only; the whole UI re-themes to
/// `Theme::skade_vinter()` and the ice-drip decor overlays. Manual toggle until
/// the server reports its profile over the gRPC Admin service.
static_mode: bool,
/// The Skade-Vinter ice-drip decor (icicles + falling melt-droplets + frost
/// shimmer) overlaid while in static mode. Time-driven; off otherwise.
ice: IceDrip,
/// Splash start time (egui seconds); shows the rabbit logo briefly then
/// auto-dismisses. Paired with `splash_done`.
splash_since: Option<f64>,
splash_done: bool,
/// The spinning znippy rabbit shown on the startup splash (a small animated
/// accent beneath the recovered hero logo).
logo: RabbitLogo3D,
/// The decoded recovered holger+znippy hero logo, lazily loaded as an egui
/// texture on the first splash frame from the compiled-in [`HOLGER_LOGO_PNG`]
/// (decode-once, then cached for the rest of the splash). `None` until decoded.
#[cfg(feature = "about-card")]
logo_texture: Option<egui::TextureHandle>,
/// Selected Tools function.
tool: ToolsFn,
/// Móðguðr (Tools#3) view mode: `false` = the verdict table, `true` = the
/// dependency-graph view (nodes coloured by verdict).
#[cfg(not(feature = "mini"))]
security_graph: bool,
/// The 3D repo graph (rebuilt when the underlying data signature changes).
#[cfg(not(feature = "mini"))]
graph: Option<Graph3D>,
/// Node-kind labels parallel to the graph's nodes — the HOLGER NEON PALETTE
/// mapping, surfaced in `state_json` so the robot asserts the graph as DATA.
#[cfg(not(feature = "mini"))]
graph_kinds: Vec<&'static str>,
/// Signature of the data the graph was built from — `(repo_count, selected
/// repo, artifact-file count)`. The graph rebuilds when this changes (a new
/// selection lights a different subgraph; a loaded archive adds artifact nodes).
#[cfg(not(feature = "mini"))]
graph_sig: (usize, Option<usize>, usize),
/// The TIME-HELIX DAG view (facett-helix) shown on the Helix tab — a plan/dep
/// DAG wound along a synthesized time axis. Rebuilt when [`Self::helix_sig`]
/// changes; `HelixView::demo()` when there is no real dep-graph data (or in the
/// hands-free demo tour) so the tab is never blank.
#[cfg(not(feature = "mini"))]
helix: Option<HelixView>,
/// Signature of the data the helix was built from — `(demo?, dep-graph
/// repository, node count, edge count)`. The helix rebuilds when this changes
/// (e.g. a Móðguðr scan populates a new repo's dependency graph).
#[cfg(not(feature = "mini"))]
helix_sig: (bool, String, usize, usize),
/// Lineage tab render mode — the rotating 3D fill or the time-helix companion.
#[cfg(not(feature = "mini"))]
lineage_mode: LineageMode,
/// The rotating 3D cache-fill graph (facett-graph3d), rebuilt as the lineage
/// view grows (`lineage_sig` changes); `None` until first built.
#[cfg(not(feature = "mini"))]
lineage_graph: Option<Graph3D>,
/// The time-helix companion (facett-helix) for the lineage view; `None` until built.
#[cfg(not(feature = "mini"))]
lineage_helix: Option<HelixView>,
/// Signature of the lineage data the widgets were built from — `(node, edge,
/// event count)`. When it changes (an arrival landed) the widgets rebuild so the
/// new node pops in; the injected clock keeps rotation/traverse continuous.
#[cfg(not(feature = "mini"))]
lineage_sig: (usize, usize, usize),
/// The LIVE feed sender the Lineage tab drips demo arrivals through (the same
/// `mpsc` seam a real [`ChannelAuditLog`](crate::data::ChannelAuditLog) tee
/// uses); `self.data.lineage` holds the paired receiver.
#[cfg(not(feature = "mini"))]
lineage_tx: std::sync::mpsc::Sender<LineageEvent>,
/// Remaining demo arrivals to drip one-per-frame so the graph visibly fills and
/// rotates when no real audit feed is wired.
#[cfg(not(feature = "mini"))]
lineage_demo: VecDeque<LineageEvent>,
/// When set, drip one demo arrival per frame while the Lineage tab is open.
#[cfg(not(feature = "mini"))]
lineage_autofill: bool,
/// Active look — an index into [`LOOKS`] (`dark` / `light` / `device`). The app applies
/// this preset every frame; `apply` installs the full egui Style AND publishes
/// the derived legacy palette so the facett Table facets re-theme coherently
/// (COH-1). Replaces the old `facett::Theme::sci_fi()` call.
look_preset: usize,
/// The session's mTLS client identity (the cert+key the UI connected with).
/// Tool #5 Airgap feeds these into the populate args when `auth=mtls`, so a
/// real push reuses the operator's connect-time identity instead of failing
/// with "--auth mtls needs --client-cert and --client-key".
session_client_cert: Option<std::path::PathBuf>,
session_client_key: Option<std::path::PathBuf>,
/// 🎬 **Demo mode** (hands-free guided tour). `Some` when launched with
/// `holger-server-ui --demo`: each frame the app applies the next due
/// [`DemoStep`](crate::demo::DemoStep), driving the same `pub` hooks a human
/// click runs (tab/tool switch, repo + graph-node selection, look re-theme),
/// with the egui frame clock so the animations between beats render.
#[cfg(feature = "demo")]
demo: Option<DemoRun>,
/// The facett **command palette** (Ctrl-K / Ctrl-Shift-P): a fuzzy launcher over
/// every tab, tool, and *repository* (owned-String command ids let the per-repo
/// commands be built at runtime). Pure data between frames; drawn last so it
/// overlays everything. Its `invoked` id is dispatched by [`dispatch_command`].
palette: CommandPalette,
/// **About** popup open flag (shared common-framework identity surface). Toggled
/// by the `Help · About holger` menu / palette command; the modal renders holger's
/// logo + wordmark + version + the shared framework it is built on.
about_open: bool,
/// The shared About card (`facett_about::AboutCard`), built lazily on first open —
/// it decodes holger's hero logo once (LFS-tolerant: an un-pulled blob yields no
/// texture and the card shows its text identity) and remembers the 360px width.
/// `None` until the About popup is first opened. This is the ADOPTION of the shared
/// widget: holger no longer hand-rolls the About egui body (LAW #5).
#[cfg(feature = "about-card")]
about_card: Option<facett_about::AboutCard>,
/// **The shared declarative pane/scene host (LAW #5 — ADOPTION).** holger's tabbed shell
/// is a `facett_app::scene::Scene`: a `Tabs` root pane with one foldable `Slot` per tab.
/// It is rendered through facett's OWN TabView + pane engine by [`scene::render`], driven
/// by a thin [`SceneHost`] that fills each slot from holger's existing `*_tab` bodies —
/// replacing the hand-rolled tab strip + central `match self.tab`. Built once in `new`.
scene: Scene,
/// The scene's runtime navigation state (active tab + fold flags), persisted across
/// frames. Kept in sync with holger's authoritative [`Self::tab`]: seeded from it before
/// each render and read back after, so palette/menu/demo dispatch and the observable
/// `state_json.tab` keep working while the scene's TabView drives the click path.
nav: NavState,
/// The last [`scene::render`]'s observable facts (device class, per-pane rects, slot
/// states) — surfaced under `state_json.scene` so a robot asserts the adopted scene +
/// its responsive device class without pixels.
last_render: Value,
/// Whether the shell chrome (look switcher + static toggle) is currently folded behind
/// the ⚙ drawer — set true on a phone-class viewport ([`Device::from_width`]). The
/// canvas-first responsive fold; surfaced under `state_json.controls_folded`.
controls_folded: bool,
/// The SHARED device-code **sign-in pane** (`facett_auth_ui::LoginPane`) mounted on the
/// Sign-in tab, driven by holger's own [`HolgerLoginSeam`] over `nornir-auth-client` (LAW
/// #5 — one pane, no per-app twin). Presentational: it holds the login state machine and
/// paints it; every transition comes from the seam. Its `state_json` is surfaced under
/// `state_json.login` (subject + credential NAMES only — never a token value).
#[cfg(not(feature = "mini"))]
login_pane: LoginPane,
/// The registry rail's repository filter (substring, case-insensitive). Empty ⇒ the
/// whole roster. Pure data between frames; reported under `state_json.scene.render.
/// slots.rail-roster.filter` so a robot can assert what the rail is showing.
rail_filter: String,
/// Mini: the Browse tab's server-side name filter (`ListArtifacts.name_filter`).
#[cfg(all(feature = "mini", not(feature = "mikro")))]
browse_filter: String,
/// Mini: the Archive tab's server-side path prefix (`ListArchiveFiles.prefix`).
#[cfg(all(feature = "mini", not(feature = "mikro")))]
archive_prefix: String,
/// Mini: the Search tab's name-axis query input.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
search_input: String,
/// Where the ⬇ save writes the fetched artifact (prefilled per fetch). Present in
/// every tier — the Artifact tab's download control is not mini-only.
save_path: String,
}
/// Live wrapper around a [`DemoWalk`](crate::demo::DemoWalk): the script plus the
/// egui-time origin it measures `elapsed_ms` from. FC-7: the walk is time-injected;
/// this only records the frame-time origin and rebases it each loop.
///
/// FULL TIER ONLY — the guided tour pulls REAL crates from crates.io through the
/// `world-connector` (→ reqwest), which is exactly what the read-only tiers exist
/// not to link.
#[cfg(feature = "demo")]
struct DemoRun {
walk: crate::demo::DemoWalk,
/// egui `input.time` (seconds) captured on the first demo frame; `None` until.
origin: Option<f64>,
}
impl HolgerUiApp {
/// Build the app over a connected data layer and prime the first views.
pub fn new(mut data: UiData) -> Self {
data.refresh_status();
// Server truth for the static "silent running" mode: a server that
// accepts no writes drives the UI read-only by default (the checkbox
// below stays a manual override the operator can flip either way).
data.refresh_profile();
data.refresh_repos();
// The runnable demo default for Tool #5 is a dry-run plan: it validates
// with no creds and shows the plan immediately (the operator unticks
// dry-run to actually push).
data.airgap.dry_run = true;
// The "Skade Vinter" winter SHOWCASE (frost badge · ice-drip decor · icy look ·
// winter `state_json`) all rides on `static_mode`, seeded here from the server's
// read-only profile. The mini tier IS a read-only server, but it is a plain
// operator UI — not the demo showcase — so it never lights the winter chrome and
// carries no manual toggle. Everything winter keys off this one flag, so pinning
// it false is the whole opt-out.
#[cfg(not(feature = "mini"))]
let static_mode = data.profile.read_only;
#[cfg(feature = "mini")]
let static_mode = false;
let repo = data
.repos
.selected_repo()
.map(|r| r.name.clone())
.unwrap_or_default();
// Wire the live lineage feed: connect the view-model to the receiver and keep
// the sender for the Lineage tab's demo drip. A real deployment installs a
// `ChannelAuditLog` tee on the server's `Arc<dyn AuditLog>` with the same
// channel, so every fetch/put streams straight into `data.lineage`.
#[cfg_attr(feature = "mini", allow(unused_variables))]
let (lineage_tx, lineage_rx) = crate::data::channel();
data.lineage.connect(lineage_rx);
// Build holger's shell as the SHARED declarative scene (facett_app::scene) + seed its
// runtime nav from it (LAW #5 — ADOPTION). The pane tree is constant, so the persisted
// `nav` stays valid; only the menubar's Repositories submenu is data-driven, so
// `render_scene` rebuilds the scene per frame to track the live roster. Fail loudly in
// debug if the addressability invariant (unique pane + menu names) is ever broken.
let scene = scene::holger_scene(&data.repos.repos);
debug_assert!(
scene.validate().is_ok(),
"shell scene names must be unique: {:?}",
scene.validate()
);
let nav = NavState::new(&scene);
Self {
data,
tab: Tab::Status,
repo,
namespace: String::new(),
name: String::new(),
version: String::new(),
#[cfg(not(feature = "mini"))]
upload_path: String::new(),
#[cfg(not(feature = "mini"))]
notice: None,
#[cfg(not(feature = "mini"))]
upload_outcome: UploadOutcome::default(),
static_mode,
ice: IceDrip::new(16, 0x5CADE_u64).enabled(true).with_period(2.8),
splash_since: None,
splash_done: false,
logo: RabbitLogo3D::new("holger"),
#[cfg(feature = "about-card")]
logo_texture: None,
tool: ToolsFn::HolgerDs,
#[cfg(not(feature = "mini"))]
security_graph: false,
#[cfg(not(feature = "mini"))]
graph: None,
#[cfg(not(feature = "mini"))]
graph_kinds: Vec::new(),
#[cfg(not(feature = "mini"))]
graph_sig: (usize::MAX, None, usize::MAX),
#[cfg(not(feature = "mini"))]
helix: None,
#[cfg(not(feature = "mini"))]
helix_sig: (false, String::new(), usize::MAX, usize::MAX),
#[cfg(not(feature = "mini"))]
lineage_mode: LineageMode::Graph3d,
#[cfg(not(feature = "mini"))]
lineage_graph: None,
#[cfg(not(feature = "mini"))]
lineage_helix: None,
#[cfg(not(feature = "mini"))]
lineage_sig: (usize::MAX, usize::MAX, usize::MAX),
#[cfg(not(feature = "mini"))]
lineage_tx,
#[cfg(not(feature = "mini"))]
lineage_demo: crate::data::demo_events().into_iter().collect(),
#[cfg(not(feature = "mini"))]
lineage_autofill: true,
look_preset: default_look_preset(),
session_client_cert: None,
session_client_key: None,
#[cfg(feature = "demo")]
demo: None,
palette: CommandPalette::default(),
about_open: false,
#[cfg(feature = "about-card")]
about_card: None,
scene,
nav,
last_render: Value::Null,
controls_folded: false,
// Mount the shared sign-in pane over holger's live seam. If a non-expired
// token is already cached (`current_identity`), the pane opens straight into
// the signed-in surface; otherwise it opens signed-out.
#[cfg(not(feature = "mini"))]
login_pane: LoginPane::new(Box::new(HolgerLoginSeam::from_env()))
.with_title("Sign in to holger"),
rail_filter: String::new(),
#[cfg(all(feature = "mini", not(feature = "mikro")))]
browse_filter: String::new(),
#[cfg(all(feature = "mini", not(feature = "mikro")))]
archive_prefix: String::new(),
#[cfg(all(feature = "mini", not(feature = "mikro")))]
search_input: String::new(),
save_path: String::new(),
}
}
/// 🎬 Arm **demo mode**: install the hands-free guided-tour walk and skip the
/// splash so the tour starts immediately. `beat_ms` is the pause between beats.
/// The live render loop then applies one due beat per frame off the egui clock.
#[cfg(feature = "demo")]
pub fn enable_demo(&mut self, beat_ms: u64) {
self.skip_splash();
self.demo = Some(DemoRun { walk: crate::demo::DemoWalk::tour(beat_ms), origin: None });
}
/// 🎬 The headless-test hook: apply every beat due at `elapsed_ms` against the
/// real app hooks (deterministic, no egui clock). Returns the labels applied.
/// The live [`demo_tick`](Self::demo_tick) is the same logic fed by frame time.
#[cfg(feature = "demo")]
pub fn demo_drive_to(&mut self, elapsed_ms: u64) -> Vec<String> {
let mut applied = Vec::new();
// Pull due beats one at a time so each `apply` runs in cursor order.
loop {
let step = match self.demo.as_mut() {
Some(run) => run.walk.due(elapsed_ms),
None => None,
};
match step {
Some(s) => {
self.apply_demo_action(s.action);
applied.push(s.label);
}
None => break,
}
}
applied
}
/// `true` when the hands-free tour is armed. Only the `demo` (embedded) build
/// has one to arm, so every other tier answers a constant `false` — which lets
/// the panes that ask (Helix's data signature, the render loop) stay one code
/// path instead of growing a `#[cfg]` at each question.
#[inline]
#[cfg_attr(feature = "mini", allow(dead_code))]
pub(crate) fn demo_running(&self) -> bool {
#[cfg(feature = "demo")]
{
self.demo.is_some()
}
#[cfg(not(feature = "demo"))]
{
false
}
}
/// `true` when demo mode is armed (for the live repaint loop). The read-only
/// tiers compile no demo, so it is constantly `false` there.
#[cfg(feature = "demo")]
pub fn demo_active(&self) -> bool {
self.demo.is_some()
}
/// 🎬 Per-frame live drive: emit any beat due at the current egui frame time,
/// looping forever (rebasing the clock each pass) so a left-running demo window
/// stays alive. Applies the beat through the real `pub` hooks, then requests a
/// repaint so the motion between beats keeps animating.
#[cfg(feature = "demo")]
fn demo_tick(&mut self, ctx: &egui::Context, now: f64) {
let elapsed_ms = {
let Some(run) = self.demo.as_mut() else { return };
if run.walk.is_done() {
run.walk.rewind();
run.origin = Some(now);
}
let origin = *run.origin.get_or_insert(now);
((now - origin).max(0.0) * 1000.0) as u64
};
let applied = self.demo_drive_to(elapsed_ms);
if !applied.is_empty() {
self.notice = applied.last().map(|l| format!("🎬 demo: {l}"));
}
// Keep painting so the next beat lands and the animations in between render.
ctx.request_repaint_after(std::time::Duration::from_millis(120));
}
/// Apply one [`DemoAction`](crate::demo::DemoAction) through the exact `pub`
/// hook a human click would run.
#[cfg(feature = "demo")]
fn apply_demo_action(&mut self, action: crate::demo::DemoAction) {
use crate::demo::DemoAction::*;
match action {
Goto(t) => self.tab = t,
SelectRepo(i) => self.select_repo(i),
SelectGraphNode(i) => self.select_graph_node(i),
SelectTool(tf) => {
self.tab = Tab::Tools;
self.tool = tf;
}
SecurityGraph(on) => {
self.tab = Tab::Tools;
self.tool = ToolsFn::Security;
self.security_graph = on;
}
Look(i) => {
self.set_look_preset(i);
}
}
}
/// Record the session's mTLS client identity (the `--cert`/`--key` the UI
/// connected with), so Tool #5 Airgap can reuse it for an `auth=mtls` push.
pub fn set_session_client_identity(
&mut self,
cert: Option<std::path::PathBuf>,
key: Option<std::path::PathBuf>,
) {
self.session_client_cert = cert;
self.session_client_key = key;
}
/// Toggle the Tool #5 Airgap dry-run flag (test/automation hook for driving
/// the validate() happy/red path without the egui checkbox).
pub fn airgap_set_dry_run(&mut self, dry_run: bool) {
self.data.airgap.dry_run = dry_run;
}
/// Set the Artifact-tab query fields (test hook: the tab's text edits live in
/// an egui Grid and aren't individually label-addressable for TypeInto, so a
/// deterministic setter drives the same state the operator would type).
pub fn set_artifact_query(&mut self, repository: &str, name: &str, version: &str) {
self.repo = repository.to_string();
self.name = name.to_string();
self.version = version.to_string();
}
/// Set the Artifact-tab **⬇ save destination** (the download path box). A test
/// hook: the box prefills to the user's download dir per fetch, so a headless
/// drive pins it to a deterministic (writable or deliberately-bad) path and then
/// clicks the real `⬇ Save artifact` button, whose outcome lands on
/// `state_json["artifact"]["saved_to" / "save_error"]`.
pub fn set_save_path(&mut self, path: &str) {
self.save_path = path.to_string();
}
/// Select repository row `i` — the exact path the Repos-tab row click runs.
/// A test hook because the repo name is rendered BOTH as the selectable row
/// and in the facett capability Table, so a label-addressed click is
/// ambiguous; this drives the same selection deterministically.
pub fn select_repo(&mut self, i: usize) {
self.data.select_repo(i);
if let Some(r) = self.data.repos.repos.get(i) {
self.repo = r.name.clone();
}
}
// ── look & feel (unified `facett::look::Theme`) ─────────────────────────────
/// The active look's [`LookTheme`].
pub fn look_theme(&self) -> LookTheme {
LOOKS[self.look_preset.min(LOOKS.len() - 1)].1()
}
/// The active look's holger label (`dark` / `light` / `device`) — what the
/// switcher shows and `state_json` reports, not facett's internal theme name.
pub fn look_name(&self) -> &'static str {
LOOKS[self.look_preset.min(LOOKS.len() - 1)].0
}
/// Switch the active look (clamped); returns the new look's label.
pub fn set_look_preset(&mut self, idx: usize) -> String {
self.look_preset = idx.min(LOOKS.len() - 1);
self.look_name().to_string()
}
/// Apply the active preset to `ctx` — installs the full egui Style and
/// publishes the derived legacy palette so the facett Table facets follow
/// (COH-1). Re-applying takes effect next frame, no restart.
pub fn apply_look(&self, ctx: &egui::Context) {
self.look_theme().apply(ctx);
}
/// The look state the operator sees, as observable JSON for headless robot
/// tests: the active preset name, the full preset roster, and the resolved
/// legacy palette (the colours every facett view actually paints with). This
/// is what proves "the active preset + a coherent palette" without a screen.
pub fn look_state_json(&self) -> Value {
let theme = self.look_theme();
let pal = theme.to_legacy_palette();
let hex = |c: egui::Color32| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
json!({
"preset": self.look_name(),
"preset_index": self.look_preset,
"presets": look_names(),
"dark": theme.is_dark(),
"palette": {
"bg": hex(pal.bg),
"text": hex(pal.text),
"text_dim": hex(pal.text_dim),
"accent": hex(pal.accent),
"panel_bg": hex(pal.panel_bg),
"node_fill": hex(pal.node_fill),
},
})
}
/// **The ⚙ Settings pane — holger's preferences surface, and the ONE home of its
/// LOOK & FEEL.**
///
/// The look-preset switcher used to live in two places at the shell ROOT: an
/// inline strip on the chrome bar (folded behind a `⚙ Controls` menu on a narrow
/// viewport) and a root menu-bar **Look** submenu. Both are gone; appearance is a
/// PREFERENCE, not navigation, and the root menu is for navigation and actions.
/// This is the same split dwarves-demo draws (its `settings` tab, glyph 🎛, holds
/// the theme picker while its menus hold nothing about appearance) — so an
/// operator moving between the suite's apps finds the look control in the same
/// place in each.
///
/// Nothing was lost in the move: the same [`LOOKS`] roster, driven through the
/// same [`set_look_preset`](Self::set_look_preset), still re-themes every view
/// (the preset is applied to the egui ctx at the top of every frame, and its
/// derived legacy palette is published so the facett Table/graph facets follow —
/// the COH-1 bridge). The `look:<name>` command still works from Ctrl-K, which is
/// a searchable index of everything, not a menu.
///
/// Returns the pane's observable state — the SAME [`look_state_json`] the shell
/// publishes, so a robot can assert on the pane without a second oracle.
///
/// [`look_state_json`]: Self::look_state_json
fn settings_pane(&mut self, ui: &mut egui::Ui) -> Value {
ui.heading("🎛 Appearance");
ui.label(
"The unified facett look & feel — one preset re-themes every view (tables, \
graphs, chrome). A local preference: nothing is sent to the server.",
);
ui.add_space(6.0);
let mut new_look: Option<usize> = None;
let look_preset = self.look_preset;
ui.horizontal_wrapped(|ui| {
ui.label("look:")
.on_hover_text("Unified facett look & feel — re-themes every view");
for (i, name) in look_names().iter().enumerate() {
if ui.selectable_label(look_preset == i, name.as_str()).clicked() {
new_look = Some(i);
}
}
});
if let Some(i) = new_look {
self.set_look_preset(i);
}
ui.add_space(6.0);
// Honest about the one case where the pick is overridden rather than silently
// ignored: the Skade-Vinter showcase pins the icy preset for the whole shell.
if self.static_mode {
ui.weak(
"❄ the Skade Vinter static showcase is engaged — it pins the winter \
preset over this pick until it is switched off (Action ▸ Toggle static)",
);
} else {
ui.weak(format!("active preset: {}", self.look_name()));
}
self.look_state_json()
}
/// The whole app's observable state — what a robot driver asserts on
/// ("see what the user sees as data", the nornir `Observable::state_json`
/// contract). Active tab, static mode, selected tool, splash/graph state,
/// the repo count behind the views, and the active look.
pub fn state_json(&self) -> Value {
let tab = match self.tab {
Tab::Status => "status",
Tab::Repos => "repos",
Tab::Browse => "browse",
Tab::Archive => "archive",
Tab::Artifact => "artifact",
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => "search",
Tab::Graph => "graph",
Tab::Helix => "helix",
Tab::Lineage => "lineage",
Tab::Tools => "tools",
Tab::Upload => "upload",
Tab::SignIn => "signin",
Tab::Settings => "settings",
};
let tool = match self.tool {
ToolsFn::HolgerDs => "holger-ds",
ToolsFn::Migrate => "migrate",
ToolsFn::Security => "security",
ToolsFn::Deploy => "deploy",
ToolsFn::Airgap => "airgap",
ToolsFn::Demo => "demo",
};
// The currently selected Tools pane's render-time self-report (the same
// signal the `pane_rendered` functional-status emitter fires): `ok=true`
// when it draws a usable operator surface, `false` ("RED on empty") when
// blanked/disabled in static read-only mode. The robot asserts this.
// FULL TIER ONLY — the read-only tiers have no Tools pane to report on.
#[cfg(not(feature = "mini"))]
let (tp_pane, tp_ok, tp_detail) = self.tools_pane_report();
#[allow(unused_mut)]
let mut j = json!({
"tab": tab,
// The shell's OWN tab roster (the registered tab-bar slot ids, in
// `TAB_ORDER`) — the tabs oracle the wire-remote per-tab scenario library
// (`scenarios::holger_tab_scenarios`) is asserted complete against. A
// removed / renamed / added tab moves this list, so the drive test that
// compares the recorded scenario set to `/tabs` goes RED.
"tabs": TAB_ORDER.iter().map(|t| t.slot_id()).collect::<Vec<_>>(),
"static_mode": self.static_mode,
"write_enabled": !self.static_mode,
"tool": tool,
"splash_done": self.splash_done,
// The version presented on the splash (under the wordmark) — asserted
// by the robot so "the splash shows a version string" is checked.
"version": HOLGER_UI_VERSION,
"repo_count": self.data.repos.repos.len(),
// The command palette as data: open flag, query, the full command id set,
// current fuzzy hits, and the last-invoked id — so a robot asserts the
// Ctrl-K launcher (and per-repo commands) without a screen.
"palette": self.palette.state_json(&self.build_commands()),
// The data-layer views behind each tab, so the robot can assert what
// the user sees on Status/Repos/Browse/Archive/Artifact as DATA.
"status": self.data.status.state_json(),
// Server-reported operating profile (server truth for static mode).
"profile": self.data.profile.state_json(),
"repos": self.data.repos.state_json(),
"browse": self.data.browse.state_json(),
"archive": self.data.archive.state_json(),
"artifact": self.data.artifact.state_json(),
// The effective look: the icy Skade-Vinter showcase while static, else
// the operator's selected preset.
"look": if self.static_mode { "skade_vinter".to_string() } else { self.look_name().to_string() },
// The About identity surface (shared common-framework parity): open flag +
// holger's identity (wordmark/version/logo) + the shared framework it is
// built on — asserted by the robot without pixels.
"about": crate::about::state_json(self.about_open),
// The ADOPTED shared pane/scene host (facett_app::scene) as DATA. `scene_state_json`
// merges BOTH observable surfaces into one object: the last `scene::render`'s facts
// (scene name, the responsive `device` class + `width`, the per-pane render map) AND
// the declarative shell's own structure (the pane tree, which panes are foldable, the
// live fold + active-tab state, the menubar group count) — so a robot asserts the
// whole shell, its menubar, its folds and its responsive canvas-first fold without
// pixels. `controls_folded` mirrors the ⚙ drawer state.
"scene": self.scene_state_json(),
"controls_folded": self.controls_folded,
});
// The FULL-TIER-only observables, spliced in rather than written inline:
// `json!` takes no `#[cfg]` on a member, and the read-only tiers compile
// none of the panes behind these keys.
#[cfg(not(feature = "mini"))]
{
// Render-time introspection for the selected Tools pane: which pane is
// shown, whether it drew a usable surface (`ok`), and a one-line detail.
// `ok=false` is the "RED on empty" contract (static-disabled write-tools).
j["tools_pane"] =
json!({ "pane": tp_pane, "ok": tp_ok, "rendered": true, "detail": tp_detail });
// The Upload tab is chrome (writable gate + notice), not a data-layer
// view, so its observable state is assembled here.
j["upload"] = json!({
"writable": self.data.repos.upload_enabled(),
"target": self.data.repos.selected_repo().map(|r| r.name.clone()),
"notice": self.notice,
// The real-write outcome as DATA (LAW #6): success/failure is a
// readable value (`ok`/`bytes`/`stage`/`error`), not a transient label.
"outcome": self.upload_outcome.state_json(),
});
// Operator Tools panes (the data-layer views) so the robot asserts
// them: Tool #1 holger-ds config + run result, Tool #3 security
// verdict, Tool #5 airgap populate plan + run.
j["holger_ds"] = self.data.holger_ds.state_json();
j["security"] = self.data.security.state_json();
j["dep_graph"] = self.data.dep_graph.state_json();
j["airgap"] = self.data.airgap_state_json();
j["migrate"] = self.data.migrate.state_json();
// Tool #4 Deploy (Skíðblaðnir) — config + last deploy result.
j["deploy"] = self.data.deploy.state_json();
// Tool #6 Demo appliance — the last jera-dispatched demo-container result
// (job id + spec + status), or null until the operator runs it. Proof the
// Tools demo goes THROUGH the shared job manager, not a bespoke spawn.
j["demo_appliance"] =
json!(self.data.demo_appliance.as_ref().map(|d| d.state_json()));
// The live artifact-lineage (proxy-cache-fill) graph as DATA — nodes/
// edges/time-events — plus which render mode the Lineage tab is showing.
j["lineage"] = self.data.lineage.state_json();
j["lineage_mode"] = json!(match self.lineage_mode {
LineageMode::Graph3d => "graph3d",
LineageMode::Helix => "helix",
});
j["graph_built"] = json!(self.graph.is_some());
j["graph"] = self.graph_state_json();
// The Helix tab's wound time-helix DAG as DATA (built lazily when the tab
// is first shown): `helix_built` mirrors `graph_built`, and `helix` carries
// the HelixView's own `state_json` (or null before the coil is wound).
j["helix_built"] = json!(self.helix.is_some());
j["helix"] = self.helix_state_json();
// The SHARED sign-in pane (facett_auth_ui) as DATA: the device-code state
// machine's current node (signed_out / awaiting_user / signed_in / error),
// the subject + credential NAMES when signed in, and the prompt when
// awaiting. NO token/secret VALUE is present — the seam exposes none, so a
// robot asserts the mounted pane without a secret ever reaching state.
j["login"] = self.login_pane.state_json();
}
j
}
/// Toggle the **About** popup (test/automation hook + the `Help · About` command
/// path). Returns the new open state.
pub fn toggle_about(&mut self) -> bool {
self.about_open = !self.about_open;
self.about_open
}
/// Dispatch the **Tool #6 demo appliance** — the same hook the pane's "Start demo
/// appliance…" button runs: records the demo container bring-up as a jera job (and
/// boots it live under the `containers` feature). Test/automation hook so a robot
/// drives the exact dispatch a click would, then asserts the observable job id.
#[cfg(not(feature = "mini"))]
pub fn run_demo_tool(&mut self) {
self.tab = Tab::Tools;
self.tool = ToolsFn::Demo;
self.data.run_demo();
}
fn status_tab(&mut self, ui: &mut Ui) {
if ui.button("⟳ refresh").clicked() {
self.data.refresh_status();
}
ui.separator();
let s = &self.data.status;
if let Some(err) = &s.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
egui::Grid::new("status_grid").striped(true).show(ui, |ui| {
ui.label("status");
ui.label(if s.status.is_empty() { "—" } else { &s.status });
ui.end_row();
ui.label("version");
ui.label(if s.version.is_empty() { "—" } else { &s.version });
ui.end_row();
ui.label("uptime (s)");
ui.label(s.uptime_seconds.to_string());
ui.end_row();
});
}
/// **Repos · grid — the MASTER half of the Repos body.**
///
/// Just the repo×capability table, in its own scene pane. It used to sit at the
/// BOTTOM of one imperative flow, under the refresh row, the error line, the repo
/// combo and four action buttons — so every row of chrome above it pushed the grid
/// down and there was no divider to win the space back. Now the grid is a window: it
/// takes 62% of the body by default, the operator drags the divider to change that,
/// and maximising it hides the inspector entirely.
fn repos_grid(&mut self, ui: &mut Ui) {
// facett Table facet for the repo×capability grid. The `writable` column is a
// write concept: on the read-only mini tier it reads "false" for every row, so
// mini drops it (a column that can only ever hold one value informs nobody).
#[cfg(not(feature = "mini"))]
let columns = vec!["name".into(), "type".into(), "writable".into(), "archive".into()];
#[cfg(feature = "mini")]
let columns = vec!["name".into(), "type".into(), "archive".into()];
let mut table = Table::new("repositories", columns);
for r in &self.data.repos.repos {
#[cfg(not(feature = "mini"))]
let row = vec![
r.name.clone(),
r.repo_type.clone(),
r.writable.to_string(),
r.has_archive.to_string(),
];
#[cfg(feature = "mini")]
let row = vec![r.name.clone(), r.repo_type.clone(), r.has_archive.to_string()];
table.push_row(row);
}
if let Some(sel) = self.data.repos.selected {
table.select_row(sel);
}
table.ui(ui);
}
/// **Repos · actions — the INSPECTOR half of the Repos body.**
///
/// Everything you DO with the selected row: refresh, the error line, the scrolling
/// repository selector, and the per-repo quick actions. Returns the pane's own
/// observable state (the roster size + the live selection) so a robot can assert the
/// inspector rendered without reading a pixel.
fn repos_actions(&mut self, ui: &mut Ui) -> Value {
if ui.button("⟳ refresh").clicked() {
self.data.refresh_repos();
}
ui.separator();
if let Some(err) = &self.data.repos.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
// Selection row (collect first so we don't hold a borrow across the click).
let entries: Vec<(usize, String)> = self
.data
.repos
.repos
.iter()
.enumerate()
.map(|(i, r)| (i, r.name.clone()))
.collect();
let selected = self.data.repos.selected;
// A scrolling dropdown of ALL repositories — one compact control that scales
// past a wrapped row when a server carries many repos (egui's ComboBox popup
// scroll-bounds the list). Quick per-repo actions sit NEXT to it as buttons:
// attaching a context menu to the combo response fought egui's popup layer and
// suppressed the dropdown, so the actions are plain buttons instead.
let mut row_action: Option<&str> = None;
// The repository dropdown is a FULL-tier convenience: mini's rail Roster is the
// one always-visible, filterable repo picker, so a second picker here would be a
// straight duplicate (the exact L5 twin the palette/menu cleanup removed).
#[cfg(not(feature = "mini"))]
{
let current = selected
.and_then(|i| entries.get(i))
.map(|(_, n)| n.clone())
.unwrap_or_else(|| "(select a repository)".to_string());
let mut pick = None;
ui.label(format!("repository ({}):", entries.len()));
egui::ComboBox::from_id_salt("repos-dropdown")
.selected_text(current)
.show_ui(ui, |ui| {
for (i, name) in &entries {
if ui.selectable_label(selected == Some(*i), name).clicked() {
pick = Some(*i);
}
}
});
if let Some(i) = pick {
self.data.select_repo(i);
self.repo = entries[i].1.clone();
}
ui.separator();
}
// NB: these per-repo quick-action buttons are deliberately NOT labelled
// plain "Browse"/"Archive" — that would SHADOW the tab-bar labels of the
// same name, so a robot (or an operator's accessibility tool) could not
// address the Browse/Archive TABS by label while the Repos pane is open
// (a duplicate-label ambiguity). The `▸` action prefix keeps them clear
// AND unique, so the tab bar stays label-addressable from every pane.
//
// They stack VERTICALLY now: the inspector is a narrow column, and a row of
// four buttons in a 38%-wide pane wraps into an unreadable ragged block.
ui.add_enabled_ui(selected.is_some(), |ui| {
// ▸ Browse / ▸ Archive only mirror the tab strip (both tabs auto-load the
// roster selection), and ▸ Scan opens the full-tier Tools / Móðguðr pane —
// mini carries none of them. Copy name is a real function (clipboard) and stays.
#[cfg(not(feature = "mini"))]
{
if ui.button("▸ Browse repo").clicked() {
row_action = Some("row:browse");
}
if ui.button("▸ Scan repo").clicked() {
row_action = Some("row:scan");
}
if ui.button("▸ Archive repo").clicked() {
row_action = Some("row:archive");
}
}
if ui.button("▸ Copy name").clicked() {
row_action = Some("row:copy");
}
});
// The quick-action buttons run on the currently selected repo: Browse / Scan /
// Archive open the matching tab; Copy puts the name on the clipboard.
if let Some(action) = row_action {
if let Some(i) = self.data.repos.selected {
if action == "row:copy" {
ui.ctx().copy_text(entries[i].1.clone());
} else {
self.apply_repo_row_nav(i, action);
}
}
}
// `mut` for the full tier's upload_enabled insert below.
#[cfg_attr(feature = "mini", allow(unused_mut))]
let mut state = json!({
"total": entries.len(),
"selected": self.data.repos.selected,
"selected_repo": self.data.repos.selected_repo().map(|r| r.name.clone()),
});
// Upload is a write concept — meaningless on the read-only mini tier.
#[cfg(not(feature = "mini"))]
{
state["upload_enabled"] = json!(self.data.repos.upload_enabled());
}
state
}
/// **Browse · artifacts — the MASTER half.** Nothing but the artifact listing.
///
/// The auto-load lives here (not in the inspector) because the master is drawn FIRST
/// in the split, so the table shows the fetched listing on the very frame the
/// selection changed rather than one frame later.
#[cfg(not(feature = "mikro"))]
fn browse_listing(&mut self, ui: &mut Ui) {
// Snapshot the selected repo name to a local String before mutating
// `self.data` (the refresh borrows it mutably).
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
// Auto-load on first view / when the selected repo changes — the pane used
// to require a manual ⟳ refresh, so it looked empty until clicked. The guard
// (browse.repository != selected) converges: refresh_browse stamps the repo,
// so it fires once per selection, not every frame.
if let Some(repo) = &target {
if self.data.browse.repository != *repo {
#[cfg(feature = "mini")]
let filter = (!self.browse_filter.trim().is_empty())
.then(|| self.browse_filter.trim().to_string());
#[cfg(not(feature = "mini"))]
let filter = None;
self.data.refresh_browse(repo, filter);
}
}
// facett Table facet for the artifact listing.
let mut table = Table::new(
"browse",
vec![
"name".into(),
"version".into(),
"size".into(),
"type".into(),
],
);
for e in &self.data.browse.entries {
table.push_row(vec![
e.name.clone(),
e.version.clone(),
e.size_bytes.to_string(),
e.content_type.clone(),
]);
}
table.ui(ui);
}
/// **Browse · source & paging — the INSPECTOR half.**
///
/// Which repository the listing came from, the refresh, the error line, and the
/// pagination footer. The footer is exactly the readout that used to sit UNDER the
/// table in one vertical flow: a long listing pushed "N shown / load more" off the
/// bottom of the pane and there was no divider to win it back. It is its own window
/// now. Returns the pane's observable state.
#[cfg(not(feature = "mikro"))]
fn browse_source(&mut self, ui: &mut Ui) -> Value {
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
ui.label(format!(
"browsing repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
// Mini: a server-side name filter (`ListArtifacts.name_filter`) — the data
// layer always supported it; this is the box that finally passes it.
#[cfg(feature = "mini")]
{
let resp = ui.add_enabled(
target.is_some(),
egui::TextEdit::singleline(&mut self.browse_filter).hint_text("🔎 filter by name"),
);
if resp.changed() {
if let Some(repo) = target.clone() {
let f = (!self.browse_filter.trim().is_empty())
.then(|| self.browse_filter.trim().to_string());
self.data.refresh_browse(&repo, f);
}
}
}
let do_refresh = ui
.add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
.clicked();
if do_refresh {
if let Some(repo) = target {
#[cfg(feature = "mini")]
let f = (!self.browse_filter.trim().is_empty())
.then(|| self.browse_filter.trim().to_string());
#[cfg(not(feature = "mini"))]
let f = None;
self.data.refresh_browse(&repo, f);
}
}
ui.separator();
if let Some(err) = &self.data.browse.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
// Pagination: count + a load-more button when the listing has a continuation
// token.
let loaded = self.data.browse.entries.len();
let has_more = !self.data.browse.next_page_token.is_empty();
ui.label(format!(
"{loaded} shown{}",
if has_more { " (more available)" } else { "" }
));
if has_more && ui.button("load more").clicked() {
self.data.load_more_browse();
}
json!({
"repository": self.data.browse.repository,
"shown": loaded,
"has_more": has_more,
"error": self.data.browse.error,
})
}
/// **Archive · files — the MASTER half.** The raw archive entry list, alone.
///
/// This is the body Rickard's concrete case describes: the STATS readout (files /
/// uncompressed bytes / archive path) sat ABOVE the file table in one vertical flow,
/// so as it grew it pushed the table down and off the bottom, with no divider to
/// adjust. The two are now separate windows with a draggable divider between them.
#[cfg(not(feature = "mikro"))]
fn archive_files(&mut self, ui: &mut Ui) {
// Snapshot the selected repo name to a local String before mutating
// `self.data` (refresh_archive borrows it mutably).
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
// Auto-load on first view / repo change (same as Browse — was manual-only).
if let Some(repo) = &target {
if self.data.archive.repository != *repo {
#[cfg(feature = "mini")]
let prefix = (!self.archive_prefix.trim().is_empty())
.then(|| self.archive_prefix.trim().to_string());
#[cfg(not(feature = "mini"))]
let prefix = None;
self.data.refresh_archive(repo, prefix);
}
}
// facett Table facet for the raw archive file paths (single column).
let mut table = Table::new("archive", vec!["path".into()]);
for path in &self.data.archive.files {
table.push_row(vec![path.clone()]);
}
table.ui(ui);
}
/// **Archive · stats — the INSPECTOR half.** The archive's own readout: which repo,
/// refresh, errors, and the file-count / byte-count / archive-path grid. Returns the
/// pane's observable state.
#[cfg(not(feature = "mikro"))]
fn archive_stats(&mut self, ui: &mut Ui) -> Value {
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
ui.label(format!(
"archive of repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
// Mini: a server-side path-prefix filter (`ListArchiveFiles.prefix`) — the
// data layer always supported it; this is the box that finally passes it.
#[cfg(feature = "mini")]
{
let resp = ui.add_enabled(
target.is_some(),
egui::TextEdit::singleline(&mut self.archive_prefix).hint_text("🔎 path prefix"),
);
if resp.changed() {
if let Some(repo) = target.clone() {
let p = (!self.archive_prefix.trim().is_empty())
.then(|| self.archive_prefix.trim().to_string());
self.data.refresh_archive(&repo, p);
}
}
}
let do_refresh = ui
.add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
.clicked();
if do_refresh {
if let Some(repo) = target {
#[cfg(feature = "mini")]
let p = (!self.archive_prefix.trim().is_empty())
.then(|| self.archive_prefix.trim().to_string());
#[cfg(not(feature = "mini"))]
let p = None;
self.data.refresh_archive(&repo, p);
}
}
ui.separator();
if let Some(err) = &self.data.archive.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
// Archive stats (file count / total uncompressed bytes / archive name).
let a = &self.data.archive;
egui::Grid::new("archive_stats").striped(true).show(ui, |ui| {
ui.label("files");
ui.label(a.file_count.to_string());
ui.end_row();
ui.label("uncompressed bytes");
ui.label(a.total_uncompressed_bytes.to_string());
ui.end_row();
ui.label("archive");
ui.label(if a.archive_path.is_empty() {
"—"
} else {
&a.archive_path
});
ui.end_row();
});
json!({
"repository": a.repository,
"files": a.file_count,
"uncompressed_bytes": a.total_uncompressed_bytes,
"archive": a.archive_path,
"error": a.error,
})
}
/// **Artifact · query — the MASTER half.** The artifact COORDINATE (repository /
/// namespace / name / version) and the fetch that resolves it.
fn artifact_query(&mut self, ui: &mut Ui) {
egui::Grid::new("artifact_inputs").show(ui, |ui| {
ui.label("repository");
ui.text_edit_singleline(&mut self.repo);
ui.end_row();
ui.label("namespace");
ui.text_edit_singleline(&mut self.namespace);
ui.end_row();
ui.label("name");
ui.text_edit_singleline(&mut self.name);
ui.end_row();
ui.label("version");
ui.text_edit_singleline(&mut self.version);
ui.end_row();
});
if ui.button("fetch").clicked() {
let id = ArtifactId {
namespace: if self.namespace.is_empty() {
None
} else {
Some(self.namespace.clone())
},
name: self.name.clone(),
version: self.version.clone(),
};
let repo = self.repo.clone();
self.data.fetch_artifact(&repo, id);
}
}
/// **Artifact · metadata — the INSPECTOR half.** What the fetch RESOLVED TO: the
/// verdict line, and the resolved artifact's metadata as a grid. Splitting it out is
/// what lets the metadata grow (SBOM rows, checksums) without ever pushing the query
/// form off the top of the pane — the divider decides who gives, not the flow.
fn artifact_metadata(&mut self, ui: &mut Ui) -> Value {
let a = &self.data.artifact;
if let Some(err) = &a.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
} else if a.repository.is_empty() {
ui.label("enter an artifact id and fetch");
} else if a.found {
// The long summary line repeated every field of the grid right below it —
// mini keeps just the verdict and lets the grid carry the resolved facts.
#[cfg(feature = "mini")]
ui.label("✔ found");
#[cfg(not(feature = "mini"))]
ui.label(format!(
"✔ {} / {} {} — {} bytes ({})",
a.repository, a.name, a.version, a.size_bytes, a.content_type
));
ui.separator();
egui::Grid::new("artifact_metadata").num_columns(2).striped(true).show(ui, |ui| {
ui.label("repository");
ui.label(&a.repository);
ui.end_row();
ui.label("name");
ui.label(&a.name);
ui.end_row();
ui.label("version");
ui.label(&a.version);
ui.end_row();
ui.label("size (bytes)");
ui.label(a.size_bytes.to_string());
ui.end_row();
ui.label("content type");
ui.label(&a.content_type);
ui.end_row();
});
} else {
ui.colored_label(egui::Color32::YELLOW, "not found");
}
// The read-tier extras, present in EVERY tier (the operator UI included) — ⬇
// save (the bytes are already in memory from the fetch), the coordinate's
// custom properties (auto-loaded with the fetch), and the hosted SBOM on
// demand. These were mini-only, so the shipped full operator UI was missing
// the whole SBOM / property / download surface; an operator inspects an
// artifact's SBOM + properties and downloads it just like the mini client.
if self.data.artifact.found {
ui.separator();
if self.save_path.is_empty() {
self.save_path = default_save_path(&self.data.artifact);
}
ui.label("save to");
ui.text_edit_singleline(&mut self.save_path);
if ui.button("⬇ Save artifact").clicked() {
let path = self.save_path.clone();
self.data.save_artifact(&path);
}
if let Some(p) = self.data.artifact.saved_to.clone() {
ui.colored_label(egui::Color32::GREEN, format!("saved: {p}"));
}
if let Some(e) = self.data.artifact.save_error.clone() {
ui.colored_label(egui::Color32::RED, e);
}
let props = self.data.artifact.properties.clone();
if !props.is_empty() {
ui.separator();
ui.label("properties");
egui::Grid::new("artifact_properties").num_columns(2).striped(true).show(ui, |ui| {
for p in &props {
ui.label(&p.key);
ui.label(p.values.join(" · "));
ui.end_row();
}
});
}
ui.separator();
if ui.button("📄 Fetch SBOM").clicked() {
self.data.fetch_sbom_current();
}
if let Some(sbom) = self.data.artifact.sbom.clone() {
ui.label(format!("SBOM: {} bytes · {}", sbom.size_bytes, sbom.content_address));
egui::ScrollArea::vertical()
.id_salt("sbom-preview")
.max_height(160.0)
.show(ui, |ui| {
ui.label(RichText::new(sbom.preview).monospace().small());
});
} else if let Some(e) = self.data.artifact.sbom_error.clone() {
ui.weak(e);
}
}
self.data.artifact.state_json()
}
/// **Upload · form — the MASTER half.** The artifact id + file path, and the upload
/// itself. The write GATE and its outcome live in the inspector beside it.
#[cfg(not(feature = "mini"))]
fn upload_form(&mut self, ui: &mut Ui) {
let writable = self.data.repos.upload_enabled();
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
egui::Grid::new("upload_inputs").show(ui, |ui| {
ui.label("name");
ui.text_edit_singleline(&mut self.name);
ui.end_row();
ui.label("version");
ui.text_edit_singleline(&mut self.version);
ui.end_row();
ui.label("file path");
ui.text_edit_singleline(&mut self.upload_path);
ui.end_row();
});
let do_upload = ui
.add_enabled(writable, egui::Button::new("⬆ Upload artifact"))
.clicked();
if do_upload {
if let Some(repo) = target {
let mut outcome = UploadOutcome { attempted: true, repo: Some(repo.clone()), ..Default::default() };
match std::fs::read(&self.upload_path) {
Ok(bytes) => {
let id = ArtifactId {
namespace: None,
name: self.name.clone(),
version: self.version.clone(),
};
self.notice = Some(match self.data.put_artifact(&repo, &id, &bytes) {
Ok(()) => {
outcome.ok = Some(true);
outcome.bytes = Some(bytes.len() as u64);
format!("uploaded {} bytes to {repo}", bytes.len())
}
Err(e) => {
outcome.ok = Some(false);
outcome.stage = Some("put");
outcome.error = Some(e.to_string());
format!("upload failed: {e}")
}
});
}
Err(e) => {
outcome.ok = Some(false);
outcome.stage = Some("read");
outcome.error = Some(e.to_string());
self.notice = Some(format!("read failed: {e}"));
}
}
self.upload_outcome = outcome;
}
}
}
/// Robot/test hook: set the **upload source file path** (the Upload form's "file
/// path" box), so a headless drive can point the upload at a real (or deliberately
/// missing) file and then click the real `upload` button. The write's outcome
/// lands on `state_json["upload"]` — see [`UploadOutcome`].
#[cfg(not(feature = "mini"))]
pub fn set_upload_path(&mut self, path: &str) {
self.upload_path = path.to_string();
}
/// Robot/test hook: set the artifact **name + version** the Upload form writes
/// under (the id's coordinate). The target repo comes from the Repos selection
/// (`select_repo`); the source bytes from [`set_upload_path`](Self::set_upload_path).
#[cfg(not(feature = "mini"))]
pub fn set_upload_coord(&mut self, name: &str, version: &str) {
self.name = name.to_string();
self.version = version.to_string();
}
/// **Upload · target — the INSPECTOR half.** The write GATE: which repository the
/// upload lands in, whether it is writable, and the outcome notice of the last
/// attempt. The notice used to be appended to the bottom of the form, so a long
/// error message pushed the form's own controls up and out of reach; it is its own
/// window now. Returns the pane's observable state.
#[cfg(not(feature = "mini"))]
fn upload_target(&mut self, ui: &mut Ui) -> Value {
let writable = self.data.repos.upload_enabled();
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
ui.label(format!(
"target repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
if !writable {
ui.colored_label(
egui::Color32::YELLOW,
"selected repo is read-only — pick a writable repo on the Repos tab",
);
}
if let Some(n) = &self.notice {
ui.separator();
ui.label(n);
}
json!({
"writable": writable,
"target": target,
"notice": self.notice,
// The slot mirrors the top-level outcome so the pane's OWN state_json is
// not "writable"-only (LAW #6): the write result is a value here too.
"outcome": self.upload_outcome.state_json(),
})
}
/// The **Sign in** tab — mounts the ONE shared `facett_auth_ui::LoginPane` (LAW #5),
/// driven by holger's own [`HolgerLoginSeam`] over the shared `nornir-auth-client`
/// device-code grant. The pane paints its own state machine (Sign in → device-code
/// prompt → signed-in subject + credential NAMES → Sign out); holger holds no bespoke
/// login chrome here. The pane auto-polls to completion while awaiting the user, so no
/// per-frame host driving is needed — this just renders it through the `Facet::ui` seam.
#[cfg(not(feature = "mini"))]
fn signin_tab(&mut self, ui: &mut Ui) {
self.login_pane.ui(ui);
}
/// **Tools · functions — the MASTER half.** The operator's ACTION LIST: one entry per
/// tool, stacked in a narrow column beside the output.
///
/// The `ui.heading("Tools")` this pane used to open with is gone on purpose: the pane
/// now wears a chrome title bar carrying its own name, and a heading reading exactly
/// `Tools` would be a second AccessKit node with the same label as the TAB header —
/// which makes `query_by_label("Tools")` ambiguous and both nodes unreachable.
///
/// The entries stack VERTICALLY (they were a `horizontal_wrapped` row): a wrapped row
/// of six buttons in a 26%-wide master column is an unreadable ragged block, and a
/// list is what an action list should be anyway.
#[cfg(not(feature = "mini"))]
fn tools_functions(&mut self, ui: &mut Ui) -> Value {
ui.label("Operator tools — a mirror of the holger CLI, with facett views.");
ui.separator();
{
let ui = &mut *ui;
// Tool #1 holger-ds publishes+fetches a synthetic corpus — a WRITE,
// so it is gated in static mode exactly like #2-#5 (was ungated).
let h = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::HolgerDs, "1 · holger-ds"),
);
if h.clicked() {
self.tool = ToolsFn::HolgerDs;
}
let m = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::Migrate, "2 · migrate / suck"),
);
if m.clicked() {
self.tool = ToolsFn::Migrate;
}
let s = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::Security, "3 · security · Móðguðr"),
);
if s.clicked() {
self.tool = ToolsFn::Security;
}
let d = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::Deploy, "4 · deploy · Skíðblaðnir"),
);
if d.clicked() {
self.tool = ToolsFn::Deploy;
}
// Tool #5 — Airgap populate. Read-path-safe to *plan*; the actual
// build/push is a write, so the button is disabled in static mode.
let g = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::Airgap, "5 · airgap populate"),
);
if g.clicked() {
self.tool = ToolsFn::Airgap;
}
// Tool #6 — the shared-framework demo appliance (start a demo registry
// container THROUGH jera). Declaring + dispatching is read-path-safe, but
// it stands up infra, so it is gated in static read-only mode like #2-#5.
let dm = ui.add_enabled(
!self.static_mode,
egui::Button::selectable(self.tool == ToolsFn::Demo, "6 · demo appliance"),
);
if dm.clicked() {
self.tool = ToolsFn::Demo;
}
}
let (pane, ok, detail) = self.tools_pane_report();
json!({ "pane": pane, "ok": ok, "detail": detail })
}
/// **Tools · output — the INSPECTOR half.** The selected tool's whole surface: its
/// form, its run button and its result. It used to sit BELOW the button row in one
/// flow, so a long benchmark / scan / deploy result grew downward until it ran off the
/// bottom of the pane with nothing to drag. Now it is a window with a divider.
#[cfg(not(feature = "mini"))]
fn tools_output(&mut self, ui: &mut Ui) -> Value {
let red = egui::Color32::from_rgb(255, 120, 120);
match self.tool {
ToolsFn::HolgerDs => {
ui.heading("holger-ds — artifact-delivery benchmark");
ui.label("Scalable synthetic publish+fetch throughput, measured over the REAL package protocol.");
ui.label("Targets: this holger (self-host) · or an EXTERNAL Nexus/Artifactory base URL (resourced fairly).");
let ds = &mut self.data.holger_ds;
ui.horizontal(|ui| {
ui.label("scale:");
for s in ["micro", "small", "large"] {
if ui.selectable_label(ds.scale == s, s).clicked() {
ds.scale = s.to_string();
}
}
});
ui.horizontal(|ui| {
ui.label("external target (blank = self-host):");
ui.text_edit_singleline(&mut ds.target);
});
ui.separator();
ui.label(RichText::new(ds.cli_command()).monospace());
// Actually run the benchmark over the package protocol. micro is
// inline + quick; the result lands in the pane + state_json.
// It publishes a synthetic corpus (a write), so it is disabled in
// static read-only mode.
// The RUN button exists only where the driver does. It stands up a
// real holger HTTP gateway IN THIS PROCESS, so it rides on `bench`
// (⇒ `gui-embedded`); the thin client still shows the pane, the
// config and the exact CLI command to run it elsewhere.
#[cfg(not(feature = "bench"))]
ui.weak(
"run it with the command above, or with the `holger-server-ui-embedded` \
build — this client links no in-process gateway",
);
#[cfg(feature = "bench")]
let run = ui
.add_enabled(
!self.static_mode,
egui::Button::new("Run benchmark… (over the package protocol)"),
)
.on_hover_text("Builds a synthetic crate corpus and bombards Holger's native HTTP door — measured over the real package protocol.")
.on_disabled_hover_text("disabled in static read-only mode (publish is a write)");
#[cfg(feature = "bench")]
if run.clicked() {
self.data.run_holger_ds();
let ok = self
.data
.holger_ds
.result
.as_ref()
.map(|r| r.error.is_none())
.unwrap_or(false);
functional_status(
"holger-server-ui/tools/holger-ds",
"benchmark_ran",
ok,
&self.data.holger_ds.scale,
);
}
let ds = &self.data.holger_ds;
if !ds.status.is_empty() {
ui.separator();
if let Some(res) = &ds.result {
if let Some(err) = &res.error {
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), format!("error: {err}"));
} else {
ui.colored_label(
egui::Color32::from_rgb(90, 220, 140),
RichText::new(format!("● {:.1} MB/s · {:.0} ops/s", res.mbs, res.ops_per_sec)).strong(),
);
ui.label(format!(
"scale {} · target {} · {} files / {} crates · {} ops",
res.scale, res.target, res.files, res.crates, res.ops
));
}
} else {
ui.label(&ds.status);
}
}
}
ToolsFn::Migrate => {
ui.heading("Migrate — \"suck\" Nexus / Artifactory → znippy");
ui.label("Pulls an upstream registry into one or many znippy archives. Owned by the holger agent (single home).");
if self.static_mode {
ui.colored_label(red, "disabled in static mode");
} else {
let m = &mut self.data.migrate;
ui.horizontal(|ui| {
ui.label("source:");
ui.selectable_value(&mut m.source, "nexus".to_string(), "nexus");
ui.selectable_value(&mut m.source, "artifactory".to_string(), "artifactory");
});
egui::Grid::new("migrate_inputs").show(ui, |ui| {
ui.label("registry url");
ui.text_edit_singleline(&mut m.url);
ui.end_row();
ui.label("output .znippy");
ui.text_edit_singleline(&mut m.output);
ui.end_row();
ui.label("repository (blank = all)");
ui.text_edit_singleline(&mut m.repository);
ui.end_row();
});
ui.separator();
ui.label(RichText::new(m.cli_command()).monospace());
// Actually invoke the agent connector (nexus/artifactory →
// znippy) and report the outcome in the pane.
let run = ui
.button("Run migrate… (via holger-agent)")
.on_hover_text("Lists the upstream registry's repositories and sucks them into the output .znippy via the holger agent connector.");
if run.clicked() {
self.data.run_migrate();
let ok = self
.data
.migrate
.result
.as_ref()
.map(|r| r.error.is_none() && r.ran)
.unwrap_or(false);
functional_status(
"holger-server-ui/tools/migrate",
"suck_ran",
ok,
&self.data.migrate.source,
);
}
let m = &self.data.migrate;
if !m.status.is_empty() {
ui.separator();
match &m.result {
Some(r) if r.error.is_some() => {
ui.colored_label(red, format!("error: {}", r.error.as_deref().unwrap_or_default()));
}
Some(r) if r.ran => {
ui.colored_label(
egui::Color32::from_rgb(90, 220, 140),
RichText::new(format!("● migrated {} repositorie(s) → {}", r.repositories, r.output)).strong(),
);
}
_ => {
ui.label(&m.status);
}
}
}
}
}
ToolsFn::Security => {
ui.heading("Security — Móðguðr");
ui.label("Vuln / license / provenance verdict (Engine A). Maps the selected repo's archive listing to modgunn and folds per-artifact verdicts into a Pass/Warn/Block report.");
if self.static_mode {
ui.colored_label(red, "not available in static (validate-then-switch only)");
} else {
let target = self
.data
.repos
.selected_repo()
.map(|r| (r.name.clone(), r.repo_type.clone()));
ui.label(format!(
"target repo: {}",
target.as_ref().map(|(n, _)| n.as_str()).unwrap_or("(none selected)")
));
let do_scan = ui
.add_enabled(target.is_some(), egui::Button::new("Scan selected repo"))
.clicked();
if do_scan {
if let Some((name, ty)) = target {
self.data.run_security_scan(&name, &ty);
functional_status(
"holger-server-ui/tools/security",
"scan_ran",
self.data.security.error.is_none() && self.data.security.loaded,
&name,
);
}
}
ui.separator();
let s = &self.data.security;
if let Some(err) = &s.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
} else if s.loaded {
let (label, color) = match s.decision.as_str() {
"block" => ("● BLOCK", egui::Color32::from_rgb(255, 80, 80)),
"warn" => ("● WARN", egui::Color32::from_rgb(240, 200, 90)),
_ => ("● PASS", egui::Color32::from_rgb(90, 220, 140)),
};
ui.colored_label(color, RichText::new(label).strong());
ui.label(format!(
"ecosystem {} · scanned {} · pass {} · warn {} · block {} · skipped {}",
s.ecosystem, s.scanned, s.pass, s.warn, s.block, s.skipped
));
if !s.source.is_empty() {
ui.label(RichText::new(format!("advisories: {}", s.source)).weak());
}
// Móðguðr view mode: the verdict table, or the dependency
// graph (every component as a node coloured by verdict).
ui.horizontal(|ui| {
ui.selectable_value(&mut self.security_graph, false, "Table");
ui.selectable_value(&mut self.security_graph, true, "Graph");
});
if self.security_graph {
render_security_graph(&self.data.dep_graph, ui);
} else {
let mut table = Table::new(
"security_findings",
vec!["name".into(), "version".into(), "decision".into(), "advisories".into()],
);
for f in &s.findings {
table.push_row(vec![
f.name.clone(),
f.version.clone(),
f.decision.clone(),
f.ids.join(", "),
]);
}
table.ui(ui);
}
} else {
ui.label("pick a repo on the Repos tab, then Scan.");
}
}
}
ToolsFn::Deploy => {
use holger_airgap::Target;
ui.heading("Deploy — Skíðblaðnir");
ui.label("Deploy the SELECTED repo's sealed znippy bundle to a test target via the airgap engine (push-only carry of the already-sealed archive).");
if self.static_mode {
ui.colored_label(red, "disabled in static mode");
} else {
// The bundle to deploy is the selected repo's sealed archive.
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
ui.label(format!(
"bundle repo: {}",
target.as_deref().unwrap_or("(none selected)")
));
let dep = &mut self.data.deploy;
ui.horizontal(|ui| {
ui.label("target:");
ui.selectable_value(&mut dep.target, Target::Holger, "holger");
ui.selectable_value(&mut dep.target, Target::Nexus, "nexus");
ui.selectable_value(&mut dep.target, Target::Artifactory, "artifactory");
});
let tgt = self.data.deploy.target;
let do_deploy = ui
.add_enabled(target.is_some(), egui::Button::new("Deploy to test…"))
.on_hover_text("Reads the selected repo's sealed znippy bundle (archive_info) and plans an airgap push-only deploy to the chosen test target.")
.clicked();
if do_deploy {
if let Some(name) = target {
self.data.run_deploy(&name, tgt);
let ok = self
.data
.deploy
.result
.as_ref()
.map(|r| r.error.is_none() && r.ran)
.unwrap_or(false);
functional_status(
"holger-server-ui/tools/deploy",
"deploy_planned",
ok,
&name,
);
}
}
let dep = &self.data.deploy;
if !dep.status.is_empty() {
ui.separator();
match &dep.result {
Some(r) if r.error.is_some() => {
ui.colored_label(red, format!("error: {}", r.error.as_deref().unwrap_or_default()));
}
Some(r) if r.ran => {
ui.colored_label(
egui::Color32::from_rgb(90, 220, 140),
RichText::new(format!("● deploy planned · {} files → {}", r.file_count, r.target)).strong(),
);
ui.label(RichText::new(&r.plan).monospace());
}
_ => {
ui.label(&dep.status);
}
}
}
}
}
ToolsFn::Airgap => {
use holger_airgap::{Auth, Scale, Stack, Target};
ui.heading("Airgap populate");
ui.label("Build→carry→push a software stack into the target via the documented populate driver. Plan is pure Rust; execution delegates to distribution/airgap/populate.");
if self.static_mode {
ui.colored_label(red, "disabled in static mode");
} else {
// Reuse the session's connect-time mTLS identity for an
// auth=mtls push (so the operator isn't told to re-supply certs).
let sess_cert = self.session_client_cert.clone();
let sess_key = self.session_client_key.clone();
let ag = &mut self.data.airgap;
ui.horizontal(|ui| {
ui.label("scale:");
ui.selectable_value(&mut ag.scale, Scale::Micro, "micro");
ui.selectable_value(&mut ag.scale, Scale::Small, "small");
ui.selectable_value(&mut ag.scale, Scale::Large, "large");
});
ui.horizontal(|ui| {
ui.label("stack:");
ui.selectable_value(&mut ag.stack, None, "preset");
for s in [Stack::Airflow, Stack::Cargo, Stack::Npm, Stack::Maven, Stack::Docker, Stack::All] {
ui.selectable_value(&mut ag.stack, Some(s), s.as_arg());
}
});
ui.horizontal(|ui| {
ui.label("target:");
ui.selectable_value(&mut ag.target, Target::Holger, "holger");
ui.selectable_value(&mut ag.target, Target::Nexus, "nexus");
ui.selectable_value(&mut ag.target, Target::Artifactory, "artifactory");
});
ui.horizontal(|ui| {
ui.label("auth:");
ui.selectable_value(&mut ag.auth, Auth::Mtls, "mtls");
ui.selectable_value(&mut ag.auth, Auth::Oidc, "oidc");
});
ui.horizontal(|ui| {
ui.checkbox(&mut ag.dry_run, "dry-run (plan only)")
.on_hover_text("Plan only — no build/push. Untick to actually populate (needs creds).");
});
// Feed the session's client cert/key into an mTLS push so it
// doesn't fail "needs --client-cert and --client-key". If none
// were supplied at connect-time, validate() surfaces a clear
// message below (unless dry-run, which needs no creds).
if ag.auth == Auth::Mtls && !ag.dry_run {
if ag.client_cert.is_none() {
ag.client_cert = sess_cert;
}
if ag.client_key.is_none() {
ag.client_key = sess_key;
}
}
ui.separator();
// The pure-Rust plan (the dry-run surface) + clear validation.
// Compute owned values so the `ag` mutable borrow is released
// before the run button calls back into `self.data`.
let plan_or_err: Result<String, String> = match ag.validate() {
Ok(()) => Ok(ag.plan()),
Err(e) => Err(e.to_string()),
};
let dry = ag.dry_run;
match plan_or_err {
Ok(plan) => {
ui.label(RichText::new(plan).monospace());
let label = if dry {
"Run populate… (dry-run plan)"
} else {
"Run populate… (delegates to driver)"
};
if ui.button(label).clicked() {
self.data.run_airgap();
let ok = self
.data
.airgap_run
.as_ref()
.map(|r| r.error.is_none() && r.success)
.unwrap_or(false);
functional_status(
"holger-server-ui/tools/airgap",
"populate_ran",
ok,
if dry { "dry-run" } else { "execute" },
);
}
}
Err(e) => {
ui.colored_label(red, format!("cannot run: {e}"));
ui.label("tick dry-run to plan without creds, or connect the UI with --cert/--key for an mTLS push.");
}
}
// Surface the last run's result/plan in the pane.
if let Some(run) = &self.data.airgap_run {
ui.separator();
if let Some(err) = &run.error {
ui.colored_label(red, format!("run error: {err}"));
} else {
let color = if run.success {
egui::Color32::from_rgb(90, 220, 140)
} else {
egui::Color32::from_rgb(240, 200, 90)
};
ui.colored_label(color, RichText::new(format!("● {}", run.status)).strong());
if !run.log.is_empty() {
ui.label(RichText::new(&run.log).monospace());
}
}
}
}
}
ToolsFn::Demo => {
ui.heading("Demo appliance — start a demo registry (via jera)");
ui.label(
"Stands up a pinned demo artifact registry THROUGH the shared job manager jera \
(a durable BOOT_CONTAINER job, routed through draupnir's OCI engine) — the same \
Tools-menu container-demo pattern korp uses. holger can then proxy / migrate from it.",
);
if self.static_mode {
ui.colored_label(red, "disabled in static mode");
} else {
let spec = crate::infra::demo_registry_spec();
egui::Grid::new("demo_infra").striped(true).show(ui, |ui| {
ui.label("image");
ui.label(RichText::new(&spec.image).monospace());
ui.end_row();
ui.label("name");
ui.label(RichText::new(&spec.name).monospace());
ui.end_row();
ui.label("port");
ui.label(spec.ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
ui.end_row();
ui.label("url (once up)");
ui.label(RichText::new(crate::infra::demo_url()).monospace());
ui.end_row();
});
ui.separator();
let run = ui
.button("Start demo appliance… (as a jera job)")
.on_hover_text(
"Records the container bring-up as a durable jera BOOT_CONTAINER job. \
Build with --features containers to actually boot it through draupnir's OCI engine.",
);
if run.clicked() {
self.data.run_demo();
let ok = self
.data
.demo_appliance
.as_ref()
.map(|d| !d.job_id.is_empty() && d.error.is_none())
.unwrap_or(false);
functional_status(
"holger-server-ui/tools/demo",
"demo_dispatched",
ok,
&spec.image,
);
}
if let Some(d) = &self.data.demo_appliance {
ui.separator();
if let Some(err) = &d.error {
ui.colored_label(red, format!("dispatch error: {err}"));
} else {
ui.colored_label(
egui::Color32::from_rgb(90, 220, 140),
RichText::new(format!("● {} · jera job {}", d.status, d.job_id)).strong(),
);
ui.label(format!("{} → {}", d.image, d.url));
}
}
}
}
}
// 🧪 Render-time introspection — the selected Tools pane self-reports that
// it drew a usable operator surface (`ok=true`), or is blank/disabled in
// static read-only mode ("RED on empty"). Fires each frame the pane is
// shown so `nornir test --features testmatrix` SEES all five Tools surfaces
// without a human, mirroring the gRPC handlers' server-side markers. Gated
// whole (not just the emit) so a plain `gui` build allocates nothing.
#[cfg(feature = "testmatrix")]
{
let (pane, ok, detail) = self.tools_pane_report();
functional_status(&format!("holger-server-ui/tools/{pane}"), "pane_rendered", ok, &detail);
}
let (pane, ok, detail) = self.tools_pane_report();
json!({ "pane": pane, "ok": ok, "detail": detail })
}
/// Render-time introspection for the currently selected Tools pane: its slug,
/// whether it drew a usable operator surface (`ok`), and a one-line detail.
/// The four write-tools (#2 migrate · #3 security · #4 deploy · #5 airgap) blank
/// to a disabled notice under static read-only mode, so they self-report **RED
/// on empty** there; #1 holger-ds always draws its config. Consumed by both the
/// render-time emitter above and `state_json.tools_pane` (the robot's oracle).
#[cfg(not(feature = "mini"))]
fn tools_pane_report(&self) -> (&'static str, bool, String) {
let live = !self.static_mode;
let gated = |detail: String| if live { detail } else { "static-disabled".to_string() };
match self.tool {
ToolsFn::HolgerDs => {
("holger-ds", true, format!("scale={}", self.data.holger_ds.scale))
}
ToolsFn::Migrate => {
("migrate", live, gated(format!("source={}", self.data.migrate.source)))
}
ToolsFn::Security => {
let repo = self
.data
.repos
.selected_repo()
.map(|r| format!("repo={}", r.name))
.unwrap_or_else(|| "no-repo".to_string());
("security", live, gated(repo))
}
ToolsFn::Deploy => {
("deploy", live, gated(format!("target={}", self.data.deploy.target.as_arg())))
}
ToolsFn::Airgap => {
("airgap", live, gated(format!("target={}", self.data.airgap.target.as_arg())))
}
ToolsFn::Demo => {
("demo", live, gated("demo-registry".to_string()))
}
}
}
/// The current graph data signature — `(repo_count, selected repo, artifact
/// file count for the selected repo)`. The graph rebuilds when this changes.
#[cfg(not(feature = "mini"))]
fn graph_data_sig(&self) -> (usize, Option<usize>, usize) {
let n = self.data.repos.repos.len();
let sel = self.data.repos.selected;
// Artifact nodes come from the selected repo's *already-loaded* archive
// listing (no new gRPC call) — count them so a refresh re-grows the graph.
let sel_name = self.data.repos.selected_repo().map(|r| r.name.as_str());
let arch_n = if sel_name.is_some() && sel_name == Some(self.data.archive.repository.as_str())
{
self.data.archive.files.len()
} else {
0
};
(n, sel, arch_n)
}
/// Build the holger graph: server → repositories → (artifacts / upstreams).
/// Nodes are coloured by [`GraphNodeKind`] (the HOLGER NEON PALETTE); edges are
/// server→repo, repo→artifact, repo→upstream. Returns the graph plus a parallel
/// vec of node-kind labels for `state_json`.
#[cfg(not(feature = "mini"))]
fn build_graph(&self) -> (Graph3D, Vec<&'static str>) {
let mut nodes: Vec<(String, egui::Color32)> = Vec::new();
let mut kinds: Vec<&'static str> = Vec::new();
let mut edges: Vec<(usize, usize)> = Vec::new();
// Node 0: the holger server (the anchor).
nodes.push(("holger".into(), GraphNodeKind::Server.color()));
kinds.push(GraphNodeKind::Server.label());
// Repositories (indices 1..=repo_count), each edged from the server.
let repos = &self.data.repos.repos;
for (i, r) in repos.iter().enumerate() {
let kind = repo_node_kind(r);
nodes.push((r.name.clone(), kind.color()));
kinds.push(kind.label());
edges.push((0, i + 1));
}
// Upstream/proxy target nodes for every proxy-flavoured repository.
for (i, r) in repos.iter().enumerate() {
if repo_node_kind(r) == GraphNodeKind::Upstream {
let up = nodes.len();
nodes.push((format!("{}↑upstream", r.name), GraphNodeKind::Upstream.color()));
kinds.push(GraphNodeKind::Upstream.label());
edges.push((i + 1, up));
}
}
// Artifact nodes for the *selected* repo from its already-loaded archive
// listing (LAW 2: real data when present; no new gRPC call). Each archive
// file becomes a gold→lime artifact node edged from its repository.
if let Some(sel) = self.data.repos.selected {
let sel_name = self.data.repos.selected_repo().map(|r| r.name.as_str());
if sel_name.is_some() && sel_name == Some(self.data.archive.repository.as_str()) {
for path in &self.data.archive.files {
let art = nodes.len();
nodes.push((path.clone(), GraphNodeKind::Artifact.color()));
kinds.push(GraphNodeKind::Artifact.label());
edges.push((sel + 1, art));
}
}
}
// `with_decor(DecorPolicy::Full)` turns ON the beauty: neon glow + click
// shockwave + subgraph lighting/dim, all from the facett kernel (no
// hand-rolled widgets). Under the `device` theme the operator can still
// dial it down; the explicit override keeps the sellpoint gorgeous here.
let mut g = Graph3D::new(nodes, edges, Layout3D::Force)
.with_title("holger")
.with_decor(DecorPolicy::Full);
g.layout();
(g, kinds)
}
/// Feed the graph a node selection (the same effect a click in the 3D view
/// has) — lights that node's subgraph and dims the rest. Used by keyboard /
/// programmatic selection and by the robot-UI test. No-op if no graph is built
/// or the index is out of range.
#[cfg(not(feature = "mini"))]
pub fn select_graph_node(&mut self, idx: usize) {
if let Some(g) = &mut self.graph {
g.select(Some(idx));
}
}
/// The graph's observable state for `state_json`: the selected node + its lit
/// subgraph (highlighted neighbours), plus the node-kind palette mapping —
/// asserted as DATA by the robot. `null` when the Graph tab hasn't built one.
#[cfg(not(feature = "mini"))]
fn graph_state_json(&self) -> Value {
let Some(g) = &self.graph else {
return Value::Null;
};
let inner = g.state_json();
let sel = inner["selected_node"].as_u64().map(|i| i as usize);
let sel_label = sel.and_then(|i| g.nodes.get(i)).map(|n| n.label.clone());
let sel_kind = sel.and_then(|i| self.graph_kinds.get(i)).copied();
let neigh_len = inner["highlighted_neighbours"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
json!({
"nodes": inner["nodes"],
"edges": inner["edges"],
"effects_policy": inner["effects_policy"],
"palette": inner["palette"],
"kinds": self.graph_kinds,
"selected_node": sel,
"selected_label": sel_label,
"selected_kind": sel_kind,
"highlighted_neighbours": inner["highlighted_neighbours"].clone(),
"highlighted_count": neigh_len,
"dimmed_alpha": inner["dimmed_alpha"],
})
}
/// The **Helix** tab's built [`HelixView`] as DATA — its `state_json` (title,
/// `coil_count`, `total_nodes`, `winds_forward`, per-coil node/edge counts and the
/// per-node kind/status) when the tab has wound its coil, else [`Value::Null`]. Mirrors
/// [`Self::graph_state_json`]: a robot asserts the time-helix DAG the operator sees
/// WITHOUT pixels — after a Móðguðr scan the coil winds the REAL scanned repo's
/// artifacts (title `holger · <repo>`, a `blocked` node per block verdict), so an
/// empty/mis-wired build goes RED, not green.
#[cfg(not(feature = "mini"))]
fn helix_state_json(&self) -> Value {
match &self.helix {
Some(h) => h.state_json(),
None => Value::Null,
}
}
/// The 3D holger graph (server → repositories → artifacts/upstreams),
/// force-directed, neon-lit. Click a repo node to light its subgraph.
/// **Graph · canvas — the MASTER half.** Nothing but the 3D graph.
///
/// The rebuild control and the caption used to sit in a row ABOVE the canvas inside
/// one flow, so the canvas could only ever be "the screen minus whatever chrome grew
/// above it". The canvas is a window now: drag the divider, or maximise it and the
/// inspector goes to zero area.
#[cfg(not(feature = "mini"))]
fn graph_canvas(&mut self, ui: &mut Ui) {
let sig = self.graph_data_sig();
if self.graph.is_none() || self.graph_sig != sig {
// Preserve the operator's current selection across a data-driven rebuild.
let prev_sel = self.graph.as_ref().and_then(|g| {
g.state_json()["selected_node"].as_u64().map(|i| i as usize)
});
let (mut g, kinds) = self.build_graph();
if let Some(s) = prev_sel {
if s < kinds.len() {
g.select(Some(s));
}
}
self.graph = Some(g);
self.graph_kinds = kinds;
self.graph_sig = sig;
}
let now = ui.input(|i| i.time);
if let Some(g) = &mut self.graph {
g.set_clock(now);
g.ui(ui);
}
ui.ctx().request_repaint();
}
/// **Graph · inspector — the INSPECTOR half.** The rebuild control, what the graph
/// IS, and the live selection: which node the operator lit, its kind, and how many
/// neighbours the click highlighted. Returns the graph's observable state (the same
/// object the app's `state_json` carries under `/graph`).
#[cfg(not(feature = "mini"))]
fn graph_inspector(&mut self, ui: &mut Ui) -> Value {
if ui.button("⟳ rebuild").clicked() {
self.graph = None;
}
ui.label("repo graph — neon 3D (holger → repos → artifacts/upstreams); click a node to light its subgraph");
ui.separator();
let st = self.graph_state_json();
if st.is_null() {
ui.weak("(the graph builds on the next frame)");
return st;
}
egui::Grid::new("graph_inspector_grid").num_columns(2).striped(true).show(ui, |ui| {
ui.label("nodes");
ui.label(st["nodes"].to_string());
ui.end_row();
ui.label("edges");
ui.label(st["edges"].to_string());
ui.end_row();
ui.label("selected");
ui.label(st["selected_label"].as_str().unwrap_or("—").to_string());
ui.end_row();
ui.label("kind");
ui.label(st["selected_kind"].as_str().unwrap_or("—").to_string());
ui.end_row();
ui.label("lit neighbours");
ui.label(st["highlighted_count"].to_string());
ui.end_row();
});
st
}
/// The Helix tab — the facett-helix **TIME-HELIX DAG view**: holger's
/// repository/artifact dependency DAG wound onto a coil that advances along a
/// synthesized time axis. Real data comes from the Móðguðr (Tools#3) scan's
/// [`DepGraphData`](crate::data::DepGraphData); with none loaded (or in the
/// hands-free demo tour) it falls back to `HelixView::demo()` so the tab is
/// never blank. The view is cached and rebuilt only when [`Self::helix_data_sig`]
/// changes (a fresh scan), preserving the traverse cursor between frames.
/// **Helix · canvas — the MASTER half.** The wound coil, alone.
#[cfg(not(feature = "mini"))]
fn helix_canvas(&mut self, ui: &mut Ui) {
let sig = self.helix_data_sig();
if self.helix.is_none() || self.helix_sig != sig {
self.helix = Some(self.build_helix());
self.helix_sig = sig;
}
let now = ui.input(|i| i.time);
if let Some(h) = &mut self.helix {
h.set_clock(now);
h.ui(ui);
}
ui.ctx().request_repaint();
}
/// **Helix · inspector — the INSPECTOR half.** The rebuild control, what the coil is
/// wound FROM, and the wound scene's own facts (coils / nodes / direction). Returns
/// the `HelixView`'s `state_json` (null until the coil is wound).
#[cfg(not(feature = "mini"))]
fn helix_inspector(&mut self, ui: &mut Ui) -> Value {
if ui.button("⟳ rebuild").clicked() {
self.helix = None;
}
ui.label(
"time-helix — the dependency DAG wound along a time axis; scan a repo on \
Tools · Móðguðr to wind its real graph (else a demo scene plays)",
);
ui.separator();
let st = self.helix_state_json();
if st.is_null() {
ui.weak("(no coil wound yet)");
return st;
}
egui::Grid::new("helix_inspector_grid").num_columns(2).striped(true).show(ui, |ui| {
ui.label("title");
ui.label(st["title"].as_str().unwrap_or("—").to_string());
ui.end_row();
ui.label("coils");
ui.label(st["coil_count"].to_string());
ui.end_row();
ui.label("nodes");
ui.label(st["total_nodes"].to_string());
ui.end_row();
ui.label("winds forward");
ui.label(st["winds_forward"].to_string());
ui.end_row();
});
st
}
/// The Helix data signature — `(demo?, dep-graph repository, node count, edge
/// count)`. The helix rebuilds when this changes (a Móðguðr scan lands a new
/// repo's dependency graph, or the demo tour toggles). Demo mode is folded in so
/// leaving/entering the hands-free tour re-seeds the scene.
#[cfg(not(feature = "mini"))]
fn helix_data_sig(&self) -> (bool, String, usize, usize) {
let g = &self.data.dep_graph;
(self.demo_running(), g.repository.clone(), g.nodes.len(), g.edges.len())
}
/// Build the [`HelixView`] from holger's data. Móðguðr's [`DepGraphData`] is a
/// single repository's scanned artifacts (+ optional warehouse-sourced dep
/// edges) but carries **no per-version timestamp**, so a time axis is
/// synthesized from node order: the repository root is a `Crate` at `t = 0` and
/// each artifact is a `Task` at `t = index + 1`, laned by its verdict
/// (pass/warn/block). Warehouse edges become intra-coil deps; artifacts with no
/// edge are hung off the root so the coil is one connected structure. Advisory
/// ids ride in as `Cve` events. With no scan loaded (or during the demo tour) it
/// returns the self-contained `HelixView::demo()` multi-coil scene.
#[cfg(not(feature = "mini"))]
fn build_helix(&self) -> HelixView {
let g = &self.data.dep_graph;
if self.demo_running() || g.nodes.is_empty() {
return HelixView::demo();
}
let mut dag = PlanDag::new();
// The repository root anchors the coil at t = 0 (a Crate lane).
let root = dag.add_idea(g.repository.clone(), "repo", 0.0, Kind::Crate);
// Each scanned artifact becomes a Task node along the synthesized time axis,
// laned (angular strand) by its verdict decision.
let mut ids: Vec<facett_helix::NodeId> = Vec::with_capacity(g.nodes.len());
for (i, n) in g.nodes.iter().enumerate() {
let t = (i + 1) as f64;
let label = if n.version.is_empty() {
n.name.clone()
} else {
format!("{} {}", n.name, n.version)
};
let nid = dag.add_idea(label, n.decision.clone(), t, Kind::Task);
// Verdict → run status (block = Blocked, warn = Active, pass = default).
match n.decision.as_str() {
"block" => {
dag.set_status(nid, Status::Blocked);
}
"warn" => {
dag.set_status(nid, Status::Active);
}
_ => {}
}
// Advisory ids ride in as a security (Cve) event on the node.
if !n.ids.is_empty() {
dag.add_event(nid, t, EventKind::Cve, n.ids.join(", "));
}
ids.push(nid);
}
// Warehouse-sourced dependency edges become intra-coil deps.
let mut linked = vec![false; ids.len()];
for (from, to) in &g.edges {
if let (Some(&fa), Some(&ta)) = (ids.get(*from), ids.get(*to)) {
if dag.add_dep(fa, ta) {
linked[*from] = true;
}
}
}
// Artifacts with no resolved edge hang off the root so the coil is one graph.
for (i, nid) in ids.iter().enumerate() {
if !linked[i] {
dag.add_dep(*nid, root);
}
}
HelixView::from_dag(format!("holger · {}", g.repository), dag)
}
/// The **Lineage** tab — the live proxy-cache-filling facet. As holger caches
/// and serves artifacts, this fills and rotates: the [`LineageView`](crate::data::LineageView)
/// (fed live via a connected `mpsc` channel — the same seam a real
/// `ChannelAuditLog` audit tee uses) is rendered two ways, toggled here:
/// **3D fill** (facett-graph3d, nodes clustered by ecosystem, slowly rotating and
/// growing) and **time helix** (facett-helix, each arrival wound onto a per-
/// ecosystem coil). With no real feed wired, a demo backlog drips one arrival per
/// frame so the graph visibly builds; the widgets rebuild when the lineage data
/// signature changes and the injected clock keeps the motion continuous.
/// **Lineage · canvas — the MASTER half.** The filling cache graph, alone.
///
/// The feed pump lives here (not in the inspector) because the master is drawn FIRST,
/// so an arrival dripped this frame is already in the widget the same frame.
#[cfg(not(feature = "mini"))]
fn lineage_canvas(&mut self, ui: &mut Ui) {
// Live: drip one demo arrival (if any), then drain the feed into the view.
if self.lineage_autofill {
self.drip_lineage_event();
}
self.data.lineage.pump();
// Rebuild the facett widgets when the lineage data grew (a node pops in).
let sig = (
self.data.lineage.nodes.len(),
self.data.lineage.edges.len(),
self.data.lineage.events.len(),
);
if self.lineage_sig != sig {
self.lineage_graph = None;
self.lineage_helix = None;
self.lineage_sig = sig;
}
let now = ui.input(|i| i.time);
match self.lineage_mode {
LineageMode::Graph3d => {
if self.lineage_graph.is_none() {
self.lineage_graph = Some(self.build_lineage_graph());
}
if let Some(g) = &mut self.lineage_graph {
g.set_clock(now);
g.ui(ui);
}
}
LineageMode::Helix => {
if self.lineage_helix.is_none() {
self.lineage_helix = Some(self.build_lineage_helix());
}
if let Some(h) = &mut self.lineage_helix {
h.set_clock(now);
h.ui(ui);
}
}
}
// Keep the fill animating (rotation + one arrival per frame) while open.
ui.ctx().request_repaint();
}
/// **Lineage · feed — the INSPECTOR half.** The render-mode switch, the auto-fill
/// drip and the live counters. The counter line used to sit between the toolbar and
/// the canvas in one flow — every row it grew came straight out of the canvas's
/// height. Now the divider decides. Returns the live lineage view's state.
#[cfg(not(feature = "mini"))]
fn lineage_feed(&mut self, ui: &mut Ui) -> Value {
if ui.button("⟳ rebuild").clicked() {
self.lineage_graph = None;
self.lineage_helix = None;
}
ui.selectable_value(&mut self.lineage_mode, LineageMode::Graph3d, "◉ 3D fill");
ui.selectable_value(&mut self.lineage_mode, LineageMode::Helix, "🌀 time helix");
ui.separator();
ui.checkbox(&mut self.lineage_autofill, "▶ auto-fill")
.on_hover_text("drip cache arrivals through the live feed so the graph fills + rotates");
if ui.button("+ arrival").clicked() {
self.drip_lineage_event();
}
ui.separator();
let l = &self.data.lineage;
ui.label(
RichText::new(format!(
"cache fill — {} artifacts · {} events · {} bytes · {} pending",
l.artifact_count(),
l.total_events,
l.total_bytes,
self.lineage_demo.len(),
))
.weak(),
);
self.data.lineage.state_json()
}
/// Send the next demo arrival through the LIVE lineage feed (the same path a
/// real audit tee uses); no-op once the backlog is drained. `pump()` then folds
/// it into the view on this or the next frame.
#[cfg(not(feature = "mini"))]
fn drip_lineage_event(&mut self) {
if let Some(ev) = self.lineage_demo.pop_front() {
let _ = self.lineage_tx.send(ev);
}
}
/// Build the rotating 3D cache-fill graph from the live [`LineageView`]: every
/// node (artifacts coloured by ecosystem cluster; repo/upstream by kind) with the
/// lineage edges (requested-by / proxied-from) mapped to node indices. Spin is on
/// so the graph slowly rotates; `DecorPolicy::Full` lights the neon glow.
#[cfg(not(feature = "mini"))]
fn build_lineage_graph(&self) -> Graph3D {
let l = &self.data.lineage;
let mut nodes: Vec<(String, egui::Color32)> = Vec::with_capacity(l.nodes.len());
let mut index: HashMap<&str, usize> = HashMap::with_capacity(l.nodes.len());
for (i, n) in l.nodes.iter().enumerate() {
nodes.push((n.label.clone(), lineage_node_color(n)));
index.insert(n.id.as_str(), i);
}
let mut edges: Vec<(usize, usize)> = Vec::with_capacity(l.edges.len());
for e in &l.edges {
if let (Some(&a), Some(&b)) = (index.get(e.from.as_str()), index.get(e.to.as_str())) {
edges.push((a, b));
}
}
let mut g = Graph3D::new(nodes, edges, Layout3D::Force)
.with_title("holger · cache fill")
.with_decor(DecorPolicy::Full);
g.view.spin = true; // slowly rotating spatial fill (spin moved into Graph3DView)
g.layout();
g
}
/// Build the time-helix companion from the live [`LineageView`]: each arrival is
/// an idea wound onto its ecosystem's coil at its arrival index along the time
/// axis, chained to the previous arrival on the same lane so each ecosystem forms
/// one connected coil. Falls back to `HelixView::demo()` before any arrival.
#[cfg(not(feature = "mini"))]
fn build_lineage_helix(&self) -> HelixView {
let l = &self.data.lineage;
if l.events.is_empty() {
return HelixView::demo();
}
let mut dag = PlanDag::new();
let mut last_on_lane: HashMap<&'static str, facett_helix::NodeId> = HashMap::new();
for (i, ev) in l.events.iter().enumerate() {
let lane = ev.ecosystem.label();
let label = short_artifact(&ev.artifact);
let nid = dag.add_idea(label, lane, i as f64, Kind::Crate);
if let Some(prev) = last_on_lane.get(lane) {
dag.add_dep(nid, *prev);
}
last_on_lane.insert(lane, nid);
}
HelixView::from_dag("holger · cache fill", dag)
}
}
/// The neon colour for a lineage node: artifacts take their ecosystem cluster
/// colour; repository and upstream nodes reuse the HOLGER NEON PALETTE kinds so the
/// Lineage graph reads coherently against the Graph tab.
#[cfg(not(feature = "mini"))]
fn lineage_node_color(n: &LineageNode) -> egui::Color32 {
match n.kind {
NodeKind::Repo => GraphNodeKind::Repository.color(),
NodeKind::Upstream => GraphNodeKind::Upstream.color(),
NodeKind::Artifact => {
let [r, g, b] = n.ecosystem.color();
egui::Color32::from_rgb(r, g, b)
}
}
}
/// Trim a long artifact id / archive path to a compact helix label (basename +
/// version), so the coil stays legible as it fills.
#[cfg(not(feature = "mini"))]
fn short_artifact(artifact: &str) -> String {
let tail = artifact.rsplit('/').next().unwrap_or(artifact);
if tail.len() > 28 {
tail[tail.len() - 28..].to_string()
} else {
tail.to_string()
}
}
impl HolgerUiApp {
/// Render the whole app from a parent `ui`. eframe drives this through the
/// `eframe::App::ui` impl below; the `egui_kittest` robot harness drives it
/// through [`HolgerUiApp::draw_ui`].
// Under `mini` the chrome control group (the ⚓ static toggle) is gated out, so the
// locals it captures (`controls`, `new_static`, …) go unused for that one tier —
// silence them here rather than thread `#[cfg]` through each.
#[cfg_attr(feature = "mini", allow(unused_variables, unused_mut))]
fn render(&mut self, ui: &mut egui::Ui) {
// Unified look & feel. In the static **Skade Vinter** showcase the whole UI
// re-themes to the icy winter palette via the COH-1 bridge — `apply` installs
// the full egui Style, publishes the derived legacy palette so every facett
// Table follows, and publishes the Full effects policy so the ice-drip decor
// lights up. Normal operation keeps the operator's selected preset.
let ctx = ui.ctx().clone();
// 🎬 Demo mode: apply the next due tour beat FIRST (off the egui frame
// clock) so this frame paints the result — a real, animated walk.
#[cfg(feature = "demo")]
if self.demo.is_some() {
let now = ui.input(|i| i.time);
self.demo_tick(&ctx, now);
}
if self.static_mode {
LookTheme::skade_vinter().apply(&ctx);
} else {
self.apply_look(&ctx);
}
// Write tabs are unavailable in static (read-only) mode.
if self.static_mode && self.tab == Tab::Upload {
self.tab = Tab::Status;
}
// Startup splash: the recovered holger+znippy hero logo (compiled-in PNG)
// + wordmark + version, with the spinning znippy rabbit as a small accent
// beneath. Shows ~2.6s then auto-dismisses (click to skip).
if !self.splash_done {
let now = ui.input(|i| i.time);
let since = *self.splash_since.get_or_insert(now);
let clicked = ui.input(|i| i.pointer.any_click());
if now - since > 2.6 || clicked {
self.splash_done = true;
} else {
self.logo.set_clock(now);
// The hero PNG rides on `about-card` (it is the same asset the About
// card decodes, and `image` is its decoder). The mikro tier drops both
// and shows the wordmark + version splash with no bitmap.
#[cfg(feature = "about-card")]
let logo_tex = self.splash_logo_texture(&ctx);
#[cfg(not(feature = "about-card"))]
let logo_tex: Option<egui::TextureHandle> = None;
egui::CentralPanel::default().show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(12.0);
// The recovered logo is the splash hero — scaled to fit
// (portrait 1024×1536), centered, above the wordmark.
if let Some(tex) = &logo_tex {
let [w, h] = tex.size();
let max_h = (ui.available_height() * 0.55).clamp(180.0, 420.0);
let scale = (max_h / h as f32).min(1.0);
let size = egui::vec2(w as f32 * scale, h as f32 * scale);
ui.add(egui::Image::new((tex.id(), size)).fit_to_exact_size(size));
}
ui.add_space(8.0);
// Caps wordmark + tier tag on the splash too, matching the chrome bar.
#[cfg(feature = "mini")]
{
ui.heading("HOLGER");
ui.label(RichText::new("mini").small().strong());
}
#[cfg(not(feature = "mini"))]
ui.heading("holger");
ui.label(
RichText::new(format!("v{HOLGER_UI_VERSION}"))
.monospace()
.strong(),
);
ui.label("airgap artifact server · znippy-backed");
});
self.logo.ui(ui);
});
ui.ctx().request_repaint();
return;
}
}
// NB: the grouped menu bar (View / Operate / Repositories / Action / Look / Help) is
// no longer hand-wired here — it is now DECLARED in the shell scene and drawn by
// facett-menu from `scene.menubar` (see the `scene` child module). It carries the same
// command set as the Ctrl-K palette and a pick still routes through the ONE
// `dispatch_command` a click/palette-invoke uses, so this is a migration of the same
// menubar onto the declarative layer, not a second one.
// ── The shell chrome (holger's non-tab controls) — RESPONSIVE, canvas-first. ─────
// The heading + ⌘K + ℹ About + the ⚓ static toggle live here (NOT in the scene's
// tab strip). On a phone-class viewport (`Device::from_width` over the available
// width) the control group folds behind one ⚙ drawer button so the tab canvas gets
// the screen — the same mobile canvas-first fold facett-demo uses, expressed with
// facett-scene's public device classifier. All widgets write LOCAL accumulators
// (never `self` inside a nested closure), applied to `self` after.
//
// **The LOOK switcher is deliberately NOT here any more.** Appearance is a
// preference, not shell navigation, so it lives in the ⚙ Settings pane
// ([`settings_pane`](Self::settings_pane)) — the same place dwarves-demo's 🎛
// `settings` tab keeps it. The ⚓ static toggle STAYS: it is the Skade-Vinter
// showcase switch, which gates WRITES (`write_enabled`, the hidden Upload tab)
// and only re-themes as a side effect — a functional control, not a look one.
let phone = matches!(Device::from_width(ui.available_width()), Device::Phone);
self.controls_folded = phone;
let mut open_palette = false;
let mut open_about = false;
let mut new_static = self.static_mode;
let static_mode = self.static_mode;
let badge = if self.data.profile.read_only && !self.data.profile.label.is_empty() {
format!("❄ SKAÐI · {}", self.data.profile.label)
} else {
"❄ SKAÐI · SKADE VINTER — STATIC SHOWCASE · READ-ONLY".to_string()
};
// The folding control group (the ⚓ static toggle) — captures only locals, so it
// is safe to run either inline or inside the `⚙` drawer's menu closure.
let controls = |ui: &mut egui::Ui, new_static: &mut bool| {
ui.checkbox(new_static, "⚓ static").on_hover_text(
"demo: Skade Vinter static showcase (icy winter scene, read-only) — \
defaulted from the server-reported profile (gRPC Admin ServerProfile); \
this checkbox is a manual override",
);
};
// The app chrome (identity + global controls): the holger wordmark + version, the
// Ctrl-K palette + About buttons, the ⚓ static (Skade Vinter) toggle, the winter frost
// badge, and the look switcher — the last two folding behind the ⚙ drawer on a
// phone-class viewport. The MENUBAR and the TAB STRIP live in the declarative scene
// (`render_scene` below), so this bar carries only the surrounding chrome.
egui::Panel::top("chrome").show(ui, |ui| {
ui.horizontal(|ui| {
// Mini wears the wordmark in caps with a small tier tag beside it, so the
// window says at a glance which of the three tiers (embedded/mini/full) this
// is; the full UI keeps the lowercase wordmark unchanged.
#[cfg(feature = "mini")]
{
ui.heading("HOLGER");
ui.label(RichText::new("mini").small().strong())
.on_hover_text("mini tier — read-only registry surface (no write, federation, or tools)");
}
#[cfg(not(feature = "mini"))]
ui.heading("holger");
ui.label(RichText::new(format!("v{HOLGER_UI_VERSION}")).monospace().weak());
// Which server this window is looking at — without it, two open
// registries are indistinguishable. Empty for in-process handles.
#[cfg(feature = "mini")]
if !self.data.endpoint.is_empty() {
ui.separator();
ui.label(RichText::new(self.data.endpoint.clone()).monospace().weak())
.on_hover_text("the holger server this window is connected to");
}
ui.separator();
if ui
.button("⌘ K")
.on_hover_text("Command palette — jump to any tab, tool, or repo (Ctrl-K)")
.clicked()
{
open_palette = true;
}
// Mini drops the top-bar About button — the identity card is already one
// click away under the menu-bar Help ▸ About holger (and the Ctrl-K palette).
#[cfg(not(feature = "mini"))]
if ui
.button("ℹ About")
.on_hover_text("About holger — identity, version, and the shared framework it is built on")
.clicked()
{
open_about = true;
}
// Full tier: the shell control group (the ⚓ static / Skade-Vinter toggle).
// The mini tier drops it wholesale — mini is plainly read-only, so it has
// no static toggle. Look & feel is NOT here on any tier: it lives in
// exactly ONE place, the ⚙ Settings pane.
#[cfg(not(feature = "mini"))]
{
ui.separator();
if phone {
// Canvas-first drawer: fold the controls behind one compact ⚙ menu.
ui.menu_button("⚙ Controls", |ui| controls(ui, &mut new_static))
.response
.on_hover_text("Shell controls (folded on a narrow viewport)");
} else {
controls(ui, &mut new_static);
}
}
});
if static_mode {
// Frost badge for the winter showcase (server label when it drove read-only).
ui.label(
RichText::new(badge)
.color(egui::Color32::from_rgb(180, 222, 245))
.strong(),
);
}
});
// Apply the chrome's picks to `self` (no `self` was borrowed inside the closures).
if open_palette {
self.palette.open();
}
if open_about {
self.about_open = true;
}
if new_static != self.static_mode {
self.static_mode = new_static;
}
// ── The shell — ADOPTED from the shared facett-scene (facett_app::scene). ────────
// holger's tabs are a declarative `Scene` (Tabs root, one FOLDABLE `Slot` per tab) plus
// a grouped MENUBAR, rendered through facett's OWN TabView + pane engine + facett-menu
// — this REPLACES both the hand-rolled `selectable_value` tab strip with its central
// `match self.tab` dispatch AND the hand-wired `menu_bar` call (LAW #5 — ADOPTION).
// `self.tab` stays holger's authoritative tab model: `render_scene` seeds it into the
// nav before rendering (so palette/menu/demo dispatch still moves the tab) and reads it
// back after (so a TabView click moves `self.tab` + `state_json.tab`), re-applying the
// static-mode Upload guard on the way out. A menu pick routes through the same
// `dispatch_command` the palette uses.
self.last_render = self.render_scene(ui);
// Skade Vinter: overlay the ice-drip decor over the whole viewport — icicles
// hang from the top edge, melt-droplets fall under gravity and fade, frost
// shimmers along the edge. Time-driven; only while in the winter showcase.
if self.static_mode {
let now = ui.input(|i| i.time) as f32;
self.ice.set_clock(now);
let screen = ui.ctx().content_rect();
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("skade_vinter_ice"),
));
self.ice.paint(&painter, screen);
ui.ctx().request_repaint();
}
// The About identity popup — the shared `facett_about::AboutCard` (LAW #5): the
// ONE render body korp/holger/nornir/dwarves share, fed holger's identity. Built
// lazily on first open (decodes the hero logo once, LFS-tolerant → text identity
// when the blob is un-pulled), then drawn each frame while open.
#[cfg(feature = "about-card")]
if self.about_open {
let id = crate::about::identity();
let card = self
.about_card
.get_or_insert_with(|| facett_about::AboutCard::new(&ctx, &id).with_max_width(360.0));
card.show(&ctx, &mut self.about_open, &id);
}
// The command palette overlays everything (Ctrl-K / Ctrl-Shift-P). Built +
// drawn LAST so its foreground scrim + search box land above the tabs, the
// content, and the winter decor. A pick runs the same hook a click would.
let commands = self.build_commands();
if let Some(id) = self.palette.ui(&ctx, &commands) {
self.dispatch_command(&id);
}
}
/// Robot/harness entry: render the whole app into a `Ui` (no eframe `Frame`),
/// so `egui_kittest` / `nornir-robotui` can drive the real app:
/// `Harness::builder().build_ui_state(|ui, app| app.draw_ui(ui), app)`.
///
/// This is now the SAME call the production [`eframe::App::ui`] impl makes —
/// `render(ui)`, straight into the root `Ui`. It used to wrap the app in a
/// `CentralPanel` that production does not have, so the robot suite was
/// driving a subtly different widget tree (an extra panel node, and its frame
/// margins) from the app a user actually sees. Taking a `Ui` also drops the
/// deprecated top-level `CentralPanel::show`.
pub fn draw_ui(&mut self, ui: &mut egui::Ui) {
self.render(ui);
}
/// Skip the startup splash so headless/robot tests start on the tabs.
pub fn skip_splash(&mut self) {
self.splash_done = true;
}
/// The recovered hero logo as an egui texture — decode-once from the
/// compiled-in [`HOLGER_LOGO_PNG`] (no runtime file read), then cached in
/// `self.logo_texture` for the rest of the splash. Returns `None` only if the
/// embedded PNG fails to decode (it never should — it's validated at build).
#[cfg(feature = "about-card")]
fn splash_logo_texture(&mut self, ctx: &egui::Context) -> Option<egui::TextureHandle> {
if let Some(tex) = &self.logo_texture {
return Some(tex.clone());
}
let rgba = image::load_from_memory(HOLGER_LOGO_PNG).ok()?.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let color = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw());
let tex = ctx.load_texture("holger-znippy-logo", color, egui::TextureOptions::LINEAR);
self.logo_texture = Some(tex.clone());
Some(tex)
}
}
impl eframe::App for HolgerUiApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.render(ui);
}
}
#[cfg(test)]
mod look_tests {
//! Robot-UI cell for the unified facett look & feel (the "all consumers,
//! holger too" migration). Runs only under `--features gui` (where `app`
//! compiles). It drives the *real* holger-server-ui app's look surface headlessly and
//! asserts its observable state — the active preset + a coherent palette — and
//! that switching the preset actually re-themes every facett view via the
//! published legacy palette (COH-1). No display, no GPU.
//!
//! LAW: inject real input + assert real output. We inject a concrete preset
//! index (a "click" on the look switcher) and assert the rendered palette
//! changes accordingly and that egui picked up the new style — never just
//! "didn't panic".
use super::*;
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::HolgerObject;
/// Emit one functional-status row for a real check. Gated behind
/// `--features testmatrix` (this module already requires `gui`) so release
/// builds strip it (the dep is optional).
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// A holger-server-ui app over an empty (no-repo) LocalHolger — enough to exercise
/// the look surface, which is independent of the data layer.
fn app() -> HolgerUiApp {
let routes = FastRoutes::new(Vec::new());
let holger: std::sync::Arc<dyn HolgerObject> = std::sync::Arc::new(LocalHolger::new(routes));
let data = UiData::new(holger).expect("runtime");
HolgerUiApp::new(data)
}
/// **Every tab is reachable from the Ctrl-K palette — exactly once.**
///
/// The palette's `goto:` set used to be a hand-written list: a twin of
/// `TAB_ORDER` that made "add a tab" a three-place edit (enum + order array +
/// this list), where forgetting the third gave the new tab a scene pane and a
/// View-menu entry but NO palette command — a pane you could click to but never
/// summon, and nothing failed. It is now derived from `TAB_ORDER` +
/// `Tab::label()`, the same source the pane tree and the menubar use.
///
/// RED-when-broken in both directions: a tab with no command, or a stray
/// `goto:` command for a tab that no longer exists.
#[test]
fn every_tab_has_exactly_one_palette_command() {
let mut app = app();
// The writable (non-static) shell: static mode legitimately hides Upload —
// asserted separately by `static_mode_drops_upload_from_the_palette_and_nothing_else`.
app.static_mode = false;
let goto: Vec<String> = app
.build_commands()
.iter()
.filter(|c| c.id.starts_with("goto:"))
.map(|c| c.id.to_string())
.collect();
let expected: Vec<String> =
TAB_ORDER.iter().map(|t| format!("goto:{}", t.slot_id())).collect();
assert_eq!(goto, expected, "the palette's tab set IS TAB_ORDER, in order");
// And each is dispatchable — the id is not just present, it moves the tab.
for t in TAB_ORDER {
app.run_command(&format!("goto:{}", t.slot_id()));
assert_eq!(
app.state_json()["tab"], t.slot_id(),
"goto:{} must actually select that tab", t.slot_id()
);
}
}
/// Static ("Skade Vinter") mode is the read-only showcase: Upload has nowhere to
/// go, so it is the ONE tab dropped from the palette — and only it.
// FULL TIER ONLY: the read-only tiers have no Upload tab to drop, so "drops
// exactly one" is not a statement about them.
#[cfg(not(feature = "mini"))]
#[test]
fn static_mode_drops_upload_from_the_palette_and_nothing_else() {
let mut app = app();
app.static_mode = true;
let goto: Vec<String> = app
.build_commands()
.iter()
.filter(|c| c.id.starts_with("goto:"))
.map(|c| c.id.to_string())
.collect();
assert!(!goto.iter().any(|c| c == "goto:upload"), "static mode hides Upload: {goto:?}");
assert_eq!(goto.len(), TAB_ORDER.len() - 1, "…and drops nothing else: {goto:?}");
}
/// The Ctrl-K command palette is a real, headless-addressable surface: its
/// command set carries every tab + tool + action, and firing a command id runs
/// the same hook a click would (tab switch, tool select). Inject a command id,
/// assert the observable state moved — never just "didn't panic".
///
/// **This test did not run.** Its `#[test]` had drifted up above the doc comment of
/// the test BELOW it — so `every_tab_has_exactly_one_palette_command` carried the
/// attribute twice (a warning nobody read) and this function carried none, compiling
/// as dead code and reporting nothing. A duplicated attribute is exactly how a cell
/// leaves the matrix without a single failure.
// FULL TIER ONLY: it dispatches TOOL commands, and the read-only tiers carry
// no Tools pane.
#[cfg(not(feature = "mini"))]
#[test]
fn command_palette_dispatches_tabs_and_tools() {
let mut app = app();
// The command set is exposed as data (stable ids), tabs + tools + actions.
let s = app.state_json();
let ids: Vec<String> = s["palette"]["commands"]
.as_array()
.expect("palette exposes its command set")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
let has = |id: &str| ids.iter().any(|i| i == id);
assert!(has("goto:browse"), "every tab is a command: {ids:?}");
assert!(has("tool:security"), "every tool is a command: {ids:?}");
assert!(has("action:refresh"), "actions are commands: {ids:?}");
// Fire a tab command → the active tab moves (same path a click runs).
app.run_command("goto:browse");
assert_eq!(app.state_json()["tab"], "browse", "goto:browse switches the tab");
// Fire a tool command → Tools tab + that tool selected, and the palette
// records the fired id on its `invoked` observable.
app.run_command("tool:security");
let s = app.state_json();
let dispatched = s["tab"] == "tools" && s["tool"] == "security";
assert!(dispatched, "tool:security → Tools/Security: {s}");
assert_eq!(s["palette"]["invoked"], "tool:security", "palette records the fired id");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"command_palette_dispatch",
has("goto:browse") && dispatched,
"goto:browse→browse, tool:security→tools/security",
);
}
/// The right-click repo-row menu routes through the same navigation hooks a
/// click/palette pick does: a row action selects the row and opens its tab.
/// Inject the action, assert the observable tab/tool moved (not just "no panic").
#[test]
fn repo_row_context_action_navigates() {
let mut app = app();
// Scan → Tools/Security (select_repo(0) no-ops on the empty test roster, but
// the navigation still applies — the action's contract is tab movement).
app.apply_repo_row_nav(0, "row:scan");
let s = app.state_json();
let scan_ok = s["tab"] == "tools" && s["tool"] == "security";
assert!(scan_ok, "row:scan → Tools/Security: {s}");
// Archive → Archive tab.
app.apply_repo_row_nav(0, "row:archive");
let archive_ok = app.state_json()["tab"] == "archive";
assert!(archive_ok, "row:archive → Archive tab");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"repo_row_context_action",
scan_ok && archive_ok,
"row:scan→tools/security, row:archive→archive",
);
}
/// **About (shared common-framework identity surface).** The `Help · About holger`
/// command opens the popup, and the app's `state_json.about` reports holger's
/// identity — wordmark, a real version string, and the shared framework it is built
/// on (incl. jera). Inject the command, assert the observable open flag flips AND
/// the identity is present — never just "didn't panic". RED-when-broken: if the
/// About wiring broke (command not dispatched / identity dropped) this fails.
#[test]
fn about_command_opens_popup_and_reports_identity_and_framework() {
let mut app = app();
// The About command is in the command set (Help group), and firing it opens
// the popup — the same path the ℹ About button / menu runs.
let ids: Vec<String> = app.state_json()["palette"]["commands"]
.as_array()
.expect("palette exposes commands")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
assert!(ids.iter().any(|i| i == "about:open"), "About is a command: {ids:?}");
assert_eq!(app.state_json()["about"]["open"], serde_json::json!(false), "closed initially");
app.run_command("about:open");
let s = app.state_json();
let opened = s["about"]["open"] == serde_json::json!(true);
assert!(opened, "about:open opens the popup: {}", s["about"]);
assert_eq!(s["about"]["wordmark"], "holger", "identity wordmark");
let version = s["about"]["version"].as_str().unwrap_or("");
assert!(!version.is_empty(), "About presents a real version string: {}", s["about"]);
let fw: Vec<String> = s["about"]["framework"]
.as_array()
.expect("framework list")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
let names_jera = fw.iter().any(|c| c.contains("jera"));
assert!(names_jera, "About names the shared jera job manager: {fw:?}");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"about_command_opens_and_reports_identity",
opened && !version.is_empty() && names_jera,
"Help·About opens the identity popup (wordmark/version/shared-framework incl. jera)",
);
}
/// **Tool #6 demo appliance dispatches THROUGH jera.** Driving the demo tool (the
/// same hook the pane's "Start demo appliance…" button runs) records the container
/// bring-up as a jera job; the app's `state_json.demo_appliance` carries back the
/// job id + the pinned demo image. Inject the dispatch, assert the observable job
/// id is non-empty + the image is the demo registry — proof it went through the
/// shared job manager, not a bespoke spawn. RED-when-broken: no job id / no dispatch.
#[cfg(not(feature = "mini"))]
#[test]
fn demo_tool_dispatches_a_jera_job_observably() {
let mut app = app();
assert!(app.state_json()["demo_appliance"].is_null(), "no dispatch yet");
app.run_demo_tool();
let s = app.state_json();
assert_eq!(s["tool"], "demo", "the demo tool is selected");
let d = &s["demo_appliance"];
let job_id = d["job_id"].as_str().unwrap_or("");
let dispatched = d["dispatched"].as_bool().unwrap_or(false);
assert!(!job_id.is_empty(), "the demo dispatch records a jera job id: {d}");
assert!(dispatched, "the demo is dispatched through jera: {d}");
assert!(
d["image"].as_str().unwrap_or("").contains("registry"),
"the dispatched demo container is the registry image: {d}"
);
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"demo_tool_dispatches_jera_job",
!job_id.is_empty() && dispatched,
"Tools·Demo starts the demo container as a jera BOOT_CONTAINER job (observable job id)",
);
}
/// The look surface exposes holger's collapsed roster (`dark`, `light`,
/// `device`) and defaults to `dark` — the value an operator would see selected
/// on first launch.
#[test]
fn default_preset_is_dark_and_roster_is_collapsed() {
let app = app();
let s = app.look_state_json();
// The roster the switcher renders is holger's collapsed look list.
let presets = s["presets"].as_array().expect("look state has a presets roster");
let names: Vec<&str> = presets.iter().map(|p| p.as_str().unwrap_or("")).collect();
assert_eq!(
names,
vec!["dark", "light", "device"],
"the look switcher lists the collapsed roster: {s}"
);
// The active look is the default (`dark`) and matches the index.
assert_eq!(s["preset"], "dark", "first-launch look is dark: {s}");
assert_eq!(s["preset_index"].as_u64(), Some(default_look_preset() as u64));
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"look_default_preset_and_roster",
names == vec!["dark", "light", "device"]
&& s["preset"] == "dark"
&& s["preset_index"].as_u64() == Some(default_look_preset() as u64),
&format!("roster {names:?}, default = dark"),
);
}
/// Injecting a preset switch (the operator clicking "device") changes the
/// active preset AND the resolved palette the facett views paint with — proof
/// the migration re-themes coherently, not just that it stored an index.
#[test]
fn switching_preset_rethemes_the_resolved_palette() {
let mut app = app();
// Find device + dark by name so the test is order-stable.
let names = look_names();
let device = names.iter().position(|n| n == "device").expect("device look exists");
let dark = names.iter().position(|n| n == "dark").expect("dark look exists");
// Drive: select dark, capture its palette.
let name = app.set_look_preset(dark);
assert_eq!(name, "dark");
let win_state = app.look_state_json();
let win_bg = win_state["palette"]["bg"].clone();
// Drive: select device (a click on the switcher), capture its palette.
let name = app.set_look_preset(device);
assert_eq!(name, "device");
let dev_state = app.look_state_json();
// Observe: the active preset + a different, fully-populated palette.
assert_eq!(dev_state["preset"], "device", "active preset switched: {dev_state}");
let dev_bg = dev_state["palette"]["bg"].clone();
assert_ne!(win_bg, dev_bg, "device must paint a different surface than dark");
// The palette is coherent — every role is a real hex colour, none empty.
let roles = ["bg", "text", "text_dim", "accent", "panel_bg", "node_fill"];
for role in roles {
let c = dev_state["palette"][role].as_str().unwrap_or("");
assert!(
c.starts_with('#') && c.len() == 7,
"palette role `{role}` must be a #rrggbb colour, got {c:?}: {dev_state}"
);
}
let all_hex = roles.iter().all(|role| {
let c = dev_state["palette"][role].as_str().unwrap_or("");
c.starts_with('#') && c.len() == 7
});
let _ = all_hex;
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"look_switch_rethemes_palette",
dev_state["preset"] == "device" && win_bg != dev_bg && all_hex,
&format!("windows-dark bg={win_bg} != device bg={dev_bg}, all 6 roles #rrggbb={all_hex}"),
);
}
/// `apply` installs the preset's full egui Style AND publishes the derived
/// legacy palette into the context, so every facett component (the Table
/// facets) follows with no per-view wiring (COH-1). We apply headlessly and
/// read the published palette back through `facett::theme(ui)`.
#[test]
fn apply_publishes_the_coherent_legacy_palette_into_egui() {
let mut app = app();
let device = look_names().iter().position(|n| n == "device").unwrap();
app.set_look_preset(device);
let ctx = egui::Context::default();
app.apply_look(&ctx);
// The egui Style picked up the preset's spacing (not egui's default).
//
// egui 0.35 REMOVED `Context::style()` (style is per-theme now: `style_of(theme)`,
// plus the theme-independent `global_style()` that `apply` writes). The assertion
// here was migrated; the `testmatrix` mirror below still read `ctx.style()` and
// nobody saw it, because the mirror is feature-gated and no default build compiles
// it. Both now read ONE binding, so the row cannot drift from the assert again.
let theme = app.look_theme();
let want_spacing = theme.metrics.item_spacing_vec();
let got_spacing = ctx.global_style().spacing.item_spacing;
assert_eq!(
got_spacing, want_spacing,
"apply installed the preset's spacing into the egui Style"
);
// The legacy palette every facett view reads is the device preset's.
let mut got = String::new();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
got = facett::theme(ui).name.to_string();
});
assert_eq!(got, "device", "facett views resolve the applied preset's palette");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"look_apply_publishes_legacy_palette",
got_spacing == want_spacing && got == "device",
&format!("egui spacing matches preset; facett::theme resolves \"{got}\""),
);
}
/// **RED-when-broken: ⚙ Settings is the ONE home of holger's look & feel, and the
/// control still works there.**
///
/// The look-preset switcher used to be advertised twice at the shell ROOT — inline
/// on the chrome bar (folding behind `⚙ Controls` on a narrow viewport) and as a
/// top-level menu-bar **Look** submenu. It now lives in the ⚙ Settings pane, the
/// same place dwarves-demo's 🎛 `settings` tab keeps it.
///
/// Asserted on the REAL rendered app through the AccessKit tree (`draw_ui`, the
/// same body eframe runs), never on the source:
/// * with a non-Settings tab active NOTHING on screen is a look control — no
/// `look:` strip, no `Look` menu button, no `⚙ Controls` drawer holding one;
/// * selecting Settings paints the Appearance block with EVERY preset in
/// [`LOOKS`] — nothing was dropped in the move;
/// * and clicking a preset in the pane really re-themes: the app's `look`
/// observable follows, and `facett::theme` resolves the new palette.
#[test]
fn settings_pane_is_the_one_home_of_look_and_feel() {
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
let names = look_names();
assert!(names.len() >= 2, "a roster worth switching: {names:?}");
// ── (a) the ROOT carries no look & feel ─────────────────────────────────
let mut app = app();
app.skip_splash();
app.static_mode = false;
app.tab = Tab::Status;
let mut h = Harness::builder()
.with_size(egui::vec2(1600.0, 1000.0))
.build_ui_state(|ui, a: &mut HolgerUiApp| a.draw_ui(ui), app);
h.run();
assert!(
h.query_by_label("look:").is_none(),
"the root chrome must not carry the look switcher — it belongs in ⚙ Settings"
);
assert!(
h.query_by_label("Look").is_none(),
"the root menubar must not carry a Look menu — it belongs in ⚙ Settings"
);
assert!(
h.query_by_label("⚙ Controls").is_none(),
"…and no ⚙ Controls drawer is hiding one on this (wide) viewport"
);
// ── (b) ⚙ Settings paints the Appearance block with the WHOLE roster ────
h.state_mut().tab = Tab::Settings;
h.run();
assert!(
h.query_by_label_contains("Appearance").is_some(),
"⚙ Settings heads its look & feel block with Appearance"
);
assert!(h.query_by_label("look:").is_some(), "⚙ Settings carries the look switcher");
for name in &names {
assert!(
h.query_by_label(name.as_str()).is_some(),
"⚙ Settings offers the `{name}` preset — no preset lost in the move"
);
}
// ── (c) …and the control is LIVE: click a preset in the pane, the whole app
// re-themes. Pick whichever preset is not the current one, so the click is a
// real change rather than a re-select. ─────────────────────────────────────
let before = h.state().look_name().to_string();
let target = names.iter().find(|n| *n != &before).expect("a second preset").clone();
h.get_by_label(target.as_str()).click();
h.run();
let after = h.state().state_json();
assert_eq!(
after["look"], target,
"clicking `{target}` in ⚙ Settings switched the app's look: {after}"
);
assert_eq!(
h.state().look_state_json()["preset"], target,
"…and the look oracle the pane itself publishes agrees"
);
// The re-theme really reached the shared palette every facett view paints with.
let want = h.state().look_theme().name;
let ctx = egui::Context::default();
h.state().apply_look(&ctx);
let mut got = String::new();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
got = facett::theme(ui).name.to_string();
});
assert_eq!(got, want, "the Settings pick drives the coherent facett palette (COH-1)");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"look_and_feel_lives_in_settings_not_at_root",
true,
"the look-preset switcher moved off the root chrome bar and out of the root menu-bar \
Look submenu into the ⚙ Settings scene pane (glyph 🎛, matching dwarves-demo); \
asserted on the real rendered app via AccessKit — absent at root, present in \
Settings with every preset, and a click there re-themes the app and the shared \
facett palette",
);
}
}