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

nordisk/holger-egui

src/app/scene.rs

//! **The holger shell, described DECLARATIVELY.** The whole top-level UI — a
//! grouped menubar over a tabbed pane tree — is expressed as one
//! [`facett_app::scene::Scene`] value and rendered through facett-app's OWN
//! primitives ([`Router`](facett_app::Router) / [`TabView`](facett_app::TabView) /
//! `facett_menu`). Nothing is forked: the scene DRIVES the existing `<tab>_tab`
//! renderers through a [`SceneHost`], returning each view-model's `state_json()`.
//!
//! - **Describe** — [`holger_scene`] builds the shell (menubar groups + a draggable
//!   master/detail split of the registry rail over a `Pane::tabs` of one foldable body
//!   per `TAB_ORDER` entry, each body itself either a DRAGGABLE master/detail split or
//!   an honest single pane — see [`body_split`]) with the Rust builder; the identical
//!   value round-trips through **RON** byte-stably.
//! - **Host** — [`SceneHostImpl`] maps each `Content::Slot(id)` to the matching
//!   `<tab>_<half>(ui)` method and folds its view-model `state_json()` into the scene
//!   state. Menu `Action::Command`s route straight into the existing
//!   [`dispatch_command`](HolgerUiApp::dispatch_command).
//! - **Render** — [`HolgerUiApp::render_scene`] calls [`facett_app::scene::render`]
//!   from the app's draw, keeping `self.tab` authoritative (so every existing
//!   command / demo / robot hook still drives the tab).
//! - **Observe** — [`HolgerUiApp::scene_state_json`] folds the pane tree + fold /
//!   active-tab state + the last render facts into the app's `state_json`.
//!
//! **holger IS a master/detail app, and the shell now says so.** This module's older
//! doc argued the shell was "a flat, single-window tabbed workspace … draggable splits
//! / drawers are deliberately unused — there is no honest master/detail or
//! peripheral-rail surface to hang them on." That was wrong about its own app. holger
//! is an artifact REGISTRY BROWSER: **one selected repository** (`repos.selected`) is
//! the master, and Browse / Archive / Artifact / Upload / Lineage are all details OF
//! that selection — five tabs that open with `selected_repo()` and render whatever it
//! points at. The selection was reachable only from inside one tab (the Repos
//! combo box), so the master was hidden behind the detail. It is now a **rail**.
//!
//! The shell therefore exercises the whole scene vocabulary:
//! - **A draggable master/detail split** ([`Pane::split`], NOT `split_fixed`): the
//!   registry rail beside the tabbed workspace, divider draggable, live ratio in
//!   `NavState` and observable in `state_json`.
//! - **A second draggable split**, vertical, INSIDE the rail: the repository roster
//!   over the selected repository's cross-tab context.
//! - **A drawer**: the rail auto-folds crossing into a phone-class viewport and
//!   restores above it (desktop rail → phone drawer); the root split collapses to
//!   `Tabs` there, so a narrow screen gets the workspace, not a 20% sliver of it.
//! - **Tabs** (the workspace layout, active-tab observable + switchable), **folds**
//!   (every tab body, every body HALF and every rail pane is foldable), and a **routed
//!   menubar** (groups → `Action::Command` → the existing dispatcher).
//!
//! **And the tab INTERNALS adopted it too.** The shell was a scene while every tab BODY
//! stayed one imperative egui flow inside a single slot pane — so minimise / maximise /
//! drag operated on a whole tab and could do NOTHING inside it. A readout that grew (an
//! archive's stats grid, a pagination footer, a tool's output) simply pushed the canvas
//! off the bottom of the pane, with no divider to correct it, because both shared one
//! vertical flow. Nine of the eleven bodies are now
//! `Split(axis, ratio, DRAGGABLE)` over `<slot>_main` (the canvas / table / list) and
//! `<slot>_inspector` (controls, readouts, detail) — two real windows, each with its own
//! chrome bar, with a divider between them. This is a DECOMPOSITION, not a rewrite: the
//! existing renderers were cut in two and are still hosted through the same
//! [`SceneHost`]. The tabs that are honestly ONE pane (**Status**, **Sign in**,
//! **Settings**) say so in [`body_split`] and keep their slot leaf — forcing a split
//! with no master/detail is worse than leaving it.
//!
//! ⚠ **facett patch wanted:** `facett_app::scene::Pane::header_title` is private, so a
//! host cannot audit the AccessKit labels its own scene publishes (chrome is default-ON,
//! and every pane TITLE is now an addressable node — a duplicate makes BOTH unreachable).
//! `every_pane_title_tab_label_and_menu_group_is_uniquely_addressable` below re-derives
//! it locally; making it `pub` would delete that copy.

// `LoginPane::state_json` (the signin slot's observable state) is a `Facet` method, so
// the trait must be in scope here as it is in the parent `app` module.
#[cfg_attr(feature = "mikro", allow(unused_imports))]
use facett::Facet;
use facett_app::scene::{
    self as fscene, Action, Axis, Layout, MenuItem, Pane, Scene, SceneHost,
};
use serde_json::{json, Value};

#[cfg(not(feature = "mini"))]
use super::ToolsFn;
use super::{
    HolgerUiApp, Tab, RAIL_PANE, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE, SHELL_PANE, TAB_ORDER,
    WORKSPACE_PANE,
};
use crate::data::RepoRow;

/// The ROOT pane's unique name — the draggable master/detail split (rail | workspace)
/// the `Router` reports as the scene's route. Aliases the app's [`SHELL_PANE`] so the
/// pane id a robot addresses (`/scene/panes/holger-shell`) has exactly ONE spelling
/// across the shell and its tests.
pub(super) const ROOT: &str = SHELL_PANE;

/// The tabbed WORKSPACE pane's name — the addressable handle whose live `active_tab`
/// index the renderer reads and a tab-header click mutates. Aliases the app's
/// [`WORKSPACE_PANE`].
pub(super) const WORKSPACE: &str = WORKSPACE_PANE;

/// The registry RAIL pane's name (the foldable drawer holding the roster + selection).
pub(super) const RAIL: &str = RAIL_PANE;

/// The width (points) below which the shell is phone-class: the rail drawer shuts and
/// the root split collapses to `Tabs`. Matches dwarves' shell breakpoint — one number
/// for one fleet-wide rail-becomes-drawer behaviour.
const PHONE_BREAKPOINT: f32 = 760.0;

/// The fold icon for a tab's collapsible body. The tab ORDER, slot ids and header
/// labels are NOT duplicated here — they come from the app's [`TAB_ORDER`] +
/// [`Tab::slot_id`] / [`Tab::label`], the single source of truth, so a tab added there
/// automatically gains a scene pane. Only the decorative icon is scene-local; an
/// unlisted tab simply folds without one.
fn fold_icon(tab: Tab) -> &'static str {
    match tab {
        Tab::Status => "🩺",
        Tab::Repos => "🗄",
        Tab::Browse => "📂",
        Tab::Archive => "🧊",
        Tab::Artifact => "📦",
        #[cfg(all(feature = "mini", not(feature = "mikro")))]
        Tab::Search => "🔎",
        Tab::Graph => "🕸",
        Tab::Helix => "🌀",
        Tab::Lineage => "◉",
        Tab::Tools => "🛠",
        Tab::Upload => "⬆",
        Tab::SignIn => "🔑",
        // 🎛 — the SAME glyph dwarves-demo's `tab_icon` gives its `settings` tab, so
        // the preferences pane wears one face across the suite.
        Tab::Settings => "🎛",
    }
}

/// The index of a [`Tab`] in the strip (its `active_tab` value). Delegates to the
/// app's [`Tab::index`] so the scene and `state_json` can never disagree.
pub(super) fn tab_index(tab: Tab) -> usize {
    tab.index()
}

/// The [`Tab`] at strip index `i` (inverse of [`tab_index`]).
pub(super) fn tab_from_index(i: usize) -> Tab {
    Tab::from_index(i)
}

/// A `<pane> → slot(id)` leaf: a host-filled widget, foldable (open by default) so
/// the operator can collapse a tab body to just its header. The pane name is
/// `"<slot>_pane"` (unique) and the slot id is the bare tab id the [`SceneHost`]
/// switches on.
fn leaf(slot: &str, title: &str, icon: &str) -> Pane {
    Pane::slot(format!("{slot}_pane"), slot).title(title).foldable_icon(true, icon)
}

/// **The master/detail decomposition of ONE tab body.**
///
/// The shell adopted the scene at the SHELL level — a rail beside a tabbed workspace —
/// but every tab BODY stayed one imperative egui flow inside a single slot pane. That is
/// the bug this type exists to kill: with one pane per tab, minimise / maximise / drag
/// operate on the whole tab and can do NOTHING inside it, so a readout that grows (an
/// archive's stats grid, a pagination footer, a tool's output) simply pushes the canvas
/// off the bottom of the screen and the operator has no divider to correct it with.
///
/// A body that declares one of these becomes `Split(axis, ratio, DRAGGABLE)` over
/// `<slot>_main` (the canvas / table / list) and `<slot>_inspector` (controls, readouts,
/// detail) — two real windows, each with its own chrome bar, and a divider between them.
struct BodySplit {
    /// Which way the body divides. Horizontal ⇒ master column beside detail column.
    axis: Axis,
    /// The MASTER's seed share of the body (what the divider then moves).
    ratio: f32,
    /// `(title, fold icon)` of the master pane.
    main: (&'static str, &'static str),
    /// `(title, fold icon)` of the inspector pane.
    inspector: (&'static str, &'static str),
}

/// The decomposition for each tab, or **`None` for a tab that is HONESTLY one pane**.
///
/// Forcing a split on a body with no master/detail is worse than leaving it whole: it
/// buys a divider nobody can use and a second chrome bar that only steals rows. Three
/// tabs are honest single panes and say so here:
///
/// * **Status** — three server facts (status · version · uptime) in one grid. There is
///   no list to master and no detail to inspect; the whole body is the readout.
/// * **Sign in** — the SHARED `facett_auth_ui::LoginPane` paints its own device-code
///   state machine end to end. Splitting it would fork a widget holger does not own.
/// * **Settings** — one Appearance block of preference controls, nothing to inspect.
fn body_split(tab: Tab) -> Option<BodySplit> {
    match tab {
        // The repo×capability grid is the master; the selector + per-repo quick actions
        // (and the refresh / error line) are what you DO with the row you picked.
        Tab::Repos => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.62,
            main: ("Repos · grid", "📋"),
            inspector: ("Repos · actions", "⚡"),
        }),
        // The artifact listing is the master; the source repo, the refresh and the
        // pagination footer are its detail. The footer used to be appended UNDER the
        // table, so a long listing pushed "N shown / load more" off the pane.
        Tab::Browse => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.66,
            main: ("Browse · artifacts", "📋"),
            inspector: ("Browse · source", "🔎"),
        }),
        // **Rickard's exact case.** The stats readout (files · bytes · archive path) sat
        // ABOVE the file table in one vertical flow and pushed it off the bottom with no
        // divider to adjust. Two windows, one divider.
        Tab::Archive => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.64,
            main: ("Archive · files", "📋"),
            inspector: ("Archive · stats", "📐"),
        }),
        // The artifact COORDINATE (the query) is the master; what it resolved to — the
        // verdict plus the metadata/SBOM grid — is the detail beside it.
        Tab::Artifact => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.48,
            main: ("Artifact · query", "🔎"),
            inspector: ("Artifact · metadata", "🧾"),
        }),
        // Hits are the master; the query + counts are the inspector beside them.
        #[cfg(all(feature = "mini", not(feature = "mikro")))]
        Tab::Search => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.68,
            main: ("Search · hits", "📋"),
            inspector: ("Search · query", "🔎"),
        }),
        // Canvas │ inspector. The rebuild control + caption used to eat rows off the top
        // of the canvas; the canvas is a window now (maximise it and the inspector goes
        // to zero area).
        Tab::Graph => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.74,
            main: ("Graph · canvas", "🕸"),
            inspector: ("Graph · inspector", "🔎"),
        }),
        Tab::Helix => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.74,
            main: ("Helix · canvas", "🌀"),
            inspector: ("Helix · inspector", "🔎"),
        }),
        Tab::Lineage => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.74,
            main: ("Lineage · canvas", "◉"),
            inspector: ("Lineage · feed", "📡"),
        }),
        // Action list │ output — the narrow master here, because the OUTPUT is what
        // grows (a benchmark result, a scan report, a deploy log).
        Tab::Tools => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.26,
            main: ("Tools · functions", "🧰"),
            inspector: ("Tools · output", "📄"),
        }),
        // The upload form is the master; the write GATE (target repo · writable · the
        // last attempt's notice) is its detail. A long failure notice used to grow under
        // the form and push its own controls out of reach.
        Tab::Upload => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.58,
            main: ("Upload · form", "📝"),
            inspector: ("Upload · target", "🎯"),
        }),
        // **Settings** is honestly ONE pane — an Appearance block of preference
        // controls. There is no list to master and no detail to inspect, so it keeps
        // its slot leaf rather than buying a divider nobody can use and a second
        // chrome bar that only steals rows. (dwarves-demo's `settings` pane is a
        // single pane for the same reason, as is korp's ⚙ Settings.)
        Tab::Status | Tab::SignIn | Tab::Settings => None,
    }
}

/// One tab's body pane: either a plain foldable slot leaf (an honest single pane) or a
/// DRAGGABLE master/detail split of two named slots. Either way the pane is named
/// `"<slot>_pane"` and titled with the tab's label, so it is still the tab-strip header
/// `TabView` reads and the handle every existing test addresses.
fn tab_body(tab: Tab) -> Pane {
    let slot = tab.slot_id();
    let pane = match body_split(tab) {
        None => leaf(slot, tab.label(), fold_icon(tab)),
        Some(b) => Pane::split(
            format!("{slot}_pane"),
            b.axis,
            b.ratio,
            vec![
                Pane::slot(format!("{slot}_main"), format!("{slot}-main"))
                    .title(b.main.0)
                    .foldable_icon(true, b.main.1),
                Pane::slot(format!("{slot}_inspector"), format!("{slot}-inspector"))
                    .title(b.inspector.0)
                    .foldable_icon(true, b.inspector.1),
            ],
        )
        .title(tab.label())
        .foldable_icon(true, fold_icon(tab)),
    };
    // Mini: the tab HEADER carries the "what does this view do" hint as a hover tooltip —
    // facett's `TabView` renders `Pane::tooltip` as `on_hover_text` on the tab button. No
    // in-body ⓘ clutter; hover the tab in the strip and the explanation appears.
    #[cfg(feature = "mini")]
    let pane = pane.tooltip(tab.help_text());
    pane
}

/// Every tab body that DECOMPOSED into a draggable master/detail split, by pane name —
/// the divider roster the internal-split robot cells drag. Derived from [`body_split`],
/// so a body silently downgraded back to one pane drops out of here.
pub(super) fn split_bodies() -> Vec<String> {
    TAB_ORDER
        .iter()
        .filter(|t| body_split(**t).is_some())
        .map(|t| format!("{}_pane", t.slot_id()))
        .collect()
}

/// The tabs that are HONESTLY one pane (no master/detail to hang a divider on) — the
/// complement of [`split_bodies`], published so the coverage test asserts the decision
/// explicitly rather than by omission.
pub(super) fn single_pane_bodies() -> Vec<String> {
    TAB_ORDER
        .iter()
        .filter(|t| body_split(**t).is_none())
        .map(|t| format!("{}_pane", t.slot_id()))
        .collect()
}

/// Build the holger shell as a declarative [`Scene`] (the Rust-builder form). The
/// pane tree is CONSTANT (one leaf per [`TAB_ORDER`] entry) so a persisted `NavState`
/// stays valid frame-to-frame; only the **Repositories** submenu is data-driven (one
/// `Action::Command("repo:<name>")` per reported repository), so this is rebuilt
/// each frame from the current repo roster.
///
/// ```text
/// holger-shell ── Split(H, 0.22, DRAGGABLE)          [phone → Tabs(active 1) under 760pt]
/// ├─ holger-rail ── Split(V, 0.60, DRAGGABLE)        (Foldable 🗄, drawer@760) "Registry"
/// │  ├─ rail_roster_pane    (Foldable 📚) slot:rail-roster     — the MASTER selection
/// │  └─ rail_selection_pane (Foldable 🔎) slot:rail-selection  — its cross-tab context
/// └─ holger-workspace ── Tabs(active 0)              "Workspace"
///    ├─ status_pane   (Foldable 🩺) slot:status            — HONESTLY one pane
///    ├─ repos_pane    ── Split(H, 0.62, DRAGGABLE)
///    │  ├─ repos_main       "Repos · grid"        slot:repos-main
///    │  └─ repos_inspector  "Repos · actions"     slot:repos-inspector
///    ├─ browse_pane   ── Split(H, 0.66, DRAGGABLE)
///    │  ├─ browse_main      "Browse · artifacts"  slot:browse-main
///    │  └─ browse_inspector "Browse · source"     slot:browse-inspector
///    ├─ archive_pane  ── Split(H, 0.64, DRAGGABLE)
///    │  ├─ archive_main     "Archive · files"     slot:archive-main
///    │  └─ archive_inspector"Archive · stats"     slot:archive-inspector
///    ├─ artifact_pane ── Split(H, 0.48, DRAGGABLE)   query │ metadata
///    ├─ graph_pane    ── Split(H, 0.74, DRAGGABLE)   canvas │ inspector
///    ├─ helix_pane    ── Split(H, 0.74, DRAGGABLE)   canvas │ inspector
///    ├─ lineage_pane  ── Split(H, 0.74, DRAGGABLE)   canvas │ feed
///    ├─ tools_pane    ── Split(H, 0.26, DRAGGABLE)   functions │ output
///    ├─ upload_pane   ── Split(H, 0.58, DRAGGABLE)   form │ target
///    └─ signin_pane   (Foldable 🔑) slot:signin            — HONESTLY one pane
/// ```
///
/// The rail's pane TITLES ("Registry" / "Roster" / "Selection") deliberately avoid the
/// tab labels and the menu-group names: a fold header, a tab header and a menu button
/// are all AccessKit-labelled nodes, so a rail titled "Repositories" would shadow the
/// Repositories MENU and make both un-addressable by a robot.
// `repos` feeds only the Repositories menu, which mini drops — hence unused there.
#[cfg_attr(feature = "mini", allow(unused_variables))]
pub(super) fn holger_scene(repos: &[RepoRow]) -> Scene {
    let leaves: Vec<Pane> = TAB_ORDER.iter().map(|t| tab_body(*t)).collect();
    let workspace = Pane::tabs(WORKSPACE, 0, leaves).title("Workspace");

    // ── The registry RAIL (master) — a draggable VERTICAL split: the repository roster
    // over what the workspace currently knows about the selected repo. The rail itself is
    // a foldable DRAWER, so a phone-class viewport shuts it and gives the screen to the
    // workspace.
    let rail = Pane::split(
        RAIL,
        Axis::Vertical,
        0.60,
        vec![
            Pane::slot(RAIL_ROSTER_PANE, "rail-roster")
                .title("Roster")
                .foldable_icon(true, "📚"),
            Pane::slot(RAIL_SELECTION_PANE, "rail-selection")
                .title("Selection")
                .foldable_icon(true, "🔎"),
        ],
    )
    .title("Registry")
    .foldable_icon(true, "🗄")
    .drawer(PHONE_BREAKPOINT);

    // ── The shell root: a DRAGGABLE horizontal master/detail split. Below the phone
    // breakpoint it collapses to `Tabs` with the WORKSPACE (child 1) active — a narrow
    // screen gets the whole detail surface, with the rail one tab away, instead of a 22%
    // sliver of each.
    let root = Pane::split(ROOT, Axis::Horizontal, 0.22, vec![rail, workspace])
        .title("holger")
        .phone(Layout::Tabs { active: 1 }, PHONE_BREAKPOINT);

    // ── The grouped menubar (mirrors the Ctrl-K command set): every menu action is
    // an `Action::Command(<existing id>)` routed straight into `dispatch_command`, so
    // the menu and the palette share ONE interpreter (REUSE, no second dispatcher).
    // Mini drops the View menu: with only five tabs the visible TabView strip already IS
    // the tab navigator, so a dropdown that just re-lists it is redundant chrome.
    #[cfg(not(feature = "mini"))]
    let view = MenuItem::new("view", "View").items(
        TAB_ORDER
            .iter()
            .enumerate()
            .map(|(i, t)| {
                let slot = t.slot_id();
                let item = MenuItem::new(format!("goto:{slot}"), t.label())
                    .action(Action::Command(format!("goto:{slot}")));
                match view_accelerator(i) {
                    Some(keys) => item.shortcut(keys),
                    // Past the digit row there is no accelerator to give — better a
                    // menu entry with none than one that lies (see `view_accelerator`).
                    None => item,
                }
            })
            .collect(),
    );

    // Mini drops the Operate menu wholesale: every entry is a full-tier tool the mini
    // server cannot serve — holger-ds (benchmark egress), Migrate (Nexus/Artifactory
    // federation), Security scan (Móðguðr), Deploy (Skíðblaðnir host provisioning),
    // Airgap populate (build→push, a WRITE), Demo appliance (jera OCI container).
    #[cfg(not(feature = "mini"))]
    let operate = MenuItem::new("operate", "Operate").items(vec![
        tool_item("tool:holger-ds", "holger-ds (delivery bench)"),
        tool_item("tool:migrate", "Migrate"),
        tool_item("tool:security", "Security scan (Móðguðr)"),
        tool_item("tool:deploy", "Deploy (Skíðblaðnir)"),
        tool_item("tool:airgap", "Airgap populate"),
        tool_item("tool:demo", "Demo appliance (jera container)"),
    ]);

    // One command per reported repository — the runtime-built submenu (the reason
    // the scene is rebuilt each frame). Mini drops it: the rail's Roster IS the repo
    // picker (filterable, always visible), so the menu only duplicated it (plus a jump
    // to Browse). The `repo:` commands still live in the Ctrl-K palette.
    #[cfg(not(feature = "mini"))]
    let repositories = MenuItem::new("repositories", "Repositories").items(
        repos
            .iter()
            .map(|r| {
                MenuItem::new(format!("repo:{}", r.name), format!("Open · {}", r.name))
                    .action(Action::Command(format!("repo:{}", r.name)))
            })
            .collect(),
    );

    // `mut` for the full tier's conditional pushes below; mini keeps only Refresh.
    #[cfg_attr(feature = "mini", allow(unused_mut))]
    let mut action_items = vec![MenuItem::new("action:refresh", "Refresh repositories")
        .shortcut("F5")
        .action(Action::Command("action:refresh".into()))];
    // Scan (Móðguðr) is a full-tier tool and the static / Skade-Vinter toggle is the
    // demo showcase — the mini tier carries neither, so its Action menu is just Refresh.
    #[cfg(not(feature = "mini"))]
    {
        action_items.push(
            MenuItem::new("action:scan-selected", "Scan selected repo")
                .action(Action::Command("action:scan-selected".into())),
        );
        action_items.push(
            MenuItem::new("action:toggle-static", "Toggle static (Skade Vinter)")
                .action(Action::Command("action:toggle-static".into())),
        );
    }
    let action = MenuItem::new("action", "Action").items(action_items);

    // ⚠ There is deliberately NO "Look" menu here any more.
    //
    // The menubar used to carry a top-level **Look** group listing every theme preset,
    // and the chrome bar carried the same switcher inline — appearance advertised twice
    // at the shell root. The root menu is for NAVIGATION (View · Repositories) and
    // ACTIONS (Operate · Action · Help); appearance is a PREFERENCE, and it now lives in
    // the ⚙ **Settings** pane, exactly as dwarves-demo splits it (its `settings` tab,
    // glyph 🎛, owns the theme picker and none of its menus mention appearance).
    //
    // Nothing was lost: Settings is a real, foldable scene pane reachable from the tab
    // strip and from `goto:settings` (View menu + Ctrl-K, both derived from `TAB_ORDER`),
    // and the `look:<name>` commands still resolve in `dispatch_command` — so the Ctrl-K
    // palette, the demo tour and every headless robot drive are unchanged. Ctrl-K is a
    // searchable index of everything the app can do, not a menu.

    let help = MenuItem::new("help", "Help").items(vec![MenuItem::new("about:open", "About holger")
        .action(Action::Command("about:open".into()))]);

    // Full adds View (tab list) + Operate (tools) on the left of the bar; mini omits both
    // (redundant tab strip / no full-tier tools). Repositories · Action · Help stay.
    let scene = Scene::new("holger", root);
    #[cfg(not(feature = "mini"))]
    let scene = scene.menu(view).menu(operate).menu(repositories);
    scene.menu(action).menu(help)
}

/// The keyboard accelerator advertised for the View-menu entry of the tab at strip
/// index `i` — `Ctrl+1`…`Ctrl+9` for the first nine, `Ctrl+0` for the tenth, and
/// **`None`** past that.
///
/// The old formula was `Ctrl+{(i + 1) % 10}`, written when the strip had ten tabs.
/// The eleventh (**Sign in**) wrapped it back around: `(10 + 1) % 10 == 1`, so the
/// menu advertised **`Ctrl+1` twice** — once for Status and once for Sign in. Two
/// entries claiming one accelerator is a menu that lies to the operator, and it is
/// exactly the drift a modulo hides: it never fails, it just silently aliases.
///
/// There are only ten digits. Beyond them the honest answer is "no accelerator",
/// and `view_accelerators_are_unique_across_the_whole_strip` holds it to that
/// however many tabs the strip grows to.
#[cfg(not(feature = "mini"))]
fn view_accelerator(i: usize) -> Option<String> {
    match i {
        0..=8 => Some(format!("Ctrl+{}", i + 1)),
        9 => Some("Ctrl+0".to_string()),
        _ => None,
    }
}

/// An Operate-menu leaf: `Action::Command("tool:<id>")` → the existing dispatcher
/// selects the Tools tab + that tool.
#[cfg(not(feature = "mini"))]
fn tool_item(id: &str, label: &str) -> MenuItem {
    MenuItem::new(id, label).action(Action::Command(id.into()))
}

/// The names of every foldable pane in a scene (introspection for `state_json` and
/// the coverage tests).
///
/// A thin delegate to facett-app's OWN [`Scene::foldable_panes`] — holger used to
/// re-walk the tree here, which is exactly the twin L5 forbids. The shared crate is the
/// one place that knows what "foldable" means.
pub(super) fn foldable_panes(scene: &Scene) -> Vec<String> {
    scene.foldable_panes()
}

/// The names of every pane whose layout is a **draggable** (`resizable`) split — the
/// divider roster the robot suite drags and `state_json` reports. A split that loses
/// `resizable: true` drops out of this list, which is exactly what the coverage test
/// asserts against. Delegates to facett-app's own [`Scene::draggable_splits`] (L5).
pub(super) fn draggable_splits(scene: &Scene) -> Vec<String> {
    scene.draggable_splits()
}

/// The names of every DRAWER pane (auto-folds crossing into a phone-class width).
pub(super) fn drawer_panes(scene: &Scene) -> Vec<String> {
    scene
        .pane_names()
        .into_iter()
        .filter(|n| scene.pane(n).map(|p| p.responsive.drawer).unwrap_or(false))
        .collect()
}

/// The slot host: draws each [`fscene::Content::Slot`] by calling the matching
/// existing `<tab>_tab` renderer on the app, and folds that view-model's
/// `state_json()` into the scene's observable state. Menu commands route straight
/// into [`HolgerUiApp::dispatch_command`].
struct SceneHostImpl<'a> {
    app: &'a mut HolgerUiApp,
}

impl SceneHost for SceneHostImpl<'_> {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value {
        let app = &mut *self.app;
        match id {
            // ── the registry RAIL (the master side) ──────────────────────────────────
            "rail-roster" => app.rail_roster(ui),
            "rail-selection" => app.rail_selection(ui),
            "status" => {
                app.status_tab(ui);
                app.data.status.state_json()
            }
            // ── the DECOMPOSED tab bodies: `<slot>-main` is the canvas / table / list,
            // `<slot>-inspector` the controls, readouts and detail beside it. Each pair is
            // a DRAGGABLE split, so minimise / maximise / drag finally reach INSIDE a tab.
            "repos-main" => {
                app.repos_grid(ui);
                app.data.repos.state_json()
            }
            "repos-inspector" => app.repos_actions(ui),
            // Browse / Archive are the mini tier's PAGING surfaces; the mikro tier
            // registers no such pane (its `TAB_ORDER` has three entries) and compiles
            // no body behind one.
            #[cfg(not(feature = "mikro"))]
            "browse-main" => {
                app.browse_listing(ui);
                app.data.browse.state_json()
            }
            #[cfg(not(feature = "mikro"))]
            "browse-inspector" => app.browse_source(ui),
            #[cfg(not(feature = "mikro"))]
            "archive-main" => {
                app.archive_files(ui);
                app.data.archive.state_json()
            }
            #[cfg(not(feature = "mikro"))]
            "archive-inspector" => app.archive_stats(ui),
            "artifact-main" => {
                app.artifact_query(ui);
                app.data.artifact.state_json()
            }
            "artifact-inspector" => app.artifact_metadata(ui),
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            "search-main" => {
                app.search_hits(ui);
                app.data.search.state_json()
            }
            #[cfg(all(feature = "mini", not(feature = "mikro")))]
            "search-inspector" => app.search_query(ui),
            // ── the FULL-TIER slots. `holger_scene` does not register these panes on
            // the read-only tiers (their `#[cfg(not(feature = "mini"))]` blocks above),
            // and the bodies behind them are not compiled there either — so the arms
            // must go too, or the dispatch would name methods that do not exist.
            #[cfg(not(feature = "mini"))]
            "graph-main" => {
                app.graph_canvas(ui);
                app.graph_state_json()
            }
            #[cfg(not(feature = "mini"))]
            "graph-inspector" => app.graph_inspector(ui),
            #[cfg(not(feature = "mini"))]
            "helix-main" => {
                app.helix_canvas(ui);
                json!({ "built": app.helix.is_some() })
            }
            #[cfg(not(feature = "mini"))]
            "helix-inspector" => app.helix_inspector(ui),
            #[cfg(not(feature = "mini"))]
            "lineage-main" => {
                app.lineage_canvas(ui);
                app.data.lineage.state_json()
            }
            #[cfg(not(feature = "mini"))]
            "lineage-inspector" => app.lineage_feed(ui),
            #[cfg(not(feature = "mini"))]
            "tools-main" => app.tools_functions(ui),
            #[cfg(not(feature = "mini"))]
            "tools-inspector" => {
                app.tools_output(ui);
                json!({ "tool": tool_id(app.tool) })
            }
            #[cfg(not(feature = "mini"))]
            "upload-main" => {
                app.upload_form(ui);
                json!({ "writable": app.data.repos.upload_enabled() })
            }
            #[cfg(not(feature = "mini"))]
            "upload-inspector" => app.upload_target(ui),
            // The SHARED device-code sign-in pane (facett_auth_ui) — its own `state_json`
            // carries the login state machine's node + the subject / credential NAMES,
            // never a token VALUE.
            #[cfg(not(feature = "mini"))]
            "signin" => {
                app.signin_tab(ui);
                app.login_pane.state_json()
            }
            // ⚙ Settings — the preferences pane and the ONE home of holger's look &
            // feel (it moved off the root chrome bar and out of the root "Look" menu;
            // see `HolgerUiApp::settings_pane`). Registered on EVERY tier: it drives
            // no gRPC op, and the read-only tiers would otherwise have no look control
            // at all. Its state IS `look_state_json`, so there is one look oracle.
            "settings" => app.settings_pane(ui),
            other => {
                ui.weak(format!("no widget for slot: {other}"));
                Value::Null
            }
        }
    }

    fn command(&mut self, id: &str) {
        self.app.dispatch_command(id);
    }
}

/// The stable tool id (mirror of the `state_json` mapping) for the tools slot's
/// observable state.
#[cfg(not(feature = "mini"))]
fn tool_id(tool: ToolsFn) -> &'static str {
    match tool {
        ToolsFn::HolgerDs => "holger-ds",
        ToolsFn::Migrate => "migrate",
        ToolsFn::Security => "security",
        ToolsFn::Deploy => "deploy",
        ToolsFn::Airgap => "airgap",
        ToolsFn::Demo => "demo",
    }
}

impl HolgerUiApp {
    /// **The rail's ROSTER pane — holger's master selection, hoisted out of a tab.**
    ///
    /// A filter box over a scrolling list of every reported repository; clicking a row
    /// runs the SAME [`select_repo`](HolgerUiApp::select_repo) the Repos tab's combo box
    /// and the `repo:<name>` menu command run, so there is exactly ONE selection in the
    /// app and every detail tab follows it. Row labels carry a writability glyph and are
    /// prefixed (`▪`) so they can never collide with the bare repo names the Repos combo
    /// popup and the Repositories menu render.
    ///
    /// Returns the pane's observable state (filter, shown/total counts, the selection).
    /// **Search · hits — the MASTER half (mini).** The cross-repo artifact hits as a
    /// table, plus the path-axis hit count. Renders whatever the last search loaded.
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    fn search_hits(&mut self, ui: &mut egui::Ui) {
        use facett::table::Table;
        let mut table = Table::new(
            "search-hits",
            vec!["repo".into(), "name".into(), "version".into(), "size".into()],
        );
        for h in &self.data.search.artifacts {
            table.push_row(vec![
                h.repository.clone(),
                match &h.id.namespace {
                    Some(ns) => format!("{ns}/{}", h.id.name),
                    None => h.id.name.clone(),
                },
                h.id.version.clone(),
                h.size_bytes.to_string(),
            ]);
        }
        table.ui(ui);
        if !self.data.search.paths.is_empty() {
            ui.weak(format!("+ {} archive-path hits", self.data.search.paths.len()));
        }
    }

    /// **Search · query — the INSPECTOR half (mini).** The name-axis input, the search
    /// trigger, and the result counts / truncation / error readout.
    #[cfg(all(feature = "mini", not(feature = "mikro")))]
    fn search_query(&mut self, ui: &mut egui::Ui) -> Value {
        ui.label("name contains");
        let resp = ui.text_edit_singleline(&mut self.search_input);
        let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
        if ui.button("🔎 Search").clicked() || submitted {
            let q = self.search_input.clone();
            self.data.refresh_search(&q);
        }
        ui.separator();
        let sv = &self.data.search;
        if let Some(err) = &sv.error {
            ui.colored_label(egui::Color32::RED, format!("error: {err}"));
        } else if sv.searched {
            ui.label(format!(
                "{} artifact hits · {} path hits{}",
                sv.artifacts.len(),
                sv.paths.len(),
                if sv.truncated { " (truncated — narrow the query)" } else { "" }
            ));
        } else {
            ui.weak("search every repository at once by name substring");
        }
        self.data.search.state_json()
    }

    fn rail_roster(&mut self, ui: &mut egui::Ui) -> Value {
        ui.horizontal(|ui| {
            ui.label("🔎");
            ui.add(
                egui::TextEdit::singleline(&mut self.rail_filter)
                    .desired_width(f32::INFINITY)
                    .hint_text("filter repositories"),
            );
        });

        let needle = self.rail_filter.trim().to_lowercase();
        // Collect first: the click handler below mutates `self.data`, so no borrow of the
        // roster may outlive this scan.
        let shown: Vec<(usize, String, bool)> = self
            .data
            .repos
            .repos
            .iter()
            .enumerate()
            .filter(|(_, r)| needle.is_empty() || r.name.to_lowercase().contains(&needle))
            .map(|(i, r)| (i, r.name.clone(), r.writable))
            .collect();

        let selected = self.data.repos.selected;
        let mut pick: Option<usize> = None;
        egui::ScrollArea::vertical()
            .auto_shrink([false, false])
            .id_salt("rail-roster-scroll")
            .show(ui, |ui| {
                if shown.is_empty() {
                    ui.weak(if self.data.repos.repos.is_empty() {
                        "(no repositories reported)"
                    } else {
                        "(no repository matches the filter)"
                    });
                }
                for (i, name, writable) in &shown {
                    // `✎` writable · `🔒` read-only. The archive/listing facts belong to
                    // the Selection pane below — a row label stays a stable, addressable
                    // identifier (a robot clicks it by name), never a live status string.
                    // Mini: every repo on the read-only tier is 🔒, so the icon carries no
                    // information — the row is just the name.
                    #[cfg(not(feature = "mini"))]
                    let label = format!("▪ {} {name}", if *writable { "✎" } else { "🔒" });
                    #[cfg(feature = "mini")]
                    let label = {
                        let _ = writable;
                        format!("▪ {name}")
                    };
                    if ui.selectable_label(selected == Some(*i), label).clicked() {
                        pick = Some(*i);
                    }
                }
            });
        if let Some(i) = pick {
            self.select_repo(i);
        }

        json!({
            "filter": self.rail_filter,
            "shown": shown.len(),
            "total": self.data.repos.repos.len(),
            "selected": self.data.repos.selected,
            "selected_repo": self.data.repos.selected_repo().map(|r| r.name.clone()),
        })
    }

    /// **The rail's SELECTION pane — the detail the Repos table cannot show.**
    ///
    /// The Repos tab renders a row PER repository; this renders what the whole workspace
    /// currently holds for the ONE selected repository: its capabilities, whether the
    /// Browse listing / Archive view in the workspace are actually loaded FOR it (they
    /// stamp the repo they fetched), and whether Upload is unlocked. That cross-tab fact
    /// exists nowhere else in the shell — it is why this is an honest second pane and not
    /// a decorative one.
    fn rail_selection(&mut self, ui: &mut egui::Ui) -> Value {
        let Some(r) = self.data.repos.selected_repo().cloned() else {
            ui.weak("(no repository selected)");
            ui.weak("pick one from the roster above");
            return json!({ "selected": Value::Null });
        };

        // Loaded-FOR-this-repo, not merely "loaded": both views stamp the repository they
        // fetched, so a stale listing from the previous selection reads as not loaded.
        let browse_loaded = self.data.browse.repository == r.name;
        let archive_loaded = self.data.archive.repository == r.name;
        let browse_entries = if browse_loaded { self.data.browse.entries.len() } else { 0 };
        let archive_files = if archive_loaded { self.data.archive.file_count } else { 0 };
        #[cfg(not(feature = "mini"))]
        let upload_enabled = self.data.repos.upload_enabled();

        ui.label(egui::RichText::new(&r.name).strong());
        egui::Grid::new("rail_selection_grid").num_columns(2).striped(true).show(ui, |ui| {
            ui.label("type");
            ui.label(&r.repo_type);
            ui.end_row();
            // "access" (always read-only) and "upload" (a write gate with no Upload tab)
            // carry no information on the read-only mini tier — dropped there.
            #[cfg(not(feature = "mini"))]
            {
                ui.label("access");
                ui.label(if r.writable { "read · write" } else { "read only" });
                ui.end_row();
            }
            ui.label("archive");
            ui.label(if r.has_archive { "znippy" } else { "—" });
            ui.end_row();
            ui.label("listed");
            ui.label(if browse_loaded { format!("{browse_entries} artifacts") } else { "—".into() });
            ui.end_row();
            ui.label("archived");
            ui.label(if archive_loaded { format!("{archive_files} files") } else { "—".into() });
            ui.end_row();
            #[cfg(not(feature = "mini"))]
            {
                ui.label("upload");
                ui.label(if upload_enabled { "unlocked" } else { "locked" });
                ui.end_row();
            }
        });

        // `mut` for the full tier's writable/upload_enabled inserts below.
        #[cfg_attr(feature = "mini", allow(unused_mut))]
        let mut state = json!({
            "selected": r.name,
            "repo_type": r.repo_type,
            "has_archive": r.has_archive,
            "browse_loaded": browse_loaded,
            "browse_entries": browse_entries,
            "archive_loaded": archive_loaded,
            "archive_files": archive_files,
        });
        #[cfg(not(feature = "mini"))]
        {
            state["writable"] = json!(r.writable);
            state["upload_enabled"] = json!(upload_enabled);
        }
        state
    }

    /// Render the shell through the declarative [`Scene`] — the menubar + the tabbed
    /// pane tree — DRIVING the existing tab renderers via [`SceneHostImpl`]. Called
    /// from [`HolgerUiApp::render`] in place of the old hand-wired menu-bar + tab
    /// strip + `match self.tab` central panel.
    ///
    /// `self.tab` stays the authoritative tab (so every command / demo / robot hook
    /// keeps driving it): it is pushed into the scene's `active_tab` before the render
    /// and, if a tab-header click moved it this frame, read back afterwards. The
    /// static-mode Upload guard is re-applied on the way out. Returns the render
    /// `state_json` (cached into `self.last_render`).
    pub(super) fn render_scene(&mut self, ui: &mut egui::Ui) -> Value {
        // Rebuild the scene each frame so the Repositories submenu tracks the live
        // roster; the pane tree is constant, so the persisted `nav` stays valid.
        let scene = holger_scene(&self.data.repos.repos);
        let mut nav = self.nav.clone();

        // Push the authoritative tab into the scene before rendering. The tab strip is
        // the WORKSPACE pane (the detail side of the root split), not the root itself.
        let pre = tab_index(self.tab);
        nav.set_active_tab(WORKSPACE, pre);

        let val = {
            let mut host = SceneHostImpl { app: self };
            fscene::render(&scene, &mut nav, ui, &mut host)
        };

        // A tab-header click this frame moved the active tab → adopt it. (A menu
        // command instead changed `self.tab` directly and left `active_tab` at `pre`,
        // so we don't clobber it.)
        let post = nav.active_tab(WORKSPACE);
        if post != pre {
            self.tab = tab_from_index(post);
        }
        // Write tabs are unavailable in static (read-only) mode — bounce Upload back.
        if self.static_mode && self.tab == Tab::Upload {
            self.tab = Tab::Status;
        }
        nav.set_active_tab(WORKSPACE, tab_index(self.tab));
        self.nav = nav;
        val
    }

    /// The declarative shell as observable data (folded into the app's `state_json`):
    /// the pane tree, which panes are foldable, the live fold + active-tab state, and
    /// the last render's facts (device class, pane rects, per-slot state).
    pub(super) fn scene_state_json(&self) -> Value {
        // Start from the last render's OWN facts (`scene` name, the responsive `device`
        // class + `width`, and the per-pane `panes` render map) so the adopted-scene and
        // responsive-fold oracles read them at the top level of `/scene`, then fold the
        // declarative shell's structure in beside them. Both surfaces, one object.
        let mut out = match self.last_render.clone() {
            Value::Object(m) => m,
            _ => serde_json::Map::new(),
        };
        out.insert("name".into(), json!(self.scene.name));
        // The pane tree as a NAME LIST (the render map above already owns `panes`, keyed
        // by pane name — this is the flat roster the addressability oracle walks).
        out.insert("pane_names".into(), json!(self.scene.pane_names()));
        out.insert("foldable".into(), json!(foldable_panes(&self.scene)));
        // The DRAGGABLE dividers, by pane name — the affordance roster a robot drags and
        // an operator grabs. Derived from the scene, so a split that silently loses its
        // `resizable` disappears from here (RED-when-broken, not "still declared").
        out.insert("draggable_splits".into(), json!(draggable_splits(&self.scene)));
        // Every pane that draws a WINDOW-CHROME title bar (fold · minimise · maximise).
        out.insert("chrome".into(), json!(self.scene.chrome_panes()));
        // facett-app's own adoption audit — the one blob naming every affordance this
        // shell ships. Asserting against THIS (rather than a hand-copied list) is what
        // makes "the chrome silently disappeared" a red test instead of a shrug.
        out.insert("affordances".into(), self.scene.affordances());
        // The DRAWER panes (auto-fold crossing into phone-class).
        out.insert("drawers".into(), json!(drawer_panes(&self.scene)));
        // **The tab INTERNALS, as data.** Which tab bodies decomposed into a draggable
        // master/detail split of `<slot>_main` | `<slot>_inspector`, and which are
        // honestly ONE pane. A robot reads this instead of hard-coding the decomposition,
        // so a tab split (or un-split) later is swept without editing the suite.
        out.insert("split_bodies".into(), json!(split_bodies()));
        out.insert("single_pane_bodies".into(), json!(single_pane_bodies()));
        out.insert("menus".into(), json!(self.scene.menubar.len()));
        // The tab strip lives on the WORKSPACE pane (the detail side of the root split).
        out.insert("active_tab".into(), json!(self.nav.active_tab.get(WORKSPACE)));
        out.insert("folded".into(), json!(self.nav.folded));
        // The live WINDOW state: which panes are collapsed to their title bar, which one
        // (at most) holds the whole shell, and the last chrome control that fired.
        out.insert("minimized".into(), json!(self.nav.minimized));
        out.insert("maximized".into(), json!(self.nav.maximized));
        out.insert("last_chrome".into(), json!(self.nav.last_chrome));
        // The LIVE split ratios (a divider drag moves them; observable without pixels).
        out.insert("split_ratio".into(), json!(self.nav.split_ratio));
        out.insert("render".into(), self.last_render.clone());
        Value::Object(out)
    }

    /// Robot/test hook: fold or unfold a scene pane by name (the same toggle a
    /// fold-header click runs). Returns the new folded state. Observable in
    /// `state_json["scene"]["folded"]`.
    pub fn toggle_scene_fold(&mut self, pane: &str) -> bool {
        self.nav.toggle_fold(pane)
    }

    /// Robot/test/command hook: **minimise** a scene pane — collapse it to its chrome
    /// title bar and hand its space back to its siblings. The same toggle the bar's `−`
    /// control runs. Returns the new minimised state; observable in
    /// `state_json["scene"]["minimized"]`, and in the pane's shrunken render `alloc`.
    pub fn toggle_scene_minimize(&mut self, pane: &str) -> bool {
        self.nav.toggle_minimize(pane)
    }

    /// Robot/test/command hook: **maximise** a scene pane — give it the whole shell
    /// area, shrinking every non-ancestor sibling to zero. At most one pane holds it.
    /// The same toggle the bar's `🗖` control runs. Returns the new maximised state;
    /// observable in `state_json["scene"]["maximized"]`.
    pub fn toggle_scene_maximize(&mut self, pane: &str) -> bool {
        self.nav.toggle_maximize(pane)
    }

    /// Robot/test/command hook: **restore** a scene pane — drop its minimise AND its
    /// maximise. The split ratios were never overwritten, so the layout returns exactly
    /// as it was. Returns true if anything changed.
    pub fn restore_scene_pane(&mut self, pane: &str) -> bool {
        self.nav.restore(pane)
    }

    /// Robot/test/command hook: **restore every pane** (the "reset layout" one-liner).
    /// Returns true if anything changed.
    pub fn restore_scene_layout(&mut self) -> bool {
        self.nav.restore_all()
    }
}

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

    fn repo(name: &str) -> RepoRow {
        RepoRow {
            name: name.into(),
            repo_type: "rust".into(),
            writable: true,
            has_archive: false,
        }
    }

    fn scene() -> Scene {
        holger_scene(&[repo("rust-dev"), repo("cache")])
    }

    /// The label a pane's chrome bar / fold header actually paints (title, else name).
    ///
    /// A local mirror of facett-app's own `Pane::header_title`, which is PRIVATE — the
    /// host cannot audit the AccessKit labels its own scene publishes without it. See the
    /// facett patch note in this module's docs.
    fn header_title(p: &Pane) -> &str {
        if p.title.is_empty() { &p.name } else { &p.title }
    }

    /// The shell scene is addressable: every pane name AND every menu name is unique
    /// (`Scene::validate`) — RED-when-broken if a tab id or menu id ever collides.
    #[test]
    fn holger_scene_is_valid() {
        scene().validate().expect("unique pane + menu names");
        // root split · rail split · 2 rail leaves · workspace tabs · one body per tab,
        // plus the two halves of every body that DECOMPOSED into a master/detail split.
        let names = scene().pane_names();
        for structural in [ROOT, RAIL, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE, WORKSPACE] {
            assert!(
                names.contains(&structural.to_string()),
                "the `{structural}` structural pane is present: {names:?}"
            );
        }
        assert_eq!(
            names.len(),
            5 + TAB_ORDER.len() + 2 * split_bodies().len(),
            "root + rail + 2 rail leaves + workspace + one body per tab + 2 halves per \
             decomposed body: {names:?}"
        );
    }

    /// **Every tab body that claims a master/detail split really is one — DRAGGABLE,
    /// two named halves, each its own foldable/chromed pane.**
    ///
    /// This is the regression that shipped: the SHELL adopted the scene, the tab
    /// INTERNALS never did, so minimise/maximise/drag operated on a whole tab and could
    /// do nothing inside it. RED-when-broken the moment a body is downgraded back to a
    /// slot leaf or its split loses `resizable`.
    #[test]
    fn every_decomposed_tab_body_is_a_draggable_split_of_two_named_halves() {
        let s = scene();
        let splits = draggable_splits(&s);
        let bodies = split_bodies();
        assert!(!bodies.is_empty(), "at least one tab body decomposes");

        for t in TAB_ORDER {
            let body = format!("{}_pane", t.slot_id());
            let pane = s.pane(&body).unwrap_or_else(|| panic!("`{body}` exists"));
            let Some(b) = body_split(t) else {
                // An HONEST single pane: a slot leaf, and it must NOT claim a divider.
                assert!(
                    matches!(pane.content, fscene::Content::Slot(_)),
                    "`{body}` is honestly one pane — a slot leaf, got {:?}",
                    pane.layout
                );
                assert!(
                    !splits.contains(&body),
                    "a single-pane body declares no divider: {splits:?}"
                );
                continue;
            };
            match &pane.layout {
                Layout::Split { axis, ratios, resizable: true } => {
                    assert_eq!(*axis, b.axis, "`{body}` splits along its declared axis");
                    assert!(
                        (ratios[0] - b.ratio).abs() < 1e-5,
                        "`{body}` seeds its master at {}: {ratios:?}",
                        b.ratio
                    );
                }
                other => panic!("`{body}` is a DRAGGABLE split, got {other:?}"),
            }
            assert!(splits.contains(&body), "`{body}` is reported draggable: {splits:?}");

            // Both halves exist, are foldable (⇒ chromed), and carry DISTINCT titles that
            // shadow neither the tab label nor each other.
            let foldable = foldable_panes(&s);
            for (half, title) in [("main", b.main.0), ("inspector", b.inspector.0)] {
                let name = format!("{}_{half}", t.slot_id());
                let p = s.pane(&name).unwrap_or_else(|| panic!("`{name}` exists"));
                assert_eq!(header_title(p), title, "`{name}` keeps its declared title");
                assert!(foldable.contains(&name), "`{name}` folds: {foldable:?}");
                assert_ne!(header_title(p), t.label(), "`{name}` does not shadow the tab label");
            }
            assert_ne!(b.main.0, b.inspector.0, "`{body}`'s halves are distinctly titled");
        }

        // The two rosters partition the strip — no tab is both, none is neither.
        assert_eq!(
            bodies.len() + single_pane_bodies().len(),
            TAB_ORDER.len(),
            "every tab is either decomposed or honestly single-pane"
        );
    }

    /// **No two addressable labels in the shell collide.** Pane titles, tab labels and
    /// menu-group names all become AccessKit-labelled nodes, and `query_by_label` ERRORS
    /// on ambiguity — so a second `🗄 Warehouse`-style duplicate makes both nodes
    /// unreachable. Chrome is default-ON, which turned every pane TITLE into such a node,
    /// so this must hold over the whole scene and not just the tab strip.
    #[test]
    fn every_pane_title_tab_label_and_menu_group_is_uniquely_addressable() {
        let s = scene();
        let mut seen: Vec<(String, String)> = Vec::new();
        let mut claim = |label: String, kind: &str| {
            if let Some((other, _)) = seen.iter().find(|(l, _)| *l == label) {
                panic!("`{label}` is claimed by BOTH a {other} and a {kind} — ambiguous");
            }
            seen.push((kind.to_string(), label));
        };
        for name in s.pane_names() {
            let p = s.pane(&name).expect("pane");
            // The tab BODIES are titled with their tab label ON PURPOSE — the TabView
            // header reads that title — so they are claimed once, as the tab.
            claim(header_title(p).to_string(), "pane title");
        }
        for m in &s.menubar {
            claim(m.label.clone(), "menu group");
        }
    }

    /// The shell round-trips through RON byte-stably (`to_ron ∘ from_ron` is a fixed
    /// point) AND the RON parses back to the SAME `Scene` the builder produced —
    /// RED-when-broken if the serde shape or the canonical RON config drifts.
    #[test]
    fn holger_scene_ron_round_trips_byte_stable() {
        let s = scene();
        let ron1 = s.to_ron();
        let reparsed = Scene::from_ron(&ron1).expect("the shell parses back from its own RON");
        assert_eq!(ron1, reparsed.to_ron(), "the shell's RON is byte-stable");
        assert_eq!(s, reparsed, "RON and the builder yield the SAME shell Scene");
    }

    /// Every tab body AND every rail pane is a foldable content pane (the fold
    /// interaction covers them all) — RED-when-broken if a leaf loses its `Foldable`.
    #[test]
    fn every_tab_leaf_and_rail_pane_is_foldable() {
        let s = scene();
        let foldable = foldable_panes(&s);
        for t in TAB_ORDER {
            let pane = format!("{}_pane", t.slot_id());
            assert!(foldable.contains(&pane), "the `{pane}` tab body is foldable: {foldable:?}");
        }
        for rail in [RAIL, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE] {
            assert!(foldable.contains(&rail.to_string()), "`{rail}` is foldable: {foldable:?}");
        }
        // …and so is every HALF of a decomposed body: the fold/minimise/maximise chrome
        // reaches INSIDE a tab, which is the whole point of the decomposition.
        for half in split_bodies() {
            let slot = half.trim_end_matches("_pane");
            for name in [format!("{slot}_main"), format!("{slot}_inspector")] {
                assert!(foldable.contains(&name), "`{name}` is foldable: {foldable:?}");
            }
        }
        assert_eq!(
            foldable.len(),
            TAB_ORDER.len() + 3 + 2 * split_bodies().len(),
            "all {} tab bodies + their split halves + the rail and its two panes fold",
            TAB_ORDER.len()
        );
    }

    /// **The shell has real structure: two DRAGGABLE dividers and a drawer.** The root
    /// is a resizable horizontal master/detail split (rail | workspace) that collapses
    /// to `Tabs` on a phone-class width; the rail is a resizable VERTICAL split and a
    /// drawer; the workspace is the `Tabs` pane whose active index maps to a real
    /// [`Tab`] both ways (the `self.tab` ⇄ `active_tab` bridge the renderer relies on).
    ///
    /// RED-when-broken the moment a split is downgraded to `split_fixed` — which is the
    /// exact regression this shell already shipped once, as a shell with no affordances.
    #[test]
    fn shell_is_a_draggable_master_detail_split_over_a_drawer_rail() {
        let s = scene();

        // The root: a DRAGGABLE horizontal split, rail-first, at the 0.22 seed.
        match s.pane(ROOT).map(|p| &p.layout) {
            Some(Layout::Split { axis: Axis::Horizontal, ratios, resizable: true }) => {
                assert!(
                    (ratios[0] - 0.22).abs() < 1e-5,
                    "the rail seeds at 0.22 of the shell width: {ratios:?}"
                );
                assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5, "ratios sum to 1");
            }
            other => panic!("the shell root is a DRAGGABLE horizontal split, got {other:?}"),
        }
        // …that collapses to Tabs with the WORKSPACE (child 1) active on a phone.
        let root = s.pane(ROOT).expect("root");
        assert_eq!(
            root.responsive.phone,
            Some(Layout::Tabs { active: 1 }),
            "phone-class collapses the split to Tabs on the workspace"
        );
        assert_eq!(root.responsive.collapse_below, Some(PHONE_BREAKPOINT));

        // The rail: a DRAGGABLE vertical split, and a drawer.
        let rail = s.pane(RAIL).expect("the registry rail");
        assert!(
            matches!(rail.layout, Layout::Split { axis: Axis::Vertical, resizable: true, .. }),
            "the rail is a DRAGGABLE vertical split, got {:?}",
            rail.layout
        );
        assert!(rail.responsive.drawer, "the rail is a phone drawer");

        // The SHELL's two dividers come first (root, then rail), and every remaining
        // draggable split is a decomposed tab BODY — nothing else claims to be draggable.
        let mut expected = vec![ROOT.to_string(), RAIL.to_string()];
        expected.extend(split_bodies());
        assert_eq!(
            draggable_splits(&s),
            expected,
            "the two shell dividers, root-first, then one per decomposed tab body"
        );
        assert_eq!(drawer_panes(&s), vec![RAIL.to_string()], "the rail is the one drawer");

        // The workspace is the Tabs pane, and the index bridge is total.
        assert!(
            matches!(s.pane(WORKSPACE).map(|p| &p.layout), Some(Layout::Tabs { .. })),
            "the workspace is a Tabs layout"
        );
        for (i, tab) in TAB_ORDER.iter().enumerate() {
            assert_eq!(tab_index(*tab), i, "tab_index round-trips");
            assert_eq!(tab_from_index(i), *tab, "tab_from_index round-trips");
        }
    }

    /// The menubar carries the five grouped menus, and each leaf routes an
    /// `Action::Command(<existing id>)` — proof the menu reuses the ONE dispatcher
    /// (no second command interpreter). RED-when-broken if a group or a wired command
    /// is dropped.
    ///
    /// There were SIX groups: a top-level **Look** submenu listed every theme preset.
    /// The root menu is navigation (View · Repositories) and actions (Operate · Action ·
    /// Help); appearance is a preference, so it moved into the ⚙ Settings pane
    /// (`HolgerUiApp::settings_pane`), matching dwarves-demo's `settings` tab. This
    /// asserts the menubar is now free of look & feel — see
    /// [`no_look_and_feel_at_the_shell_root`] for the RED-when-it-comes-back guard, and
    /// [`super::super::look_tests::settings_pane_is_the_one_home_of_look_and_feel`] for
    /// the proof the control still works where it landed.
    ///
    /// [`no_look_and_feel_at_the_shell_root`]: self::no_look_and_feel_at_the_shell_root
        // FULL TIER ONLY: the read-only tiers carry no View / Operate / Repositories
    // groups (no full-tier commands to route), so this asserts the full menubar.
    #[cfg(not(feature = "mini"))]
    #[test]
    fn menubar_groups_route_existing_commands() {
        let s = scene();
        let groups: Vec<&str> = s.menubar.iter().map(|m| m.name.as_str()).collect();
        assert_eq!(
            groups,
            vec!["view", "operate", "repositories", "action", "help"],
            "the five grouped menus, in order — navigation + actions, no appearance"
        );
        // A representative leaf from each functional group carries a Command action
        // whose id the existing `dispatch_command` understands.
        for id in ["goto:browse", "tool:security", "action:refresh", "about:open"] {
            let item = s.menu_item(id).unwrap_or_else(|| panic!("menu item {id} exists"));
            assert_eq!(
                item.action,
                Some(Action::Command(id.to_string())),
                "menu `{id}` routes Action::Command(\"{id}\") into the shared dispatcher"
            );
        }
        // Settings is reachable from the ROOT the same way every other view is — the
        // View menu's `goto:` entry, derived from `TAB_ORDER`. Moving the look control
        // off the menubar did not put it behind a hidden door.
        let item = s.menu_item("goto:settings").expect("View ▸ Settings exists");
        assert_eq!(item.action, Some(Action::Command("goto:settings".into())));
        // The runtime Repositories submenu carries one command per reported repo.
        assert!(s.menu_item("repo:rust-dev").is_some(), "a repo becomes a menu command");
    }

    /// **RED-when-broken: NO look & feel at the shell ROOT.**
    ///
    /// The declarative scene is the whole root surface — the menubar plus the pane
    /// tree — so this walks every menu group, every menu leaf id and every leaf LABEL
    /// and asserts none of them is an appearance control. A re-added `Look` menu, or a
    /// stray `look:<preset>` leaf smuggled into another group, fails here.
    ///
    /// The look control is not gone, it MOVED: `settings` is a registered pane in this
    /// same scene (asserted below), and `look:<name>` still resolves in the shared
    /// `dispatch_command` for Ctrl-K and the headless drives.
    #[test]
    fn no_look_and_feel_at_the_shell_root() {
        let s = scene();

        // (a) No menubar GROUP is an appearance group.
        for m in &s.menubar {
            assert_ne!(m.name, "look", "the root Look menu is gone — it lives in ⚙ Settings");
            assert!(
                !m.label.eq_ignore_ascii_case("look") && !m.label.eq_ignore_ascii_case("theme"),
                "no appearance group on the menubar: {:?}",
                m.label
            );
        }

        // (b) No menu LEAF anywhere in the bar is a look command — not by id, and not
        // by label (a preset re-added under a different id would still read "Look: …").
        for name in super::super::look_names() {
            assert!(
                s.menu_item(&format!("look:{name}")).is_none(),
                "the `look:{name}` preset must not be a menubar leaf"
            );
        }

        // (c) …and the pane it moved to really exists in this scene, foldable, with the
        // shared 🎛 glyph — a REAL pane, not a modal or a dropdown.
        let pane = s.pane("settings_pane").expect("the ⚙ Settings pane is registered");
        assert_eq!(pane.title, "Settings");
        assert!(foldable_panes(&s).iter().any(|p| p == "settings_pane"), "Settings folds");
        assert_eq!(fold_icon(Tab::Settings), "🎛", "the suite-wide Settings glyph");
    }

    /// **No two View-menu entries may claim the same keyboard accelerator.**
    ///
    /// RED-when-broken, and it WAS broken: the accelerator came from
    /// `Ctrl+{(i + 1) % 10}`, written when the strip had ten tabs. The eleventh
    /// (Sign in) wrapped — `(10 + 1) % 10 == 1` — so the menu advertised `Ctrl+1`
    /// for BOTH Status and Sign in. A modulo never fails; it silently aliases. This
    /// asserts uniqueness over the whole strip, so the twelfth tab cannot repeat it.
    // The View menu's digit accelerators exist only on the full tier (the read-only
    // tiers carry no View menu), so the test that holds them to uniqueness rides on
    // the same gate its subject does.
    #[cfg(not(feature = "mini"))]
    #[test]
    fn view_accelerators_are_unique_across_the_whole_strip() {
        let s = scene();
        let view = s
            .menubar
            .iter()
            .find(|m| m.name == "view")
            .expect("the View menu exists");

        let mut seen: Vec<(&str, &str)> = Vec::new();
        for item in &view.items {
            let Some(keys) = item.shortcut.as_deref() else { continue };
            if let Some((other, _)) = seen.iter().find(|(_, k)| *k == keys) {
                panic!("View menu advertises `{keys}` for BOTH `{other}` and `{}`", item.name);
            }
            seen.push((item.name.as_str(), keys));
        }

        // Every tab is offered; the first ten carry a digit accelerator, the rest
        // honestly carry none (there are only ten digits).
        assert_eq!(view.items.len(), TAB_ORDER.len(), "one View entry per tab");
        assert_eq!(seen.len(), TAB_ORDER.len().min(10), "the digit row, and no more");

        // The generator itself, at the boundary that broke.
        assert_eq!(view_accelerator(0).as_deref(), Some("Ctrl+1"));
        assert_eq!(view_accelerator(8).as_deref(), Some("Ctrl+9"));
        assert_eq!(view_accelerator(9).as_deref(), Some("Ctrl+0"));
        assert_eq!(view_accelerator(10), None, "the 11th tab gets none, not a duplicate");
        assert_eq!(view_accelerator(11), None);
    }
}