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

nordisk/nornir

src/viz/shell_scene.rs

//! **The Urðr-Threads master shell, described DECLARATIVELY.** nornir's adoption
//! of facett-app's [`scene`](facett_app::scene) layer: instead of hand-wiring the
//! right detail `SidePanel`, the bottom action-log `TopBottomPanel`, and the
//! central pane dispatch, the shell's master/detail LAYOUT is a
//! [`Scene`](facett_app::scene::Scene) value (a menubar + a tree of panes + nav
//! routes) rendered by [`facett_app::scene::render`] through facett-app's OWN
//! primitives (Router / TabView / facett-menu). Authored with the **Rust builder**;
//! the identical value round-trips through **RON** ([`Scene::to_ron`] /
//! [`Scene::from_ron`], byte-stable).
//!
//! **Interactions (all declarative):**
//! - **Draggable / resizable splits** — the master/detail
//!   [`Split`](facett_app::scene::Layout::Split)s have real drag handles; dragging
//!   the workspace|detail divider (or the panes|log divider) re-apportions the live
//!   ratio (kept in `NavState`, observable in `state_json`).
//! - **Fold / unfold** — the 🔍 Time-machine inspector and the 🐞 action-log are
//!   [`Foldable`](facett_app::scene::Fold::Foldable); folding draws only the header
//!   (contents leave the layout AND the AccessKit tree).
//! - **Rail → drawer** — the action-log is a `.drawer(760)` that auto-folds when the
//!   width crosses into phone-class and restores above it.
//! - **Responsive desktop ↔ phone** — under 760pt the master/detail `Split`
//!   collapses to **Tabs**.
//!
//! The central `panes` slot is filled by the host with the ACTIVE tab's pane (the
//! 28 [`Tab`] panes, addressed by [`Tab::id`]); `detail` hosts the Time-machine
//! inspector and `action_log` the action trail. Nothing is forked: the scene
//! DRIVES the existing panes (the top-bar tab strip stays the `facett_ui::TabBar`
//! menubar host, per the migration brief). See `.nornir/scene-design.md`.
//!
//! # Stage 2 — the tab INTERNALS are panes too
//!
//! Adopting the scene at the SHELL level left every tab body a single opaque pane:
//! minimise / maximise / drag operated on the whole tab and could do **nothing
//! inside it**. A stats readout that grew pushed the canvas off the bottom, with no
//! divider to adjust, because both shared one imperative vertical flow.
//!
//! So a tab whose body is honestly master/detail declares that structure — its
//! `panes` position is replaced by a DRAGGABLE split of two named, chromed slots:
//!
//! ```text
//! <Tab>_body ── Split(H|V, ratio, DRAGGABLE)
//! ├─ <Tab>_main      ← the graph / chart / table / results
//! └─ <Tab>_inspector ← controls, stats, legend, selection detail  (drawer@760)
//! ```
//!
//! [`body_split`] is the ONE table saying which tabs are decomposed and how; a tab
//! absent from it is honestly ONE pane and keeps the single `panes` slot (forcing a
//! split with no master/detail is worse than leaving it). Chrome (fold · minimise ·
//! maximise) comes free — `Chrome::Auto` is the facett-app default, so every named
//! pane gains the window controls the moment it exists.

use facett_app::scene::{Action, Axis, Fold, Layout, MenuItem, Pane, Route, Scene, Target};

use super::app::Tab;

/// The slot id of the central pane the host fills with the ACTIVE tab's content.
/// (The host delegates it to the active [`Tab::id`], so every tab is itself a
/// host-filled slot — see [`tab_slot_ids`].)
pub const ACTIVE_PANE_SLOT: &str = "active_pane";
/// The slot id of the right master/detail inspector (the "Time machine" panel).
pub const DETAIL_SLOT: &str = "detail";
/// The slot id of the bottom action-log drawer.
pub const ACTION_LOG_SLOT: &str = "action_log";

/// The unique pane name of the DRAGGABLE master/detail split (workspace | detail).
pub const ROOT_SPLIT: &str = "root";
/// The unique pane name of the DRAGGABLE panes | action-log split.
pub const WORKSPACE_SPLIT: &str = "workspace";
/// The unique pane name of the central panes pane.
pub const PANES: &str = "panes";

/// The phone-class collapse breakpoint (points of available width) shared by the
/// root master/detail split (→ Tabs) and the action-log drawer (→ auto-fold).
pub const PHONE_BREAKPOINT: f32 = 760.0;

/// The initial master fraction of the workspace | detail split (the detail
/// inspector gets the rest — ~26%, mirroring the old 360pt `SidePanel`).
pub const DETAIL_MASTER_RATIO: f32 = 0.74;
/// The initial fraction of the panes | action-log split (the log drawer gets the
/// rest — ~18%, mirroring the old 160pt bottom panel).
pub const LOG_MASTER_RATIO: f32 = 0.82;

/// The host-filled tab slot ids — one per [`Tab::ALL`] variant (== [`Tab::id`]).
/// The central `active_pane` slot delegates to whichever of these is active, so
/// every tab is a `Content::Slot(Tab::id())` the [`SceneHost`](facett_app::scene::SceneHost)
/// fills with the tab's real pane widget.
pub fn tab_slot_ids() -> Vec<&'static str> {
    Tab::ALL.iter().map(|t| t.id()).collect()
}

/// **The master/detail decomposition of ONE tab's internals** — the declarative
/// replacement for a tab body that used to be one imperative `egui` flow (or an
/// internal `SidePanel` / `TopBottomPanel` the shell could not see).
///
/// `ratio` is the MAIN half's seed fraction; the inspector gets the rest. The
/// inspector is a [`drawer`](Pane::drawer) at [`PHONE_BREAKPOINT`], so a narrow
/// window gives the whole tab to the canvas instead of a sliver of each.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BodySplit {
    /// Divider orientation — `Horizontal` for canvas │ inspector, `Vertical` for
    /// canvas ── over ── readout.
    pub axis: Axis,
    /// The MAIN half's seed fraction of the tab body (the inspector gets `1 - ratio`).
    pub ratio: f32,
    /// Is the MAIN half the FIRST child (left / top)? False puts the inspector first —
    /// the honest order for a controls-over-results tab, where a query strip belongs
    /// above its hits and a rail of runs belongs left of the run it selects. The
    /// declared `ratio` still means the MAIN half either way.
    pub main_first: bool,
    /// The main pane's chrome-bar title.
    pub main_title: &'static str,
    /// The inspector pane's chrome-bar title.
    pub inspector_title: &'static str,
    /// The inspector's fold icon.
    pub inspector_icon: &'static str,
}

impl BodySplit {
    /// The seed ratios in CHILD order (`[first, second]`), honouring [`Self::main_first`].
    pub fn child_ratios(&self) -> [f32; 2] {
        if self.main_first {
            [self.ratio, 1.0 - self.ratio]
        } else {
            [1.0 - self.ratio, self.ratio]
        }
    }
}

/// Which half of a decomposed tab body a slot id addresses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyPart {
    /// The `<Tab>_main` half — the graph / chart / table / results.
    Main,
    /// The `<Tab>_inspector` half — controls, stats, legend, selection detail.
    Inspector,
}

/// The `<Tab>_body` pane name — the DRAGGABLE split a decomposed tab's internals become.
pub fn body_pane_name(tab: Tab) -> String {
    format!("{}_body", tab.id())
}

/// The `<Tab>_main` slot id (== its pane name): the master half of a decomposed tab.
pub fn main_slot_id(tab: Tab) -> String {
    format!("{}_main", tab.id())
}

/// The `<Tab>_inspector` slot id (== its pane name): the detail half of a decomposed tab.
pub fn inspector_slot_id(tab: Tab) -> String {
    format!("{}_inspector", tab.id())
}

/// Resolve a host slot id back to `(tab, half)` — the inverse of [`main_slot_id`] /
/// [`inspector_slot_id`], used by the app's `SceneHost` to route a split slot to the
/// right half of the right tab. `None` for anything that isn't a body-split slot.
pub fn body_slot(id: &str) -> Option<(Tab, BodyPart)> {
    if let Some(name) = id.strip_suffix("_main") {
        return Tab::from_name(name).map(|t| (t, BodyPart::Main));
    }
    if let Some(name) = id.strip_suffix("_inspector") {
        return Tab::from_name(name).map(|t| (t, BodyPart::Inspector));
    }
    None
}

/// **The decomposition table: which tabs have an honest master/detail body, and how
/// it splits.** `None` ⇒ the tab is honestly ONE pane and keeps the single `panes`
/// slot — a forced split with no master/detail is worse than no split at all.
///
/// Every entry here corresponds to structure the tab body ALREADY had implicitly
/// (an internal `SidePanel` / `TopBottomPanel`, or a list-then-detail flow); the
/// split makes it explicit, draggable, foldable and minimisable, and puts both
/// halves' rects into `$NORNIR_VIZ_STATE`.
pub fn body_split(tab: Tab) -> Option<BodySplit> {
    match tab {
        // 🗄 Warehouse — `SidePanel::left("wh_tables")` (240pt) + a CentralPanel
        // preview/chart. The textbook table-list │ preview master/detail.
        Tab::Warehouse => Some(BodySplit {
            axis: Axis::Horizontal,
            ratio: 0.24,
            main_first: true,
            main_title: "Tables",
            inspector_title: "Preview",
            inspector_icon: "🔎",
        }),
        // 🗺 Knowledge — **the bug, named.** The per-crate summary table and the
        // bubble chart shared ONE vertical flow, so a workspace with many crates grew
        // the table until it pushed the chart off the bottom, with no divider to
        // adjust. Now: the table is the FIRST child (a controls-and-stats strip
        // belongs above its canvas), the chart is the main half below it, and the
        // divider between them is draggable — or the table minimises to a title bar.
        Tab::Knowledge => Some(BodySplit {
            axis: Axis::Vertical,
            ratio: 0.62,
            main_first: false,
            main_title: "Bubble chart",
            inspector_title: "Scanned crates",
            inspector_icon: "📊",
        }),
        // 📡 Live Run — the release-run feed and the build-info live-tail were TWO
        // independent scrolling feeds separated by a bare `ui.separator()` in one
        // vertical flow. Whichever grew took the other's room and there was nothing to
        // drag. They are two panes; now they say so.
        Tab::LiveRun => Some(BodySplit {
            axis: Axis::Vertical,
            ratio: 0.62,
            main_first: true,
            main_title: "Run feed",
            inspector_title: "Build info",
            inspector_icon: "📡",
        }),
        _ => None,
    }
}

/// Every tab whose body is a declared master/detail split, in [`Tab::ALL`] order —
/// the roster the rect oracles walk. Its complement is the honestly-single-pane set.
pub fn decomposed_tabs() -> Vec<Tab> {
    Tab::ALL
        .into_iter()
        .filter(|t| body_split(*t).is_some())
        .collect()
}

/// The pane occupying the shell's central workspace position for `tab`: either the
/// tab's DRAGGABLE master/detail body split (when [`body_split`] declares one) or
/// the single [`PANES`] slot the host fills with the whole tab pane.
fn tab_body_pane(tab: Tab) -> Pane {
    let Some(s) = body_split(tab) else {
        return Pane::slot(PANES, ACTIVE_PANE_SLOT).title("Workspace");
    };
    let main = Pane::slot(main_slot_id(tab), main_slot_id(tab))
        .title(s.main_title)
        .foldable(true);
    let inspector = Pane::slot(inspector_slot_id(tab), inspector_slot_id(tab))
        .title(s.inspector_title)
        .foldable_icon(true, s.inspector_icon)
        .drawer(PHONE_BREAKPOINT);
    let ratios = s.child_ratios();
    let children = if s.main_first {
        vec![main, inspector]
    } else {
        vec![inspector, main]
    };
    // **The body container carries NO chrome, deliberately.** It is pure layout: its
    // two halves own the fold / minimise / maximise controls, and maximising the
    // container that already fills the tab means nothing. It also may not carry a
    // TITLE: a chrome bar titled `tab.label()` puts a second AccessKit node reading
    // "🧪 Test" on screen beside the tab-strip button of the same name, and a robot
    // that clicks by label then finds two matches and can address neither. (holger's
    // shell hit the identical hazard with a rail named after a menu.)
    Pane::split_n(body_pane_name(tab), s.axis, ratios.to_vec(), children).no_chrome()
}

/// Build the Urðr-Threads master shell as a declarative [`Scene`] (the Rust-builder
/// form). Byte-stable through RON.
///
/// Layout tree (splits are DRAGGABLE; the detail + log panes foldable):
/// ```text
/// root  ─ Split(H, 0.74, draggable)          [phone → Tabs under 760pt]
/// ├─ workspace ─ Split(V, 0.82, draggable)
/// │  ├─ panes      (Fixed)  slot:active_pane  — the ACTIVE tab's pane (28 Tab slots)
/// │  └─ action_log (Foldable 🐞, drawer@760) slot:action_log — the action trail
/// └─ detail        (Foldable 🔍)  slot:detail — the "Time machine" inspector
/// ```
///
/// This is the shell with the tab body UNDECOMPOSED (the single `panes` slot). The
/// live app renders [`nornir_shell_scene_for`] instead, which swaps that position for
/// the ACTIVE tab's master/detail body split when it declares one.
pub fn nornir_shell_scene() -> Scene {
    shell_around(Pane::slot(PANES, ACTIVE_PANE_SLOT).title("Workspace"))
}

/// The shell scene for the ACTIVE `tab` — identical to [`nornir_shell_scene`] except
/// that the central workspace position carries that tab's declared master/detail body
/// split (see [`body_split`]) instead of the single `panes` slot.
///
/// The pane NAMES are per-tab (`<Tab>_body` / `<Tab>_main` / `<Tab>_inspector`), so
/// each tab keeps its OWN live fold + split ratio across tab switches: dragging the
/// 🗄 Warehouse divider cannot move the 🔍 Search one.
pub fn nornir_shell_scene_for(tab: Tab) -> Scene {
    shell_around(tab_body_pane(tab))
}

/// The shell (menubar + routes + the detail / action-log panes) wrapped around
/// whichever pane occupies the central workspace position.
fn shell_around(panes: Pane) -> Scene {
    // The action-log rail: a foldable slot that also acts as a DRAWER — it
    // auto-folds when the width crosses into phone-class (and restores on the way
    // back). Starts folded (the top-bar 🐞 toggle drives it), matching the old
    // hidden-by-default bottom panel.
    let action_log = Pane::slot(ACTION_LOG_SLOT, ACTION_LOG_SLOT)
        .title("Action trail")
        .foldable_icon(false, "🐞")
        .drawer(PHONE_BREAKPOINT);

    let workspace = Pane::split(
        WORKSPACE_SPLIT,
        Axis::Vertical,
        LOG_MASTER_RATIO,
        vec![panes, action_log],
    )
    .title("Workspace + log");

    // The Time-machine inspector: the right master/detail pane, foldable so the
    // operator can reclaim the width for the central pane.
    let detail = Pane::slot(DETAIL_SLOT, DETAIL_SLOT)
        .title("Time machine")
        .foldable_icon(true, "🔍");

    let root = Pane::split(
        ROOT_SPLIT,
        Axis::Horizontal,
        DETAIL_MASTER_RATIO,
        vec![workspace, detail],
    )
    .title("Urðr Threads")
    // Responsive: on a phone-class width the master/detail split collapses to Tabs.
    .phone(Layout::Tabs { active: 0 }, PHONE_BREAKPOINT);

    Scene::new("urdr-threads", root)
        .menu(MenuItem::new("view", "View").items(vec![
            MenuItem::new("view.detail", "Toggle Time machine")
                .shortcut("Ctrl+I")
                .action(Action::Toggle(DETAIL_SLOT.into())),
            MenuItem::new("view.log", "Toggle Action trail")
                .shortcut("Ctrl+L")
                .action(Action::Toggle(ACTION_LOG_SLOT.into())),
            MenuItem::new("view.reload", "Reload")
                .shortcut("Ctrl+R")
                .action(Action::Command("refresh".into())),
        ]))
        .menu(MenuItem::new("go", "Go").items(vec![
                MenuItem::new("go.home", "Home (nornir)")
                    .shortcut("Ctrl+1")
                    .action(Action::Command("go:Nornir".into())),
                MenuItem::new("go.release", "Release")
                    .shortcut("Ctrl+2")
                    .action(Action::Command("go:Release".into())),
                MenuItem::new("go.test", "Test")
                    .shortcut("Ctrl+3")
                    .action(Action::Command("go:Test".into())),
                MenuItem::new("go.warehouse", "Warehouse")
                    .shortcut("Ctrl+4")
                    .action(Action::Command("go:Warehouse".into())),
            ]))
        .menu(MenuItem::new("help", "Help").items(vec![
                MenuItem::new("help.about", "About Urðr Threads")
                    .shortcut("F1")
                    .action(Action::Command("about".into())),
            ]))
        // Declared routes (over the shared Router): a shortcut + a button focus the
        // Time-machine inspector, and the Go menu records nav history on the panes.
        .route(Route::on_shortcut(
            "Ctrl+I",
            Target::Toggle(DETAIL_SLOT.into()),
        ))
        .route(Route::on_button(
            "focus-detail",
            Target::Focus(DETAIL_SLOT.into()),
        ))
        .route(Route::on_menu("go.release", Target::Show(PANES.into())))
}

/// The names of every foldable pane in the shell (for coverage assertions).
pub fn foldable_panes(scene: &Scene) -> Vec<String> {
    scene
        .pane_names()
        .into_iter()
        .filter(|n| matches!(scene.pane(n).map(|p| &p.fold), Some(Fold::Foldable { .. })))
        .collect()
}

/// The names of every `drawer` pane (auto-folds into a drawer on phone-class width).
pub 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 names of every DRAGGABLE (resizable) split pane in the shell.
pub fn draggable_splits(scene: &Scene) -> Vec<String> {
    scene
        .pane_names()
        .into_iter()
        .filter(|n| {
            matches!(
                scene.pane(n).map(|p| &p.layout),
                Some(Layout::Split {
                    resizable: true,
                    ..
                })
            )
        })
        .collect()
}

/// The names of every pane that draws **window chrome** — a title bar carrying
/// *minimise* and *maximise/restore* beside the fold toggle. Chrome is ON by default
/// in facett-app (`Chrome::Auto`) for every pane but the scene root, so a pane missing
/// from this list opted OUT; publishing it makes "the chrome silently disappeared" a
/// red assertion rather than a shrug. Delegates to facett-app's own resolver (L5).
pub fn chrome_panes(scene: &Scene) -> Vec<String> {
    scene
        .pane_names()
        .into_iter()
        .filter(|n| {
            let spec = scene.chrome_of(n);
            spec.minimize && spec.maximize
        })
        .collect()
}

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

    #[test]
    fn shell_scene_is_valid_with_unique_names() {
        let scene = nornir_shell_scene();
        scene.validate().expect("unique pane + menu names");
        // The master/detail + panes/log panes are addressable by their stable names.
        for want in [
            ROOT_SPLIT,
            WORKSPACE_SPLIT,
            PANES,
            DETAIL_SLOT,
            ACTION_LOG_SLOT,
        ] {
            assert!(scene.pane(want).is_some(), "the `{want}` pane exists");
        }
    }

    #[test]
    fn shell_scene_ron_round_trips_byte_stable() {
        // RED-when-broken: parse ∘ serialize is a fixed point (a schema drift or a
        // non-deterministic serialize breaks this).
        let scene = nornir_shell_scene();
        let ron1 = scene.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!(
            scene, reparsed,
            "RON and the builder yield the SAME shell Scene"
        );
    }

    #[test]
    fn the_detail_and_log_panes_are_foldable() {
        let scene = nornir_shell_scene();
        let foldable = foldable_panes(&scene);
        for want in [DETAIL_SLOT, ACTION_LOG_SLOT] {
            assert!(
                foldable.contains(&want.to_string()),
                "the `{want}` pane is foldable"
            );
        }
    }

    #[test]
    fn folding_the_detail_pane_is_observable() {
        // RED-when-broken: a fold must round-trip through NavState (the render + the
        // AccessKit tree read it).
        let scene = nornir_shell_scene();
        let mut nav = NavState::new(&scene);
        assert!(
            !nav.is_folded(DETAIL_SLOT),
            "the Time-machine inspector starts unfolded"
        );
        assert!(nav.toggle_fold(DETAIL_SLOT), "fold it");
        assert!(
            nav.is_folded(DETAIL_SLOT),
            "the fold state is observable in NavState"
        );
    }

    #[test]
    fn the_master_detail_splits_are_draggable_with_live_ratios() {
        // RED-when-broken: both the master/detail split and the panes/log split must
        // be DRAGGABLE (resizable), with live, observable, seeded ratios.
        let scene = nornir_shell_scene();
        let splits = draggable_splits(&scene);
        assert!(
            splits.contains(&ROOT_SPLIT.to_string()),
            "the workspace|detail split is draggable"
        );
        assert!(
            splits.contains(&WORKSPACE_SPLIT.to_string()),
            "the panes|log split is draggable"
        );

        let nav = NavState::new(&scene);
        let root = nav.split_ratios(ROOT_SPLIT).expect("root has a live ratio");
        assert!(
            (root[0] - DETAIL_MASTER_RATIO).abs() < 1e-5,
            "root seeds the master fraction, got {root:?}"
        );
        let ws = nav
            .split_ratios(WORKSPACE_SPLIT)
            .expect("workspace has a live ratio");
        assert!(
            (ws[0] - LOG_MASTER_RATIO).abs() < 1e-5,
            "workspace seeds the panes fraction, got {ws:?}"
        );
    }

    #[test]
    fn dragging_the_master_detail_divider_reapportions_the_live_ratio() {
        // RED-when-broken: a programmatic drag (set_split_ratios) mutates the live
        // ratio kept in NavState — the same state a real handle-drag writes back.
        let scene = nornir_shell_scene();
        let mut nav = NavState::new(&scene);
        nav.set_split_ratios(ROOT_SPLIT, vec![0.5, 0.5]);
        let r = nav.split_ratios(ROOT_SPLIT).expect("ratio present");
        assert!(
            (r[0] - 0.5).abs() < 1e-5 && (r[1] - 0.5).abs() < 1e-5,
            "the dragged ratio is live, got {r:?}"
        );
    }

    #[test]
    fn the_action_log_is_a_drawer_at_the_phone_breakpoint() {
        let scene = nornir_shell_scene();
        assert!(
            drawer_panes(&scene).contains(&ACTION_LOG_SLOT.to_string()),
            "the action-log is a drawer"
        );
        assert_eq!(
            scene
                .pane(ACTION_LOG_SLOT)
                .unwrap()
                .responsive
                .collapse_below,
            Some(PHONE_BREAKPOINT),
            "the drawer collapses at the same breakpoint the root split does"
        );
    }

    #[test]
    fn the_root_split_collapses_to_tabs_on_a_phone_width() {
        let scene = nornir_shell_scene();
        let root = scene.pane(ROOT_SPLIT).unwrap();
        assert!(
            matches!(root.responsive.phone, Some(Layout::Tabs { .. })),
            "phone override is Tabs"
        );
        assert_eq!(root.responsive.collapse_below, Some(PHONE_BREAKPOINT));
    }

    #[test]
    fn every_tab_is_a_host_slot_id() {
        // The 28 Tab panes are the host-filled slots the central `active_pane`
        // delegates to — one per Tab::ALL variant, matching Tab::id().
        let ids = tab_slot_ids();
        assert_eq!(
            ids.len(),
            Tab::ALL.len(),
            "one slot id per Tab::ALL variant"
        );
        for t in Tab::ALL {
            assert!(ids.contains(&t.id()), "Tab::{t:?} has a slot id");
        }
    }

    #[test]
    fn the_menubar_carries_actions_shortcuts_and_routes() {
        let scene = nornir_shell_scene();
        assert_eq!(scene.menubar.len(), 3, "View / Go / Help menus");
        let reload = scene.menu_item("view.reload").expect("a Reload menu item");
        assert_eq!(
            reload.shortcut.as_deref(),
            Some("Ctrl+R"),
            "the Reload item hints its shortcut"
        );
        assert!(
            matches!(reload.action, Some(Action::Command(_))),
            "Reload fires a host command"
        );
        assert!(!scene.routes.is_empty(), "the shell declares nav routes");
    }

    // ── Stage 2: the tab INTERNALS are panes ────────────────────────────────────

    /// A decomposed tab's body is a DRAGGABLE split of two uniquely-named slots, the
    /// shell around it is unchanged, and the whole scene still validates (no name
    /// collides with a shell pane). RED-when-broken if a body split is downgraded to
    /// `split_fixed` or a half loses its own name.
    #[test]
    fn a_decomposed_tab_body_is_a_draggable_split_of_two_named_slots() {
        for tab in decomposed_tabs() {
            let s = nornir_shell_scene_for(tab);
            s.validate()
                .unwrap_or_else(|e| panic!("Tab::{tab:?} scene has unique names: {e}"));

            let body = body_pane_name(tab);
            let want = body_split(tab).expect("a decomposed tab has a split");
            match s.pane(&body).map(|p| &p.layout) {
                Some(Layout::Split {
                    axis,
                    ratios,
                    resizable: true,
                }) => {
                    assert_eq!(*axis, want.axis, "`{body}` splits along the declared axis");
                    let seed = want.child_ratios();
                    assert!(
                        (ratios[0] - seed[0]).abs() < 1e-5 && (ratios[1] - seed[1]).abs() < 1e-5,
                        "`{body}` seeds its declared fractions {seed:?}, got {ratios:?}"
                    );
                    assert!(
                        (ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5,
                        "ratios sum to 1"
                    );
                }
                other => panic!("`{body}` is a DRAGGABLE split, got {other:?}"),
            }
            // The declared child ORDER: `main_first` decides which half is left/top.
            let kids = match &s.pane(&body).expect("body").content {
                facett_app::scene::Content::Children(k) => {
                    k.iter().map(|p| p.name.clone()).collect::<Vec<_>>()
                }
                other => panic!("`{body}` holds children, got {other:?}"),
            };
            let expect_order = if want.main_first {
                vec![main_slot_id(tab), inspector_slot_id(tab)]
            } else {
                vec![inspector_slot_id(tab), main_slot_id(tab)]
            };
            assert_eq!(
                kids, expect_order,
                "`{body}` lays its halves out in the declared order"
            );

            // Both halves exist, are foldable, and the inspector is a phone drawer.
            let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
            let foldable = foldable_panes(&s);
            for half in [&main, &insp] {
                assert!(s.pane(half).is_some(), "the `{half}` pane exists");
                assert!(
                    foldable.contains(half),
                    "`{half}` is foldable: {foldable:?}"
                );
            }
            assert!(
                drawer_panes(&s).contains(&insp),
                "`{insp}` is a phone drawer"
            );

            // The shell around it is untouched: both shell dividers still draggable.
            let splits = draggable_splits(&s);
            for shell_split in [ROOT_SPLIT, WORKSPACE_SPLIT, body.as_str()] {
                assert!(
                    splits.contains(&shell_split.to_string()),
                    "`{shell_split}` is draggable: {splits:?}"
                );
            }
            // A decomposed tab REPLACES the single `panes` slot — the body IS the pane.
            assert!(
                s.pane(PANES).is_none(),
                "Tab::{tab:?} hosts its body split, not the `panes` slot"
            );

            // Round-trips through RON byte-stably, like the undecomposed shell.
            let ron1 = s.to_ron();
            let back =
                Scene::from_ron(&ron1).expect("the per-tab shell parses back from its own RON");
            assert_eq!(
                ron1,
                back.to_ron(),
                "Tab::{tab:?}'s shell RON is byte-stable"
            );
        }
    }

    /// An UNdecomposed tab keeps the single `panes` slot — the honest answer for a
    /// tab with no master/detail structure. RED-when-broken if a split is forced on
    /// one (a divider with nothing on the other side is worse than no divider).
    #[test]
    fn an_undecomposed_tab_keeps_the_single_panes_slot() {
        for tab in Tab::ALL {
            if body_split(tab).is_some() {
                continue;
            }
            let s = nornir_shell_scene_for(tab);
            assert!(s.pane(PANES).is_some(), "Tab::{tab:?} is honestly one pane");
            assert_eq!(
                draggable_splits(&s),
                vec![ROOT_SPLIT.to_string(), WORKSPACE_SPLIT.to_string()],
                "Tab::{tab:?} adds no divider of its own"
            );
        }
        // …and the neutral shell (no tab) is exactly that undecomposed form.
        assert_eq!(nornir_shell_scene(), nornir_shell_scene_for(Tab::Timeline));
    }

    /// Slot ids round-trip: `<Tab>_main` / `<Tab>_inspector` resolve back to the tab
    /// and the half, and nothing else does. This is the routing the app's `SceneHost`
    /// depends on — a mis-parse would silently draw the wrong tab's inspector.
    #[test]
    fn body_slot_ids_round_trip_to_their_tab_and_half() {
        for tab in Tab::ALL {
            assert_eq!(body_slot(&main_slot_id(tab)), Some((tab, BodyPart::Main)));
            assert_eq!(
                body_slot(&inspector_slot_id(tab)),
                Some((tab, BodyPart::Inspector))
            );
        }
        for other in [
            ACTIVE_PANE_SLOT,
            DETAIL_SLOT,
            ACTION_LOG_SLOT,
            PANES,
            "Nope_main",
            "",
        ] {
            assert_eq!(body_slot(other), None, "`{other}` is not a body-split slot");
        }
    }

    /// **Chrome comes free, and it is asserted rather than assumed.** Every pane draws
    /// minimise + maximise (`Chrome::Auto` is facett-app's default) — including BOTH
    /// halves of every decomposed tab body — except exactly two panes that are pure
    /// layout and say so: the scene root (nothing to minimise into) and a tab's
    /// `<Tab>_body` container (its halves own the controls, and a titled bar there
    /// would shadow the tab-strip button's AccessKit label).
    ///
    /// RED-when-broken the moment any OTHER pane opts out and the operator silently
    /// loses the control.
    #[test]
    fn every_pane_but_the_layout_containers_draws_minimise_and_maximise() {
        for tab in Tab::ALL {
            let s = nornir_shell_scene_for(tab);
            let chromed = chrome_panes(&s);
            let bare: Vec<String> = match body_split(tab) {
                Some(_) => vec![ROOT_SPLIT.to_string(), body_pane_name(tab)],
                None => vec![ROOT_SPLIT.to_string()],
            };
            for name in s.pane_names() {
                if bare.contains(&name) {
                    assert!(
                        !chromed.contains(&name),
                        "Tab::{tab:?}: `{name}` is pure layout, stays bare"
                    );
                } else {
                    assert!(
                        chromed.contains(&name),
                        "Tab::{tab:?}: `{name}` draws minimise + maximise"
                    );
                }
            }
            assert_eq!(
                chromed.len(),
                s.pane_names().len() - bare.len(),
                "all but the {} layout container(s): {chromed:?}",
                bare.len()
            );
        }
    }

    // ── The RECT oracle: geometry, not booleans ─────────────────────────────────

    /// A stub host: one queryable label per slot, so a folded/minimised body is
    /// provably absent from the AccessKit tree as well as from `state_json`.
    struct StubHost;
    impl facett_app::scene::SceneHost for StubHost {
        fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
            ui.label(format!("SLOT:{id}"));
            serde_json::json!({ "slot": id })
        }
    }

    /// `[x, y, w, h]` out of a rect the render published.
    fn rect(v: &serde_json::Value) -> (f32, f32, f32, f32) {
        let a = v
            .as_array()
            .unwrap_or_else(|| panic!("a rect array, got {v}"));
        (
            a[0].as_f64().unwrap() as f32,
            a[1].as_f64().unwrap() as f32,
            a[2].as_f64().unwrap() as f32,
            a[3].as_f64().unwrap() as f32,
        )
    }

    /// The centre of a published rect — where a robot clicks it.
    fn centre(v: &serde_json::Value) -> egui::Pos2 {
        let (x, y, w, h) = rect(v);
        egui::pos2(x + w / 2.0, y + h / 2.0)
    }

    /// The extent of a rect ALONG a split's axis (width for horizontal, height for
    /// vertical) — the one number a divider drag re-apportions.
    fn extent(axis: Axis, v: &serde_json::Value) -> f32 {
        let (_, _, w, h) = rect(v);
        match axis {
            Axis::Horizontal => w,
            Axis::Vertical => h,
        }
    }

    /// Render `scene`/`nav` at 1400×900 (the shell's desktop size) and hand back the
    /// harness + the live render `state_json` (re-read after every `run`).
    #[allow(clippy::type_complexity)]
    fn harness(
        scene: &Scene,
        nav: std::rc::Rc<std::cell::RefCell<NavState>>,
    ) -> (
        egui_kittest::Harness<'static>,
        std::rc::Rc<std::cell::RefCell<serde_json::Value>>,
    ) {
        let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
        let (s, n, sc) = (state.clone(), nav, scene.clone());
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1400.0, 900.0))
            .build_ui(move |ui| {
                *s.borrow_mut() =
                    facett_app::scene::render(&sc, &mut n.borrow_mut(), ui, &mut StubHost);
            });
        h.run();
        (h, state)
    }

    /// A real pointer click at `p` (press + release with a frame between).
    fn click(h: &mut egui_kittest::Harness<'static>, p: egui::Pos2) {
        h.event(egui::Event::PointerMoved(p));
        h.run();
        h.event(egui::Event::PointerButton {
            pos: p,
            button: egui::PointerButton::Primary,
            pressed: true,
            modifiers: Default::default(),
        });
        h.run();
        h.event(egui::Event::PointerButton {
            pos: p,
            button: egui::PointerButton::Primary,
            pressed: false,
            modifiers: Default::default(),
        });
        h.run();
    }

    /// **RED-when-broken, at RECT level: dragging a tab body's divider moves BOTH
    /// halves in OPPOSITE directions, and their sum is conserved.**
    ///
    /// The ratio alone was never evidence. A renderer that updated the stored fraction
    /// and laid the panes out exactly as before would satisfy a ratio assertion
    /// completely, and the divider would still be nailed down on screen. So this reads
    /// the two halves' allotted rects: one shrinks by what the other gains, and they
    /// still tile the same body area.
    #[test]
    fn dragging_a_tab_body_divider_moves_both_halves_with_the_sum_conserved() {
        for tab in decomposed_tabs() {
            let spec = body_split(tab).expect("decomposed");
            let scene = nornir_shell_scene_for(tab);
            let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
            let body = body_pane_name(tab);
            let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));

            // The MAIN half's index in the split (0 when it is the first child).
            let mi = usize::from(!spec.main_first);
            let (mut h, state) = harness(&scene, nav.clone());
            let before = nav.borrow().split_ratios(&body).expect("a live body ratio")[mi];
            let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                main0 > 1.0 && insp0 > 1.0,
                "Tab::{tab:?}: both halves start with extent"
            );

            // The handle rect is published for robot addressing — drag it TOWARD the
            // main half (shrinking it) by a third of that half's extent. Toward the
            // main half is "back" when it is the first child and "forward" when it is
            // the second, so the same assertions hold whichever order it declares.
            let sep = state.borrow()["panes"][&body]["separators"][0].clone();
            assert!(
                sep.is_array(),
                "Tab::{tab:?}: the body split publishes a drag handle rect"
            );
            let from = centre(&sep);
            let delta = if spec.main_first {
                -(main0 / 3.0)
            } else {
                main0 / 3.0
            };
            let to = match spec.axis {
                Axis::Horizontal => egui::pos2(from.x + delta, from.y),
                Axis::Vertical => egui::pos2(from.x, from.y + delta),
            };
            h.event(egui::Event::PointerMoved(from));
            h.drag_at(from);
            h.event(egui::Event::PointerMoved(to));
            h.run();
            h.drop_at(to);
            h.run();

            let after = nav.borrow().split_ratios(&body).expect("a live body ratio")[mi];
            assert!(
                after < before - 1e-3,
                "Tab::{tab:?}: the drag re-apportioned ({before} → {after})"
            );
            assert!(
                after > 0.0,
                "Tab::{tab:?}: the ratio stays clamped above zero"
            );

            let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                main1 < main0 - 1.0,
                "Tab::{tab:?}: the main half SHRANK {main0} → {main1}"
            );
            assert!(
                insp1 > insp0 + 1.0,
                "Tab::{tab:?}: the inspector GREW {insp0} → {insp1}"
            );
            assert!(
                ((main0 + insp0) - (main1 + insp1)).abs() < 8.0,
                "Tab::{tab:?}: the halves still tile the same body area \
                 ({main0}+{insp0} vs {main1}+{insp1})",
            );
        }
    }

    /// **RED-when-broken, at RECT level: maximising one half of a tab body drives its
    /// sibling's area to ZERO, and restore returns the EXACT prior rects.**
    ///
    /// Both are geometry: the sibling's allotted rect has zero extent and zero area
    /// (plus the renderer's own `hidden_by: "maximize"` marker), and the restored
    /// widths match the pre-maximise ones to within a point — which is only true
    /// because a maximise never overwrites the stored split ratios.
    #[test]
    fn maximising_a_half_zeroes_its_sibling_and_restore_returns_the_exact_rects() {
        for tab in decomposed_tabs() {
            let spec = body_split(tab).expect("decomposed");
            let scene = nornir_shell_scene_for(tab);
            let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
            let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
            let (mut h, state) = harness(&scene, nav.clone());

            let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);

            let btn = state.borrow()["panes"][&main]["chrome"]["buttons"]["maximize"].clone();
            assert!(
                btn.is_array(),
                "Tab::{tab:?}: the main half publishes a maximise button rect"
            );
            click(&mut h, centre(&btn));

            assert!(
                nav.borrow().is_maximized(&main),
                "Tab::{tab:?}: the click maximised the main half"
            );
            let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                main1 > main0 + 1.0,
                "Tab::{tab:?}: the maximised half GREW {main0} → {main1}"
            );
            assert!(
                insp1 <= 1.0,
                "Tab::{tab:?}: the sibling is allotted zero extent, got {insp1}"
            );
            assert_eq!(
                state.borrow()["panes"][&insp]["area"].as_f64().unwrap(),
                0.0,
                "Tab::{tab:?}: …and zero AREA",
            );
            assert_eq!(state.borrow()["panes"][&insp]["hidden_by"], "maximize");

            // Restore (a second click) hands the sibling its EXACT extent back.
            let btn = state.borrow()["panes"][&main]["chrome"]["buttons"]["maximize"].clone();
            click(&mut h, centre(&btn));
            assert!(
                !nav.borrow().is_maximized(&main),
                "Tab::{tab:?}: the second click restored"
            );
            let main2 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp2 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                (main2 - main0).abs() < 1.0,
                "Tab::{tab:?}: main restored EXACTLY {main0} → {main2}"
            );
            assert!(
                (insp2 - insp0).abs() < 1.0,
                "Tab::{tab:?}: sibling restored EXACTLY {insp0} → {insp2}"
            );
        }
    }

    /// **RED-when-broken, at RECT level: minimising the inspector leaves ONLY its
    /// chrome bar, hands the freed extent to the main half, drops its body from the
    /// AccessKit tree AND `state_json` — and restore puts all three back exactly.**
    ///
    /// This is the affordance the whole complaint was about: the stats readout that
    /// grew until it pushed the canvas off screen now collapses to a 22pt title bar
    /// that is still there to click back open.
    #[test]
    fn minimising_the_inspector_leaves_only_its_chrome_bar_and_restore_is_exact() {
        use egui_kittest::kittest::Queryable;

        for tab in decomposed_tabs() {
            let spec = body_split(tab).expect("decomposed");
            let scene = nornir_shell_scene_for(tab);
            let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
            let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
            let (mut h, state) = harness(&scene, nav.clone());

            let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                h.query_by_label(&format!("SLOT:{insp}")).is_some(),
                "Tab::{tab:?}: the inspector's body is drawn"
            );

            let btn = state.borrow()["panes"][&insp]["chrome"]["buttons"]["minimize"].clone();
            assert!(
                btn.is_array(),
                "Tab::{tab:?}: the inspector publishes a minimise button rect"
            );
            click(&mut h, centre(&btn));

            assert!(
                nav.borrow().is_minimized(&insp),
                "Tab::{tab:?}: the click minimised the inspector"
            );
            let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
            let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                insp1 < insp0 - 1.0,
                "Tab::{tab:?}: the inspector COLLAPSED {insp0} → {insp1}"
            );
            assert!(
                main1 > main0 + 1.0,
                "Tab::{tab:?}: the main half took the freed extent"
            );
            // ONLY the chrome bar is left — the collapsed extent is the bar itself.
            assert!(
                (insp1 - facett_app::scene::CHROME_BAR).abs() < 2.0,
                "Tab::{tab:?}: a minimised inspector collapses to its {}pt chrome bar, got {insp1}",
                facett_app::scene::CHROME_BAR,
            );
            assert!(
                state.borrow()["panes"][&insp]["chrome"]["bar_height"]
                    .as_f64()
                    .unwrap()
                    > 0.0,
                "Tab::{tab:?}: the title bar survives — a minimised pane must stay recoverable",
            );
            assert!(
                h.query_by_label(&format!("SLOT:{insp}")).is_none(),
                "Tab::{tab:?}: minimised — the body left the AccessKit tree"
            );
            assert!(
                state.borrow()["slots"].get(&insp).is_none(),
                "Tab::{tab:?}: …and left state_json"
            );
            // The body rect really is zero-extent (a measurement, not a flag).
            let (_, _, _, body_h) = rect(&state.borrow()["panes"][&insp]["content_rect"]);
            assert_eq!(
                body_h, 0.0,
                "Tab::{tab:?}: the minimised body occupied no height"
            );

            // Restore returns the geometry AND the body. A minimised pane's
            // un-minimise control is published under `restore` when the bar collapsed
            // to a narrow RAIL (a minimised child of a horizontal split) and under
            // `minimize` (drawn as 🗗, labelled "restore") when the bar kept its full
            // width — either way the pane must publish SOMETHING that gets it back,
            // which is the invariant that matters.
            let buttons = state.borrow()["panes"][&insp]["chrome"]["buttons"].clone();
            let btn = if buttons["restore"].is_array() {
                buttons["restore"].clone()
            } else {
                buttons["minimize"].clone()
            };
            assert!(
                btn.is_array(),
                "Tab::{tab:?}: a minimised inspector publishes a control that restores it, got {buttons}"
            );
            click(&mut h, centre(&btn));
            assert!(
                !nav.borrow().is_minimized(&insp),
                "Tab::{tab:?}: the click restored the inspector"
            );
            let insp2 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
            assert!(
                (insp2 - insp0).abs() < 1.0,
                "Tab::{tab:?}: restore is EXACT {insp0} → {insp2}"
            );
            assert!(
                h.query_by_label(&format!("SLOT:{insp}")).is_some(),
                "Tab::{tab:?}: restored — the body is back"
            );
        }
    }
}