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

nordisk/dwarves

.nornir/dwarves-ui-wave-plan.md

dwarves-gui UI wave plan — scene migration + About-at-startup + Convergence + Runa

Class: design · Function axis: f0 rebase the stranded facett-scene commits · f1 resolve the facett-app fold-only DRIFT · f2 About open at startup (#31) · f3 mount a Convergence pane (#28, facett-releaseview) · f4 mount a Runa pane (facett-pipeline RunaPipelineAdapter) · f5 tests + docs. Scope: dwarves-gui only (the native tabbed shell). The headless release engine crates/dwarves is UNTOUCHED. Web/wasm host keeps the single Forge panel. Reference: korp/src/scene_shell.rs (the CURRENT, working scene pattern) and facett/.nornir/scene-design.md (the facett-app scene LAW doc).


0 — the situation, and the REAL drift

Two commits are stranded on feat/facett-scene (branch tip a547636, base c7224d4):

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:

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:

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:


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:

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:

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:


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.rs tests) builds via TabbedView::new(...).with_about(...) and asserts about.open == false initially — it MUST stay green, which is why the startup-open is a separate builder used only by build_native_app (§6a/6b), not baked into new.


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
}

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:

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.)

# 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))
}

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.rs has a fn panel(); the new file is runa.rs (module runa) — no clash with the runa CRATE because inside runa.rs the crate is referenced as ::runa / runa::Pipeline resolves to the extern crate (the module doesn't shadow the crate at the path runa:: from other modules; if the compiler complains inside runa.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

Commit boundaries (each builds+tests green on its own):

  1. rebase + fold-only drift fix (tabbed.rs, robot_scene.rs, scene-design.md, Cargo.lock).
  2. About-at-startup (#31).
  3. Convergence pane (#28).
  4. 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