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

nordisk/facett

facett-app/src/scene.rs

//! **`scene` — a DECLARATIVE UI-SCENE layer over facett-app.** Describe a whole
//! app's shell as *data* — a menubar, a tree of foldable panes, and a set of
//! navigation routes — then render it through this crate's OWN primitives
//! ([`Router`](crate::Router) is the nav engine, [`TabView`](crate::TabView) draws
//! tab strips, [`facett_menu`] draws the menubar). Nothing here reinvents
//! navigation or layout: the scene is a thin, serde-friendly *description* that
//! [`render`] walks.
//!
//! Two authoring forms, ONE [`Scene`] value out (both via serde):
//! - **RON** — [`Scene::from_ron`] / [`Scene::to_ron`] (byte-stable round-trip).
//! - **Rust builder** — [`Pane::flow`]/[`Pane::split`]/… + [`Scene::new`].
//!
//! **Interactions (all declarative-data-driven):**
//! - **Per-pane WINDOW CHROME — on by DEFAULT.** Every pane except the scene root
//!   draws a [`Chrome`] title bar carrying *fold* · *minimise* · *maximise/restore*.
//!   Minimising collapses the pane to that bar and **hands its space back to its
//!   siblings** (a split child shrinks to [`CHROME_BAR`] points); maximising gives the
//!   pane the whole scene area and shrinks every non-ancestor sibling to **zero**;
//!   restore returns the EXACT prior ratios (they are never overwritten). All of it is
//!   in `state_json` (`nav.minimized` / `nav.maximized`, plus per-pane `chrome` button
//!   rects), so a robot can drive it and read the resulting GEOMETRY back.
//!   Opt out per pane with [`Pane::no_chrome`] — the default is ON because capability
//!   that ships default-off is capability nobody ever sees.
//! - **Draggable / resizable splits.** A [`Layout::Split`] carries per-child `ratios`
//!   and a `resizable` flag (**defaulting to `true`**, including for hand-authored RON
//!   that omits it). When resizable, the renderer draws a real drag handle
//!   between each pair of children; dragging it re-apportions the two adjacent ratios,
//!   and the LIVE ratios live in [`NavState`] (observable in `state_json`) — this is a
//!   genuine binary/N-way split tree (the tiling-WM / `egui_dock` / Golden-Layout
//!   model), not a static fraction.
//! - **Fold / unfold.** A [`Fold::Foldable`] pane renders a header with a fold toggle
//!   and, when folded, draws ONLY that header — its contents are omitted from the
//!   layout (and so from the AccessKit tree), mirroring the ⚙ fold in `facett-graphnav`.
//!   Fold is the *authored* section collapse (it hides the body but KEEPS the pane's
//!   slot); chrome's *minimise* is the runtime window collapse (it hides the body AND
//!   gives the slot back). The chrome bar **hosts** the fold toggle rather than drawing
//!   a second header beside it (L5 — extend the fold primitive, never twin it).
//! - **Runtime presence.** A pane the HOST declares absent ([`NavState::set_absent`])
//!   does not exist on screen: zero extent, no chrome, no slot fill, adjacent split
//!   dividers stand down, no tab header — its siblings absorb the space by their
//!   unchanged ratios, so presence returning restores the exact prior arrangement.
//!   For a pane whose *subject* does not exist right now (a configurator for a recipe
//!   that declares none); fold/minimise keep chrome on screen, presence removes it.
//!   Never authored: RON scenes declare what CAN exist, the host says what DOES.
//! - **Responsive desktop ↔ phone.** The renderer picks a [`Device`] class from the
//!   available width; a pane's [`Responsive`] can swap its layout on phone/tablet
//!   (e.g. a `Split` collapses to `Tabs`), and a `drawer` pane auto-folds when the
//!   width crosses into phone-class (a desktop rail becomes a phone drawer).
//!
//! Navigation reuses the [`Router`](crate::Router): [`NavState`] wraps it with the
//! per-pane fold + active-tab + split-ratio + focus state the renderer reads and the
//! routes mutate. Unique pane names make every pane addressable (deep-link `app#pane`).
//!
//! # The AREA / EDITOR model (Blender's, not the IDE's)
//!
//! An IDE *perspective* has a privileged central editor with tool windows arranged
//! around it. Blender has no centre: the window is tiled into **areas**, and **any area
//! can host any editor type**, swapped from a dropdown in that area's own header. For a
//! control centre that is strictly more powerful — a set of "tabs" stops being a set of
//! *screens* and becomes a set of **editor types**, so an analyst can put Graph and
//! Tables side by side, or two Graphs at different queries. The tab model forbids both
//! by construction.
//!
//! | Blender | here |
//! |---|---|
//! | area (tiled rect, never floating) | a [`Pane`] with [`Content::Area`] |
//! | editor type, swappable per area | an [`Editor`](crate::editor::Editor) in the host's [`EditorRegistry`](crate::editor::EditorRegistry) |
//! | workspace tabs (Layout / Shading / …) | [`Workspace`] — a named BODY tree, spliced at the scene's [`Content::Body`] pane |
//! | area header | the [`Chrome`] title bar, which now also carries the editor dropdown |
//! | split / join by dragging an edge | [`Layout::Split`] with its live ratios |
//! | Ctrl+Space maximise area | [`NavState::toggle_maximize`] |
//! | the topbar / status bar | ordinary panes with [`Pane::fixed`](struct.Pane.html#structfield.fixed) |
//!
//! **ONE shell, N workspaces.** [`Scene::root`] is the shell skeleton — menu bar ·
//! workspace bar · toolbar · body · status bar — and it is authored **once**; a
//! [`Workspace`] owns the body and nothing else. So shell chrome cannot drift between
//! workspaces, and `shell.menubar` / `shell.statusbar` address the same panes in every
//! workspace: a saved route, a persisted layout key and a robot's `panes["…"]` do not
//! shift underneath the caller when the workspace changes. Pane-name uniqueness is scoped
//! to (shell ∪ ONE workspace), so `main` / `side` may repeat across workspaces —
//! [`NavState`] keeps the flat maps for shell ∪ the ACTIVE body and parks the rest under
//! [`NavState::workspaces`], which is how a layout file tells the two apart without
//! qualifying a single address.
//!
//! **The pane NAME is identity; the editor it hosts is STATE.** The binding lives in
//! [`NavState::area_editor`] and is published in `state_json`, so a robot addresses the
//! same pane, and reads back the same rect, across every swap.
//!
//! # The bug this model makes structurally impossible
//!
//! > korp→Graph's centrality readout grew a row per node until it pushed the graph canvas
//! > off the bottom of the screen, because canvas and readout were consecutive widgets in
//! > ONE pane's vertical flow.
//!
//! Three locks, each asserted on measured geometry rather than a flag:
//!
//! 1. **Composition.** An inspector is a *sibling area* under a [`Layout::Split`], never
//!    a widget in the canvas's flow. the split apportionment derives rects from
//!    `(parent rect, ratios)` alone (`split_extents`) — **content length is not an input to that
//!    function** — so a 100× larger readout leaves the canvas's `alloc` byte-identical.
//! 2. **Clip + overflow.** The allotment holding is not the pixels staying inside it: a
//!    child `Ui` inherits its parent's clip rect, so an over-long leaf can paint over its
//!    sibling while every rect assertion passes. Every bounded leaf now clips to its
//!    allotment and, under [`Overflow::Auto`], scrolls inside it. `state_json` publishes
//!    `clipped` and `overflowed` per pane, so "the content escaped" is measurable.
//! 3. **Authoring.** [`Content::Area`] takes **one** editor id, not a list. There is no
//!    affordance in the model for "two widgets in one area" — to show two things you
//!    split. The bug's shape is unrepresentable.
//!
//! # Persistence
//!
//! Chrome state used to die with the process. [`NavState`] is now serde and
//! [`crate::persist`] writes it to `<state dir>/<app>.layout.json`: the active workspace,
//! the shell ∪ active-body arrangement (split ratios, minimises, the maximise, folds,
//! active tabs, per-area editor bindings), and every inactive workspace's parked body.
//! The *scene* is never written — it is the app's authored design and ships with the
//! binary.

use crate::router::Router;
use crate::tabview::TabView;
use crate::workspace::{WORKSPACEBAR_H, Workspace, WorkspaceSlot};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;

// ─────────────────────────────────────────────────────────────────────────────
// SCHEMA — the whole app UI as data. serde-derive on every node.
// ─────────────────────────────────────────────────────────────────────────────

/// A whole app's UI described as data: a menubar, a root pane tree, and routes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Scene {
    /// The scene / app name (also the deep-link namespace, and the layout file's
    /// persistence key).
    pub name: String,
    /// Display title — what a workspace tab shows. Falls back to `name`. Skipped on
    /// serialize while empty, so every pre-existing scene's RON is byte-identical.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub title: String,
    /// The nested menubar (`mainmenu { menu1 { menu1.1 } }`).
    #[serde(default)]
    pub menubar: Vec<MenuItem>,
    /// The root pane — the whole layout tree hangs off it.
    pub root: Pane,
    /// Navigation routes: trigger → target (over the shared [`Router`]).
    #[serde(default)]
    pub routes: Vec<Route>,
    /// **The WORKSPACES** — named body arrangements, spliced into the shell's
    /// [`Content::Body`] pane one at a time. Empty ⇒ a plain single-arrangement scene,
    /// exactly as before (and the field is skipped on serialize, so no existing RON moved
    /// a byte).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub workspaces: Vec<Workspace>,
    /// Which workspace is authored as the default (index into `workspaces`). The LIVE one
    /// is [`NavState::active_workspace`].
    #[serde(default, skip_serializing_if = "is_zero")]
    pub active_workspace: usize,
}

/// Serde skip predicate for the default workspace index.
fn is_zero(n: &usize) -> bool {
    *n == 0
}

/// Every pane name in a tree, preorder. Used for the SHELL half of every scoped query
/// (a workspace body is walked by [`Workspace::pane_names`]).
fn shell_names(root: &Pane) -> Vec<String> {
    let mut out = Vec::new();
    fn walk(p: &Pane, out: &mut Vec<String>) {
        out.push(p.name.clone());
        if let Content::Children(kids) = &p.content {
            for k in kids {
                walk(k, out);
            }
        }
    }
    walk(root, &mut out);
    out
}

/// One (possibly nested) menu entry. `name` is UNIQUE across the whole menubar.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MenuItem {
    /// Unique id (addressable by [`Trigger::Menu`]).
    pub name: String,
    /// The human label shown in the menu.
    pub label: String,
    /// Optional keyboard-shortcut hint (display only; the OS binding is the host's).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shortcut: Option<String>,
    /// What clicking it does (a leaf without children usually carries one).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub action: Option<Action>,
    /// Child entries (a submenu). Empty ⇒ a leaf.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub items: Vec<MenuItem>,
}

/// A layout pane. `name` is UNIQUE across the whole scene (⇒ addressable / foldable).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Pane {
    /// Unique id — the fold/nav/deep-link handle.
    pub name: String,
    /// Header title (shown on the fold header; falls back to `name`).
    #[serde(default)]
    pub title: String,
    /// Optional hover tooltip for this pane's TAB header — when this pane is a child of a
    /// [`Layout::Tabs`] parent, [`TabView`] renders it as `on_hover_text` on the tab button
    /// (a "what does this view do" hint). `None` (the default) leaves the strip
    /// byte-identical, and it is skipped on serialize so existing RON scenes round-trip.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tooltip: Option<String>,
    /// How this pane arranges its children (ignored for a [`Content::Slot`]).
    #[serde(default)]
    pub layout: Layout,
    /// Fixed, or foldable (THE interaction).
    #[serde(default)]
    pub fold: Fold,
    /// Per-pane window chrome — the title bar with fold / minimise / maximise.
    /// **Defaults to [`Chrome::Auto`] (ON for every pane but the scene root)**; it is
    /// skipped on serialize while it holds that default, so an existing RON scene
    /// round-trips BYTE-IDENTICALLY.
    #[serde(default, skip_serializing_if = "Chrome::is_auto")]
    pub chrome: Chrome,
    /// Optional per-device layout overrides + a collapse breakpoint.
    #[serde(default, skip_serializing_if = "Responsive::is_empty")]
    pub responsive: Responsive,
    /// **How this pane's content behaves when it outgrows its allotment** — the CLIP
    /// LOCK. Defaults to [`Overflow::Auto`] (scroll inside a bounded parent), skipped on
    /// serialize while it holds that default.
    #[serde(default, skip_serializing_if = "Overflow::is_auto")]
    pub overflow: Overflow,
    /// **A FIXED extent (points) along the parent [`Layout::Split`]'s axis**, instead of
    /// a share of the ratios. This is what makes the shell's own chrome — the menu bar,
    /// the toolbar, the status bar — ordinary panes: they are rows of the root split
    /// that ask for an exact height, not special-cased widgets outside the tree.
    ///
    /// The remaining extent is apportioned among the *flexible* siblings by their
    /// unchanged ratios, exactly as a minimised sibling's [`CHROME_BAR`] is. Ignored
    /// outside a `Split` parent. Skipped on serialize while unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fixed: Option<f32>,
    /// Child panes or a host-provided widget slot.
    pub content: Content,
}

/// How a pane arranges its children.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub enum Layout {
    /// Horizontal wrapped flow (children laid left-to-right, wrapping).
    #[default]
    Flow,
    /// An N-way split along `axis`. `ratios` is one fraction per child (they are
    /// normalised to sum to 1). When `resizable`, a real drag handle sits between each
    /// adjacent pair; dragging re-apportions those two ratios and the LIVE ratios are
    /// kept in [`NavState`] (so a drag is observable in `state_json`). When not
    /// `resizable` the ratios are a fixed apportionment (no handle).
    ///
    /// `resizable` **defaults to `true`**: a hand-authored RON `Split` that omits the
    /// field gets a real, draggable divider. Being un-draggable must be an explicit,
    /// deliberate act ([`Pane::split_fixed`]) — never an accident of omission.
    Split {
        /// Which way the children are laid out.
        axis: Axis,
        /// One fraction per child (normalised to sum to 1).
        ratios: Vec<f32>,
        /// Whether a real drag handle sits between adjacent children (**default true**).
        #[serde(default = "yes")]
        resizable: bool,
    },
    /// A grid of `cols` columns.
    Grid { cols: usize },
    /// A tab strip; `active` is the initially-selected child index.
    Tabs { active: usize },
}

/// Split axis.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum Axis {
    /// Left / right.
    Horizontal,
    /// Top / bottom.
    Vertical,
}

/// A pane's content: child panes, a host-supplied widget slot, or an **area** bound to
/// a swappable editor type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Content {
    /// Child panes laid out by this pane's [`Layout`].
    Children(Vec<Pane>),
    /// A named slot the [`SceneHost`] fills with a real widget. A slot is bound to ONE
    /// widget for the life of the scene — use [`Content::Area`] when the operator should
    /// be able to change what is shown there.
    Slot(String),
    /// **The scene's MENU BAR, as a pane.** Draws [`Scene::menubar`] in place, so the top
    /// menu is a fixed-height region of the pane tree like any other — it has a name, a
    /// rect and an `alloc` in `state_json`, and it can be moved, folded or dropped by
    /// editing the tree. A scene with a menubar pane does NOT also draw the implicit top
    /// strip; without one, the implicit strip is drawn exactly as before.
    Menubar,
    /// **THE SPLICE POINT** — where the active [`Workspace`]'s body tree is grafted into
    /// the shell at render time. Everything around it (menu bar, workspace bar, toolbar,
    /// status bar) is authored ONCE and does not move when the workspace changes; only
    /// what hangs below this pane varies. With no workspaces declared it draws nothing.
    Body,
    /// **The workspace TAB STRIP, as a pane** — Blender's topbar tabs. A fixed-height row
    /// of the shell, so it has a name, a rect and an `alloc` like every other pane, and
    /// each tab publishes a clickable rect plus a `"workspace <name>"` AccessKit label.
    WorkspaceBar,
    /// **An AREA** (Blender's model): a tiled rect that can host *any* registered editor
    /// type, swapped at runtime from the dropdown in its own header. `editor` is the
    /// **default** id — the live binding is *state* ([`NavState::area_editor`]), so the
    /// pane NAME stays the stable handle a robot addresses while the editor underneath
    /// changes. `allow` gates which types the header offers.
    ///
    /// **It takes ONE editor id, not a list.** There is no affordance anywhere in the
    /// model for "two widgets in one area" — to show two things you split into two
    /// areas. That is the third lock on the korp→Graph bug: its shape is unrepresentable.
    Area {
        /// The default editor id.
        editor: String,
        /// Which editor types this area's header offers.
        #[serde(default, skip_serializing_if = "Allow::is_any")]
        allow: Allow,
    },
}

/// Which editor types an [`Content::Area`]'s header dropdown offers.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Allow {
    /// **The default** — every type in the host's registry.
    #[default]
    Any,
    /// Only these ids (in this order), intersected with the registry.
    Only(Vec<String>),
    /// **Pinned**: no dropdown at all. The area shows its editor's name as a plain label
    /// and cannot be swapped — for the handful of surfaces where a swap is meaningless.
    Fixed,
}
impl Allow {
    /// Is this the (skipped-on-serialize) default?
    pub fn is_any(&self) -> bool {
        matches!(self, Allow::Any)
    }
    /// Filter the host's registry options down to what this area offers.
    fn filter(&self, options: Vec<(String, String)>) -> Vec<(String, String)> {
        match self {
            Allow::Any => options,
            Allow::Fixed => Vec::new(),
            Allow::Only(ids) => {
                ids.iter().filter_map(|w| options.iter().find(|(o, _)| o == w).cloned()).collect()
            }
        }
    }
}

/// A pane's fold behaviour — the ONE interaction.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub enum Fold {
    /// Always laid out; no fold affordance.
    #[default]
    Fixed,
    /// Foldable to a small header. `default_open` seeds [`NavState`]; `icon` decorates
    /// the header.
    Foldable {
        /// Whether the pane starts unfolded.
        default_open: bool,
        /// Optional header glyph (e.g. `"⚙"`).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        icon: Option<String>,
    },
}

/// Serde default for [`Layout::Split`]'s `resizable`: **draggable**.
fn yes() -> bool {
    true
}

/// **The CLIP LOCK** — what a pane does when its content outgrows its allotted rect.
///
/// [`Layout::Split`] apportions rects up front from `(parent rect, ratios)`, and content
/// length is not an input to that function — so a sibling area can never *shrink* another
/// one. That protects the **allotment**. It does not protect the **pixels**: in egui a
/// child `Ui` built with `UiBuilder::max_rect(alloc)` inherits its parent's clip rect,
/// not its own, so a leaf whose content exceeds its allotment paints straight over its
/// sibling — the rect assertion passes while the screen is wrong. This closes that.
///
/// Every leaf clips to its allotment, and [`Overflow::Auto`] (the default) resolves to
/// [`Overflow::Scroll`] inside a bounded parent (a `Split` or `Tabs` child, which has an
/// exact rect) and [`Overflow::Grow`] inside a `Flow`/`Grid` (which has none to overflow
/// out of). The default is ON, for the same reason [`Chrome::Auto`] is: capability that
/// ships opt-in is capability nobody sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Overflow {
    /// **The default.** `Scroll` under a bounded parent, `Grow` under `Flow`/`Grid`.
    #[default]
    Auto,
    /// Scroll the content inside the allotment.
    Scroll,
    /// Clip the content to the allotment (no scrollbars — what is past the edge is gone).
    Clip,
    /// **Opt out**: let the content take whatever height it wants. Honest only under a
    /// `Flow`/`Grid` parent; under a `Split` this is how the korp→Graph bug looked.
    Grow,
}
impl Overflow {
    /// Is this the (skipped-on-serialize) default?
    pub fn is_auto(&self) -> bool {
        matches!(self, Overflow::Auto)
    }
    /// The stable tag string.
    pub fn tag(self) -> &'static str {
        match self {
            Overflow::Auto => "auto",
            Overflow::Scroll => "scroll",
            Overflow::Clip => "clip",
            Overflow::Grow => "grow",
        }
    }
    /// Resolve `Auto` against whether the parent allotted an exact rect.
    fn resolve(self, bounded: bool) -> Overflow {
        match self {
            Overflow::Auto if bounded => Overflow::Scroll,
            Overflow::Auto => Overflow::Grow,
            other => other,
        }
    }
}

/// **Per-pane window chrome** — the title bar that carries the fold, minimise and
/// maximise/restore affordances.
///
/// ## The default is [`Chrome::Auto`], and `Auto` means ON
/// Every pane in the tree gets a chrome bar EXCEPT the scene root (there is nothing
/// for the root to minimise into and maximising it is the identity — [`Chrome::Always`]
/// forces one anyway). This is deliberate and it is the whole point: a window
/// affordance that a host must opt into is an affordance no operator ever discovers.
/// A pane that genuinely must stay bare says so — [`Pane::no_chrome`].
///
/// `Auto` is skipped on serialize, so adding chrome did NOT change any existing
/// scene's RON by one byte.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Chrome {
    /// **The default.** A full title bar (fold · minimise · maximise) on every pane
    /// except the scene root.
    #[default]
    Auto,
    /// A full title bar ALWAYS — including on the scene root.
    Always,
    /// A title bar carrying only the fold toggle (no minimise / maximise).
    FoldOnly,
    /// **Opt out**: no title bar at all. A [`Fold::Foldable`] pane falls back to the
    /// plain collapsing header it had before chrome existed, so an opted-out pane is
    /// pixel-identical to the pre-chrome renderer.
    None,
}
impl Chrome {
    /// Is this the (skipped-on-serialize) default?
    pub fn is_auto(&self) -> bool {
        matches!(self, Chrome::Auto)
    }
    /// The stable tag string (`"auto"` / `"always"` / `"fold_only"` / `"none"`).
    pub fn tag(self) -> &'static str {
        match self {
            Chrome::Auto => "auto",
            Chrome::Always => "always",
            Chrome::FoldOnly => "fold_only",
            Chrome::None => "none",
        }
    }
}

/// Which chrome controls a pane actually draws this frame, after resolving its
/// [`Chrome`] against its position in the tree. Returned by [`Scene::chrome_of`] so a
/// host or a robot can ask "what will this pane show?" without rendering.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct ChromeSpec {
    /// Draw the title bar at all.
    pub bar: bool,
    /// Draw the fold ⏷/⏵ toggle (only for a [`Fold::Foldable`] pane).
    pub fold: bool,
    /// Draw the minimise control.
    pub minimize: bool,
    /// Draw the maximise / restore control.
    pub maximize: bool,
    /// Draw the **editor-type dropdown** — true for a [`Content::Area`] pane whose
    /// [`Allow`] is not [`Allow::Fixed`]. This is the one chrome control that is a
    /// property of the pane's CONTENT rather than of its [`Chrome`] setting.
    pub editor: bool,
}
impl ChromeSpec {
    /// This spec as JSON (folded into `state_json`).
    fn json(&self) -> Value {
        json!({ "bar": self.bar, "fold": self.fold, "minimize": self.minimize, "maximize": self.maximize, "editor": self.editor })
    }
}

/// Resolve a pane's [`Chrome`] into the controls it draws. `is_root` is true only for
/// the scene's root pane.
fn chrome_spec(pane: &Pane, is_root: bool) -> ChromeSpec {
    let foldable = matches!(pane.fold, Fold::Foldable { .. });
    // The editor picker follows the CONTENT, not the Chrome setting: an area that says
    // `Allow::Fixed` has nothing to swap to and claims no affordance.
    let editor = matches!(&pane.content, Content::Area { allow, .. } if !matches!(allow, Allow::Fixed));
    match pane.chrome {
        Chrome::None => ChromeSpec::default(),
        Chrome::FoldOnly => ChromeSpec { bar: !is_root, fold: foldable, minimize: false, maximize: false, editor },
        Chrome::Auto => {
            ChromeSpec { bar: !is_root, fold: foldable, minimize: !is_root, maximize: !is_root, editor }
        }
        Chrome::Always => ChromeSpec { bar: true, fold: foldable, minimize: true, maximize: true, editor },
    }
}

/// Per-device layout overrides + a collapse breakpoint (in points of available width).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Responsive {
    /// Layout to use on a phone-class width (else the pane's own [`Layout`]).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phone: Option<Layout>,
    /// Layout to use on a tablet-class width.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tablet: Option<Layout>,
    /// Below this width (points) the pane is treated as phone-class.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub collapse_below: Option<f32>,
    /// When true, a foldable pane auto-folds (becomes a "drawer") as the width crosses
    /// into phone-class, and auto-restores to its `default_open` when it crosses back —
    /// the desktop-rail → phone-drawer pattern. Only applied on a device-class
    /// *transition*, so the user can still open/close the drawer while on a phone.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub drawer: bool,
}
impl Responsive {
    fn is_empty(&self) -> bool {
        self.phone.is_none() && self.tablet.is_none() && self.collapse_below.is_none() && !self.drawer
    }
}

/// What a menu action does.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Action {
    /// Navigate the [`Router`] to a pane.
    NavigateTo(String),
    /// Fire a host command id (routed to [`SceneHost::command`]).
    Command(String),
    /// Toggle a pane's fold.
    Toggle(String),
    /// Toggle a pane's **minimise** (collapse to its chrome title bar).
    Minimize(String),
    /// Toggle a pane's **maximise** (take the whole scene area, hiding siblings).
    Maximize(String),
    /// **Restore** a pane: un-minimise it and drop any maximise it holds.
    Restore(String),
    /// **Swap an AREA's editor type** — Blender's header dropdown, as a menu action.
    SetEditor {
        /// The area pane (its NAME — unchanged by the swap).
        pane: String,
        /// The editor id to mount.
        editor: String,
    },
    /// **Switch WORKSPACE** — Blender's topbar tabs, as a menu action.
    Workspace(String),
}

/// A navigation route: a trigger and its target, resolved over the [`Router`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Route {
    /// What fires this route.
    pub trigger: Trigger,
    /// What it does.
    pub target: Target,
}

/// What fires a [`Route`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Trigger {
    /// A menu item was chosen (by unique name).
    Menu(String),
    /// A keyboard shortcut string.
    Shortcut(String),
    /// A named button was clicked.
    Button(String),
    /// A host event id.
    Event(String),
}

/// A [`Route`]'s effect on [`NavState`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Target {
    /// Navigate to a pane (records back-history).
    Show(String),
    /// Focus a pane (navigate + set focus).
    Focus(String),
    /// Push a pane onto the stack.
    Push(String),
    /// Pop back one step.
    Pop,
    /// Toggle a pane's fold.
    Toggle(String),
    /// Toggle a pane's **minimise** (collapse to its chrome title bar).
    Minimize(String),
    /// Toggle a pane's **maximise** (take the whole scene area, hiding siblings).
    Maximize(String),
    /// **Restore** a pane: un-minimise it and drop any maximise it holds.
    Restore(String),
    /// Navigate to a pane carrying an argument.
    ShowWith {
        /// Target pane.
        pane: String,
        /// The argument handed along (recorded in state).
        arg: String,
    },
    /// **Swap an AREA's editor type.** The pane keeps its name, its rect and its chrome;
    /// only what is mounted inside changes.
    SetEditor {
        /// The area pane.
        pane: String,
        /// The editor id to mount.
        editor: String,
    },
    /// **Switch WORKSPACE** — swap the body tree spliced into the shell. The shell does
    /// not move.
    Workspace(String),
}

// ─────────────────────────────────────────────────────────────────────────────
// RON <-> Scene (byte-stable) + validation.
// ─────────────────────────────────────────────────────────────────────────────

impl Scene {
    /// The canonical RON pretty config — a FIXED format so [`to_ron`](Self::to_ron)
    /// is byte-stable (parse ∘ serialize is a fixed point).
    fn ron_config() -> ron::ser::PrettyConfig {
        ron::ser::PrettyConfig::new().indentor("  ".to_string()).struct_names(true)
    }

    /// Parse a [`Scene`] from RON text.
    pub fn from_ron(text: &str) -> Result<Scene, ron::error::SpannedError> {
        ron::from_str(text)
    }

    /// Serialize this scene to canonical (byte-stable) RON.
    pub fn to_ron(&self) -> String {
        ron::ser::to_string_pretty(self, Self::ron_config()).expect("Scene serializes to RON")
    }

    /// Build a scene from a root pane (Rust-builder entry point). With workspaces, the
    /// root is the **shell skeleton** and carries a [`Content::Body`] splice pane.
    pub fn new(name: impl Into<String>, root: Pane) -> Self {
        Self {
            name: name.into(),
            title: String::new(),
            menubar: Vec::new(),
            root,
            routes: Vec::new(),
            workspaces: Vec::new(),
            active_workspace: 0,
        }
    }
    /// Add a [`Workspace`] (builder). Its body tree is spliced into the shell's
    /// [`Content::Body`] pane when it is the active one.
    pub fn workspace(mut self, ws: Workspace) -> Self {
        self.workspaces.push(ws);
        self
    }
    /// Start on the workspace called `name` (builder). Unknown names are ignored.
    pub fn default_workspace(mut self, name: &str) -> Self {
        if let Some(i) = self.workspaces.iter().position(|w| w.name == name) {
            self.active_workspace = i;
        }
        self
    }

    // ── Workspace lookup ─────────────────────────────────────────────────────────

    /// A workspace by name.
    pub fn workspace_named(&self, name: &str) -> Option<&Workspace> {
        self.workspaces.iter().find(|w| w.name == name)
    }
    /// The AUTHORED-default workspace (`None` when the scene declares none).
    pub fn default_ws(&self) -> Option<&Workspace> {
        self.workspaces.get(self.active_workspace.min(self.workspaces.len().saturating_sub(1)))
    }
    /// Resolve a workspace name to a workspace, falling back to the authored default —
    /// the one lookup the renderer and every query use, so a stale name can never make a
    /// pane un-addressable.
    pub fn ws_or_default(&self, name: Option<&str>) -> Option<&Workspace> {
        name.and_then(|n| self.workspace_named(n)).or_else(|| self.default_ws())
    }
    /// Every workspace name, in tab order.
    pub fn workspace_names(&self) -> Vec<&str> {
        self.workspaces.iter().map(|w| w.name.as_str()).collect()
    }
    /// The pane in the shell that hosts the [`Content::Body`] splice, if any.
    pub fn body_pane(&self) -> Option<String> {
        shell_names(&self.root).into_iter().find(|n| matches!(self.shell_pane(n).map(|p| &p.content), Some(Content::Body)))
    }
    /// The pane in the shell that hosts the workspace tab strip, if any.
    pub fn workspace_bar_pane(&self) -> Option<String> {
        shell_names(&self.root)
            .into_iter()
            .find(|n| matches!(self.shell_pane(n).map(|p| &p.content), Some(Content::WorkspaceBar)))
    }
    /// A pane in the SHELL only (never a workspace body).
    pub fn shell_pane(&self, name: &str) -> Option<&Pane> {
        fn walk<'a>(p: &'a Pane, name: &str) -> Option<&'a Pane> {
            if p.name == name {
                return Some(p);
            }
            if let Content::Children(kids) = &p.content {
                for k in kids {
                    if let Some(hit) = walk(k, name) {
                        return Some(hit);
                    }
                }
            }
            None
        }
        walk(&self.root, name)
    }
    /// A pane in **shell ∪ workspace `ws`** — the scoped lookup every query is built on.
    pub fn pane_in(&self, ws: Option<&str>, name: &str) -> Option<&Pane> {
        self.shell_pane(name).or_else(|| self.ws_or_default(ws).and_then(|w| w.pane(name)))
    }
    /// Every pane name in **shell ∪ workspace `ws`**, shell first.
    pub fn pane_names_in(&self, ws: Option<&str>) -> Vec<String> {
        let mut out = shell_names(&self.root);
        if let Some(w) = self.ws_or_default(ws) {
            out.extend(w.pane_names());
        }
        out
    }
    /// Set the display title (what a workspace tab shows).
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }
    /// The display title, falling back to the scene name.
    pub fn header_title(&self) -> &str {
        if self.title.is_empty() { &self.name } else { &self.title }
    }
    /// Add a top-level menu (builder).
    pub fn menu(mut self, item: MenuItem) -> Self {
        self.menubar.push(item);
        self
    }
    /// Add a route (builder).
    pub fn route(mut self, route: Route) -> Self {
        self.routes.push(route);
        self
    }

    /// Depth-first pane lookup by unique name, over **shell ∪ the authored-default
    /// workspace**. Use [`pane_in`](Self::pane_in) to scope it to a specific workspace.
    pub fn pane(&self, name: &str) -> Option<&Pane> {
        self.pane_in(None, name)
    }

    /// Every pane name in preorder, over **shell ∪ the authored-default workspace**.
    pub fn pane_names(&self) -> Vec<String> {
        self.pane_names_in(None)
    }

    /// Validate the addressability invariant.
    ///
    /// **Name uniqueness is scoped to (shell ∪ ONE workspace)** — across workspaces the
    /// same body pane name may, and should, repeat (`nav`, `main`, `side`, `out` exist in
    /// every workspace). Each workspace is therefore checked against the shell
    /// independently. Workspace names themselves are globally unique, and so are menu
    /// names. Returns the first violation found.
    pub fn validate(&self) -> Result<(), String> {
        let shell = shell_names(&self.root);
        let mut seen = std::collections::BTreeSet::new();
        for n in &shell {
            if !seen.insert(n.clone()) {
                return Err(format!("duplicate pane name: {n}"));
            }
        }
        let mut ws_seen = std::collections::BTreeSet::new();
        for w in &self.workspaces {
            if !ws_seen.insert(w.name.clone()) {
                return Err(format!("duplicate workspace name: {}", w.name));
            }
            // Shell ∪ THIS workspace — never workspace ∪ workspace.
            let mut scope: std::collections::BTreeSet<String> = seen.clone();
            for n in w.pane_names() {
                if !scope.insert(n.clone()) {
                    return Err(format!("workspace {}: duplicate pane name: {n}", w.name));
                }
            }
        }
        if !self.workspaces.is_empty() && self.body_pane().is_none() {
            return Err("a scene with workspaces needs a Content::Body pane to splice them into".into());
        }
        let mut mseen = std::collections::BTreeSet::new();
        fn walk_menu(items: &[MenuItem], seen: &mut std::collections::BTreeSet<String>) -> Result<(), String> {
            for it in items {
                if !seen.insert(it.name.clone()) {
                    return Err(format!("duplicate menu name: {}", it.name));
                }
                walk_menu(&it.items, seen)?;
            }
            Ok(())
        }
        walk_menu(&self.menubar, &mut mseen)
    }

    /// The ancestor chain from the shell root down to `name`, inclusive. For a **body**
    /// pane the chain runs shell-root → … → the [`Content::Body`] splice pane → the
    /// workspace's own body root → … → `name`, so a maximise inside a workspace keeps
    /// every shell ancestor on the path and the shell chrome stays put.
    pub fn path_to(&self, name: &str) -> Option<Vec<String>> {
        self.path_to_in(None, name)
    }

    /// [`path_to`](Self::path_to), scoped to a named workspace.
    pub fn path_to_in(&self, ws: Option<&str>, name: &str) -> Option<Vec<String>> {
        fn walk(p: &Pane, name: &str, acc: &mut Vec<String>) -> bool {
            acc.push(p.name.clone());
            if p.name == name {
                return true;
            }
            if let Content::Children(kids) = &p.content {
                for k in kids {
                    if walk(k, name, acc) {
                        return true;
                    }
                }
            }
            acc.pop();
            false
        }
        let mut acc = Vec::new();
        if walk(&self.root, name, &mut acc) {
            return Some(acc);
        }
        // A body pane: shell path down to the splice, then the workspace's own path.
        let w = self.ws_or_default(ws)?;
        let inner = w.path_to(name)?;
        let mut acc = Vec::new();
        walk(&self.root, &self.body_pane()?, &mut acc).then(|| {
            acc.extend(inner);
            acc
        })
    }

    /// The chrome controls `name` will draw, resolved against its position in the tree
    /// (the shell root is bare under [`Chrome::Auto`]). All-false for an unknown pane.
    pub fn chrome_of(&self, name: &str) -> ChromeSpec {
        self.chrome_of_in(None, name)
    }
    /// [`chrome_of`](Self::chrome_of), scoped to a named workspace.
    pub fn chrome_of_in(&self, ws: Option<&str>, name: &str) -> ChromeSpec {
        match self.pane_in(ws, name) {
            Some(p) => chrome_spec(p, name == self.root.name),
            None => ChromeSpec::default(),
        }
    }

    /// Every pane that draws a chrome title bar (in preorder).
    pub fn chrome_panes(&self) -> Vec<String> {
        self.pane_names().into_iter().filter(|n| self.chrome_of(n).bar).collect()
    }

    /// Every pane that renders a **draggable** divider (a resizable [`Layout::Split`]
    /// with ≥2 children), in preorder.
    pub fn draggable_splits(&self) -> Vec<String> {
        self.pane_names()
            .into_iter()
            .filter(|n| match self.pane(n) {
                Some(p) => {
                    let kids = match &p.content {
                        Content::Children(k) => k.len(),
                        _ => 0,
                    };
                    matches!(p.layout, Layout::Split { resizable: true, .. }) && kids >= 2
                }
                None => false,
            })
            .collect()
    }

    /// Every **AREA** pane (a [`Content::Area`] — a rect whose editor is swappable), in
    /// preorder, as `(pane name, default editor id)`.
    pub fn areas(&self) -> Vec<(String, String)> {
        self.areas_in(None)
    }
    /// [`areas`](Self::areas), scoped to a named workspace (whose per-area `editors`
    /// overrides are applied).
    pub fn areas_in(&self, ws: Option<&str>) -> Vec<(String, String)> {
        let w = self.ws_or_default(ws);
        self.pane_names_in(ws)
            .into_iter()
            .filter_map(|n| match self.pane_in(ws, &n).map(|p| &p.content) {
                Some(Content::Area { editor, .. }) => {
                    let e = w.and_then(|w| w.editors.get(&n)).cloned().unwrap_or_else(|| editor.clone());
                    Some((n, e))
                }
                _ => None,
            })
            .collect()
    }

    /// The pane that hosts the MENU BAR ([`Content::Menubar`]), if the tree carries one.
    /// When it does, [`render`] does not also draw the implicit top strip.
    pub fn menubar_pane(&self) -> Option<String> {
        self.pane_names().into_iter().find(|n| matches!(self.pane(n).map(|p| &p.content), Some(Content::Menubar)))
    }

    /// Every pane asking for a FIXED extent, in preorder, as `(name, points)` — the
    /// shell's own chrome rows (menu bar / toolbar / status bar) and anything else
    /// pinned. A shell that claims a status bar and reports none here has not got one.
    pub fn fixed_panes(&self) -> Vec<(String, f32)> {
        self.pane_names().into_iter().filter_map(|n| self.pane(&n).and_then(|p| p.fixed).map(|f| (n, f))).collect()
    }

    /// The DEFAULT editor id authored for area pane `name` (`None` for a non-area pane).
    pub fn area_editor(&self, name: &str) -> Option<&str> {
        match self.pane(name).map(|p| &p.content) {
            Some(Content::Area { editor, .. }) => Some(editor.as_str()),
            _ => None,
        }
    }

    /// Every [`Fold::Foldable`] pane, in preorder.
    pub fn foldable_panes(&self) -> Vec<String> {
        self.pane_names()
            .into_iter()
            .filter(|n| matches!(self.pane(n).map(|p| &p.fold), Some(Fold::Foldable { .. })))
            .collect()
    }

    /// **The adoption audit** — one JSON blob naming every interactive affordance this
    /// scene actually ships. A host's robot test asserts against THIS instead of
    /// re-deriving it: a shell with an empty `chrome` list, or one that wants
    /// master/detail but reports zero `draggable_splits`, is a shell the operator
    /// cannot drive.
    pub fn affordances(&self) -> Value {
        // area -> its default editor, and the inverse: editor -> the areas that can host
        // it. A robot reads the second to answer "where can I put a Graph?".
        let areas: BTreeMap<String, String> = self.areas().into_iter().collect();
        let mut by_editor: BTreeMap<String, Vec<String>> = BTreeMap::new();
        for (pane, editor) in &areas {
            by_editor.entry(editor.clone()).or_default().push(pane.clone());
        }
        json!({
            "scene": self.name,
            "panes": self.pane_names(),
            "chrome": self.chrome_panes(),
            "draggable_splits": self.draggable_splits(),
            "foldable": self.foldable_panes(),
            "areas": areas,
            "editors": by_editor,
            "fixed_panes": self.fixed_panes().into_iter().map(|(p, _)| p).collect::<Vec<_>>(),
            "menubar_pane": self.menubar_pane(),
            // The shell is authored ONCE; only what hangs under `body_pane` varies.
            "workspaces": self.workspace_names(),
            "active_workspace": self.default_ws().map(|w| w.name.clone()),
            "shell_panes": shell_names(&self.root),
            "body_pane": self.body_pane(),
            "workspace_bar_pane": self.workspace_bar_pane(),
        })
    }

    /// Find a [`MenuItem`] by unique name (depth-first).
    pub fn menu_item(&self, name: &str) -> Option<&MenuItem> {
        fn walk<'a>(items: &'a [MenuItem], name: &str) -> Option<&'a MenuItem> {
            for it in items {
                if it.name == name {
                    return Some(it);
                }
                if let Some(hit) = walk(&it.items, name) {
                    return Some(hit);
                }
            }
            None
        }
        walk(&self.menubar, name)
    }
}

// ── Pane / MenuItem / Route builders (the pure-Rust authoring form) ────────────

impl Pane {
    fn base(name: impl Into<String>, layout: Layout, content: Content) -> Self {
        let name = name.into();
        Self {
            title: name.clone(),
            tooltip: None,
            name,
            layout,
            fold: Fold::Fixed,
            chrome: Chrome::Auto,
            responsive: Responsive::default(),
            overflow: Overflow::Auto,
            fixed: None,
            content,
        }
    }
    /// A host-widget slot pane (leaf). The widget is FIXED for the life of the scene.
    pub fn slot(name: impl Into<String>, slot_id: impl Into<String>) -> Self {
        Self::base(name, Layout::Flow, Content::Slot(slot_id.into()))
    }
    /// **An AREA** — a leaf pane that can host any registered editor type, swappable at
    /// runtime from the dropdown in its own chrome header (Blender's area/editor model).
    ///
    /// `editor_id` is only the DEFAULT: the live binding lives in
    /// [`NavState::area_editor`] and is published in `state_json`, so
    /// - the pane **name** is the stable handle (`click_label`, deep link, persisted
    ///   layout key) and never changes when the editor does, and
    /// - a robot swaps the editor with [`NavState::set_editor`] (or the
    ///   [`Target::SetEditor`] route) and reads the *same* rect back.
    ///
    /// ```no_run
    /// # use facett_app::{Pane, Axis};
    /// // Two areas side by side, each hosting a different editor — the arrangement a
    /// // tab model cannot express at all.
    /// let body = Pane::split("body", Axis::Horizontal, 0.6, vec![
    ///     Pane::area("area.left", "graph").title("Graph"),
    ///     Pane::area("area.right", "tables").title("Tables"),
    /// ]);
    /// ```
    pub fn area(name: impl Into<String>, editor_id: impl Into<String>) -> Self {
        Self::base(name, Layout::Flow, Content::Area { editor: editor_id.into(), allow: Allow::Any })
    }
    /// Restrict which editor types this area's header offers (no-op for a non-area pane).
    /// The listed ids are intersected with the host's registry, in the order given.
    pub fn only_editors(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
        if let Content::Area { allow, .. } = &mut self.content {
            *allow = Allow::Only(ids.into_iter().map(Into::into).collect());
        }
        self
    }
    /// **Pin this area's editor**: no dropdown, no swap (no-op for a non-area pane).
    pub fn fixed_editor(mut self) -> Self {
        if let Content::Area { allow, .. } = &mut self.content {
            *allow = Allow::Fixed;
        }
        self
    }
    /// A flow container over children.
    pub fn flow(name: impl Into<String>, children: Vec<Pane>) -> Self {
        Self::base(name, Layout::Flow, Content::Children(children))
    }
    /// A two-way split with a **draggable** divider; `ratio` is the first child's
    /// initial fraction (the second gets the rest). Use [`Pane::split_fixed`] for a
    /// static (non-draggable) apportionment, or [`Pane::split_n`] for N children.
    pub fn split(name: impl Into<String>, axis: Axis, ratio: f32, children: Vec<Pane>) -> Self {
        let ratios = vec![ratio, (1.0 - ratio).max(0.0)];
        Self::base(name, Layout::Split { axis, ratios, resizable: true }, Content::Children(children))
    }
    /// A two-way split with a STATIC divider (no drag handle).
    pub fn split_fixed(name: impl Into<String>, axis: Axis, ratio: f32, children: Vec<Pane>) -> Self {
        let ratios = vec![ratio, (1.0 - ratio).max(0.0)];
        Self::base(name, Layout::Split { axis, ratios, resizable: false }, Content::Children(children))
    }
    /// An N-way **draggable** split; `ratios` is one fraction per child (normalised).
    pub fn split_n(name: impl Into<String>, axis: Axis, ratios: Vec<f32>, children: Vec<Pane>) -> Self {
        Self::base(name, Layout::Split { axis, ratios, resizable: true }, Content::Children(children))
    }
    /// Toggle whether this pane's [`Layout::Split`] has a draggable handle (no-op for a
    /// non-Split layout).
    pub fn resizable(mut self, on: bool) -> Self {
        if let Layout::Split { resizable, .. } = &mut self.layout {
            *resizable = on;
        }
        self
    }
    /// A grid over children.
    pub fn grid(name: impl Into<String>, cols: usize, children: Vec<Pane>) -> Self {
        Self::base(name, Layout::Grid { cols }, Content::Children(children))
    }
    /// A tab strip over children.
    pub fn tabs(name: impl Into<String>, active: usize, children: Vec<Pane>) -> Self {
        Self::base(name, Layout::Tabs { active }, Content::Children(children))
    }
    /// Set the header title.
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }
    /// Set a hover tooltip for this pane's TAB header — [`TabView`] shows it as
    /// `on_hover_text` when this pane is a tab (a "what does this view do" hint).
    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
        self.tooltip = Some(tooltip.into());
        self
    }
    /// Set this pane's window [`Chrome`] explicitly.
    pub fn chrome(mut self, chrome: Chrome) -> Self {
        self.chrome = chrome;
        self
    }
    /// **Opt out** of the window chrome: no title bar, no minimise/maximise. A
    /// [`Fold::Foldable`] pane falls back to its plain collapsing header.
    pub fn no_chrome(mut self) -> Self {
        self.chrome = Chrome::None;
        self
    }
    /// Force the full chrome bar even on the scene ROOT (which [`Chrome::Auto`] leaves
    /// bare).
    pub fn chrome_always(mut self) -> Self {
        self.chrome = Chrome::Always;
        self
    }
    /// Make the pane foldable (THE interaction).
    pub fn foldable(mut self, default_open: bool) -> Self {
        self.fold = Fold::Foldable { default_open, icon: None };
        self
    }
    /// Make the pane foldable with a header icon.
    pub fn foldable_icon(mut self, default_open: bool, icon: impl Into<String>) -> Self {
        self.fold = Fold::Foldable { default_open, icon: Some(icon.into()) };
        self
    }
    /// Set the phone-class layout override + the collapse breakpoint.
    pub fn phone(mut self, layout: Layout, collapse_below: f32) -> Self {
        self.responsive.phone = Some(layout);
        self.responsive.collapse_below = Some(collapse_below);
        self
    }
    /// Set the tablet-class layout override.
    pub fn tablet(mut self, layout: Layout) -> Self {
        self.responsive.tablet = Some(layout);
        self
    }
    /// Make this (foldable) pane a **drawer**: auto-fold when the width crosses into
    /// phone-class (`collapse_below` points) and auto-restore above it — the
    /// desktop-rail → phone-drawer pattern.
    pub fn drawer(mut self, collapse_below: f32) -> Self {
        self.responsive.drawer = true;
        self.responsive.collapse_below = Some(collapse_below);
        self
    }

    /// **Ask for an exact extent** (points) along the parent split's axis instead of a
    /// share of its ratios. See [`Pane::fixed`](struct.Pane.html#structfield.fixed).
    pub fn fixed(mut self, points: f32) -> Self {
        self.fixed = Some(points.max(0.0));
        self
    }
    /// Drop a fixed extent — the pane goes back to taking its ratio share.
    pub fn flexible(mut self) -> Self {
        self.fixed = None;
        self
    }
    /// Set this pane's [`Overflow`] (the clip lock) explicitly.
    pub fn overflow(mut self, overflow: Overflow) -> Self {
        self.overflow = overflow;
        self
    }

    // ── The SHELL's own chrome, as panes ─────────────────────────────────────────
    // The menu bar, the toolbar and the status bar are not widgets bolted on outside
    // the tree: they are fixed-height ROWS of the root split. That means each has a
    // name, a rect and an `alloc` in `state_json`, each is addressable by a robot, and
    // moving or removing one is an edit to the pane tree rather than a code path.

    /// **The menu bar, as a pane** — a fixed-height row that draws [`Scene::menubar`] in
    /// place. Bare by default (a menu bar with its own minimise button would be absurd);
    /// say `.chrome(Chrome::Auto)` if you really want one.
    pub fn menubar(name: impl Into<String>) -> Self {
        Self::base(name, Layout::Flow, Content::Menubar).fixed(MENUBAR_H).no_chrome()
    }
    /// **The workspace TAB STRIP, as a pane** — Blender's topbar tabs, a fixed-height
    /// row of the shell. Bare like the menu bar.
    pub fn workspace_bar(name: impl Into<String>) -> Self {
        Self::base(name, Layout::Flow, Content::WorkspaceBar).fixed(WORKSPACEBAR_H).no_chrome()
    }
    /// **THE BODY SPLICE** — the pane the active [`Workspace`]'s tree is grafted into.
    /// Flexible by default (it takes whatever the fixed chrome rows leave) and bare: the
    /// window controls belong to the areas inside it, not to the hole they sit in.
    pub fn body(name: impl Into<String>) -> Self {
        Self::base(name, Layout::Flow, Content::Body).no_chrome()
    }
    /// **A toolbar / status bar, as a pane** — a fixed-height slot row, bare like the
    /// menu bar. `Pane::bar("statusbar", "status", 22.0)`.
    pub fn bar(name: impl Into<String>, slot_id: impl Into<String>, height: f32) -> Self {
        Self::slot(name, slot_id).fixed(height).no_chrome()
    }
    /// **The whole shell as one vertical stack**: fixed-height chrome rows around a
    /// flexible body. Any child carrying [`fixed`](Pane::fixed) takes exactly that many
    /// points; the rest share what is left.
    ///
    /// ```no_run
    /// # use facett_app::{Axis, Pane, Scene, Workspace, STATUSBAR_H, TOOLBAR_H};
    /// // The SHELL — authored once, identical in every workspace.
    /// let scene = Scene::new("korp", Pane::shell("shell.root", vec![
    ///     Pane::menubar("shell.menubar"),                     // fixed MENUBAR_H
    ///     Pane::workspace_bar("shell.workspaces"),            // fixed WORKSPACEBAR_H
    ///     Pane::bar("shell.toolbar", "tools", TOOLBAR_H),     // fixed
    ///     Pane::body("shell.body"),                           // ← the SPLICE, flexible
    ///     Pane::bar("shell.statusbar", "status", STATUSBAR_H),// fixed
    /// ]))
    /// // …and the BODIES that take turns in it.
    /// .workspace(Workspace::new("investigate", "🔎 Investigate",
    ///     Pane::split("ws.root", Axis::Horizontal, 0.25, vec![
    ///         Pane::area("nav", "browser"),
    ///         Pane::area("main", "graph"),
    ///     ])))
    /// .workspace(Workspace::new("data", "🧮 Data", Pane::area("main", "tables")));
    /// ```
    pub fn shell(name: impl Into<String>, rows: Vec<Pane>) -> Self {
        let n = rows.len().max(1);
        Self::base(
            name,
            Layout::Split { axis: Axis::Vertical, ratios: vec![1.0 / n as f32; n], resizable: true },
            Content::Children(rows),
        )
    }

    fn header_title(&self) -> &str {
        if self.title.is_empty() { &self.name } else { &self.title }
    }
}

impl MenuItem {
    /// A leaf/branch menu item.
    pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
        Self { name: name.into(), label: label.into(), shortcut: None, action: None, items: Vec::new() }
    }
    /// Attach a shortcut hint.
    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
        self.shortcut = Some(s.into());
        self
    }
    /// Attach an action.
    pub fn action(mut self, a: Action) -> Self {
        self.action = Some(a);
        self
    }
    /// Attach child items (a submenu).
    pub fn items(mut self, items: Vec<MenuItem>) -> Self {
        self.items = items;
        self
    }
}

impl Route {
    /// A route on a menu-item name.
    pub fn on_menu(name: impl Into<String>, target: Target) -> Self {
        Self { trigger: Trigger::Menu(name.into()), target }
    }
    /// A route on a button name.
    pub fn on_button(name: impl Into<String>, target: Target) -> Self {
        Self { trigger: Trigger::Button(name.into()), target }
    }
    /// A route on a shortcut.
    pub fn on_shortcut(keys: impl Into<String>, target: Target) -> Self {
        Self { trigger: Trigger::Shortcut(keys.into()), target }
    }
    /// A route on a host event.
    pub fn on_event(id: impl Into<String>, target: Target) -> Self {
        Self { trigger: Trigger::Event(id.into()), target }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// NAVSTATE — wraps the Router with the per-scene runtime state the renderer reads.
// ─────────────────────────────────────────────────────────────────────────────

/// The runtime navigation state for a [`Scene`]: the shared [`Router`] (history)
/// plus per-pane active-tab, fold, and focus. The renderer READS it (which tab is
/// active / which pane is folded); triggers MUTATE it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NavState {
    /// The nav engine — route stack with back/forward + deep-link hash.
    pub router: Router,
    /// Per-pane (Tabs layout) active child index.
    pub active_tab: BTreeMap<String, usize>,
    /// Per-pane folded flag (`true` = folded, contents omitted).
    pub folded: BTreeMap<String, bool>,
    /// Per-pane MINIMISED flag (`true` = collapsed to its chrome title bar, and its
    /// space handed back to its siblings). Seeded `false` for every pane, so
    /// `state_json` always carries the full roster a robot can address.
    pub minimized: BTreeMap<String, bool>,
    /// The ONE maximised pane, if any (window-manager semantics: at most one). While
    /// set, every pane that is not an ancestor/descendant of it is allotted ZERO area.
    /// The split ratios are NOT touched, so restoring returns the exact prior layout.
    pub maximized: Option<String>,
    /// The last chrome control a click fired, as `"<action>:<pane>"` (e.g.
    /// `"maximize:body"`) — the robot's read-back that its click LANDED.
    pub last_chrome: Option<String>,
    /// Per-pane (Split layout) LIVE child ratios — seeded from the layout, mutated by
    /// dragging a divider (so a drag round-trips through the observable state).
    pub split_ratio: BTreeMap<String, Vec<f32>>,
    /// **Per-pane runtime PRESENCE: the panes the HOST has declared ABSENT this frame.**
    ///
    /// An absent pane does not EXIST on screen: it is allotted **zero** extent, draws
    /// **no chrome and no content** (unlike a minimise, which keeps the title bar), its
    /// adjacent split dividers stand down, and a `Layout::Tabs` strip grows no header
    /// for it. Its siblings absorb the space **by their unchanged ratios** — exactly the
    /// maximise discipline — so presence returning restores the exact prior layout,
    /// folds and dragged dividers included.
    ///
    /// This is *presence*, not *visibility sugar*: it is for a pane whose subject does
    /// not exist right now (a configurator for a recipe that declares none, an
    /// inspector with nothing to inspect), where fold/minimise would still show chrome
    /// promising content that cannot come. The host re-asserts it every frame from its
    /// own domain facts ([`set_absent`](Self::set_absent)); it is never authored in the
    /// scene (RON scenes carry no presence — a scene declares what CAN exist, the host
    /// says what DOES). Serde: absent-empty serializes to nothing, so persisted layouts
    /// round-trip unchanged; a stale persisted entry is overwritten on the first frame.
    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
    pub absent: std::collections::BTreeSet<String>,
    /// **Per-AREA editor binding** — pane name → the editor id it currently hosts.
    /// Seeded from each [`Content::Area`]'s authored default and mutated by the header
    /// dropdown / [`set_editor`](Self::set_editor). This is the line that makes the
    /// editor *state* rather than *identity*: the key never changes, the value does.
    pub area_editor: BTreeMap<String, String>,
    /// The area whose editor-type dropdown is OPEN, if any (at most one — it is a menu).
    pub editor_menu: Option<String>,
    /// **The LIVE workspace** — whose body tree is spliced into the shell's
    /// [`Content::Body`] pane this frame. Empty for a scene with no workspaces.
    #[serde(default)]
    pub active_workspace: String,
    /// **The PARKED body state of every workspace that is not showing.**
    ///
    /// The flat maps above hold shell ∪ the ACTIVE workspace's body — exactly the panes
    /// on screen, so two workspaces both naming a pane `main` never collide there.
    /// Everything else lives here under its workspace name, which is what lets a
    /// persisted layout tell `main` in *investigate* from `main` in *data* without
    /// qualifying a single robot address.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub workspaces: BTreeMap<String, WorkspaceSlot>,
    /// The last workspace switch, as `"workspace:<name>"` — the robot's read-back.
    #[serde(default)]
    pub last_workspace: Option<String>,
    /// The focused pane, if any.
    pub focus: Option<String>,
    /// The last `ShowWith` argument delivered (host reads it).
    pub arg: Option<String>,
    /// The device class the previous render resolved (drives drawer transitions).
    pub last_device: Option<Device>,
}

impl NavState {
    /// Walk a tree and seed the four per-pane maps plus the area bindings from what it
    /// authors. Shared by [`NavState::new`] and
    /// [`WorkspaceSlot::seeded`](crate::workspace::WorkspaceSlot::seeded) so a workspace
    /// body is seeded by exactly the same rules as the shell.
    #[allow(clippy::type_complexity)]
    pub fn seed_maps(
        root: &Pane,
    ) -> (
        BTreeMap<String, bool>,
        BTreeMap<String, bool>,
        BTreeMap<String, usize>,
        BTreeMap<String, Vec<f32>>,
        BTreeMap<String, String>,
    ) {
        let mut folded = BTreeMap::new();
        let mut minimized = BTreeMap::new();
        let mut active_tab = BTreeMap::new();
        let mut split_ratio = BTreeMap::new();
        let mut area_editor = BTreeMap::new();
        fn walk(
            p: &Pane,
            folded: &mut BTreeMap<String, bool>,
            minimized: &mut BTreeMap<String, bool>,
            active_tab: &mut BTreeMap<String, usize>,
            split_ratio: &mut BTreeMap<String, Vec<f32>>,
            area_editor: &mut BTreeMap<String, String>,
        ) {
            if let Fold::Foldable { default_open, .. } = &p.fold {
                folded.insert(p.name.clone(), !*default_open);
            }
            minimized.insert(p.name.clone(), false);
            if let Content::Area { editor, .. } = &p.content {
                area_editor.insert(p.name.clone(), editor.clone());
            }
            match &p.layout {
                Layout::Tabs { active } => {
                    active_tab.insert(p.name.clone(), *active);
                }
                Layout::Split { ratios, .. } => {
                    let n = match &p.content {
                        Content::Children(kids) => kids.len(),
                        _ => ratios.len(),
                    };
                    split_ratio.insert(p.name.clone(), normalized_ratios(ratios, n));
                }
                _ => {}
            }
            if let Content::Children(kids) = &p.content {
                for k in kids {
                    walk(k, folded, minimized, active_tab, split_ratio, area_editor);
                }
            }
        }
        walk(root, &mut folded, &mut minimized, &mut active_tab, &mut split_ratio, &mut area_editor);
        (folded, minimized, active_tab, split_ratio, area_editor)
    }

    /// Seed the runtime state from a scene: the SHELL's maps, the authored-default
    /// workspace's body merged into them (it is the one on screen), and every other
    /// workspace parked in [`workspaces`](Self::workspaces).
    pub fn new(scene: &Scene) -> Self {
        let (mut folded, mut minimized, mut active_tab, mut split_ratio, mut area_editor) =
            Self::seed_maps(&scene.root);
        let active = scene.default_ws().map(|w| w.name.clone()).unwrap_or_default();
        let mut workspaces = BTreeMap::new();
        for w in &scene.workspaces {
            let slot = WorkspaceSlot::seeded(w);
            if w.name == active {
                // The active workspace's body IS on screen, so it lives in the flat maps.
                folded.extend(slot.folded);
                minimized.extend(slot.minimized);
                active_tab.extend(slot.active_tab);
                split_ratio.extend(slot.split_ratio);
                area_editor.extend(slot.area_editor);
            } else {
                workspaces.insert(w.name.clone(), slot);
            }
        }
        Self {
            router: Router::new(scene.root.name.clone()),
            active_tab,
            folded,
            minimized,
            maximized: None,
            last_chrome: None,
            split_ratio,
            absent: Default::default(),
            area_editor,
            editor_menu: None,
            active_workspace: active,
            workspaces,
            last_workspace: None,
            focus: None,
            arg: None,
            last_device: None,
        }
    }

    // ── WORKSPACE switching: park the outgoing body, un-park the incoming one ─────

    /// The live workspace name (empty for a scene with no workspaces).
    pub fn workspace(&self) -> &str {
        &self.active_workspace
    }

    /// **Switch workspace.** The outgoing body's state is parked under its own name and
    /// the incoming one is un-parked into the flat maps — so the shell's entries never
    /// move, every shell pane address stays valid, and returning to a workspace returns
    /// the EXACT arrangement you left it in. Returns true if it changed.
    pub fn set_workspace(&mut self, scene: &Scene, name: &str) -> bool {
        if self.active_workspace == name || scene.workspace_named(name).is_none() {
            return false;
        }
        // Park the outgoing body (only the panes that belong to it — the shell stays put).
        if let Some(out) = scene.workspace_named(&self.active_workspace) {
            let mut slot = WorkspaceSlot::default();
            for n in out.pane_names() {
                if let Some(v) = self.folded.remove(&n) {
                    slot.folded.insert(n.clone(), v);
                }
                if let Some(v) = self.minimized.remove(&n) {
                    slot.minimized.insert(n.clone(), v);
                }
                if let Some(v) = self.active_tab.remove(&n) {
                    slot.active_tab.insert(n.clone(), v);
                }
                if let Some(v) = self.split_ratio.remove(&n) {
                    slot.split_ratio.insert(n.clone(), v);
                }
                if let Some(v) = self.area_editor.remove(&n) {
                    slot.area_editor.insert(n.clone(), v);
                }
                // A maximise on a BODY pane travels with the body; one on a shell pane
                // stays where it is.
                if self.maximized.as_deref() == Some(n.as_str()) {
                    slot.maximized = self.maximized.take();
                }
            }
            self.workspaces.insert(out.name.clone(), slot);
        }
        // Un-park the incoming one, seeding anything the parked slot lacks.
        let incoming = scene.workspace_named(name).expect("checked above");
        let mut slot = self.workspaces.remove(name).unwrap_or_default();
        let seed = WorkspaceSlot::seeded(incoming);
        for (k, v) in seed.folded {
            slot.folded.entry(k).or_insert(v);
        }
        for (k, v) in seed.minimized {
            slot.minimized.entry(k).or_insert(v);
        }
        for (k, v) in seed.active_tab {
            slot.active_tab.entry(k).or_insert(v);
        }
        for (k, v) in seed.split_ratio {
            slot.split_ratio.entry(k).or_insert(v);
        }
        for (k, v) in seed.area_editor {
            slot.area_editor.entry(k).or_insert(v);
        }
        self.folded.extend(slot.folded);
        self.minimized.extend(slot.minimized);
        self.active_tab.extend(slot.active_tab);
        self.split_ratio.extend(slot.split_ratio);
        self.area_editor.extend(slot.area_editor);
        if let Some(m) = slot.maximized {
            self.maximized = Some(m);
        }
        self.active_workspace = name.to_string();
        self.last_workspace = Some(format!("workspace:{name}"));
        true
    }

    /// **Reconcile a RESTORED state with the scene it is being applied to.**
    ///
    /// A persisted layout is older than the build loading it: panes get renamed, added
    /// and removed between releases. This drops every entry naming a pane the scene no
    /// longer has, seeds the ones it gained, and clears a maximise pointing at a pane
    /// that is gone — so a stale layout **degrades to the authored default for the parts
    /// that moved and keeps the user's arrangement for the parts that did not**, rather
    /// than being thrown away wholesale (which is what "your layout resets on upgrade"
    /// actually is).
    ///
    /// Call it right after loading. [`render`] does NOT call it per frame; the renderer
    /// already tolerates stale keys, and walking every pane twice a frame to re-derive a
    /// conclusion that only changes at load time is work nobody asked for.
    pub fn adopt(&mut self, scene: &Scene) {
        // A saved active workspace that no longer exists falls back to the authored one.
        if scene.workspace_named(&self.active_workspace).is_none() {
            self.active_workspace = scene.default_ws().map(|w| w.name.clone()).unwrap_or_default();
        }
        // Drop parked slots for workspaces this build removed, and reconcile the rest
        // against their OWN tree (that is where per-workspace keys are disambiguated).
        let live: std::collections::BTreeSet<&str> = scene.workspace_names().into_iter().collect();
        self.workspaces.retain(|k, _| live.contains(k.as_str()) && k != &self.active_workspace);
        for w in &scene.workspaces {
            if w.name == self.active_workspace {
                continue;
            }
            let slot = self.workspaces.entry(w.name.clone()).or_default();
            let seed = WorkspaceSlot::seeded(w);
            slot.folded.retain(|k, _| seed.folded.contains_key(k));
            slot.minimized.retain(|k, _| seed.minimized.contains_key(k));
            slot.active_tab.retain(|k, _| seed.active_tab.contains_key(k));
            slot.split_ratio.retain(|k, v| seed.split_ratio.get(k).is_some_and(|f| f.len() == v.len()));
            slot.area_editor.retain(|k, _| seed.area_editor.contains_key(k));
            if slot.maximized.as_deref().is_some_and(|m| w.pane(m).is_none()) {
                slot.maximized = None;
            }
            for (k, v) in seed.folded {
                slot.folded.entry(k).or_insert(v);
            }
            for (k, v) in seed.minimized {
                slot.minimized.entry(k).or_insert(v);
            }
            for (k, v) in seed.active_tab {
                slot.active_tab.entry(k).or_insert(v);
            }
            for (k, v) in seed.split_ratio {
                slot.split_ratio.entry(k).or_insert(v);
            }
            for (k, v) in seed.area_editor {
                slot.area_editor.entry(k).or_insert(v);
            }
        }

        // The flat maps hold shell ∪ the ACTIVE workspace's body — reconcile against that.
        let fresh = NavState::new(scene);
        self.folded.retain(|k, _| fresh.folded.contains_key(k));
        for (k, v) in &fresh.folded {
            self.folded.entry(k.clone()).or_insert(*v);
        }
        self.minimized.retain(|k, _| fresh.minimized.contains_key(k));
        for k in fresh.minimized.keys() {
            self.minimized.entry(k.clone()).or_insert(false);
        }
        self.active_tab.retain(|k, _| fresh.active_tab.contains_key(k));
        for (k, v) in &fresh.active_tab {
            self.active_tab.entry(k.clone()).or_insert(*v);
        }
        // A ratio vector whose child COUNT changed is meaningless — take the authored one.
        self.split_ratio.retain(|k, v| fresh.split_ratio.get(k).is_some_and(|f| f.len() == v.len()));
        for (k, v) in &fresh.split_ratio {
            self.split_ratio.entry(k.clone()).or_insert_with(|| v.clone());
        }
        // An area binding survives only if the pane is still an area.
        self.area_editor.retain(|k, _| fresh.area_editor.contains_key(k));
        for (k, v) in &fresh.area_editor {
            self.area_editor.entry(k.clone()).or_insert_with(|| v.clone());
        }
        if self.maximized.as_deref().is_some_and(|m| scene.pane_in(Some(&self.active_workspace), m).is_none()) {
            self.maximized = None;
        }
        // A persisted absence naming a pane this build no longer authors is noise; a
        // persisted absence naming a live pane is at most one frame stale — the host
        // re-asserts presence from its own domain facts every frame (`set_absent`).
        self.absent.retain(|k| scene.pane_in(Some(&self.active_workspace), k).is_some());
        if self.editor_menu.as_deref().is_some_and(|m| !self.area_editor.contains_key(m)) {
            self.editor_menu = None;
        }
        if scene.pane_in(Some(&self.active_workspace), self.router.current()).is_none() {
            self.router = Router::new(scene.root.name.clone());
        }
    }

    // ── AREA ⇄ EDITOR binding (Blender's "any area can host any editor type") ─────

    /// The editor id area `pane` currently hosts (`None` for a non-area pane).
    pub fn editor_of(&self, pane: &str) -> Option<&str> {
        self.area_editor.get(pane).map(String::as_str)
    }
    /// **Swap an area's editor.** The pane name, its rect and its chrome are untouched —
    /// only what is mounted changes. Returns true if it changed. Records
    /// `last_chrome = "editor:<pane>=<id>"`, so a robot reads the swap back the same way
    /// it reads a minimise. A pane with no area binding is a no-op (never a panic).
    pub fn set_editor(&mut self, pane: &str, editor: &str) -> bool {
        match self.area_editor.get(pane) {
            Some(cur) if cur == editor => false,
            Some(_) => {
                self.area_editor.insert(pane.to_string(), editor.to_string());
                self.editor_menu = None;
                self.last_chrome = Some(format!("editor:{pane}={editor}"));
                true
            }
            None => false,
        }
    }
    /// Is `pane`'s editor-type dropdown open?
    pub fn editor_menu_open(&self, pane: &str) -> bool {
        self.editor_menu.as_deref() == Some(pane)
    }
    /// Open / close `pane`'s editor-type dropdown (at most one is open at a time).
    pub fn set_editor_menu(&mut self, pane: &str, open: bool) {
        self.editor_menu = if open { Some(pane.to_string()) } else { None };
    }

    /// The live child ratios for a Split pane (if any).
    pub fn split_ratios(&self, pane: &str) -> Option<&Vec<f32>> {
        self.split_ratio.get(pane)
    }
    /// Set the live child ratios for a Split pane (normalised to sum to 1).
    pub fn set_split_ratios(&mut self, pane: &str, ratios: Vec<f32>) {
        let n = ratios.len();
        self.split_ratio.insert(pane.to_string(), normalized_ratios(&ratios, n));
    }

    /// Is `pane` currently folded? (Fixed panes are never folded.)
    pub fn is_folded(&self, pane: &str) -> bool {
        self.folded.get(pane).copied().unwrap_or(false)
    }
    /// Toggle a pane's fold. No-op for a non-foldable pane. Returns the new state.
    pub fn toggle_fold(&mut self, pane: &str) -> bool {
        if let Some(f) = self.folded.get_mut(pane) {
            *f = !*f;
            *f
        } else {
            false
        }
    }
    /// Set a pane's fold explicitly (no-op for non-foldable).
    pub fn set_fold(&mut self, pane: &str, folded: bool) {
        if let Some(f) = self.folded.get_mut(pane) {
            *f = folded;
        }
    }
    // ── Runtime presence: a pane the host declares ABSENT does not exist on screen ──

    /// Is `pane` absent this frame (allotted nothing, drawing nothing — see
    /// [`absent`](Self::absent))?
    pub fn is_absent(&self, pane: &str) -> bool {
        self.absent.contains(pane)
    }
    /// Declare `pane` present (`false`) or absent (`true`). Idempotent — a host calls
    /// this every frame from its own domain facts, which is what keeps presence a
    /// consequence and never a stale flag. Fold / minimise / ratio state is deliberately
    /// NOT touched, so a pane that returns comes back in the exact arrangement it left.
    pub fn set_absent(&mut self, pane: &str, absent: bool) {
        if absent {
            self.absent.insert(pane.to_string());
        } else {
            self.absent.remove(pane);
        }
    }

    // ── Window chrome: minimise / maximise / restore ─────────────────────────────
    // These are the headless drive points. They work for ANY pane, wherever it sits in
    // the tree (inside a Split, inside Tabs, nested) — [`Chrome`] governs whether the
    // *affordance* is drawn, never whether the capability exists.

    /// Is `pane` currently minimised (collapsed to its title bar)?
    pub fn is_minimized(&self, pane: &str) -> bool {
        self.minimized.get(pane).copied().unwrap_or(false)
    }
    /// Set a pane's minimised state explicitly. Returns true if it changed.
    pub fn set_minimized(&mut self, pane: &str, on: bool) -> bool {
        let prev = self.is_minimized(pane);
        self.minimized.insert(pane.to_string(), on);
        if prev != on {
            self.last_chrome = Some(format!("{}:{pane}", if on { "minimize" } else { "unminimize" }));
        }
        prev != on
    }
    /// Toggle a pane's minimise. Returns the NEW state.
    pub fn toggle_minimize(&mut self, pane: &str) -> bool {
        let next = !self.is_minimized(pane);
        self.set_minimized(pane, next);
        next
    }

    /// Is `pane` the maximised one?
    pub fn is_maximized(&self, pane: &str) -> bool {
        self.maximized.as_deref() == Some(pane)
    }
    /// The maximised pane, if any.
    pub fn maximized_pane(&self) -> Option<&str> {
        self.maximized.as_deref()
    }
    /// Maximise `pane` (replacing any other maximise). Un-minimises it too — you cannot
    /// be both. Returns true if it changed.
    pub fn set_maximized(&mut self, pane: &str) -> bool {
        let changed = self.maximized.as_deref() != Some(pane);
        self.maximized = Some(pane.to_string());
        self.minimized.insert(pane.to_string(), false);
        if changed {
            self.last_chrome = Some(format!("maximize:{pane}"));
        }
        changed
    }
    /// Toggle `pane`'s maximise. Returns the NEW state (`true` = now maximised).
    pub fn toggle_maximize(&mut self, pane: &str) -> bool {
        if self.is_maximized(pane) {
            self.maximized = None;
            self.last_chrome = Some(format!("restore:{pane}"));
            false
        } else {
            self.set_maximized(pane);
            true
        }
    }
    /// **Restore** `pane`: un-minimise it and drop the maximise if it holds it. The
    /// split ratios were never touched, so the layout returns EXACTLY as it was.
    /// Returns true if it changed anything.
    pub fn restore(&mut self, pane: &str) -> bool {
        let was_min = self.is_minimized(pane);
        let was_max = self.is_maximized(pane);
        self.minimized.insert(pane.to_string(), false);
        if was_max {
            self.maximized = None;
        }
        if was_min || was_max {
            self.last_chrome = Some(format!("restore:{pane}"));
        }
        was_min || was_max
    }
    /// Restore EVERY pane: clear all minimises and the maximise (the "reset layout"
    /// one-liner a host menu hangs off).
    pub fn restore_all(&mut self) -> bool {
        let changed = self.maximized.is_some() || self.minimized.values().any(|v| *v);
        for v in self.minimized.values_mut() {
            *v = false;
        }
        self.maximized = None;
        if changed {
            self.last_chrome = Some("restore_all:*".to_string());
        }
        changed
    }

    /// The active child index for a Tabs pane.
    pub fn active_tab(&self, pane: &str) -> usize {
        self.active_tab.get(pane).copied().unwrap_or(0)
    }
    /// Set the active child index for a Tabs pane.
    pub fn set_active_tab(&mut self, pane: &str, idx: usize) {
        self.active_tab.insert(pane.to_string(), idx);
    }

    /// Apply a [`Target`] to the state, **scoped to a scene** (which
    /// [`Target::Workspace`] needs in order to park the outgoing body and un-park the
    /// incoming one). Returns true if it changed anything.
    pub fn apply_in(&mut self, scene: &Scene, target: &Target) -> bool {
        match target {
            Target::Workspace(w) => self.set_workspace(scene, w),
            other => self.apply(other),
        }
    }

    /// Apply a [`Target`] to the state. Returns true if it changed anything.
    /// [`Target::Workspace`] is a no-op here — it needs the scene, so use
    /// [`apply_in`](Self::apply_in) (which [`fire`](Self::fire) and [`render`] both do).
    pub fn apply(&mut self, target: &Target) -> bool {
        match target {
            Target::Show(p) => self.router.navigate(p.clone()),
            Target::Focus(p) => {
                self.focus = Some(p.clone());
                self.router.navigate(p.clone())
            }
            Target::Push(p) => self.router.navigate(p.clone()),
            Target::Pop => self.router.back(),
            Target::Toggle(p) => {
                self.toggle_fold(p);
                true
            }
            Target::Minimize(p) => {
                self.toggle_minimize(p);
                true
            }
            Target::Maximize(p) => {
                self.toggle_maximize(p);
                true
            }
            Target::Restore(p) => self.restore(p),
            Target::ShowWith { pane, arg } => {
                self.arg = Some(arg.clone());
                self.router.navigate(pane.clone())
            }
            Target::SetEditor { pane, editor } => self.set_editor(pane, editor),
            Target::Workspace(_) => false,
        }
    }

    /// Fire the first route whose trigger matches, applying its target. Returns true
    /// if a route matched (and its target was applied).
    pub fn fire(&mut self, scene: &Scene, trigger: &Trigger) -> bool {
        if let Some(route) = scene.routes.iter().find(|r| &r.trigger == trigger) {
            let target = route.target.clone();
            self.apply_in(scene, &target);
            true
        } else {
            false
        }
    }

    /// Resolve a deep link (`app#pane` or `#pane`) by unique pane name. Returns true
    /// if the pane exists and the router moved to it.
    pub fn deep_link(&mut self, scene: &Scene, link: &str) -> bool {
        let frag = link.rsplit('#').next().unwrap_or(link);
        match Router::route_from_hash(frag) {
            Some(name) if scene.pane(&name).is_some() => self.router.navigate(name),
            _ => false,
        }
    }

    /// The runtime state as JSON (folded into the scene's `state_json`).
    pub fn state_json(&self) -> Value {
        json!({
            "router": self.router.state_json(),
            "focus": self.focus,
            "arg": self.arg,
            "folded": self.folded,
            "minimized": self.minimized,
            "maximized": self.maximized,
            "last_chrome": self.last_chrome,
            "active_tab": self.active_tab,
            "split_ratio": self.split_ratio,
            "absent": self.absent,
            "area_editor": self.area_editor,
            "editor_menu": self.editor_menu,
            "active_workspace": self.active_workspace,
            "last_workspace": self.last_workspace,
            // Parked bodies, keyed by workspace — this is where `main` in one workspace
            // is told apart from `main` in another.
            "workspaces": self.workspaces.iter().map(|(k, v)| (k.clone(), v.state_json())).collect::<BTreeMap<_, _>>(),
        })
    }
}

/// Normalise `ratios` to exactly `n` non-negative fractions that sum to 1. Missing
/// entries default to an equal share; a zero/degenerate sum falls back to equal shares.
fn normalized_ratios(ratios: &[f32], n: usize) -> Vec<f32> {
    if n == 0 {
        return Vec::new();
    }
    let mut v: Vec<f32> = (0..n).map(|i| ratios.get(i).copied().unwrap_or(1.0 / n as f32).max(0.0)).collect();
    let sum: f32 = v.iter().sum();
    if sum <= f32::EPSILON {
        return vec![1.0 / n as f32; n];
    }
    for r in &mut v {
        *r /= sum;
    }
    v
}

// ─────────────────────────────────────────────────────────────────────────────
// RENDER — walk the pane tree, apply each Layout via facett-app primitives.
// ─────────────────────────────────────────────────────────────────────────────

/// The host that fills each [`Content::Slot`] with a real widget. Implemented by the
/// app adopting the scene (e.g. facett-demo), where the concrete widgets live.
pub trait SceneHost {
    /// Draw the widget for slot `id` into `ui`. Return its `state_json` (folded into
    /// the scene's observable state); return [`Value::Null`] if there's nothing to add.
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value;
    /// The host app's **DEV-ID component** — the `<component>` of
    /// [`facett_core::errcode`]'s `facet-<component>-<n>`, so the identity a pane shows
    /// and the identity its errors carry share one vocabulary.
    ///
    /// Non-release builds paint `component/slot_id` as a click-to-copy chip on every
    /// slot ([`facett_core::devid`]), so a screenshot or a chat message says exactly
    /// which app and which pane. **Defaulted to `""`** — purely additive, every
    /// existing `SceneHost` keeps compiling and simply renders the honest
    /// `⟨unregistered⟩` marker until it declares one.
    fn component(&self) -> &'static str {
        ""
    }
    /// Handle a host command id (from an [`Action::Command`] menu action).
    fn command(&mut self, _id: &str) {}

    // ── AREAS (Blender's model). Both defaulted, so this trait stayed ADDITIVE: an
    // existing host that never authors a `Pane::area` implements nothing new. ──────

    /// Draw the editor currently mounted in AREA pane `pane` (editor id `editor_id`).
    ///
    /// The canonical implementation is one line over
    /// [`EditorAreas::draw`](crate::editor::EditorAreas::draw):
    ///
    /// ```ignore
    /// fn area(&mut self, pane: &str, editor_id: &str, ui: &mut egui::Ui) -> Value {
    ///     self.areas.draw(&self.registry, pane, editor_id, ui)
    /// }
    /// ```
    ///
    /// **Defaults to [`slot`](Self::slot)** with the editor id as the slot id, so a host
    /// that already switches on string ids gets areas working before it adopts the
    /// registry at all.
    fn area(&mut self, _pane: &str, editor_id: &str, ui: &mut egui::Ui) -> Value {
        self.slot(editor_id, ui)
    }

    /// The `(editor id, display name)` pairs an area's header dropdown offers — i.e.
    /// [`EditorRegistry::options`](crate::editor::EditorRegistry::options). **Defaults to
    /// empty**, in which case an area header shows its editor's id as a plain label and
    /// no dropdown is drawn (there is nothing to swap to, so no affordance is claimed).
    fn editors(&self) -> Vec<(String, String)> {
        Vec::new()
    }
}

/// The device class the renderer picked from the available width + `collapse_below`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Device {
    /// Phone-class (narrow).
    Phone,
    /// Tablet-class.
    Tablet,
    /// Desktop-class (wide).
    Desktop,
}
impl Device {
    /// Classify an available width (points) into a device class using the default
    /// breakpoints (`< 600` ⇒ phone, `< 900` ⇒ tablet, else desktop). This is the
    /// public entry a host SHELL calls to decide whether to FOLD its control strip on
    /// a narrow (phone-class) viewport so the content/canvas gets the screen — the
    /// same classification [`render`] applies to a [`Scene`]'s panes.
    pub fn from_width(width: f32) -> Device {
        classify(width, None)
    }
    /// The stable tag string (`"phone"` / `"tablet"` / `"desktop"`).
    pub fn tag(self) -> &'static str {
        match self {
            Device::Phone => "phone",
            Device::Tablet => "tablet",
            Device::Desktop => "desktop",
        }
    }
}

/// Default breakpoints (points of available width).
const TABLET_MAX: f32 = 900.0;
const PHONE_MAX: f32 = 600.0;

/// Classify the width into a device class, honouring a pane's `collapse_below`
/// override (below it ⇒ always phone-class).
fn classify(width: f32, collapse_below: Option<f32>) -> Device {
    if let Some(cb) = collapse_below && width < cb {
        return Device::Phone;
    }
    if width < PHONE_MAX {
        Device::Phone
    } else if width < TABLET_MAX {
        Device::Tablet
    } else {
        Device::Desktop
    }
}

/// The effective layout for a pane given its device class + responsive overrides.
fn effective_layout(pane: &Pane, width: f32) -> Layout {
    match classify(width, pane.responsive.collapse_below) {
        Device::Phone => pane.responsive.phone.clone().unwrap_or_else(|| pane.layout.clone()),
        Device::Tablet => pane.responsive.tablet.clone().unwrap_or_else(|| pane.layout.clone()),
        Device::Desktop => pane.layout.clone(),
    }
}

/// On a device-class *transition*, auto-fold every `drawer` pane entering phone-class
/// and restore it (to its `default_open`) leaving phone-class. Applied only on a change
/// so the user can still open/close a drawer while it stays phone-class.
fn apply_drawer_transitions(scene: &Scene, nav: &mut NavState, device: Device) {
    let was_phone = matches!(nav.last_device, Some(Device::Phone));
    let is_phone = matches!(device, Device::Phone);
    if nav.last_device.is_some() && was_phone == is_phone {
        return; // no crossing of the phone boundary
    }
    let mut acts: Vec<(String, bool)> = Vec::new();
    fn walk(p: &Pane, is_phone: bool, acts: &mut Vec<(String, bool)>) {
        if p.responsive.drawer && let Fold::Foldable { default_open, .. } = &p.fold {
            // Entering phone → folded (drawer shut); leaving → restore default.
            acts.push((p.name.clone(), if is_phone { true } else { !*default_open }));
        }
        if let Content::Children(kids) = &p.content {
            for k in kids {
                walk(k, is_phone, acts);
            }
        }
    }
    walk(&scene.root, is_phone, &mut acts);
    for (name, folded) in acts {
        nav.set_fold(&name, folded);
    }
}

/// The render context threaded through the recursive walk.
struct Ctx<'a> {
    nav: &'a mut NavState,
    host: &'a mut dyn SceneHost,
    /// The scene's root pane name (so `Chrome::Auto` can leave the root bare).
    root: String,
    /// The ancestor chain of the maximised pane (root → maximised, inclusive), empty
    /// when nothing is maximised. A split child NOT on this chain is allotted ZERO.
    max_path: std::collections::BTreeSet<String>,
    /// The scene's menubar items — so a [`Content::Menubar`] PANE can draw them in place.
    menubar: &'a [MenuItem],
    /// The menu item a [`Content::Menubar`] pane was clicked on this frame.
    menu_hit: Option<String>,
    /// The ACTIVE workspace, spliced in at the [`Content::Body`] pane.
    workspace: Option<&'a Workspace>,
    /// Every workspace, for the [`Content::WorkspaceBar`] strip.
    all_workspaces: &'a [Workspace],
    /// The workspace a tab click asked for this frame.
    switch_workspace: Option<String>,
    /// Per-workspace-tab rects, published for robot addressing.
    workspace_tabs: BTreeMap<String, [f32; 4]>,
    /// Per-pane observable render facts (folded / layout / rect).
    panes: serde_json::Map<String, Value>,
    /// Per-slot `state_json`.
    slots: serde_json::Map<String, Value>,
    /// Per-split-pane drag-handle rects `[x, y, w, h]` (robot-addressable for a drag).
    sep_rects: serde_json::Map<String, Value>,
    /// Per-BODY-TAB header rects `[x, y, w, h]`, keyed by the tab pane's name — the
    /// visible-window (watch) analogue of [`workspace_tabs`](Self::workspace_tabs), so a
    /// robot can travel to and CLICK a tab header by position (not only by AccessKit
    /// label). Empty until a [`Layout::Tabs`] strip has been drawn once.
    tab_rects: BTreeMap<String, [f32; 4]>,
}

impl Ctx<'_> {
    /// Is a maximise in force this frame?
    fn maximizing(&self) -> bool {
        !self.max_path.is_empty()
    }
    /// Is `pane` on the path to the maximised pane (⇒ it keeps its area)?
    fn on_max_path(&self, pane: &str) -> bool {
        self.max_path.contains(pane)
    }
    /// Record a pane that was allotted **zero area** — because a sibling is maximised
    /// (`"maximize"`) or because the host declared it absent (`"absent"`). It is a real
    /// layout fact, not a flag: the rect has zero extent along the split axis, which is
    /// what the RED-when-broken assertions read — and the REASON is data too, so a
    /// robot can tell "the host says this pane's subject does not exist" apart from
    /// "another pane holds the screen".
    fn record_hidden(&mut self, pane: &Pane, rect: egui::Rect, by: &str) {
        self.panes.insert(
            pane.name.clone(),
            json!({
                "title": pane.header_title(),
                "hidden_by": by,
                "rect": [rect.min.x, rect.min.y, rect.width(), rect.height()],
                "alloc": [rect.min.x, rect.min.y, rect.width(), rect.height()],
                "area": rect.width() * rect.height(),
            }),
        );
    }
    /// Why `kids[i]` renders nothing this frame, if it does: absence wins the naming
    /// (an absent pane is gone whatever else is going on), a maximise elsewhere zeroes
    /// the rest.
    fn hidden_reason(&self, pane: &Pane) -> &'static str {
        if self.nav.is_absent(&pane.name) { "absent" } else { "maximize" }
    }
}

/// The thickness (points) of a resizable-split drag handle.
const SPLIT_HANDLE: f32 = 6.0;
/// The smallest fraction a split child may be dragged down to.
const SPLIT_MIN: f32 = 0.05;

/// **The extent (points) a MINIMISED pane collapses to** — the height of its chrome
/// title bar (and, in a horizontal split, the width of the rail it becomes). Public so
/// a test or a host can assert the exact collapsed geometry rather than a boolean.
pub const CHROME_BAR: f32 = 22.0;

/// **The default height (points) of a [`Pane::menubar`] row.** Public so a test can
/// assert the menu bar's exact allotted height instead of "it is there".
pub const MENUBAR_H: f32 = 26.0;
/// The conventional height (points) of a toolbar row — see [`Pane::bar`].
pub const TOOLBAR_H: f32 = 28.0;
/// The conventional height (points) of a status-bar row — see [`Pane::bar`].
pub const STATUSBAR_H: f32 = 22.0;
/// **The smallest an area may usefully be.** A split child below this is a strip nobody
/// can work in; a host clamps a drag against it.
pub const AREA_MIN_PT: f32 = 48.0;

/// Below this width a chrome bar draws in **rail** mode — just the restore control,
/// stacked — because a minimised child of a *horizontal* split is a narrow strip.
const CHROME_RAIL_MAX_W: f32 = 60.0;

/// The chrome glyphs. Every one of these is a character egui's own bundled fonts
/// render (`⏷`/`⏵` are egui's collapsing triangles, `−` its minus, `🗖`/`🗗` live in the
/// same icon-font block as the `🗑` egui itself uses) — asserted by
/// `chrome_glyphs_are_renderable` so a font swap can never silently ship tofu.
const GLYPH_FOLD_OPEN: &str = "⏷";
const GLYPH_FOLD_SHUT: &str = "⏵";
const GLYPH_MINIMIZE: &str = "−";
const GLYPH_MAXIMIZE: &str = "🗖";
const GLYPH_RESTORE: &str = "🗗";
/// **Every chrome glyph**, so a host that swaps egui's fonts can assert coverage the way
/// `chrome_glyphs_are_renderable` does — a missing glyph puts a tofu box in every pane
/// header, and nothing else would notice.
pub const CHROME_GLYPHS: &str = "⏷⏵−🗖🗗";

/// Render a scene through facett-app's own primitives, mutating `nav` from any
/// interaction, and return the whole scene's `state_json` (scene name, active route,
/// per-pane folded state, device class, pane rects, and each slot's own state).
pub fn render(scene: &Scene, nav: &mut NavState, ui: &mut egui::Ui, host: &mut dyn SceneHost) -> Value {
    let width = ui.available_width();
    let device = classify(width, scene.root.responsive.collapse_below);

    // A scene drawn INSIDE another container (a deck pane, a wrapped canvas) is that
    // container's content, in draw order; at the top level there is no open scope and
    // this is a no-op.
    facett_core::navgraph::declare_own_draw_order(ui);
    // navgraph phase 2: the scene root is the graph's declared root, unless the host has
    // declared an order of its own (its `NavOrder` then names the roots).
    if facett_core::navgraph::collecting(ui.ctx()) && !facett_core::navgraph::has_order(ui.ctx()) {
        facett_core::navgraph::set_order(
            ui.ctx(),
            facett_core::navgraph::NavOrder::new().roots([scene.root.name.as_str()]),
        );
    }

    // Responsive drawer transitions: on a device-class change, auto-fold `drawer` panes
    // entering phone-class and auto-restore them leaving it (desktop rail → phone drawer).
    apply_drawer_transitions(scene, nav, device);
    nav.last_device = Some(device);

    // The maximise in force this frame: the ancestor chain of the maximised pane. An
    // unknown/stale name simply resolves to "nothing maximised" (never a panic).
    // The ACTIVE workspace: whatever `nav` says, falling back to the authored default so
    // a stale saved name can never leave the body blank.
    let active_ws = scene.ws_or_default(Some(nav.active_workspace.as_str()));
    if let Some(w) = active_ws && nav.active_workspace != w.name {
        nav.active_workspace = w.name.clone();
    }
    let ws_name = active_ws.map(|w| w.name.clone());
    // A maximise resolves over shell ∪ the active workspace, so maximising a BODY pane
    // keeps every shell ancestor on the path and the shell chrome stays exactly put.
    // …and a maximise naming an ABSENT pane resolves to "nothing maximised": a pane
    // that does not exist on screen cannot hold the screen (the maximise itself is NOT
    // cleared — presence returning restores it, like every other arrangement fact).
    let max_path: std::collections::BTreeSet<String> = nav
        .maximized
        .clone()
        .filter(|m| !nav.is_absent(m))
        .and_then(|m| scene.path_to_in(ws_name.as_deref(), &m))
        .map(|p| p.into_iter().collect())
        .unwrap_or_default();

    // Does the tree host the menu bar itself (`Pane::menubar`)? If so the implicit top
    // strip stands down — the shell's chrome is a PANE, and drawing both would twin it.
    let menu_in_tree = scene.menubar_pane().is_some();

    let mut ctx = Ctx {
        nav,
        host,
        root: scene.root.name.clone(),
        max_path,
        menubar: &scene.menubar,
        menu_hit: None,
        workspace: active_ws,
        all_workspaces: &scene.workspaces,
        switch_workspace: None,
        workspace_tabs: BTreeMap::new(),
        panes: serde_json::Map::new(),
        slots: serde_json::Map::new(),
        sep_rects: serde_json::Map::new(),
        tab_rects: BTreeMap::new(),
    };

    // ── Menubar (facett-menu — the SAME surface the AppShell uses). ──────────────
    let mut menu_hit: Option<String> = None;
    if !scene.menubar.is_empty() && !menu_in_tree {
        let commands = menubar_commands(&scene.menubar);
        egui::Panel::top(ui.id().with(("scene_menubar", &scene.name))).show(ui, |ui| {
            menu_hit = facett_menu::menu_bar(ui, &commands);
        });
    }
    if let Some(name) = menu_hit {
        apply_menu_hit(&mut ctx, scene, &name);
    }

    // ── The pane tree (the root claims the remaining central area). ──────────────
    egui::CentralPanel::default().show(ui, |ui| {
        draw_pane(&mut ctx, &scene.root, width, true, ui);
    });
    // A hit on the menu bar PANE is only known once the tree has been drawn, so it is
    // applied here (it lands one frame later than the implicit strip's — the same
    // discipline `Layout::Tabs` already uses for a header that changed this frame).
    if let Some(name) = ctx.menu_hit.take() {
        apply_menu_hit(&mut ctx, scene, &name);
    }
    // A workspace tab click is only known once the strip has been drawn. The switch lands
    // now and the NEW body draws next frame with a clean layout pass — the same discipline
    // `Layout::Tabs` uses for a header that changed this frame.
    if let Some(name) = ctx.switch_workspace.take() {
        ctx.nav.set_workspace(scene, &name);
    }

    json!({
        "scene": scene.name,
        "device": device.tag(),
        "width": width,
        "route": ctx.nav.router.current(),
        "focus": ctx.nav.focus,
        "folded": ctx.nav.folded,
        "minimized": ctx.nav.minimized,
        "maximized": ctx.nav.maximized,
        "active_tab": ctx.nav.active_tab,
        "panes": Value::Object(ctx.panes),
        "slots": Value::Object(ctx.slots),
        "workspace": ctx.nav.active_workspace,
        "workspaces": scene.workspace_names(),
        "workspace_tabs": ctx.workspace_tabs,
        "tab_rects": ctx.tab_rects,
        "nav": ctx.nav.state_json(),
        // The DEV-ID gate for THIS process — `{enabled, env, debug_assertions, source}`.
        // korp draws every pane through this one `render`, so `$KORP_STATE` now says
        // whether the chips are switched on and which of the env var or the build profile
        // decided it. It reports the GATE, never that a chip is on screen: `enabled: true`
        // was equally true of both wiring misses. See `facett_core::devid::gate_json`.
        "devid": facett_core::devid::gate_json(),
    })
}

/// Apply a menubar hit: a declared route wins; otherwise the item's own [`Action`] runs.
/// Shared by the implicit top strip and the menu bar PANE, so both behave identically.
fn apply_menu_hit(ctx: &mut Ctx, scene: &Scene, name: &str) {
    if ctx.nav.fire(scene, &Trigger::Menu(name.to_string())) {
        return;
    }
    let Some(item) = scene.menu_item(name) else { return };
    match item.action.clone() {
        Some(Action::NavigateTo(p)) => {
            ctx.nav.router.navigate(p);
        }
        Some(Action::Toggle(p)) => {
            ctx.nav.toggle_fold(&p);
        }
        Some(Action::Minimize(p)) => {
            ctx.nav.toggle_minimize(&p);
        }
        Some(Action::Maximize(p)) => {
            ctx.nav.toggle_maximize(&p);
        }
        Some(Action::Restore(p)) => {
            ctx.nav.restore(&p);
        }
        Some(Action::SetEditor { pane, editor }) => {
            ctx.nav.set_editor(&pane, &editor);
        }
        Some(Action::Workspace(w)) => {
            ctx.nav.set_workspace(scene, &w);
        }
        Some(Action::Command(id)) => ctx.host.command(&id),
        None => {}
    }
}

/// Flatten the nested menubar into facett-menu commands (top-level label = group,
/// leaf name = id) — reusing facett-menu's menubar surface (L5, no new menu engine).
fn menubar_commands(menubar: &[MenuItem]) -> Vec<facett_menu::Command> {
    let mut out = Vec::new();
    fn walk(items: &[MenuItem], group: &str, out: &mut Vec<facett_menu::Command>) {
        for it in items {
            if it.items.is_empty() {
                let g = if group.is_empty() { "Menu" } else { group };
                let mut c = facett_menu::Command::new(it.name.clone(), it.label.clone(), g.to_string());
                if let Some(s) = &it.shortcut {
                    c = c.shortcut(s.clone());
                }
                out.push(c);
            } else {
                walk(&it.items, &it.label, out);
            }
        }
    }
    walk(menubar, "", &mut out);
    out
}

/// Draw one pane (recursively). Applies its effective layout; a chromed pane wraps its
/// content in a **window title bar** (fold · minimise · maximise) and, when folded or
/// minimised, draws ONLY that bar. A pane that opted out of chrome falls back to the
/// plain collapsing header (the pre-chrome renderer, unchanged).
fn draw_pane(ctx: &mut Ctx, pane: &Pane, width: f32, bounded: bool, ui: &mut egui::Ui) {
    let layout = effective_layout(pane, width);
    let foldable = matches!(pane.fold, Fold::Foldable { .. });
    let folded = foldable && ctx.nav.is_folded(&pane.name);
    let spec = chrome_spec(pane, pane.name == ctx.root);
    let maximized = ctx.nav.is_maximized(&pane.name);
    // Maximise beats minimise: you asked for the whole screen, you get it.
    let minimized = !maximized && ctx.nav.is_minimized(&pane.name);
    let body_hidden = folded || minimized;

    // Record the observable facts for this pane.
    let layout_tag = layout_tag(&layout);
    let content_tag = match &pane.content {
        Content::Slot(id) => format!("slot:{id}"),
        Content::Area { editor, .. } => {
            format!("area:{}", ctx.nav.editor_of(&pane.name).unwrap_or(editor))
        }
        Content::Menubar => "menubar".to_string(),
        Content::Body => format!("body:{}", ctx.nav.active_workspace),
        Content::WorkspaceBar => "workspace_bar".to_string(),
        Content::Children(_) => "children".to_string(),
    };
    let alloc = ui.available_rect_before_wrap();
    // ── navgraph phase 2 (navgraph-design §6): every scene pane is a node, and the scene
    // TREE is the declared order — the pane list is data, so no host writes a `NavOrder`
    // for it. A children pane declares its kids in authored order; a tab set declares its
    // strip first and the active tab in the wildcard slot (the key changes with the tab,
    // the number does not — the same rule `FacetDeck` follows); the body splice declares
    // its one child, the active workspace's root. Slot/area content belongs to the host
    // (phase 3) and numbers in draw order until the host declares it.
    let nav_token = facett_core::navgraph::begin(ui, &pane.name, nav_kind(pane, pane.name == ctx.root), pane.header_title());
    match &pane.content {
        Content::Children(kids) => match &layout {
            Layout::Tabs { .. } => {
                facett_core::navgraph::declare_children(ui, nav_token, ["tabs", facett_core::navgraph::WILDCARD]);
            }
            _ => facett_core::navgraph::declare_children(ui, nav_token, kids.iter().map(|k| k.name.as_str())),
        },
        Content::Body => facett_core::navgraph::declare_children(ui, nav_token, [facett_core::navgraph::WILDCARD]),
        // A slot or area is the host's: its own widgets and the dev-id pane number in the
        // order they draw, unless the host declares otherwise inside (phase 3).
        Content::Slot(_) | Content::Area { .. } => facett_core::navgraph::declare_draw_order(ui, nav_token),
        _ => {}
    }
    let mut chrome_json: Option<Value> = None;
    // The rect the pane's BODY actually occupied. Zero-extent when the body was
    // omitted — so "the contents left the layout" is a MEASUREMENT, not a flag, and a
    // robot can tell fold (body gone, slot kept) from minimise (body AND slot gone).
    let mut content_rect = egui::Rect::from_min_size(alloc.min, egui::Vec2::ZERO);

    let rect = if spec.bar {
        // ── Chromed: ONE title bar carrying the fold toggle AND the window controls.
        // (L5 — the bar *hosts* the fold rather than sitting beside a second header.)
        let mut buttons = serde_json::Map::new();
        let r = ui.scope(|ui| {
            let bar_rect = draw_chrome_bar(ctx, pane, spec, minimized, maximized, folded, ui, &mut buttons);
            let body = if body_hidden {
                egui::Rect::from_min_size(bar_rect.left_bottom(), egui::Vec2::ZERO)
            } else {
                ui.scope(|ui| draw_content(ctx, pane, &layout, width, bounded, ui)).response.rect
            };
            (bar_rect, body)
        });
        let ((bar_rect, body), whole) = (r.inner, r.response.rect);
        content_rect = body;
        chrome_json = Some(json!({
            "spec": spec.json(),
            "minimized": minimized,
            "maximized": maximized,
            "bar_rect": [bar_rect.min.x, bar_rect.min.y, bar_rect.width(), bar_rect.height()],
            "bar_height": bar_rect.height(),
            "buttons": Value::Object(buttons),
        }));
        whole
    } else if foldable {
        // ── Opted out of chrome, but foldable: the ORIGINAL CollapsingHeader (L5 —
        // egui's own, like facett-graphnav's ⚙ fold). When folded, `.open(Some(false))`
        // means the body closure's content is NOT laid out ⇒ omitted from AccessKit. ──
        let icon = match &pane.fold {
            Fold::Foldable { icon: Some(i), .. } => format!("{i} "),
            _ => String::new(),
        };
        let arrow = if folded { "▸" } else { "▾" };
        let header = format!("{arrow} {icon}{}", pane.header_title());
        let resp = egui::CollapsingHeader::new(header)
            .id_salt(("scene_fold", &pane.name))
            .open(Some(!folded))
            .show(ui, |ui| {
                draw_content(ctx, pane, &layout, width, bounded, ui);
            });
        if resp.header_response.clicked() {
            ctx.nav.toggle_fold(&pane.name);
        }
        if let Some(body) = resp.body_response.as_ref() {
            content_rect = body.rect;
        }
        resp.header_response.rect
    } else if minimized {
        // No chrome bar but minimised anyway (headless drive on an opted-out pane):
        // honour the state — the body is omitted.
        ui.scope(|_| {}).response.rect
    } else {
        let r = ui.scope(|ui| draw_content(ctx, pane, &layout, width, bounded, ui));
        content_rect = r.response.rect;
        r.response.rect
    };

    let mut facts = json!({
        "title": pane.header_title(),
        "foldable": foldable,
        "folded": folded,
        "minimized": minimized,
        "maximized": maximized,
        "layout": layout_tag,
        "content": content_tag,
        "rect": [rect.min.x, rect.min.y, rect.width(), rect.height()],
        // The rect the PARENT allotted this pane — the geometry oracle a maximise /
        // minimise assertion reads (a hidden sibling's is zero-extent).
        "alloc": [alloc.min.x, alloc.min.y, alloc.width(), alloc.height()],
        "area": alloc.width() * alloc.height(),
        // The rect the pane's BODY occupied — exactly zero-extent when the body was
        // omitted (folded or minimised). "The contents left the layout" is therefore a
        // measurement any robot can assert, not a boolean anyone has to trust.
        "content_rect": [content_rect.min.x, content_rect.min.y, content_rect.width(), content_rect.height()],
        "content_height": content_rect.height(),
        // ── Lock 2, made observable. `overflowed` is the assertion a rect-only test
        // cannot make: the allotment held (lock 1) but the PIXELS escaped it. It must be
        // false for every bounded leaf, whatever its content does.
        "overflow": pane.overflow.resolve(bounded).tag(),
        "clipped": !matches!(pane.overflow.resolve(bounded), Overflow::Grow),
        "overflowed": content_rect.height() > alloc.height() + 0.5 || content_rect.width() > alloc.width() + 0.5,
    });
    if let Some(c) = chrome_json {
        facts.as_object_mut().unwrap().insert("chrome".into(), c);
    }
    // An AREA publishes its LIVE editor binding + what it may be switched to, keyed by
    // the pane name. A robot reads `panes.<area>.editor.id` before and after a swap and
    // `panes.<area>.rect` to prove the rect did not move.
    if let Content::Area { editor: default_editor, allow } = &pane.content {
        let id = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
        let options = allow.filter(ctx.host.editors());
        let name = options.iter().find(|(o, _)| *o == id).map(|(_, n)| n.clone());
        facts.as_object_mut().unwrap().insert(
            "editor".into(),
            json!({
                "id": id,
                "name": name,
                "default": default_editor,
                "menu_open": ctx.nav.editor_menu_open(&pane.name),
                "options": options.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>(),
            }),
        );
    }
    // For a Split laid out this frame, add its live ratios, whether it's resizable, and
    // the drag-handle rects (so a robot can drag a divider by position).
    if let Layout::Split { resizable, .. } = &layout {
        let obj = facts.as_object_mut().unwrap();
        if let Some(ratios) = ctx.nav.split_ratio.get(&pane.name) {
            obj.insert("ratios".into(), json!(ratios));
        }
        obj.insert("resizable".into(), json!(*resizable));
        if let Some(seps) = ctx.sep_rects.remove(&pane.name) {
            obj.insert("separators".into(), seps);
        }
    }
    ctx.panes.insert(pane.name.clone(), facts);
    facett_core::navgraph::end(ui, nav_token, rect);
}

/// The navgraph kind of a scene pane (navgraph-design §1): the root is the `App`, the body
/// splice is the `Surface` a workspace fills, a slot or area (a host widget) is a `Pane`,
/// and every other container (children, menu bar, workspace bar) is a `Group`.
fn nav_kind(pane: &Pane, is_root: bool) -> facett_core::navgraph::NavKind {
    use facett_core::navgraph::NavKind;
    if is_root {
        return NavKind::App;
    }
    match &pane.content {
        Content::Slot(_) | Content::Area { .. } => NavKind::Pane,
        Content::Body => NavKind::Surface,
        Content::Children(_) | Content::Menubar | Content::WorkspaceBar => NavKind::Group,
    }
}

/// Draw a pane's **window title bar** and act on its controls. Returns the bar's rect
/// and fills `buttons` with each control's rect `[x, y, w, h]` — a robot clicks the
/// centre of those rects (the same addressing the split `separators` already use), and
/// each control ALSO carries a unique AccessKit label (`"minimize <pane>"`, …) so
/// `click_label` works too.
///
/// Narrow strips (a minimised child of a *horizontal* split) draw in **rail** mode: the
/// restore control alone, so the pane is always recoverable.
#[allow(clippy::too_many_arguments)]
fn draw_chrome_bar(
    ctx: &mut Ctx,
    pane: &Pane,
    spec: ChromeSpec,
    minimized: bool,
    maximized: bool,
    folded: bool,
    ui: &mut egui::Ui,
    buttons: &mut serde_json::Map<String, Value>,
) -> egui::Rect {
    let avail = ui.available_rect_before_wrap();
    let rail = avail.width() < CHROME_RAIL_MAX_W;
    // In a top-down parent (a Split child, a Tabs body — every real shell) the bar spans
    // the pane's full width and the window controls sit hard right. In a HORIZONTAL
    // parent (a `Layout::Flow` row) it must shrink to its own content instead, or the
    // first pane in the row would claim the whole row for its title bar.
    let stretch = !ui.layout().main_dir().is_horizontal();
    let bar_w = avail.width().max(1.0);
    let bar_h = CHROME_BAR.min(avail.height().max(1.0));
    let size = if rail { egui::vec2(bar_w, avail.height().max(bar_h)) } else { egui::vec2(bar_w, bar_h) };

    // A visible bar: a filled strip + a hairline under it, so the operator SEES that
    // this pane is a window with controls, not an anonymous region.
    // Copy the two values used — cloning the whole `Visuals` here cost more than the bar
    // it paints (it is a large struct, and this runs once per pane per frame).
    let (fill, edge) = {
        let v = ui.visuals();
        let fill = if maximized {
            v.selection.bg_fill
        } else if minimized {
            v.widgets.active.bg_fill
        } else {
            v.widgets.noninteractive.bg_fill
        };
        (fill, v.widgets.noninteractive.bg_stroke)
    };

    let layout = if rail {
        egui::Layout::top_down(egui::Align::Center)
    } else {
        egui::Layout::left_to_right(egui::Align::Center)
    };
    let mut acts: Vec<&'static str> = Vec::new();
    // Reserve two slots in the paint list FIRST, fill them once the bar's true rect is
    // known. A shrink-to-fit bar does not know its own width until its controls are laid
    // out, and painting the strip afterwards would cover them.
    let bg = ui.painter().add(egui::Shape::Noop);
    let underline = ui.painter().add(egui::Shape::Noop);
    let mut draw = |ui: &mut egui::Ui, acts: &mut Vec<&'static str>, buttons: &mut serde_json::Map<String, Value>| {
        ui.spacing_mut().item_spacing.x = 2.0;

        if rail {
            // Narrow rail: the ONE control that gets the pane back.
            let resp = ui.add(egui::Button::new(GLYPH_RESTORE).small());
            label_button(&resp, "restore", &pane.name);
            record_button(buttons, "restore", resp.rect);
            if resp.clicked() {
                acts.push("restore");
            }
            return;
        }

        if spec.fold {
            let glyph = if folded { GLYPH_FOLD_SHUT } else { GLYPH_FOLD_OPEN };
            let resp = ui.add(egui::Button::new(glyph).small());
            label_button(&resp, if folded { "unfold" } else { "fold" }, &pane.name);
            record_button(buttons, "fold", resp.rect);
            if resp.clicked() {
                acts.push("fold");
            }
        }
        // ── The EDITOR-TYPE dropdown — the region this whole header exists for. It
        // sits at the head of the SAME bar that carries minimise/maximise; there is no
        // second header row (L5 — extend the chrome bar, never stack a twin on it).
        draw_editor_picker(ctx, pane, ui, buttons);
        // The icon rides INSIDE the title text (one node, as the fold header always
        // did) so the pane's title stays a single addressable label. No icon ⇒ no
        // allocation: the title is borrowed straight out of the pane.
        match &pane.fold {
            Fold::Foldable { icon: Some(i), .. } => {
                ui.label(egui::RichText::new(format!("{i} {}", pane.header_title())).strong());
            }
            _ => {
                ui.label(egui::RichText::new(pane.header_title()).strong());
            }
        }

        // The window controls sit hard right, like every title bar ever built — unless
        // the bar shrank to its content (a Flow row), where they simply follow the title.
        let controls = |ui: &mut egui::Ui, acts: &mut Vec<&'static str>, buttons: &mut serde_json::Map<String, Value>| {
            if spec.maximize {
                let glyph = if maximized { GLYPH_RESTORE } else { GLYPH_MAXIMIZE };
                let resp = ui.add(egui::Button::new(glyph).small());
                label_button(&resp, if maximized { "restore" } else { "maximize" }, &pane.name);
                record_button(buttons, "maximize", resp.rect);
                if resp.clicked() {
                    acts.push("maximize");
                }
            }
            if spec.minimize {
                let glyph = if minimized { GLYPH_RESTORE } else { GLYPH_MINIMIZE };
                let resp = ui.add(egui::Button::new(glyph).small());
                label_button(&resp, if minimized { "restore" } else { "minimize" }, &pane.name);
                record_button(buttons, "minimize", resp.rect);
                if resp.clicked() {
                    acts.push("minimize");
                }
            }
        };
        if stretch {
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| controls(ui, acts, buttons));
        } else {
            controls(ui, acts, buttons);
        }
    };

    // A stretched bar is allocated at the pane's full width; a shrink-to-fit one lets
    // `ui.horizontal` size it to the controls it holds.
    let inner_rect = if stretch {
        ui.allocate_ui_with_layout(size, layout, |ui| draw(ui, &mut acts, buttons)).response.rect
    } else {
        ui.horizontal(|ui| draw(ui, &mut acts, buttons)).response.rect
    };
    // Now the strip's real extent is known — fill the reserved slots BEHIND the controls.
    ui.painter().set(bg, egui::Shape::rect_filled(inner_rect, 2.0, fill));
    ui.painter().set(
        underline,
        egui::Shape::LineSegment {
            points: [inner_rect.left_bottom(), inner_rect.right_bottom()],
            stroke: edge,
        },
    );

    for a in acts {
        match a {
            "fold" => {
                ctx.nav.toggle_fold(&pane.name);
                ctx.nav.last_chrome = Some(format!("fold:{}", pane.name));
            }
            "minimize" => {
                if minimized {
                    ctx.nav.restore(&pane.name);
                } else {
                    ctx.nav.toggle_minimize(&pane.name);
                }
            }
            "maximize" => {
                ctx.nav.toggle_maximize(&pane.name);
            }
            "restore" => {
                ctx.nav.restore(&pane.name);
            }
            _ => {}
        }
    }
    inner_rect
}

/// Draw the **editor-type dropdown** for an AREA pane, inside its chrome title bar.
///
/// This is the load-bearing control of the whole area model: it is where "swap this pane
/// to a different editor" lives. It is a *region of the existing bar*, not a second
/// header — the bar already carried fold/minimise/maximise and now carries this too.
///
/// - Non-area pane ⇒ nothing drawn (returns immediately).
/// - Area pane, host offers no editors ([`SceneHost::editors`] empty) ⇒ a plain label of
///   the current editor id and **no** dropdown: an affordance with nothing behind it is
///   worse than no affordance.
/// - Otherwise: a button showing the current editor's display name, and — while open —
///   a popup of every registered type. Each carries a unique AccessKit label
///   (`"editor <pane>"` / `"editor <pane> <id>"`) **and** a published rect
///   (`buttons["editor"]` / `buttons["editor.<id>"]`), so a robot can drive the swap by
///   label or by position, exactly like every other chrome control.
fn draw_editor_picker(ctx: &mut Ctx, pane: &Pane, ui: &mut egui::Ui, buttons: &mut serde_json::Map<String, Value>) {
    let Content::Area { editor: default_editor, allow } = &pane.content else { return };
    let current = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
    let options = allow.filter(ctx.host.editors());
    let label =
        options.iter().find(|(id, _)| *id == current).map(|(_, n)| n.clone()).unwrap_or_else(|| current.clone());

    if options.is_empty() {
        let resp = ui.label(egui::RichText::new(&label).monospace());
        record_button(buttons, "editor", resp.rect);
        return;
    }

    // The dropdown indicator is `⏷` — the SAME glyph the fold toggle uses, deliberately:
    // it is the one down-caret egui's bundled fonts actually carry
    // (`chrome_glyphs_are_renderable` proves `▾`/`▴` are tofu here), and shipping a tofu
    // box in every area header would be worse than reusing a caret.
    let open = ctx.nav.editor_menu_open(&pane.name);
    let btn = ui.add(egui::Button::new(format!("{label} {GLYPH_FOLD_OPEN}")).small());
    label_button(&btn, "editor", &pane.name);
    record_button(buttons, "editor", btn.rect);
    if btn.clicked() {
        ctx.nav.set_editor_menu(&pane.name, !open);
    }

    if !open {
        return;
    }
    let mut chosen: Option<String> = None;
    let mut opt_rects: Vec<(String, egui::Rect)> = Vec::new();
    egui::Area::new(ui.id().with(("scene_editor_menu", &pane.name)))
        .order(egui::Order::Foreground)
        .fixed_pos(btn.rect.left_bottom())
        .show(ui.ctx(), |ui| {
            egui::Frame::popup(ui.style()).show(ui, |ui| {
                ui.set_min_width(btn.rect.width().max(120.0));
                for (id, name) in &options {
                    let resp = ui.add(egui::Button::selectable(*id == current, name));
                    resp.widget_info(|| {
                        egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("editor {} = {id}", pane.name))
                    });
                    opt_rects.push((id.clone(), resp.rect));
                    if resp.clicked() {
                        chosen = Some(id.clone());
                    }
                }
            });
        });
    for (id, rect) in opt_rects {
        record_button(buttons, &format!("editor.{id}"), rect);
    }
    if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
        ctx.nav.set_editor_menu(&pane.name, false);
    }
    if let Some(id) = chosen {
        // The SWAP. Nothing about the pane's geometry is touched — its rect, its split
        // ratio and its chrome state all survive, because none of them was ever keyed on
        // the editor.
        ctx.nav.set_editor(&pane.name, &id);
    }
}

/// Give a chrome control a UNIQUE AccessKit label (`"<action> <pane>"`), so a robot can
/// `click_label("minimize body")` as well as click its published rect.
fn label_button(resp: &egui::Response, action: &str, pane: &str) {
    // Built INSIDE the closure: egui only calls it when it actually needs the node, so a
    // release frame with no AccessKit consumer allocates nothing here.
    resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action} {pane}")));
}

/// Publish a control's rect for rect-addressed robot clicks.
fn record_button(buttons: &mut serde_json::Map<String, Value>, key: &str, rect: egui::Rect) {
    buttons.insert(key.to_string(), json!([rect.min.x, rect.min.y, rect.width(), rect.height()]));
}

fn layout_tag(l: &Layout) -> &'static str {
    match l {
        Layout::Flow => "flow",
        Layout::Split { .. } => "split",
        Layout::Grid { .. } => "grid",
        Layout::Tabs { .. } => "tabs",
    }
}

/// Draw a pane's content (its layout over children, or its slot widget).
fn draw_content(ctx: &mut Ctx, pane: &Pane, layout: &Layout, width: f32, bounded: bool, ui: &mut egui::Ui) {
    match &pane.content {
        Content::Slot(id) => {
            // Captured BEFORE the slot draws: a slot that consumes its whole `Ui`
            // leaves the cursor somewhere unhelpful to anchor a chip to.
            let rect = ui.max_rect();
            let component = ctx.host.component();
            let state = with_overflow(pane, bounded, ui, |ui| ctx.host.slot(id, ui));
            // The DEV-ID chip — ONE draw site, so every scene-hosted pane in every
            // scene-based app gets it for free (LAW 5) instead of each app painting its
            // own. No-op in release builds.
            facett_core::devid::badge(ui, rect, component, id);
            if !state.is_null() {
                ctx.slots.insert(id.clone(), state);
            }
        }
        Content::Area { editor: default_editor, .. } => {
            // The LIVE binding wins over the authored default — that is what makes a
            // swap a swap. Keyed by the pane NAME in `slots`, never by the editor id, so
            // an area's observable state stays at a fixed address across every swap.
            let editor = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
            let state = with_overflow(pane, bounded, ui, |ui| ctx.host.area(&pane.name, &editor, ui));
            if !state.is_null() {
                ctx.slots.insert(pane.name.clone(), state);
            }
        }
        Content::Menubar => {
            // The shell's menu bar, drawn INSIDE its pane's allotted rect through the
            // same facett-menu surface the implicit top strip uses (L5 — one menu
            // engine, two placements).
            if !ctx.menubar.is_empty() {
                let commands = menubar_commands(ctx.menubar);
                let hit = with_overflow(pane, bounded, ui, |ui| facett_menu::menu_bar(ui, &commands));
                if let Some(hit) = hit {
                    ctx.menu_hit = Some(hit);
                }
            }
        }
        Content::Body => {
            // ── THE SPLICE. The active workspace's body tree is drawn HERE, inside the
            // rect the shell allotted it. Everything above and below — menu bar,
            // workspace bar, toolbar, status bar — was authored once and does not move.
            let Some(ws) = ctx.workspace.cloned() else { return };
            let root = ws.root.clone();
            draw_pane(ctx, &root, width, true, ui);
        }
        Content::WorkspaceBar => {
            // Blender's topbar tabs. Reuses the SAME TabView the scene's `Layout::Tabs`
            // uses, with a label prefix so `"workspace data"` can never collide with a
            // pane title of the same word.
            let tabs: Vec<(String, String)> =
                ctx.all_workspaces.iter().map(|w| (w.name.clone(), w.tab_label())).collect();
            if tabs.is_empty() {
                return;
            }
            let mut tv = TabView::new(tabs).label_prefix("workspace");
            tv.select(&ctx.nav.active_workspace);
            let picked = tv.show(ui);
            ctx.workspace_tabs = tv.rects().iter().map(|(k, v)| (k.clone(), *v)).collect();
            if let Some(name) = picked {
                ctx.switch_workspace = Some(name);
            }
        }
        Content::Children(kids) => draw_children(ctx, pane, layout, kids, width, ui),
    }
}

/// **Lock 2 — the clip lock.** Draw a leaf's content inside its allotment: clip the
/// painter to the allotted rect (egui hands a child `Ui` its *parent's* clip rect, so a
/// leaf that outgrows its allotment otherwise paints straight over its sibling — the rect
/// assertion passes while the screen is wrong), and, when the resolved [`Overflow`] is
/// `Scroll`, put the content in a scroll area bounded by that rect.
fn with_overflow<R>(pane: &Pane, bounded: bool, ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
    let alloc = ui.available_rect_before_wrap();
    match pane.overflow.resolve(bounded) {
        // The explicit opt-out: the content sizes itself and the parent grows with it.
        Overflow::Grow => f(ui),
        Overflow::Scroll => {
            ui.shrink_clip_rect(alloc);
            egui::ScrollArea::both()
                .id_salt(("scene_overflow", &pane.name))
                .max_height(alloc.height())
                .max_width(alloc.width())
                .show(ui, f)
                .inner
        }
        _ => {
            ui.shrink_clip_rect(alloc);
            f(ui)
        }
    }
}

/// Lay children out by `layout`, reusing facett-app primitives.
fn draw_children(ctx: &mut Ctx, pane: &Pane, layout: &Layout, kids: &[Pane], width: f32, ui: &mut egui::Ui) {
    if kids.is_empty() {
        return;
    }
    // While a maximise is in force, only the child on the path to the maximised pane is
    // laid out; every sibling is recorded with a ZERO rect (it really was allotted
    // nothing) so the "siblings shrink to zero area" assertion has geometry to read.
    let max_child: Option<usize> =
        if ctx.maximizing() { kids.iter().position(|k| ctx.on_max_path(&k.name)) } else { None };

    match layout {
        Layout::Flow => {
            ui.horizontal_wrapped(|ui| {
                for (i, k) in kids.iter().enumerate() {
                    if ctx.nav.is_absent(&k.name) || max_child.is_some_and(|m| i != m) {
                        let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
                        let by = ctx.hidden_reason(k);
                        ctx.record_hidden(k, z, by);
                        continue;
                    }
                    draw_pane(ctx, k, width, false, ui);
                }
            });
        }
        Layout::Split { axis, resizable, .. } => {
            draw_split(ctx, pane, *axis, *resizable, kids, ui);
        }
        Layout::Grid { cols } => {
            let cols = (*cols).max(1);
            let cell_w = (width / cols as f32).max(1.0);
            egui::Grid::new(ui.id().with(("scene_grid", &pane.name))).num_columns(cols).show(ui, |ui| {
                for (i, k) in kids.iter().enumerate() {
                    if ctx.nav.is_absent(&k.name) || max_child.is_some_and(|m| i != m) {
                        let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
                        let by = ctx.hidden_reason(k);
                        ctx.record_hidden(k, z, by);
                        continue;
                    }
                    draw_pane(ctx, k, cell_w, false, ui);
                    if (i + 1) % cols == 0 {
                        ui.end_row();
                    }
                }
            });
        }
        Layout::Tabs { active } => {
            // Reuse facett-app TabView for the header strip; the active index lives in
            // NavState (the renderer reads it, a click mutates it). A maximise inside a
            // tab forces that tab to the front — you cannot maximise what isn't shown.
            // An ABSENT child does not exist: no header in the strip, no body — and a
            // selection resting on it falls to the first present sibling (the active-tab
            // index is NOT rewritten, so presence returning restores the exact tab).
            let present: Vec<&Pane> = kids.iter().filter(|k| !ctx.nav.is_absent(&k.name)).collect();
            for k in kids {
                if !ctx.nav.is_absent(&k.name) {
                    continue;
                }
                let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
                ctx.record_hidden(k, z, "absent");
            }
            if present.is_empty() {
                return;
            }
            let mut sel = max_child
                .unwrap_or_else(|| ctx.nav.active_tab.get(&pane.name).copied().unwrap_or(*active))
                .min(kids.len() - 1);
            if ctx.nav.is_absent(&kids[sel].name) {
                sel = kids.iter().position(|k| !ctx.nav.is_absent(&k.name)).unwrap_or(0);
            }
            let mut tv = TabView::new(present.iter().map(|k| (k.name.clone(), k.header_title().to_string())))
                .tooltips(present.iter().filter_map(|k| k.tooltip.clone().map(|t| (k.name.clone(), t))));
            tv.select(&kids[sel].name);
            let picked = tv.show(ui);
            // Publish each header's on-screen rect (keyed by pane name) so a visible-window
            // robot can travel to a tab and CLICK it by position — the SAME geometry the
            // workspace strip already publishes as `workspace_tabs`.
            for (id, r) in tv.rects() {
                ctx.tab_rects.insert(id.clone(), *r);
            }
            if let Some(new_id) = picked && let Some(idx) = kids.iter().position(|k| k.name == new_id) {
                ctx.nav.set_active_tab(&pane.name, idx);
                return; // header changed this frame; draw the (new) active next frame
            }
            ui.separator();
            draw_pane(ctx, &kids[sel], width, true, ui);
        }
    }
}

/// Lay an N-way [`Layout::Split`] out along `axis`, honouring the LIVE per-child ratios
/// in [`NavState`]. When `resizable`, a real drag handle sits between each adjacent pair;
/// dragging it re-apportions those two ratios (written back to `NavState`, so the drag is
/// observable in `state_json`). The handle rects are recorded for robot addressing.
///
/// **Chrome affects the APPORTIONMENT, never the stored ratios** — which is what makes
/// restore exact:
/// - a **minimised** child is allotted exactly [`CHROME_BAR`] points along the axis and
///   the remainder is re-apportioned among the rest by their unchanged ratios;
/// - while a **maximise** is in force, the child on the path to it takes the WHOLE
///   extent, every sibling is allotted zero (and recorded as such), and the dividers
///   stand down.
fn draw_split(ctx: &mut Ctx, pane: &Pane, axis: Axis, resizable: bool, kids: &[Pane], ui: &mut egui::Ui) {
    let n = kids.len();
    if n == 0 {
        return;
    }
    let ratios = normalized_ratios(
        &ctx.nav.split_ratio.get(&pane.name).cloned().unwrap_or_else(|| vec![1.0 / n as f32; n]),
        n,
    );
    // Keep NavState seeded even if this split was authored without a pre-seed.
    ctx.nav.split_ratio.entry(pane.name.clone()).or_insert_with(|| ratios.clone());

    let avail = ui.available_rect_before_wrap();
    let max_child: Option<usize> =
        if ctx.maximizing() { kids.iter().position(|k| ctx.on_max_path(&k.name)) } else { None };
    // No dividers while maximised — there is nothing left to apportion.
    // A divider between two children only means something if BOTH can give ground: a
    // FIXED row (the menu bar, the status bar) has no ratio to re-apportion, so no handle
    // is drawn beside it — a handle that cannot move is a promise the layout will not keep.
    let movable = |i: usize| kids.get(i).is_some_and(|k| k.fixed.is_none());
    // …and a divider beside an ABSENT child stands down too: there is nothing on the
    // other side of it to give ground, so a handle there would drag against a pane
    // that does not exist.
    let here = |i: usize| kids.get(i).is_some_and(|k| !ctx.nav.is_absent(&k.name));
    let sep_at: Vec<bool> =
        (0..n.saturating_sub(1)).map(|i| movable(i) && movable(i + 1) && here(i) && here(i + 1)).collect();
    let sep_count = if resizable && max_child.is_none() { sep_at.iter().filter(|s| **s).count() } else { 0 };
    let handle = if sep_count > 0 { SPLIT_HANDLE } else { 0.0 };
    let draw_seps = resizable && max_child.is_none();

    let mut new_ratios = ratios.clone();
    let mut changed = false;
    let mut seps: Vec<Value> = Vec::new();

    match axis {
        Axis::Horizontal => {
            let total = (avail.width() - handle * sep_count as f32).max(1.0);
            let extents = split_extents(ctx.nav, kids, &ratios, total, max_child);
            let (top, h) = (avail.min.y, avail.height());
            let mut x = avail.min.x;
            for i in 0..n {
                let w = extents[i];
                let rect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(w, h));
                if w <= 0.0 {
                    let by = ctx.hidden_reason(&kids[i]);
                    ctx.record_hidden(&kids[i], egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(0.0, h)), by);
                    continue;
                }
                ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
                    draw_pane(ctx, &kids[i], w, true, ui);
                });
                x += w;
                if draw_seps && i + 1 < n && sep_at[i] {
                    let hrect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(handle, h));
                    let resp = ui.allocate_rect(hrect, egui::Sense::drag());
                    paint_handle(ui, hrect, true, resp.hovered(), resp.dragged());
                    if resp.hovered() || resp.dragged() {
                        ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
                    }
                    seps.push(json!([hrect.min.x, hrect.min.y, hrect.width(), hrect.height()]));
                    if resp.dragged() {
                        adjust_pair(&mut new_ratios, i, resp.drag_delta().x / total);
                        changed = true;
                    }
                    x += handle;
                }
            }
            ui.allocate_rect(egui::Rect::from_min_max(avail.min, egui::pos2(x, top + h)), egui::Sense::hover());
        }
        Axis::Vertical => {
            let total = (avail.height() - handle * sep_count as f32).max(1.0);
            let extents = split_extents(ctx.nav, kids, &ratios, total, max_child);
            let (left, w) = (avail.min.x, avail.width());
            let mut y = avail.min.y;
            for i in 0..n {
                let ch = extents[i];
                let rect = egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, ch));
                if ch <= 0.0 {
                    let by = ctx.hidden_reason(&kids[i]);
                    ctx.record_hidden(&kids[i], egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, 0.0)), by);
                    continue;
                }
                ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
                    draw_pane(ctx, &kids[i], w, true, ui);
                });
                y += ch;
                if draw_seps && i + 1 < n && sep_at[i] {
                    let hrect = egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, handle));
                    let resp = ui.allocate_rect(hrect, egui::Sense::drag());
                    paint_handle(ui, hrect, false, resp.hovered(), resp.dragged());
                    if resp.hovered() || resp.dragged() {
                        ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
                    }
                    seps.push(json!([hrect.min.x, hrect.min.y, hrect.width(), hrect.height()]));
                    if resp.dragged() {
                        adjust_pair(&mut new_ratios, i, resp.drag_delta().y / total);
                        changed = true;
                    }
                    y += handle;
                }
            }
            ui.allocate_rect(egui::Rect::from_min_max(avail.min, egui::pos2(left + w, y)), egui::Sense::hover());
        }
    }

    if !seps.is_empty() {
        ctx.sep_rects.insert(pane.name.clone(), Value::Array(seps));
    }
    if changed {
        ctx.nav.set_split_ratios(&pane.name, new_ratios);
    }
}

/// Apportion `total` points among a split's children — **the whole minimise/maximise/
/// fixed-row geometry, in one pure function** (so it is unit-testable without a GUI in
/// the loop).
///
/// - `max_child = Some(k)` ⇒ child `k` takes all of `total` **except what the FIXED rows
///   keep**, and every other flexible child gets exactly `0.0` (RED-when-broken: siblings
///   measurably shrink to zero area). A fixed row — the menu bar, the workspace bar, the
///   status bar — is not a sibling competing for space; it is the shell. Maximising a
///   graph must not make the status bar vanish, for the same reason switching workspace
///   must not move the menu: shell chrome that comes and goes is chrome nobody can find.
///   Maximising the fixed row itself still gives it everything.
/// - otherwise, in order: each MINIMISED child takes exactly [`CHROME_BAR`] (its title
///   bar); each child with a [`Pane::fixed`](struct.Pane.html#structfield.fixed) extent
///   takes exactly that (the menu bar / toolbar / status bar rows); and the remainder is
///   shared among the rest **in their unchanged ratios** — which is why restoring
///   returns the exact prior layout, and why growing the body never moves the status bar.
fn split_extents(nav: &NavState, kids: &[Pane], ratios: &[f32], total: f32, max_child: Option<usize>) -> Vec<f32> {
    let n = kids.len();
    // An ABSENT child is allotted exactly nothing, whatever else is going on — not the
    // CHROME_BAR a minimise keeps (there is no title bar to keep: the pane does not
    // exist on screen) and no fixed extent either. Its share is re-apportioned among
    // its siblings by their UNCHANGED ratios, the maximise discipline — which is what
    // makes presence returning restore the exact prior layout.
    let gone: Vec<bool> = kids.iter().map(|k| nav.is_absent(&k.name)).collect();
    if let Some(k) = max_child {
        let kept: Vec<Option<f32>> = (0..n)
            .map(|i| {
                if gone[i] {
                    Some(0.0)
                } else if i == k {
                    None
                } else {
                    kids[i].fixed.map(|f| f.clamp(0.0, total.max(1.0)))
                }
            })
            .collect();
        let fixed: f32 = kept.iter().flatten().sum();
        let rest = (total - fixed).max(1.0);
        return (0..n).map(|i| if i == k && !gone[i] { rest } else { kept[i].unwrap_or(0.0) }).collect();
    }
    let mini: Vec<bool> = kids.iter().map(|k| nav.is_minimized(&k.name)).collect();
    let bar = CHROME_BAR.min(total / n as f32);
    // Each child's non-negotiable extent, if it has one: absence wins over everything,
    // a minimise wins over a fixed row (you asked for it to collapse), and a fixed row
    // never exceeds the whole split.
    let pinned: Vec<Option<f32>> = (0..n)
        .map(|i| {
            if gone[i] {
                Some(0.0)
            } else if mini[i] {
                Some(bar)
            } else {
                kids[i].fixed.map(|f| f.clamp(0.0, total.max(1.0)))
            }
        })
        .collect();
    let fixed: f32 = pinned.iter().flatten().sum();
    let flex_sum: f32 = ratios.iter().zip(&pinned).filter(|(_, p)| p.is_none()).map(|(r, _)| *r).sum();
    let flex_total = (total - fixed).max(1.0);
    let open = pinned.iter().filter(|p| p.is_none()).count().max(1) as f32;
    (0..n)
        .map(|i| match pinned[i] {
            Some(p) => p,
            None if flex_sum > f32::EPSILON => (flex_total * ratios[i] / flex_sum).max(1.0),
            None => (flex_total / open).max(1.0),
        })
        .collect()
}

/// Move `delta` (a fraction of the split's total extent) from child `i+1` into child `i`
/// (or the reverse for a negative delta), clamping both to [`SPLIT_MIN`].
fn adjust_pair(ratios: &mut [f32], i: usize, delta: f32) {
    if i + 1 >= ratios.len() {
        return;
    }
    let pair = ratios[i] + ratios[i + 1];
    let a = (ratios[i] + delta).clamp(SPLIT_MIN, pair - SPLIT_MIN);
    ratios[i] = a;
    ratios[i + 1] = pair - a;
}

/// Paint a split drag handle: a line down the gutter PLUS a three-dot grip at its
/// middle, brighter when hovered/active. The grip is what tells an operator at a glance
/// that the line is draggable — a hairline nobody recognises is a handle nobody grabs.
fn paint_handle(ui: &egui::Ui, rect: egui::Rect, horizontal: bool, hovered: bool, dragged: bool) {
    let v = ui.visuals();
    let color = if dragged {
        v.selection.bg_fill
    } else if hovered {
        v.widgets.hovered.bg_fill
    } else {
        v.widgets.noninteractive.fg_stroke.color.gamma_multiply(0.55)
    };
    // A slim line centred in the handle gutter.
    let bar = if horizontal { rect.shrink2(egui::vec2(rect.width() * 0.35, 0.0)) } else { rect.shrink2(egui::vec2(0.0, rect.height() * 0.35)) };
    ui.painter().rect_filled(bar, 1.0, color);
    // The grip: three dots at the centre of the gutter, the universal "drag me".
    let c = rect.center();
    let step = 4.0;
    let r = if hovered || dragged { 1.6 } else { 1.2 };
    for k in -1i32..=1 {
        let p = if horizontal { egui::pos2(c.x, c.y + k as f32 * step) } else { egui::pos2(c.x + k as f32 * step, c.y) };
        ui.painter().circle_filled(p, r, color);
    }
}

#[cfg(test)]
#[path = "scene_tests.rs"]
mod scene_tests;