Gunnar

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`):

- `b09664f` — migrate `dwarves-gui/src/tabbed.rs` `TabbedView` → `facett_app::scene`
  (+ `.nornir/scene-design.md`, Cargo.toml/Cargo.lock).
- `a547636` — split the demo into a library (`src/lib.rs`) so `tests/robot_scene.rs`
  can reach the scene surface; slim `main.rs`; widen scene ids to `pub`.

**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.lock`** conflict on both commits → resolve by taking `main`'s lock, finish,
  then regenerate at the end (`cargo update -p dwarves-gui` is unnecessary; a
  `cargo build -p dwarves-gui` rewrites it). Or `git checkout --theirs Cargo.lock` per
  step then `git add`.
- **`dwarves-gui/Cargo.toml`**: NO conflict expected (version line untouched by the
  scene commits). If one appears, keep `version = "0.1.4"` AND the added
  `facett-app` dep + `[dev-dependencies] egui_kittest` block.
- `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)`:
```rust
// 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:
```rust
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, update `robot_scene.rs` §4 accordingly.

**2e. `state_json()` — drop the dead keys.** In the scene-migrated `state_json`,
remove:
```rust
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:
```rust
"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.rs` exists and re-exports `pub mod {panel,tabbed,warehouse_select,
  about,jera,zone_tools,pipeline,status}` + `pub fn build_native_app` + `pub const
  NATIVE_TITLE`.
- Confirm `main.rs` uses `use dwarves_gui::{NATIVE_TITLE, build_native_app};` and
  `use dwarves_gui::panel::ForgeView;`.
- The new modules from §7/§8 (`convergence`, `runa`) must be added to `lib.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 to `Tabs{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 to `robot_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:
- `f4` interactions 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:convergence` and `runa
  (Foldable 🧬) slot:runa` to the slot list.
- Point the reference at `korp/src/scene_shell.rs` as the sibling adoption + the
  facett-app law doc.
- Follow the `.nornir` docs doctrine: `design` = what-is (this file), keep it to the
  shape we run. (holger's `.nornir/README` is 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`):
```rust
/// 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())`:
```rust
.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):
```toml
# 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:
```rust
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::new` derives `status` via `derive_status` (`model.rs:131`), so the
  fixture's statuses are computed, not hand-set — pick version triples that land on the
  intended `ConvergeStatus` (see the precedence table `model.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 the `BumpDrift` branch — adjust if
  `derive_status` classifies 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-pipeline` dep (line ~85):
  ```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`.)
- Add the `runa` edda leaf directly (for the `Pipeline` type + `from_ron_str` parse):
  ```toml
  # 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:
```rust
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_str` parses AND validates the DAG (`edda/crates/runa/src/lib.rs:93`);
  it's the same call `crates/dwarves` uses (`runa_backend.rs:484`).
- `PipelineView::from_adapter` is the exact constructor `pipeline.rs:89` already 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.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
+ 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 `| tail` mask 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 — the `robotui watch`/kittest legs are the headless proof
(testable-code + robot-tested-UI LAW).

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

- **facett-app is a shared-dep repo mid-build** (READ-ONLY note in the task). The plan
  consumes facett-app's CURRENT `scene` API (verified against
  `facett-app/src/scene.rs`); if facett-app lands further scene changes before this runs,
  re-verify §2/§4 API names before editing.
- **edda `runa` path** — confirm `../../edda/crates/runa` resolves from `dwarves-gui`
  (canonical edda is `/var/run/media/rickard/T9/git/edda`). If the workspace root's
  `edda/` 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 intended
  `ConvergeStatus`; verify with a quick unit assert before the robot test.
- **CLI half of #28** (`dwarves` emitting a `ReleaseConvergence` from 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
  in `dwarves-gui` (the `publish = false` demo crate).
```