dwarves-gui shell — the declarative facett-scene migration — design
Class:
design· Function axis:f0(the tabbed shell as data) ·f1thedwarves_scene()builder ·f2TabbedViewas aSceneHost(reuse, not rewrite) ·f3render wiring + the state seam ·f4interactions (draggable split · drawer · foldable tabs · responsive · menubar) ·f5tests. Strict: this file states only the shape we run. The shell lives indwarves-gui/src/tabbed.rs; the headless release enginecrates/dwarvesis UNTOUCHED. Reference:../../facett/.nornir/scene-design.md(the facett-appsceneLAW doc).
f0 — the tabbed shell as DATA
dwarves-gui's native window was a hand-rolled TabbedView (a manual tab-bar strip + a warehouse-selector/About header). It is now DESCRIBED as one facett_app::scene::Scene value — a menubar + a foldable pane tree + routes — and rendered through facett-app's OWN primitives (Router / TabView / facett-menu) by scene::render. Nothing was forked: the scene DRIVES the SAME existing Box<dyn Panel> tabs and the SAME warehouse selector / About widgets.
The builder is dwarves_scene(tab_labels) (dwarves-gui/src/tabbed.rs), derived from whatever tab labels the shell is constructed with. Those labels come from ONE place — dwarves_gui::DWARVES_TABS, the (slot-id, label) roster in dwarves-gui/src/lib.rs (korp's proven scene_shell::KORP_TABS shape). The shipped shell, the scene, the menubar and the whole robot suite all read that const, so there is no second hardcoded tab list anywhere and "no tab dropped" is an oracle the compiler and the tests enforce, not a comment (see f5).
shell ─ Split(H, 0.20, DRAGGABLE) [tabbed.rs:64 · phone → Tabs under 760pt]
├─ rail (Foldable 🗄, DRAWER@760) slot:rail — warehouse selector + About; phone drawer
└─ body ─ Tabs(active 0) — the workspace (tabbed.rs:60)
├─ warehouse (Foldable 🗄) slot:warehouse ─┐
├─ forge (Foldable 🔨) slot:forge │ one FOLDABLE Slot per existing
├─ pipeline (Foldable 🪈) slot:pipeline │ tab panel; the Slot id = the
├─ builds (Foldable 🧱) slot:builds │ lowercased label, the SAME key
├─ funnel (Foldable 🫙) slot:funnel │ the shell folds each pane's
├─ claude (Foldable ✳) slot:claude │ state under.
├─ jera (Foldable 🗂) slot:jera │
├─ tools (Foldable 🧰) slot:tools │
├─ status (Foldable 🚦) slot:status │
├─ ci-cd (Foldable ⚙) slot:ci-cd │ …the roster is 18 tabs; the
├─ release (Foldable 🚀) slot:release │ order here is the DWARVES_TABS
├─ benches (Foldable 📊) slot:benches │ order (warehouse · forge ·
├─ fleet (Foldable 🛰) slot:fleet │ pipeline · ci/cd · builds ·
├─ binary (Foldable 🧬) slot:binary │ funnel · claude · jera · tools ·
├─ coderoom (Foldable 📚) slot:coderoom │ status · release · benches ·
├─ saga (Foldable 🎞) slot:saga │ fleet · binary · coderoom ·
├─ settings (Foldable 🎛) slot:settings │ saga · settings · profile).
└─ profile (Foldable 👤) slot:profile ─┘
menubar: View (fold toggles) · Go (switch tab, Ctrl+1..N) · Help (About, F1)
routes : on_button focus-warehouse→Focus · on_shortcut Ctrl+Home→Show · on_event toggle-rail→Toggle(rail)
The three stable pane ids are ROOT/RAIL/BODY (tabbed.rs:28-32). Scene::validate (the addressability invariant — unique pane + menu names) is asserted in TabbedView::new (tabbed.rs:166).
f1 — the dwarves_scene() builder (Describe)
dwarves_scene (tabbed.rs:71) uses the Rust builder form: Pane::slot/.title/ .foldable_icon per tab, Pane::tabs for the workspace, Pane::split (DRAGGABLE by default) for the master/detail root, .drawer(760) on the rail, .phone(Layout::Tabs, 760) on the root, and Scene::new(...).menu(...).route(...). The identical value round-trips through canonical RON (Scene::to_ron/from_ron, byte-stable — the RED-when-broken test below).
f2 — TabbedView is a thin SceneHost (Host — reuse, not rewrite)
TabbedView (tabbed.rs:135) now carries the scene + its nav: NavState + last_render, and it is impl Facet so it still drops into the facett-wasm-demo FacetApp harness unchanged (lib.rs build_native_app). The slot host is Host (tabbed.rs:406, impl SceneHost at :418):
slot("rail", ui)(:419) draws the EXISTINGWarehouseSelector+ the sharedfacett-aboutcard/button, returning theirstate_json.slot("<tab>", ui)finds the existingBox<dyn Panel>by its lowercased label, calls its.ui(ui), and returns its untouched.state_json()— the panels are reused verbatim.command(id)(:454) handleshelp.about(open About) andwh:<name>(select a warehouse) inline;go:<tab>is recorded and applied after the render.
f3 — render wiring + the observable seam (Render + Observe)
TabbedView::ui (tabbed.rs:279) splits the struct into disjoint borrows, builds Host over the slot widgets, and calls scene::render(&scene, &mut nav, ui, &mut host) (:296) — replacing the old manual tab-bar draw. A go:<tab> command then drives the body Tabs active index; a rail warehouse change re-binds every tab (the same broadcast nornir's switch_workspace does).
Facet::state_json (tabbed.rs:317) folds the render Value in under render, and adds the shell-as-data facts (scene, panes, foldable, drawers, draggable_splits, folded, split_ratio, menus, routes) ALONGSIDE the pre-existing keys (active_tab, tabs, selected_warehouse, per-tab folded state) — additive, so every existing robot oracle still resolves. Facet::update_json gains {"toggle_fold":"<pane>"} and drives {"set_tab":…} through the scene's body Tabs active index; unknown messages still forward to the active pane.
f4 — interactions (all declarative, all observable)
- Draggable master/detail split — the root
shellsplit has a real drag handle; a drag re-apportions the live ratio (kept inNavState, reported instate_json.render.panes.shell.{ratios,separators}). - Drawer rail — the
railis.drawer(760): it auto-folds crossing INTO phone-class and restores above it (desktop rail → phone drawer). - Foldable content panes — every tab leaf is
Foldable; folding draws only its header and OMITS its content from the layout AND the AccessKit tree. - Responsive — the root
.phone(Tabs, 760)collapses the master/detail split to Tabs under 760pt. - Menubar + routes — View/Go/Help render in-window via the scene menubar (facett_menu), with shortcut hints; routes fire over the shared Router.
f5 — Tests (L4, recorded matrix rows)
In dwarves-gui/src/tabbed.rs mod scene_shell_tests (:1156), all green in the worktree (cargo test -p dwarves-gui, 32 passed incl. the pre-existing robot suite — no regressions):
dwarves_scene_is_valid_foldable_drawer_and_draggable(:1173) — valid names, root is a draggable split, tabs+rail foldable, rail is a drawer@760, 3 menus + 3 routes.dwarves_scene_ron_round_trips_byte_stable(:1197) — RED-when-broken: the shell's RON is a byte-stable fixed point and equals the builder value.a_declared_route_fires_over_the_router(:1207) — an event route folds the rail, a button route focuses the first tab, over the shared Router.robot_scene_fold_and_tab_switch_over_the_seam(:1222) — headless drive over state_json/update_json: shell-as-data present, fold observable, tab switch drives the scene, existing panels still reused + folded under their labels. Emits a testmatrix cell.robot_render_fold_omits_content_from_the_tree(:1275) — RED-when-broken (kittest render): folding a content pane OMITS its slot from the AccessKit tree; header retained.robot_render_drag_reapportions_the_master_detail_ratio(:1314) — RED-when-broken (kittest render): a real pointer drag on the divider re-apportions the live split ratio, observable in state_json.
The roster oracle + the dead lib/bin twin
dwarves-gui used to be a lib/bin TWIN, not a lib/bin split: main.rs re-declared every module lib.rs declared, so each file compiled TWICE into two separate crates — and the two copies then silently DRIFTED. The lib's build_native_app had an always-thin endpoint: String funnel and 9 tabs; the bin's had the FAT/embedded nornir_server: Option<String> default and 18. The compiler could not see the divergence, and tests/robot_scene.rs — which linked the LIB — was therefore driving a 9-tab shell while asserting it was "the EXACT shipped native shell".
That is fixed:
- ONE crate.
lib.rsowns every module;main.rsdeclares NONE and is a thin host overdwarves_gui::build_native_app. MOVE = DELETE. - The surviving behaviour is the FAT/embedded one —
build_native_apptakesnornir_server: Option<String>, soNone⇒ the in-process funnel and dwarves works with nornir-server DOWN. (Taking the lib's always-thin signature would have been a silent regression.) - Slot ids are SLUGGED, in one place.
tabbed::slug(label)lowercases and collapses every non-alphanumeric run to-, so"CI/CD"keys asci-cd, notci/cd. A/is a path separator in the deep-linkapp#paneform, so normalising it at the single point every id is derived (the scene builder, thestate_jsonfold key, and the host's slot→panel lookup all callslug) means no future label can reintroduce the trap. The user-visible label staysCI/CD.roster_testsasserts every key equalsslug(label)AND that every key is[a-z0-9-]+, so a bad id fails RED. DWARVES_TABSis the roster oracle.build_native_appZIPS its constructed panels against the const, so a panel with no roster entry (or vice versa) trips a length assert;NATIVE_TITLE, the scene, the menubar and the robot suite all derive from the same list.roster_tests(lib.rs) asserts the shipped shell mounts exactly the roster, every key is its label lowercased and unique, the scene declares a pane per tab, and the title names all 18.
Because the suite now reads the roster instead of a copied 9-name list, all 7 tests/robot_scene.rs scene tests cover 18 panes rather than 9 — the tab switch + slot state, fold/unfold, menubar View/Go items, and deep-link-by-pane-name checks all scale with the shell. The shipped app is additionally exercised end-to-end by app_tests::robot_zone_framework_rollout_no_duality_no_demo_button (now in lib.rs).