dwarves-gui UI wave plan — scene migration + About-at-startup + Convergence + Runa
Class:
design· Function axis:f0rebase the stranded facett-scene commits ·f1resolve the facett-app fold-only DRIFT ·f2About open at startup (#31) ·f3mount a Convergence pane (#28, facett-releaseview) ·f4mount a Runa pane (facett-pipelineRunaPipelineAdapter) ·f5tests + docs. Scope:dwarves-guionly (the native tabbed shell). The headless release enginecrates/dwarvesis UNTOUCHED. Web/wasm host keeps the single Forge panel. Reference:korp/src/scene_shell.rs(the CURRENT, working scene pattern) andfacett/.nornir/scene-design.md(the facett-appsceneLAW doc).
0 — the situation, and the REAL drift
Two commits are stranded on feat/facett-scene (branch tip a547636, base c7224d4):
b09664f— migratedwarves-gui/src/tabbed.rsTabbedView→facett_app::scene(+.nornir/scene-design.md, Cargo.toml/Cargo.lock).a547636— split the demo into a library (src/lib.rs) sotests/robot_scene.rscan reach the scene surface; slimmain.rs; widen scene ids topub.
Text-level drift is tiny. git diff --stat c7224d4 main -- dwarves-gui/ is a one line change: dwarves-gui/Cargo.toml version 0.1.2 → 0.1.4. main.rs, tabbed.rs, and every pane module are byte-identical between the merge-base and current main. So the rebase applies cleanly at the text level (only Cargo.lock truly conflicts, regen-resolvable; the Cargo.toml version line is context-untouched by b09664f, which edits lines 78+/120+).
The drift that matters is in facett-app, not in dwarves. The stranded commits were authored 2026-07-16 against a facett_app::scene that HAD:
| stranded API used | current facett-app scene |
evidence |
Pane::….drawer(760.0) (auto-folding drawer) |
removed — no drawer method, no Responsive.drawer field |
facett-app/src/scene.rs:148 Responsive { phone, tablet, collapse_below }; grep drawer facett-app/ → nothing |
draggable master/detail Split + NavState.split_ratio |
removed — splits are STATIC, NavState has no split_ratio |
scene.rs:94 "A Layout::Split ratio is STATIC (no drag)"; scene.rs:801 renders with .resizable(false); NavState fields = router, active_tab, folded, focus, arg only |
Layout::Split { resizable: true, .. } pattern |
removed — variant is Split { axis, ratio } |
scene.rs:91–96 |
facett-app now enforces fold/unfold as the ONLY pane interaction (no drag/float/ dock/reorder/resize) — the law korp already adopted (korp/src/scene_shell.rs:14–16). So the rebased tabbed.rs/robot_scene.rs will fail to compile against current facett-app until the dead-API usages are reworked. korp's scene_shell.rs is the working template to converge on.
Net: this is a rebase whose conflicts are code-shaped, resolved by editing three files (tabbed.rs, tests/robot_scene.rs, .nornir/scene-design.md) to the current fold-only API, then layering the three new features on top.
1 — rebase mechanics (exact steps)
All git/cargo runs are for the build agent (this plan is authored READ-ONLY). Work on a fresh branch off main; do NOT rebase onto a shared/mid-build checkout — use a dedicated worktree (memory: multi-agent worktree isolation).
cd /run/media/rickard/T9/git/dwarves
git fetch --all
git switch -c feat/facett-scene-rebase main
git rebase --onto main c7224d4 feat/facett-scene # replay b09664f + a547636 onto main
Expected during replay:
Cargo.lockconflict on both commits → resolve by takingmain's lock, finish, then regenerate at the end (cargo update -p dwarves-guiis unnecessary; acargo build -p dwarves-guirewrites it). Orgit checkout --theirs Cargo.lockper step thengit add.dwarves-gui/Cargo.toml: NO conflict expected (version line untouched by the scene commits). If one appears, keepversion = "0.1.4"AND the addedfacett-appdep +[dev-dependencies] egui_kittestblock.tabbed.rs,lib.rs,main.rs,tests/robot_scene.rs,.nornir/scene-design.md: clean apply.
After the rebase completes the tree will NOT yet build (facett-app drift). Proceed to §2.
Alternative if the rebase is noisy: git cherry-pick c7224d4..feat/facett-scene onto the new branch (same two commits) — equivalent, sometimes cleaner on the lock.
2 — resolve the facett-app fold-only drift in dwarves-gui/src/tabbed.rs
Edit the rebased tabbed.rs (scene version — see git show feat/facett-scene:dwarves-gui/src/tabbed.rs). Converge its shell shape onto korp's current pattern. Concrete edits:
2a. dwarves_scene() — the rail Pane. Remove the dead .drawer(760.0):
// BEFORE (stranded):
let rail = Pane::slot(RAIL, "rail").title("Warehouse").foldable_icon(true, "🗄").drawer(760.0);
// AFTER (current API — keep it foldable; phone-collapse lives on the split, see 2b):
let rail = Pane::slot(RAIL, "rail").title("Warehouse").foldable_icon(true, "🗄");
2b. dwarves_scene() — the root split. Keep the STATIC split + the .phone(...) collapse (both are supported: Pane::split at scene.rs:344, .phone at scene.rs:371). This line is already valid and is what drops the rail on a phone-class width:
let root = Pane::split(ROOT, Axis::Horizontal, 0.20, vec![rail, body])
.title("dwarves")
.phone(Layout::Tabs { active: 1 }, 760.0); // <760pt ⇒ shell renders as Tabs showing body (child 1)
No change needed here beyond confirming it compiles (it does — static split is the current model). The "drawer" behavior is now expressed as the split collapsing to Tabs on phone, not an auto-folding rail.
2c. Remove draggable_splits() helper (matches the deleted Layout::Split { resizable: true, .. }). Delete the whole method. Nothing on a static-split model is "draggable".
2d. Rework or drop drawer_panes(). The p.responsive.drawer field is gone. Two options — pick (i) for minimal surface:
- (i) Delete
drawer_panes()and its"drawers"state_json key (§2e). - (ii) Redefine it as "panes with a phone override": filter
self.scene.pane(n).map(|p| p.responsive.phone.is_some()).unwrap_or(false)— the root split qualifies. Keep the"drawers"key if you want the responsive-collapse oracle. If you keep it, updaterobot_scene.rs§4 accordingly.
2e. state_json() — drop the dead keys. In the scene-migrated state_json, remove:
obj.insert("draggable_splits".into(), serde_json::json!(self.draggable_splits())); // DELETE (2c)
obj.insert("split_ratio".into(), serde_json::json!(self.nav.split_ratio)); // DELETE — NavState has no split_ratio
obj.insert("drawers".into(), serde_json::json!(self.drawer_panes())); // DELETE (or keep per 2d-ii)
Keep: scene, panes, foldable, folded (self.nav.folded — still exists), menus, routes, render (the last_render Value, which already carries device, slots, panes, active_tab from scene::render — scene.rs:684). These are the observable facts the robot suite reads.
2f. Sanity: everything else in the stranded tabbed.rs is current-API-clean — Pane::slot/tabs/split/title/foldable_icon, Scene::new/menu/route, MenuItem::new/items/action/shortcut, Action::Toggle/Command, Route::on_button/on_shortcut/on_event, Target::Focus/Show/Toggle, NavState::new/ active_tab/set_active_tab/toggle_fold/is_folded/folded, scene::render, the SceneHost { slot, command } trait, and the Host<'a> struct all match facett-app/src/scene.rs (verified). The foldable_panes() helper stays (still valid).
2g. Add the two new tab glyphs to tab_icon() (used to decorate the foldable slot header) so the new panes get an icon instead of the ▪ fallback:
"convergence" => "🎯",
"runa" => "🧬",
3 — lib.rs / main.rs split (from a547636) — reconcile
a547636 moved the modules + build_native_app + NATIVE_TITLE into dwarves-gui/src/lib.rs and slimmed main.rs to consume dwarves_gui::{…}. Because main.rs on main is byte-identical to the merge-base, this applies clean. After rebase:
- Confirm
src/lib.rsexists and re-exportspub mod {panel,tabbed,warehouse_select, about,jera,zone_tools,pipeline,status}+pub fn build_native_app+pub const NATIVE_TITLE. - Confirm
main.rsusesuse dwarves_gui::{NATIVE_TITLE, build_native_app};anduse dwarves_gui::panel::ForgeView;. - The new modules from §7/§8 (
convergence,runa) must be added tolib.rs's module list (both#[cfg(not(target_arch = "wasm32"))], native-only, like the other panes).
4 — fix tests/robot_scene.rs (the two drift tests)
git show feat/facett-scene:dwarves-gui/tests/robot_scene.rs — 380 lines, 7 scenarios. Five are current-API-clean. Two touch the dead API and must be reworked:
4a. robot_render_drawer_rail_autofolds_crossing_phone_class (test #4, ~line 234). It asserts the rail auto-folds crossing 760pt (the removed .drawer behavior). Rewrite to assert the current phone-collapse: render the exact shipped scene through egui_kittest::Harness at a wide then narrow viewport and assert the render Value:
- wide (≥760):
render["device"] == "desktop",render["slots"]contains"rail"(the rail slot is laid out), menubar renders. - narrow (<760):
render["device"] == "phone",render["slots"]does NOT contain"rail"(the split collapsed toTabs{active:1}→ body only), and"body"/the active tab slot IS present. RED-when-broken: drop the.phone(...)on the root split and the narrow render still shows the rail. Keep the kittest harness scaffolding from the original test; only the assertions change. Rename torobot_render_shell_collapses_to_tabs_on_phone_class.
4b. scene_shape_is_draggable_split_over_a_foldable_drawer (test #6, ~line 373). It asserts rail.responsive.drawer and a draggable split — both gone. Rewrite as scene_shape_is_split_over_a_foldable_rail:
matches!(scene.pane(ROOT).unwrap().layout, Layout::Split { .. })(static split ok);scene.pane(ROOT).unwrap().responsive.phone.is_some()(collapses on phone);matches!(scene.pane(RAIL).unwrap().fold, Fold::Foldable { .. })(rail is foldable);- the body pane is
Layout::Tabs { .. }with one child per tab label.
4c. Also delete/rewrite any bidirectional-DRAG assertion in test #4's neighbor (the a547636 message mentions "a real kittest pointer drag on the master/detail divider … re-apportions BOTH ratios"). There is no divider drag anymore. Replace that scenario with a fold-omits-content kittest leg copied from korp's proven pattern (korp/src/scene_shell.rs:178–225 folding_a_korp_tab_omits_its_content): fold the active body tab, assert its SLOT:<id> leaves the AccessKit tree AND render["slots"] omits it; unfold reveals it. This keeps the "real render" matrix cell without inventing an interaction facett-app doesn't have.
4d. Keep the 5 clean scenarios (all-9-tabs-switch, fold-every-foldable-pane, menubar+routes fire, deep-link resolve/reject, per-panel state folded under its id). Verify they only touch folded/active_tab/panes/routes/menus (they do).
Every scenario keeps its #[cfg(feature = "testmatrix")] nornir_testmatrix:: functional_status(...) cell (LAW: testmatrix-gated, red-when-broken).
5 — update .nornir/scene-design.md
The stranded scene-design.md documents the dead API (Split(H, 0.20, DRAGGABLE), DRAWER@760, f4 interactions (draggable split · drawer …)). After rebase, edit it to the fold-only shape:
f4interactions line → "foldable tabs · foldable rail · responsive phone-collapse · menubar" (drop "draggable split · drawer").- The ASCII shell diagram →
shell ─ Split(H, 0.20, static) [phone → Tabs(body) under 760pt],rail (Foldable 🗄) slot:rail. - Add the two new leaves
convergence (Foldable 🎯) slot:convergenceandruna (Foldable 🧬) slot:runato the slot list. - Point the reference at
korp/src/scene_shell.rsas the sibling adoption + the facett-app law doc. - Follow the
.nornirdocs doctrine:design= what-is (this file), keep it to the shape we run. (holger's.nornir/READMEis the gold template if a README refresh is wanted — out of scope here.)
6 — About popup OPEN at startup (#31)
Reuse-first: dwarves already consumes the shared facett-about card (dwarves-gui/src/about.rs supplies the Identity; render/dismiss live in facett-about). The only change is the initial about_open flag.
Current: TabbedView inits about_open: false (both the main-tree and scene versions). AboutCard::show(ctx, &mut open, id) (facett-about/src/lib.rs:187) gates purely on the bool. To open at program start WITHOUT breaking the many robot tests that construct TabbedView::new(...) and assert About starts closed:
6a. Add an opt-in builder on TabbedView (in tabbed.rs, next to with_about):
/// Open the About popup on the first frame (the shipped shell's startup identity
/// card). Off by default so headless test shells start closed.
pub fn with_about_open(mut self, open: bool) -> Self {
self.about_open = open;
self
}
6b. Wire it in the shipped shell only — in lib.rs::build_native_app, chain it after .with_about(about::identity()):
.with_about(about::identity())
.with_about_open(true)
This opens About at startup for cargo run -p dwarves-gui, while every TabbedView::new(...)-based robot test keeps its closed-start assumption.
6c. New robot test (in tests/robot_scene.rs, driving the real shipped shell via build_native_app, offline-safe dead ports): assert state_json["about"]["open"] == true immediately after construction, then drive {"about": false} over the seam and assert it closes. Emit a testmatrix cell about_opens_at_startup. RED-when-broken: remove .with_about_open(true) ⇒ opens-closed ⇒ test fails.
Note the existing
robot_warehouse_selector_binds_funnel_release_and_about(tabbed.rstests) builds viaTabbedView::new(...).with_about(...)and assertsabout.open == falseinitially — it MUST stay green, which is why the startup-open is a separate builder used only bybuild_native_app(§6a/6b), not baked intonew.
7 — Convergence pane (#28) — facett-releaseview fed a ReleaseConvergence
The release-convergence 3-graph viz. facett_releaseview::ReleaseView is a full Facet (impl_facet_via_elm! at facett-releaseview/src/lib.rs:703) ⇒ a Box<dyn Panel>, so it mounts as a tab with zero glue. It takes an engine-agnostic model::ReleaseConvergence IN and renders (no dwarves/engine dep — the one-way adapter pattern, lib.rs:26–31).
7a. Cargo dep — add to dwarves-gui/Cargo.toml (native [dependencies] block, near the other facett-* UI deps at line ~85):
# The release-CONVERGENCE viz (#28): the same constellation DAG in three states
# (LOCAL / SPARRING / CRATES_IO) that a healthy release drives to converge. Engine-
# agnostic: dwarves feeds a `ReleaseConvergence` model IN; the viewer never learns the
# engine (the one-way adapter pattern). Native-only tab.
facett-releaseview = { path = "../../facett/facett-releaseview", version = "0.1" }
7b. New module dwarves-gui/src/convergence.rs — build the pane from a faithful nordisk fixture (the same "fixture, no server" approach status.rs / nornir-warehouse-nav use; a live feeder from graph_diff + the three version sources is the documented follow-up, out of this UI wave's scope). Shape:
use facett_releaseview::{ReleaseView, model::{ReleaseConvergence, CrateConvergence, DepEdge}};
/// A faithful nordisk release-3 convergence fixture: a handful of constellation crates
/// across the three graphs. Mixes converged (all-equal) + diverged (needs-release /
/// bump-drift / lock-desync) nodes so the 3-plane + tri-badge renders both show signal.
pub fn convergence_view() -> ReleaseView {
let conv = ReleaseConvergence::new("nordisk release-3")
.with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1")) // converged
.with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.2")) // needs-release (cratesio<local)
.with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.9")) // converged
.with_node(CrateConvergence::new("nornir", "0.5.4", "0.5.4", "0.5.3")) // needs-release
.with_node(CrateConvergence::new("edda", "0.3.0", "0.3.1", "0.3.0")) // bump-drift (sparring>local... see note)
.with_edge(DepEdge::new(1, 0)) // dwarves → skade
.with_edge(DepEdge::new(3, 4)) // nornir → edda
.with_edge(DepEdge::new(1, 2)); // dwarves → holger
ReleaseView::from_model(conv).with_clock(0.0) // pin the clock ⇒ deterministic headless render
}
CrateConvergence::newderivesstatusviaderive_status(model.rs:131), so the fixture's statuses are computed, not hand-set — pick version triples that land on the intendedConvergeStatus(see the precedence tablemodel.rs:19–26). Cross-check the chosen versions against the derive table when authoring so the fixture reads as intended (e.g. edda's triple must actually satisfy theBumpDriftbranch — adjust ifderive_statusclassifies it otherwise)..with_clock(0.0)pins the animation clock so the headless render is a pure function of state (lib.rs:199,ReleaseState.clock_s).
7c. Wire the tab in lib.rs::build_native_app: build let convergence: Box<dyn Panel> = Box::new(convergence::convergence_view()); and add ("Convergence".to_string(), convergence) to the TabbedView::new vec (place it after Status, or after Funnel — your call; the scene derives the slot from the label).
7d. Robot test (tests/robot_scene.rs): switch to the Convergence tab over {"set_tab":"Convergence"} and assert the folded pane state. ReleaseView's state_json (lib.rs:620–661) exposes component:"ReleaseView", node_count, statuses.{converged,needs_release,…}, converged, converged_fraction, planes. Assert e.g. convergence["component"] == "ReleaseView", node_count == 5, statuses["needs_release"] >= 1, converged == false. Drive a SelectCrate over the seam if the pane forwards it (via update_json → the active pane); assert selected. Emit testmatrix cell convergence_pane_drivable.
8 — Runa pane — facett-pipeline RunaPipelineAdapter
Show dwarves' own runa CI/CD pipeline on the SAME reused facett-pipeline viewer the Pipeline tab uses — "another engine's pipeline = a new ADAPTER, not a new viewer" (facett-pipeline/src/runa_adapter.rs:8). PipelineView is already a Facet/Panel (used by pipeline.rs).
8a. Cargo deps — dwarves-gui/Cargo.toml:
- Enable the adapter feature on the EXISTING
facett-pipelinedep (line ~85):
facett-pipeline = { path = "../../facett/facett-pipeline", version = "0.1", features = ["runa-adapter"] }
(runa-adapter is off-by-default and is the only thing that pulls runa into facett-pipeline — facett-pipeline/Cargo.toml:18.)
- Add the
runaedda leaf directly (for thePipelinetype +from_ron_strparse):
# dwarves' typed CI/CD pipeline authoring layer (edda leaf). Parses `*.runa.ron` and
# feeds facett-pipeline's RunaPipelineAdapter — a pure one-way map (runa stays a leaf).
runa = { path = "../../edda/crates/runa", version = "0.1" }
Path check: dwarves-gui is dwarves/dwarves-gui; ../../edda/crates/runa resolves to /run/media/rickard/T9/git/edda/crates/runa (same base facett-pipeline uses, facett-pipeline/Cargo.toml:31). Confirm the edda checkout is present there (/var/run/media/rickard/T9/git/edda is the canonical path; a sibling edda/ is also under the git root).
8b. New module dwarves-gui/src/runa.rs — parse the shipped holger.runa.ron (dwarves repo root) and render it via the adapter:
use facett_pipeline::{PipelineView, RunaPipelineAdapter};
/// dwarves' shipped `holger.runa.ron` release pipeline on the REUSED facett-pipeline
/// viewer, via the one-way `RunaPipelineAdapter` (runa Stage DAG → PipelineModel).
/// Static (no live status) ⇒ every stage renders Idle; a live run would attach a
/// `stage → StageStatus` map (`RunaPipelineAdapter::with_status`).
pub fn runa_view() -> PipelineView {
// include_str! is relative to THIS file (dwarves-gui/src/runa.rs):
// ../../holger.runa.ron == dwarves/holger.runa.ron (repo root).
const RON: &str = include_str!("../../holger.runa.ron");
let pipeline = runa::Pipeline::from_ron_str(RON).expect("holger.runa.ron parses + validates");
PipelineView::from_adapter(&RunaPipelineAdapter::new(&pipeline))
}
Pipeline::from_ron_strparses AND validates the DAG (edda/crates/runa/src/lib.rs:93); it's the same callcrates/dwarvesuses (runa_backend.rs:484).PipelineView::from_adapteris the exact constructorpipeline.rs:89already uses.
8c. Wire the tab in lib.rs::build_native_app: let runa: Box<dyn Panel> = Box::new(runa::runa_view()); and add ("Runa".to_string(), runa) to the TabbedView::new vec.
8d. Robot test (tests/robot_scene.rs): {"set_tab":"Runa"} then assert the folded pane. PipelineView exposes counts + node/edge counts (see pipeline.rs test using view.counts() / view.state().nodes). Over the seam assert the runa pipeline mapped: node count == number of stages in holger.runa.ron, edges == number of needs, all Idle (static). Read the actual stage/needs counts from holger.runa.ron when authoring the exact expected numbers. Emit testmatrix cell runa_pane_drivable.
Module-name caution: the wasm entry
main.rshas afn panel(); the new file isruna.rs(moduleruna) — no clash with therunaCRATE because insideruna.rsthe crate is referenced as::runa/runa::Pipelineresolves to the extern crate (the module doesn't shadow the crate at the pathruna::from other modules; if the compiler complains insideruna.rs, use::runa::Pipeline).
9 — NATIVE_TITLE + tab roster
lib.rs::NATIVE_TITLE (and its twin doc-comment in main.rs) currently reads: "dwarves — Warehouse | Forge | Pipeline | Builds | Funnel | Claude | Jera | Tools | Status". Extend it to include the two new tabs in the same order they're pushed to the vec, e.g.: "… | Status | Convergence | Runa". Keep it in ONE place (lib.rs); main.rs only references it. Any robot test that snapshots the tab set (the build_native_app rollout test, main.rs robot_zone_framework_rollout_…) must have its expected tab list updated to include "Convergence" and "Runa" (it currently asserts on Warehouse/Forge/Jera/Builds/Tools presence + no-demo/no-duality — those stay true; just don't let a hard-coded full-list equality fail).
10 — build / test / verify (build agent)
Native-only surface; do NOT run under the shared mid-build target dir — set a private CARGO_TARGET_DIR on /mnt/work4t (memory: disk layout) e.g.:
cd /run/media/rickard/T9/git/dwarves
CARGO_TARGET_DIR=/mnt/work4t/cargo-target/dwarves-gui cargo build -p dwarves-gui
CARGO_TARGET_DIR=/mnt/work4t/cargo-target/dwarves-gui cargo test -p dwarves-gui --features testmatrix
Green bar target (extends the stranded suite's "39 passed"): the 6 in-crate scene tests
- the reworked robot_scene scenarios (now including phone-collapse, fold-omits, convergence, runa, about-at-startup) + every pre-existing pane robot test, no regressions. Gate the commit on the build+test exit (memory: gate-commits-on-build — do NOT
| tailmask the exit). Optionally drive the shipped window once (cargo run -p dwarves-gui, offline is fine) to eyeball About-opens-at-startup + the two new tabs render — therobotui watch/kittest legs are the headless proof (testable-code + robot-tested-UI LAW).
Commit boundaries (each builds+tests green on its own):
- rebase + fold-only drift fix (tabbed.rs, robot_scene.rs, scene-design.md, Cargo.lock).
- About-at-startup (#31).
- Convergence pane (#28).
- Runa pane. Push each per the autonomous directive; no
--force, SSH remotes only, no AI attribution in commit messages (memory laws).
11 — risks / open questions
- facett-app is a shared-dep repo mid-build (READ-ONLY note in the task). The plan consumes facett-app's CURRENT
sceneAPI (verified againstfacett-app/src/scene.rs); if facett-app lands further scene changes before this runs, re-verify §2/§4 API names before editing. - edda
runapath — confirm../../edda/crates/runaresolves fromdwarves-gui(canonical edda is/var/run/media/rickard/T9/git/edda). If the workspace root'sedda/symlink/checkout differs, adjust the dep path (SSH-only law is about git remotes, not path deps — path deps are fine). - Convergence fixture statuses are derived, not asserted — author the version triples against
derive_status(model.rs:131) so each node lands on the intendedConvergeStatus; verify with a quick unit assert before the robot test. - CLI half of #28 (
dwarvesemitting aReleaseConvergencefrom real graph_diff + the three version sources) is explicitly OUT of this UI wave — the pane consumes a fixture now; the live feeder is the documented follow-up. - Do not touch
crates/dwarves(the headless release engine) — every change here is indwarves-gui(thepublish = falsedemo crate).