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

nordisk/dwarves

dwarves-gui/src/build_scene.rs

//! **⚒ The build-thing scene** — a build surface that knows no product by name.
//!
//! The pane this replaces was ~3.6k lines of render plus ~1k of test, and it knew
//! products: which cell exports a remote desktop, which one boots a graphical
//! framebuffer, which one is only a scaffold. Every one of those was an `if` over a
//! name, and every new delivery target added another.
//!
//! Here the recipe answers with **data** and the scene renders it. The whole switch is
//! [`PaneKind::for_artifact`] — three arms, no product mentioned:
//!
//! ```text
//! ArtifactKind::Exe          -> PaneKind::Exec      argv + stdout, run IN A CONTAINER
//! ArtifactKind::Container    -> PaneKind::Log       start/stop + log + ports
//! ArtifactKind::Iso | ::Vm   -> PaneKind::Console   run/reboot + serial AND graphical,
//!                                                   both shown, never a toggle
//! ```
//!
//! # The flow, one field and one selection at a time
//!
//! ```text
//! 1  type an ARTIFACT NAME    the code resolves the recipe itself
//! 2  the home matrix appears  every row that recipe declares
//! 3  pick ONE row             one at a time
//! 4  pick ONE column          the delivery targets that row declares
//! 5  BUILD                    -> a pane appears BELOW, dynamically
//! ```
//!
//! # What a feature costs, BEFORE the build
//!
//! Between step 4 and step 5 sits the picture that makes step 5 a decision: click a
//! package away and see which feature goes dark, switch a feature off and see which
//! packages vanish. Both directions are the same connection read from opposite ends
//! and share one model ([`Axis`], drawn by one pane), so they cannot drift apart —
//! two tables that must agree is one table too many.
//!
//! Steps 3 and 4 are single-selection *because* step 5 needs an unambiguous subject.
//! Picking a second row therefore clears the column, the outcome and the pane
//! ([`BuildScene::pick_row`]) rather than leaving a pane below that belongs to a row
//! nobody is looking at any more.
//!
//! # Two entry points, ONE state
//!
//! A name is resolved to a recipe; a [`Coordinate`] names the recipe outright, for the
//! case where no artifact exists yet to read a receipt out of. Both land in the same
//! [`BuildScene::menu`], so nothing downstream can tell which door was used — there is
//! one state machine here, not two.
//!
//! # What is deliberately absent
//!
//! No process is started from this file. Every verb is an [`Act`] handed to a
//! [`Forge`], which is the seam a recipe crate plugs into. That is what keeps the
//! product knowledge on the other side of the trait, and it is what
//! `tests/robot_build_scene.rs` measures: the scene's own source, tokenised, may not
//! contain a single product name.

use std::collections::{BTreeMap, BTreeSet};

use dwarves::build_thing::BuildReport;
use dwarves::feature_axis_source::{axis_road, AxisRoad, DerivedAxis};
use egui::{Color32, RichText, Ui};
use facett_app::widgets::CopyableBlock;
use facett_buildthing::galois_pane::GaloisPane;
use facett_core::clip::{put_typed, ClipPayload};
use facett_core::{Facet, Severity};
use nornir_build_thing::contract::{Coordinate, PaneKind, Receipt};
use nornir_build_thing::options::OptionSpec;
use nornir_build_thing::params::{FixtureKind, ParamKind, ParamSpec, FIXTURE_VALUE};
use nornir_build_thing::receipt_proof::ReceiptVerdict;
use nornir_build_thing::tree::{
    Edge, SettingsTree, ToggleDecision, ToggleRequest, KERNEL_PATH, PATH_SEP,
};
use nornir_build_thing::variant::ArtifactKind;
use nornir_build_thing::verbs::Action;
use serde_json::{json, Value};

use crate::dag_graph::DagPane;
use crate::settings_tree::{NodeView, Standing, TreeView};

/// The stable dev-id chip this scene shows, and the `facet-<component>-<n>` component
/// its errors carry.
pub const COMPONENT: &str = "buildscene";

/// How often the pane asks for a frame while a build, a boot or a dependency graph is
/// still running somewhere else. See [`Facet::ui`] for what it cost to not have it.
const WORKING_REPAINT: std::time::Duration = std::time::Duration::from_millis(120);

// ── what a recipe answers with ────────────────────────────────────────────────

/// Which door named the recipe. Both resolve into the same [`Menu`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Query {
    /// An artifact name, typed. The forge finds the recipe that declares it.
    Artifact(String),
    /// The recipe itself, addressed. No artifact need exist yet.
    At(Coordinate),
}

/// One declared setting of a target, already resolved.
///
/// `fixed` is the difference between a value the operator may change and one the
/// recipe decided. Both are shown: a setting that is missing from the screen is a
/// setting that ends up in the receipt without anyone having seen it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Setting {
    /// The receipt key this becomes, e.g. `gpu`.
    pub key: String,
    /// The resolved value.
    pub value: String,
    /// The recipe decided it; the scene renders it read-only.
    pub fixed: bool,
    /// Why it is fixed, when saying so helps ("declared by the recipe").
    pub note: Option<String>,
    /// **The declared shape**, when this setting came from a cell's
    /// `[[build.installer.params]]` declaration rather than a cargo axis.
    ///
    /// `None` for every cargo axis, which carries no such shape. Present, the scene
    /// renders the declaration's own label, validates against its kind, and refuses
    /// to build without a required value — reading all three out of the SAME
    /// `ParamSpec` the CLI door reads, so the pane and `<forge> buildthing
    /// --installer --param` cannot drift into asking for different things.
    pub declared: Option<DeclaredParam>,
    /// **This setting IS an opt-out toggle** the recipe declared
    /// (`[[build.installer.options]]`), rather than a value. `None` for every
    /// other setting, including the cargo axis's own `open` switches — those
    /// flip a feature and nothing else, while an option ALSO decides which
    /// parameters below it apply and what gets baked onto the cmdline.
    pub declared_option: Option<DeclaredOption>,
    /// **This setting is a parameter an opt-out GOVERNS**: it belongs to one
    /// state of one toggle, and has no meaning in the other.
    ///
    /// Held here rather than recomputed in the scene because the rule is the
    /// recipe's — `OptionSpec::params_when_on` / `params_when_off`, read once
    /// where the settings are built. The scene only compares the toggle's
    /// CURRENT value against `shown_when`, so the pane, the CLI door and the
    /// forge cannot drift into disagreeing about which inputs a build needs.
    pub governed: Option<Governed>,
    /// **The closed set of values this setting may take.** Empty for every
    /// free-text setting and every toggle, which is nearly all of them.
    ///
    /// A setting whose vocabulary is a fixed list is neither a text field (four
    /// spellings, three of them typos) nor a checkbox (there are four of them,
    /// not two). The scene draws the list and knows nothing about what the words
    /// mean; the forge decides what is in it and what each choice COSTS, and
    /// carries that cost in [`Choice::note`] so the consequence of a pick is on
    /// screen beside the pick rather than derived a second time somewhere else.
    pub choices: Vec<Choice>,
}

/// One value a [`Setting`] may take, with what taking it means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
    /// The value written into the settings map, verbatim.
    pub value: String,
    /// What this choice resolves to downstream, in the forge's own words. Shown
    /// only for the choice currently taken — four consequences beside four
    /// buttons is a wall nobody reads. Empty renders nothing.
    pub note: String,
}

impl Choice {
    /// A choice with its consequence.
    #[must_use]
    pub fn new(value: impl Into<String>, note: impl Into<String>) -> Self {
        Self {
            value: value.into(),
            note: note.into(),
        }
    }
}

/// An opt-out the recipe declares, carried into the scene verbatim.
///
/// The scene knows no capability by name. `dhcp` and `wireguard` appear here
/// because a recipe declares them, exactly as `hub_conf` appears because a
/// recipe declares that — a product that declares no options renders no
/// checkboxes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredOption {
    /// What the checkbox says — the declaration's own words.
    pub label: String,
    /// The cargo feature this toggle decides. OFF means the app binary is BUILT
    /// without it, so the code is not in the artifact at all; shown in the
    /// feature-delta line so an operator can see what the tick actually costs.
    pub feature: String,
}

/// Which toggle, and in which of its states, a parameter belongs to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Governed {
    /// The option's key.
    pub option: String,
    /// The state of that option in which this parameter APPLIES. A parameter
    /// shown in the other state would be an input nothing on the medium could
    /// read.
    pub shown_when: bool,
}

/// A parameter the recipe DECLARES, carried into the scene verbatim.
///
/// This is what keeps the pane data-driven. A hardcoded "WireGuard hub key" text
/// box would break [`BuildScene`]'s founding rule — the scene knows no product by
/// name — and would drift the moment a product declared a fifth parameter or
/// renamed one. A product that declares no parameters renders no inputs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredParam {
    /// What a human is asked for — the declaration's own words.
    pub label: String,
    /// The value's declared shape, which decides the validation.
    pub kind: ParamKind,
    /// A build without this value must REFUSE, and say which one is missing.
    pub required: bool,
    /// **The fixture the recipe declares for this parameter**, by its kind's
    /// label (`wg-hub`), when it declares one (`[build.installer.fixtures]`).
    /// The scene offers an "använd fixtur" toggle ONLY where this is `Some`;
    /// choosing it supplies the same `@fixture` word the CLI supplies, and the
    /// forge mints the fixture at build time. The scene knows no fixture by
    /// name — it renders whatever label the declaration carries.
    pub fixture: Option<String>,
    /// The value is a FILE's contents (`kind = "text"` landing as a
    /// `staged_file`, e.g. a hub.conf) — rendered as a multi-line editor, since
    /// a multi-line file in a single-line field was the 08-16 friction.
    pub multiline: bool,
}

impl DeclaredParam {
    /// Is `value` acceptable for the declared [`kind`](Self::kind)? The message is
    /// the operator's, so it names the shape rather than the rule's implementation.
    ///
    /// Empty is judged by [`required`](Self::required), not here: "you left it
    /// blank" and "that is not a key" are different sentences, and merging them
    /// tells an operator who typed nothing that their nothing is malformed.
    pub fn check(&self, value: &str) -> Result<(), String> {
        let v = value.trim();
        if v.is_empty() {
            return if self.required {
                Err(format!("{} is required", self.label))
            } else {
                Ok(())
            };
        }
        // `@fixture` is legal exactly where the recipe declares a fixture; the
        // forge mints the value at build time, so there is no text to shape-check
        // here. Anywhere else it is refused BY NAME — the recipe, not the scene,
        // decides where a fixture exists.
        if v == FIXTURE_VALUE {
            return match &self.fixture {
                Some(_) => Ok(()),
                None => Err(format!(
                    "{}: {FIXTURE_VALUE} begärd men receptet deklarerar ingen fixtur för den",
                    self.label
                )),
            };
        }
        match self.kind {
            ParamKind::Text => Ok(()),
            // 32 bytes base64-encoded is 44 chars with exactly one '=' of padding —
            // the spelling every WireGuard public key has.
            ParamKind::Base64Key => {
                let ok = v.len() == 44
                    && v.ends_with('=')
                    && v[..43]
                        .bytes()
                        .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
                ok.then_some(()).ok_or_else(|| {
                    format!(
                        "{}: expected a 44-character base64 key, got {} characters",
                        self.label,
                        v.len()
                    )
                })
            }
            ParamKind::Endpoint => v
                .parse::<std::net::SocketAddrV4>()
                .map(|_| ())
                .map_err(|_| format!("{}: expected <a.b.c.d>:<port>", self.label)),
            // The contract crate, 0.1.8. Declarable only through an installer
            // option's `param_kinds` table (a `[[…params]]` row may not carry the
            // spelling — `ParamKind` is a closed enum in the published 0.1.7 loader
            // every parity mirror reads through), which is where the DHCP-off static
            // address gets its shape.
            ParamKind::Ipv4 => v
                .parse::<std::net::Ipv4Addr>()
                .map(|_| ())
                .map_err(|_| format!("{}: förväntade en IPv4-adress <a.b.c.d>", self.label)),
            // The SINGULAR of `CidrList`: an interface takes exactly one address, so a
            // comma is refused rather than silently taking the head.
            ParamKind::Ipv4Cidr => {
                if v.contains(',') {
                    return Err(format!(
                        "{}: ett gränssnitt tar exakt en adress — ta bort kommat",
                        self.label
                    ));
                }
                let Some((addr, prefix)) = v.split_once('/') else {
                    return Err(format!("{}: {v:?} saknar /<prefix>", self.label));
                };
                if addr.parse::<std::net::Ipv4Addr>().is_err() {
                    return Err(format!("{}: {addr:?} är ingen IPv4-adress", self.label));
                }
                if !prefix.parse::<u8>().is_ok_and(|p| p <= 32) {
                    return Err(format!(
                        "{}: prefix {prefix:?} måste vara 0..=32",
                        self.label
                    ));
                }
                Ok(())
            }
            ParamKind::CidrList => {
                for part in v.split(',') {
                    let part = part.trim();
                    let Some((addr, prefix)) = part.split_once('/') else {
                        return Err(format!("{}: {part:?} is missing /<prefix>", self.label));
                    };
                    if addr.parse::<std::net::Ipv4Addr>().is_err() {
                        return Err(format!("{}: {addr:?} is not an IPv4 address", self.label));
                    }
                    if !prefix.parse::<u8>().is_ok_and(|p| p <= 32) {
                        return Err(format!("{}: prefix {prefix:?} must be 0..=32", self.label));
                    }
                }
                Ok(())
            }
        }
    }
}

impl Setting {
    /// A value the operator may change.
    #[must_use]
    pub fn open(key: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            key: key.into(),
            value: value.into(),
            fixed: false,
            note: None,
            declared: None,
            declared_option: None,
            governed: None,
            choices: Vec::new(),
        }
    }

    /// **A parameter the recipe DECLARED** — the site value an operator types.
    ///
    /// Open by construction (a declared parameter the recipe decided would be a
    /// fixed setting, not a parameter), and carrying the declaration's own label,
    /// kind and required-ness so the scene renders what the recipe said rather than
    /// what the UI guessed.
    ///
    /// `fixture` is what the recipe declares for this name in its cell-level
    /// `[build.*.fixtures]` table — `None` for most parameters, which renders no
    /// fixture affordance at all.
    #[must_use]
    pub fn param(spec: &ParamSpec, fixture: Option<FixtureKind>) -> Self {
        Self {
            key: spec.name.clone(),
            value: String::new(),
            fixed: false,
            note: None,
            declared: Some(DeclaredParam {
                label: if spec.label.trim().is_empty() {
                    spec.name.clone()
                } else {
                    spec.label.clone()
                },
                kind: spec.kind,
                required: spec.required,
                fixture: fixture.map(|f| f.label().to_string()),
                multiline: spec.kind == ParamKind::Text && !spec.staged_file.trim().is_empty(),
            }),
            declared_option: None,
            governed: None,
            choices: Vec::new(),
        }
    }

    /// **An opt-out the recipe DECLARED** — a checkbox, ticked to whatever the
    /// declaration's `default` says.
    ///
    /// An opt-out defaults ON, and that default is the recipe's rather than the
    /// scene's: a pane that started every capability unticked would quietly
    /// offer to ship an appliance stripped of things nobody asked to remove.
    #[must_use]
    pub fn option(spec: &OptionSpec) -> Self {
        Self {
            key: spec.name.clone(),
            value: spec.default.to_string(),
            fixed: false,
            note: None,
            declared: None,
            declared_option: Some(DeclaredOption {
                label: spec.display_label().to_string(),
                feature: spec.feature.clone(),
            }),
            governed: None,
            choices: Vec::new(),
        }
    }

    /// A value the recipe decided, with the reason shown beside it.
    #[must_use]
    pub fn fixed(
        key: impl Into<String>,
        value: impl Into<String>,
        note: impl Into<String>,
    ) -> Self {
        Self {
            key: key.into(),
            value: value.into(),
            fixed: true,
            note: Some(note.into()),
            declared: None,
            declared_option: None,
            governed: None,
            choices: Vec::new(),
        }
    }

    /// **A value the operator picks from a closed list** — open, like every
    /// other control that reaches [`Act::Build`], and carrying the whole
    /// vocabulary so the scene never has to invent one.
    ///
    /// This exists because the alternative shapes both lie. A `fixed` setting
    /// showing the recipe's answer is a LABEL, and a label is what the ⚒ cell
    /// showed about a kernel for as long as the configurator lived in another
    /// tab: four true words the operator could not act on. A free text field
    /// accepts three spellings that the door then refuses by name.
    ///
    /// # Panics
    ///
    /// Never — but a caller handing an empty list gets a setting that renders as
    /// a value nobody can change, which is a `fixed` one written the long way.
    #[must_use]
    pub fn choice(key: impl Into<String>, value: impl Into<String>, choices: Vec<Choice>) -> Self {
        Self {
            key: key.into(),
            value: value.into(),
            fixed: false,
            note: None,
            declared: None,
            declared_option: None,
            governed: None,
            choices,
        }
    }

    /// Is this setting VISIBLE given the toggles the operator has set?
    ///
    /// A parameter governed by an opt-out disappears in the toggle's other
    /// state. Greying it would be wrong for the same reason a greyed column is
    /// RIGHT: the column strip is a MAP of every delivery target the repo has,
    /// while this block is the argv the build will run with. With DHCP on there
    /// is no static address to be had in any state of this build, so a field for
    /// one has no business here — and a value left behind in a hidden field
    /// would be forwarded to a door that refuses it BY NAME.
    #[must_use]
    pub fn visible_in(&self, toggles: &BTreeMap<String, bool>) -> bool {
        match &self.governed {
            None => true,
            Some(g) => toggles.get(&g.option).copied().unwrap_or(true) == g.shown_when,
        }
    }

    /// True when this reads as a two-state value, so the scene draws a checkbox
    /// instead of a text field. Decided by the VALUE, never by the key's name.
    #[must_use]
    pub fn is_toggle(&self) -> bool {
        matches!(self.value.as_str(), "true" | "false")
    }
}

/// One delivery target of one row — a column of the home matrix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
    /// The column label the recipe chose, e.g. `exe`, `iso-installer`.
    pub name: String,
    /// What comes out. This, and nothing else, decides the pane.
    pub kind: ArtifactKind,
    /// A recipe may override the pane its kind would get. The override is the
    /// exception, never the interface — see [`Target::pane`].
    pub pane: Option<PaneKind>,
    /// The recipe declares this cell. A column that is not declared is still SHOWN,
    /// greyed: a target that silently disappears reads as a target that does not
    /// exist, and the matrix is supposed to be the map.
    pub declared: bool,
    /// The declared settings, already resolved.
    pub settings: Vec<Setting>,
    /// **The variant this column BUILDS when it is not the row itself** — the declared
    /// delivery variant behind an `installer` / `iso` / `container` column
    /// (`<x>-installer` on the `<x>-server` row). `None` for the row's own kind.
    /// The build graph's node for the column, and the consumption edge from it to the
    /// row it packs, are minted from this.
    pub variant: Option<String>,
}

impl Target {
    /// **The whole switch.** The kind decides; a recipe may override.
    #[must_use]
    pub fn pane(&self) -> PaneKind {
        self.pane
            .unwrap_or_else(|| PaneKind::for_artifact(self.kind))
    }
}

/// One row of the home matrix: an atom the recipe builds, with its columns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    /// The atom's name, as the recipe spells it.
    pub name: String,
    /// Free-form marks the recipe attaches (role, linkage, whether it is the
    /// default). The scene renders them and reads none of them.
    pub badges: Vec<String>,
    /// The delivery targets, in the recipe's column order.
    pub targets: Vec<Target>,
}

/// The home matrix, and the recipe it came out of.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Menu {
    /// Which recipe answered.
    pub coordinate: Coordinate,
    /// How the recipe was found — shown beside the coordinate so the operator can
    /// tell a resolved name from a typed address.
    pub how: String,
    /// Every row that recipe declares.
    pub rows: Vec<Row>,
}

/// What a finished build produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
    /// The build succeeded.
    pub ok: bool,
    /// The artifact, as the thing that produced it names it — a path for a file, an
    /// image reference for a store.
    pub artifact: String,
    /// Size, when the artifact is a file that can be stat-ed.
    pub size_bytes: Option<u64>,
    /// Content hash, when one was taken.
    pub sha: Option<String>,
    /// The receipt written into the artifact, when the writer ran.
    pub receipt: Option<Receipt>,
    /// The receipt's claims, measured against the artifact's own symbol table.
    ///
    /// `None` only when there was no artifact to measure. A receipt that could not
    /// be read is a [`Proof`] with a verdict saying so, never an absent one — the
    /// design's third level is *proven*, and an audit that quietly disappears is
    /// the same as one that always passes.
    pub proof: Option<Proof>,
    /// Wall clock.
    pub elapsed_ms: u64,
    /// Ports the recipe declared for this cell. The [`PaneKind::Log`] pane offers them.
    pub ports: Vec<u16>,
    /// One line of extra context the recipe wants shown (a base image, a format).
    pub note: Option<String>,
    /// **The per-site parameter record the build APPLIED**, one line each, as
    /// the forge spells it (`hub_conf ← fixture wg-hub (…)`, `site = stockholm-7`).
    /// Empty for a cargo build. A fixture-filled value is never silent here.
    pub applied: Vec<String>,
    /// **Which cargo features built it** — the build's own record (declared +
    /// chosen, and the two flags), as `BuildReport::features_line` spells it.
    /// `None` for a cell that is no cargo build.
    pub features: Option<String>,
    /// **Where this build's provenance record landed** — the one line the report
    /// already writes, verbatim.
    ///
    /// ── WHY THIS ROW EXISTS ───────────────────────────────────────────────
    ///
    /// The records pane showed EMPTY four separate times because no producer ever
    /// submitted a provenance record. The producer is wired now (MEASURED
    /// 2026-08-24: three `build_provenance` parquet files in the served warehouse,
    /// against zero before), and the submit receipt was written so that a submit
    /// "must never be invisible" — its own words. But this pane never read it:
    /// [`Outcome::from_report`] took the report and dropped its warehouse receipt
    /// on the floor, and grepping this file and its forge for `warehouse`
    /// answered with nothing at all. So the report's own line — documented as
    /// "the one line the pane/log shows" — reached the command line and never the
    /// pane.
    ///
    /// What that cost the operator: press BYGG on a box with no server address in
    /// the environment, get a green build, and the record goes NOWHERE with the
    /// screen saying nothing about it. A build filed nowhere rendered identically
    /// to one filed — the same blindness as the four empty rounds, seen from the
    /// producing end instead of the reading end.
    pub warehouse_line: String,
    /// A server WAS configured and the record nonetheless did not land — the
    /// report's own keyed error. Its own red line, deliberately not folded into
    /// [`ok`](Self::ok): a compile that produced a binary is a real compile even
    /// when the record missed.
    pub warehouse_red: bool,
    /// Rows genuinely landed.
    ///
    /// NOT the negation of [`warehouse_red`](Self::warehouse_red), and that is the
    /// whole point of carrying both: a build that never tried (no endpoint) is
    /// `false` here and `false` there, and it is a THIRD state that must not be
    /// coloured like either of the other two.
    pub warehouse_landed: bool,
}

impl Outcome {
    /// **Fold a finished [`BuildReport`] into the row the pane
    /// draws** — the ONE place that decides which file the word "artefakt" names.
    ///
    /// It lives here, pure and beside the struct it builds, because it used to be
    /// inline in `BuildForge::settle` and was therefore reachable only by running a
    /// real build. A pick nothing can test is a pick that goes wrong quietly, and it
    /// did:
    ///
    /// > MEASURED live 2026-08-20 (stinky `kopiera-5`): after KLAR the pane read
    /// > `artefakt /boot/vmlinuz-7.0.0-29-generic 16.5 MB`, while the thing that had
    /// > just been built was the cell's own `.iso-out/<variant>.iso` at 32 MB.
    ///
    /// An iso-kind build lists its provenance INPUTS first — kernel, chained
    /// payload, then the mastered image — a splice order that is load-bearing for
    /// the attestation and is not reordered here. `artifacts.first()` therefore
    /// names an input. [`BuildReport::medium`] is the
    /// kind-aware pick, and it is the same one the headline and the ▶ boot read, so
    /// the row, the CLI line and QEMU's `-cdrom` cannot disagree about what was
    /// built.
    ///
    /// `size_bytes` comes off the SAME entry as the path. Taking the path from one
    /// artifact and the size from another is exactly how the live report managed to
    /// show a 16.5 MB figure beside a 32 MB file.
    #[must_use]
    pub fn from_report(
        report: &BuildReport,
        ports: Vec<u16>,
        receipt: Option<Receipt>,
        proof: Option<Proof>,
    ) -> Self {
        let medium = report.medium().cloned();
        Self {
            ok: report.is_green(),
            // A kind whose product is not a file has no medium on disk; then the
            // produced reference (an image ref) IS the artifact. Never a plausible
            // path invented to fill the row.
            artifact: medium
                .as_ref()
                .map(|(p, _)| p.display().to_string())
                .or_else(|| report.produced_ref.clone())
                .unwrap_or_default(),
            size_bytes: medium.map(|(_, n)| n),
            sha: report
                .produced_ref
                .as_ref()
                .and_then(|r| r.split_once("sha256:").map(|(_, h)| h.to_string())),
            receipt,
            proof,
            elapsed_ms: report.elapsed_ms,
            ports,
            note: report.setup_error.clone(),
            applied: report.applied_params.clone(),
            features: report.features_line(),
            // Read off the SAME writer the CLI reads, never re-worded here: three
            // states with three sentences, and this pane only chooses the colour.
            warehouse_line: report.warehouse_line(),
            warehouse_red: report.warehouse_error_code().is_some(),
            warehouse_landed: report.warehouse.as_ref().is_some_and(|r| r.landed()),
        }
    }
}

/// **A node's note, whole.** Indented under its row and WRAPPED at the panel's width —
/// never one clipped line of the row's horizontal layout.
pub(crate) fn node_note(ui: &mut Ui, text: &str, depth: usize) {
    ui.horizontal_top(|ui| {
        ui.add_space(depth as f32 * 18.0 + 18.0);
        ui.add(egui::Label::new(RichText::new(text).small().color(AMBER)).wrap());
    });
}

/// **The third level.** What the artifact's own symbol table says about the claims
/// its receipt makes.
///
/// ```text
/// declared    the operator picked the pattern
/// validated   the recipe said the pattern is legal
/// proven      the symbol table of the built binary shows it came out that way
/// ```
///
/// Only [`ReceiptVerdict::Proven`] passes. `NotMeasured` — a stripped binary, an
/// artifact that is not an object file at all — is its own outcome and is shown as
/// one, because an unchecked claim is not a verified claim and folding the two
/// together is what makes a guard green for ever.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Proof {
    /// The verdict, as the audit reached it.
    pub verdict: ReceiptVerdict,
    /// The audit's own sentences: a headline, then one per measured claim.
    pub lines: Vec<String>,
}

impl Proof {
    /// Whether the artifact backs up its own receipt.
    #[must_use]
    pub fn holds(&self) -> bool {
        self.verdict.holds()
    }

    /// The one word the pane shows beside `bevis`.
    #[must_use]
    pub fn label(&self) -> &'static str {
        match self.verdict {
            ReceiptVerdict::Proven => "BEVISAT",
            ReceiptVerdict::Disproven => "MOTSAGT",
            ReceiptVerdict::NoReceipt => "INGET KVITTO",
            ReceiptVerdict::MalformedReceipt => "TRASIGT KVITTO",
            ReceiptVerdict::NothingProvable => "INGET ATT BEVISA",
            ReceiptVerdict::NotMeasured => "EJ MÄTT",
        }
    }
}

/// A frame of a graphical console. Never synthesised: a console with no frame yet
/// says so.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Screen {
    /// Width in pixels.
    pub width: usize,
    /// Height in pixels.
    pub height: usize,
    /// Row-major RGBA8, `width * height * 4` bytes.
    pub rgba: Vec<u8>,
}

/// Where the forge is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
    /// Nothing has been asked for.
    Idle,
    /// A build is in flight.
    Building,
    /// A build finished; see [`Outcome::ok`].
    Done,
    /// A build could not be started or did not finish. Carries the reason verbatim.
    Failed(String),
}

impl Phase {
    /// The label the result header shows.
    #[must_use]
    pub fn label(&self) -> &'static str {
        match self {
            Phase::Idle => "VILAR",
            Phase::Building => "BYGGER",
            Phase::Done => "KLAR",
            Phase::Failed(_) => "FEL",
        }
    }
}

/// Everything the scene draws about work in flight or finished. Polled once a frame.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Report {
    /// The build's phase.
    pub phase: Option<Phase>,
    /// Wall clock of the build.
    pub elapsed_ms: u64,
    /// The finished build, if there is one.
    pub outcome: Option<Outcome>,
    /// The pane's subject is up: a process, a container, a guest.
    pub live: bool,
    /// stdout / log / serial, cumulative.
    pub lines: Vec<String>,
    /// The graphical console's latest frame, when it has one.
    pub screen: Option<Screen>,
    /// An honest reason the pane cannot do what it offers — a missing runtime, a
    /// feature not compiled in. Shown instead of a fabricated result.
    pub note: Option<String>,
    /// **The matrix, re-answered.** A forge whose answer to the standing query changed
    /// under the scene — the recipe crate's wasm landed and the CODE now answers where
    /// the table did — hands the new matrix up here, once. The scene swaps it in and
    /// keeps the operator's row, column and edits by name. `None` on every other frame.
    pub menu: Option<Menu>,
}

/// **What the dependency picture is a picture OF.**
///
/// The row alone is not enough, and that was the bug. MEASURED 2026-08-22: the
/// picture was derived from the picked ROW's own cargo features, so an
/// INSTALLER row — which declares none, because the thing it builds is the
/// appliance it packs — resolved to `Axis::Failed("… bygger utan features …")`
/// and drew nothing, while the app row beside it drew fine. Moving between the
/// two is what "the picture sometimes flickers in" actually was.
///
/// It is also not enough to be honest: a picture of the DECLARED feature set on
/// a build whose opt-outs subtract from it shows parts the artifact will not
/// contain. So the pick carries all three, and the scene re-asks whenever any
/// of them changes.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AxisPick {
    /// The picked row.
    pub row: String,
    /// The picked column, when one is picked. An installer/iso column makes the
    /// picture about the variant that column PACKS.
    pub target: Option<String>,
    /// The opt-outs as they stand. A feature switched off is subtracted before
    /// the picture is derived.
    pub toggles: BTreeMap<String, bool>,
    /// **The crates whose minimal-cause hunt the operator has actually asked for.**
    ///
    /// Part of the PICK, not a side channel, and that is the whole mechanism: the
    /// scene re-asks whenever the pick changes, so clicking "hunt this one" is the
    /// same event as picking a different row. The forge spends its budget on exactly
    /// this set — see the axis source's own `AxisRequest::focus`.
    ///
    /// Empty is the normal state of a fresh pick: no hunt, and the press lands.
    pub focus: BTreeSet<String>,
}

/// What the scene asks the forge to do. The scene runs nothing itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Act {
    /// Step 5. The row and column are named, never indexed: an index is meaningless
    /// to whoever implements this.
    Build {
        /// The picked row's name.
        row: String,
        /// The picked column's name.
        target: String,
        /// The settings as they stand on screen, resolved.
        settings: BTreeMap<String, String>,
    },
    /// [`PaneKind::Exec`]: run the artifact with this argv.
    Run(String),
    /// [`PaneKind::Log`] / [`PaneKind::Console`]: bring the subject up.
    Start,
    /// Take it down.
    Stop,
    /// [`PaneKind::Console`]: power-cycle it.
    Reboot,
}

/// **What the forge has for a row's dependency axis** — the picture of what a
/// feature costs, asked for BEFORE anything is built.
///
/// Four states and no fifth, because the three ways of having no picture are three
/// different sentences and an empty graph renders identically to all of them:
/// "this row has no axis", "it is being worked out" and "it could not be derived,
/// here is why" are what an operator has to be able to tell apart.
///
/// [`Working`](Self::Working) is the only non-terminal one. Deriving an axis means
/// resolving a whole lockfile universe and then hunting minimal causes over it —
/// measured in tens of seconds on this constellation — so the forge answers with it
/// immediately and the scene keeps asking. Doing that work on the frame that asked
/// for it would be a frozen window, not a picture.
#[derive(Debug, Clone)]
pub enum Axis {
    /// This forge derives no axis. Not a failure: a forge that has no manifests to
    /// read has nothing to say, and saying nothing is the honest answer.
    Absent,
    /// Being derived. Terminal only when it is not.
    Working,
    /// Ready — the picture, PLUS the removable crates whose minimal-cause hunt was
    /// not paid for. The second half is not a detail: an unhunted crate is absent
    /// from the graph, and absent reads as "not in the build" unless something says
    /// otherwise.
    Ready(Box<DerivedAxis>),
    /// It could not be derived, and this is the reason, verbatim.
    Failed(String),
}

/// **An extra surface the FORGE draws inside the pane** — the seam that lets a
/// product-shaped configurator live in ⚒ without ⚒ learning what it configures.
///
/// The scene owns the frame (a named, foldable, resizable section under the
/// settings block) and the forge owns everything inside it. That division is the
/// same one the rest of this file keeps: the scene knows there is a leaf, its
/// title, and that its state folds into `state_json`; it does not know that any
/// particular leaf exists.
///
/// Three answers and no fourth, for the reason [`Axis`] has four: an absent leaf,
/// a leaf that cannot be shown, and a leaf. **A refusal is not an empty frame** —
/// "this selection has no such configurator" and "it has one and here is why it
/// is not up" are different sentences, and a blank pane says neither.
pub enum Leaf<'a> {
    /// This selection has no such surface at all. Nothing is drawn.
    None,
    /// It would have one, and this is why it is not there — verbatim, in the
    /// forge's own words.
    Refused(String),
    /// The surface. Drawn by the scene, owned by the forge.
    Pane(&'a mut dyn Facet),
}

// There used to be a `BROWSE_NONE_HINT` const here — the kernel leaf's "pick a recipe"
// sentence for the nothing-resolved state. It is GONE because the state it decorated
// is gone from the screen: the kernel leaf does not EXIST until the resolved recipe
// declares a `[[build.kernel]]` ([`Forge::kernel_declared`] → `LeafPanel::leaf_absent`
// → the scene's runtime presence), so there is no region for the sentence to sit in.
// Dead text left standing would be read as the running layout — the exact failure
// `build_thing_scene.rs`'s docs record about `body()`'s callerless years.

/// **The seam a recipe plugs into.** Everything product-shaped is on the far side.
pub trait Forge {
    /// Steps 1–2: a name, or a coordinate, becomes a home matrix.
    ///
    /// # Errors
    ///
    /// The reason, in words, when nothing answers to the query. The scene shows it
    /// verbatim — a resolver that fails silently is indistinguishable from an empty
    /// product.
    fn resolve(&mut self, query: &Query) -> Result<Menu, String>;

    /// **The picked selection's feature↔dependency axis**, for the picture drawn ABOVE
    /// the build button.
    ///
    /// Per ROW, because a row is an assembly: what a feature costs depends on which
    /// assembly you are carving, and one axis for the whole repo would answer a
    /// question nobody asked.
    ///
    /// Called every frame until it answers something terminal, so an implementation
    /// must be cheap on the repeat calls — start the work on the first one, hand
    /// back [`Axis::Working`], and answer from the finished result afterwards.
    ///
    /// No default implementation on purpose. A defaulted `Axis::Absent` would let a
    /// forge that MEANT to derive one silently stop, and the scene cannot tell that
    /// apart from a forge that never had one.
    fn axis(&mut self, pick: &AxisPick) -> Axis;

    /// **The forge's own configurator for this selection**, drawn between the
    /// settings and the button — the place where the thing being chosen is
    /// chosen, rather than eleven tabs away.
    ///
    /// Called once a frame with the settings AS THEY STAND, which is what makes
    /// the leaf and the settings block one control instead of two: a forge that
    /// mirrors a settings value into its leaf here cannot show a different pick
    /// than the one [`Act::Build`] will carry.
    ///
    /// Defaulted to [`Leaf::None`] — unlike [`axis`](Forge::axis), because
    /// "this forge has no extra surface" is the honest answer for a forge that
    /// never had one, and there is no second story a default could hide. A forge
    /// that MEANT to draw one and stopped shows nothing, exactly as it should.
    #[allow(unused_variables)]
    fn leaf(&mut self, row: &str, target: &str, settings: &BTreeMap<String, String>) -> Leaf<'_> {
        Leaf::None
    }

    /// **The same configurator with NOTHING picked** — the standing question, not the
    /// selection's.
    ///
    /// [`leaf`](Forge::leaf) can only answer about a cell, so for as long as this seam
    /// was the only one the ⚒ configurator vanished the moment the selection cleared,
    /// and *"what is in a `slim` kernel"* had no home but a separate ⚙ tab. That is a
    /// real question — it is the one an operator has BEFORE they know which row they
    /// want — and one tab per un-selected question is not a design.
    ///
    /// The contract that makes hosting the same facet twice safe is that it is the SAME
    /// INSTANCE: an implementation must hand back the very pane [`leaf`](Forge::leaf)
    /// hands back, with its own controls turned ON because no host control is pointing
    /// at it this frame. Two instances would be two presets, and the operator would pick
    /// in one and build the other.
    ///
    /// Defaulted to [`Leaf::None`] for the same reason [`leaf`](Forge::leaf) is: "this
    /// forge has no standing surface" is honest, and there is no second story it hides.
    fn browse(&mut self) -> Leaf<'_> {
        Leaf::None
    }

    /// **Does the currently resolved recipe have a KERNEL ANSWER — a
    /// `[[build.kernel]]` of its own, or a donor's kernel bound by
    /// `[build.installer.kernel_donor]`?**
    ///
    /// This is the kernel leaf's PRESENCE, not its content: the ⚒ tree only grows a
    /// kernel region at all when this answers `true` (`LeafPanel::leaf_absent` → the
    /// scene's runtime presence). [`leaf`](Forge::leaf) and [`browse`](Forge::browse)
    /// answer *what the region shows* — the configurator for an own-kernel recipe, the
    /// donor NAMED (and where the choice is made) for a donor-consuming installer;
    /// a question about what a region should show is meaningless for a region that
    /// must not exist, which is why this is its own method and not inferred from those
    /// answers (calling `browse` to peek has side effects — it flips the
    /// configurator's chrome and builds the pane lazily).
    ///
    /// `false` with nothing resolved: no recipe is no declaration — the strict rule is
    /// *"the kernel leaf must not exist until the resolved recipe declares a
    /// `[[build.kernel]]`"* (owner, 2026-08-26, on seeing the pane frame before he
    /// had typed anything). `false` for a resolved recipe with neither declaration: a
    /// plain library gets a four-leaf ⚒. `true` for the donor road: the installer's
    /// medium BOOTS the donor repo's kernel, and a hidden leaf would call that "no
    /// kernel", which is false — the donor-naming surface IS that recipe's kernel
    /// answer. Defaulted `false` —
    /// "this forge builds no kernel" is the honest answer for a forge that never had
    /// one, the same reasoning as `leaf`/`browse`'s defaults.
    fn kernel_declared(&self) -> bool {
        false
    }

    /// **The settings TREE for this selection** — the model crate's
    /// [`SettingsTree`] (one recursive node per variant:
    /// feature ▸ setting ▸ val, paths as ids, the kernel donor as an edge), with the
    /// values AS THEY STAND folded in where the shape depends on them (the preset
    /// decides which kernel symbols exist).
    ///
    /// With a row and a column picked, the picked variant's tree. With a recipe resolved
    /// and nothing picked, the STANDING question's: the variant that carries the `kärna`
    /// feature — the recipe's own kernel, or the installer with the donor edge — or an
    /// empty tree when none does. `None` only with no recipe resolved.
    ///
    /// Defaulted to `None`: "this forge declares no settings" is the honest answer for a
    /// forge that never had any. Stage 2 of the model answers this from the loaded
    /// build thing's `options(path)` over wasm; today the forge derives it from the
    /// declaration through the model crate's `SettingsTree::of_set`.
    #[allow(unused_variables)]
    fn tree(
        &mut self,
        row: Option<&str>,
        target: Option<&str>,
        settings: &BTreeMap<String, String>,
    ) -> Option<SettingsTree> {
        None
    }

    /// Step 5, and every verb the pane below offers.
    /// **The judge for a feature click** — the model crate's `try_toggle` (cascade and contract
    /// lock included), answered by the loaded build thing over wasm when the code road
    /// is open, by the derived tree otherwise. Defaulted to a refusal that says so: a
    /// forge that never had a judge must not silently accept a click.
    fn try_toggle(&mut self, req: &ToggleRequest) -> ToggleDecision {
        ToggleDecision {
            path: req.path.clone(),
            on: req.on,
            verdict: crate::actions::refuse("den här smedjan har ingen domare för klick"),
            cascade: Vec::new(),
            refuses: Vec::new(),
            activates: Vec::new(),
        }
    }

    /// **The verbs a typ bears** — the model crate's `actions(kind)`: the loaded build thing's
    /// answer over wasm when the code road is open, the crate's own table
    /// (the model crate's table) otherwise. Data either way; the pane
    /// branches on nothing.
    fn actions(&mut self, kind: ArtifactKind) -> Vec<Action> {
        crate::actions::verbs_for(kind)
    }

    /// **The product word the CLI takes** for the resolved recipe — `<forge> buildthing
    /// <product>`, the same spelling line 119 uses — so the line the node menu shows is
    /// the line the prompt accepts.
    /// `None` when the forge cannot name it; the menu then spells `<produkt>`, never a
    /// guess.
    fn product(&self) -> Option<String> {
        None
    }

    /// **The `--param` line a variant's DECLARED parameters come to**, given the site
    /// values as they stand — `["admin_key=ssh-ed25519 AAAA…"]`, exactly as
    /// the `buildthing --param` flag spells them.
    ///
    /// Its own method rather than something the scene derives, because only the forge
    /// holds the cell that declares which settings ARE parameters and which toggle
    /// governs each one — an unticked WireGuard must drop a typed `hub_conf` rather than
    /// forward it to a door that would refuse it by name. It is asked twice on purpose
    /// and by ONE writer: once by the plan the click sends, once by the CLI line printed
    /// beside the button. A printed command missing the operator's own typed values is
    /// refused at the prompt for a required parameter that was in fact supplied.
    ///
    /// Defaulted to empty — "this forge declares no parameters" is honest for a forge
    /// that never had any, the same reasoning as `leaf`/`browse`.
    #[allow(unused_variables)]
    fn declared_params(
        &mut self,
        variant: &str,
        settings: &BTreeMap<String, String>,
    ) -> Vec<String> {
        Vec::new()
    }

    /// **`BYGG <kant>` — the chained build of an edge's build thing** (wave 3, gap #4):
    /// the donor as a step with the scoped `--opt` line, then the consumer, in DAG
    /// order, run by runa in the toolchain container. `Ok(())` when the run started;
    /// `Err` with the NAMED reason when it did not — a forge without the runner says
    /// [`crate::build_steps::missing_runner`], never a silent no-op. Defaulted to that
    /// refusal: "this forge has no chain runner" is the honest answer for one that
    /// never had it, and the step console shows the refusal on every step.
    ///
    /// # Errors
    /// The reason, verbatim, when nothing started.
    ///
    /// Answers the plan it ACTUALLY started, which is rarely the one it was handed:
    /// the click can only ever name two steps (this node's edge and the variant),
    /// while the chain is a DAG and the donor's donor is a step too. The console
    /// restages on the answer, so what is drawn is what runa was given.
    fn build_edge(
        &mut self,
        plan: &crate::build_steps::StepPlan,
    ) -> Result<crate::build_steps::StepPlan, String> {
        let _ = plan;
        Err(crate::build_steps::missing_runner())
    }

    /// **The chain this edge WOULD run — planned, nothing spawned.** The dry pass,
    /// the pane's `--list --build`: the donor checkouts resolved and type-checked,
    /// the opts routed, the order derived, and for a kernel the pin asked what it
    /// would do. Reads; never builds.
    ///
    /// Its own method rather than a flag on [`build_edge`](Forge::build_edge)
    /// because the difference is not a shade of the same act: a robot that wants to
    /// compare the two doors' plans must be able to ask WITHOUT starting a kernel
    /// build, and a parameter that decides whether minutes of compute happen is a
    /// parameter somebody will pass wrong.
    ///
    /// # Errors
    /// The named refusal — the same one `build_edge` would give, from the same
    /// planner, before anything spawns.
    fn plan_edge(
        &mut self,
        plan: &crate::build_steps::StepPlan,
    ) -> Result<crate::build_steps::StepPlan, String> {
        let _ = plan;
        Err(crate::build_steps::missing_runner())
    }

    /// **The chained build's steps as they stand** — one report per step the runner
    /// has touched, polled every frame while a `build_edge` runs. `None` when nothing
    /// is running (the default, and the answer of a forge without the runner).
    fn steps(&mut self) -> Option<Vec<crate::build_steps::StepReport>> {
        None
    }

    fn act(&mut self, act: Act);

    /// Called once a frame. Cheap; the forge caches.
    fn poll(&mut self) -> Report;
}

// ── the scene ─────────────────────────────────────────────────────────────────

/// **The consoles a [`PaneKind::Console`] pane shows — all of them, at once.**
///
/// Not a selector. During an install the serial says what is HAPPENING and the graphical
/// says what the USER SEES, and a toggle forces a choice between them in the one
/// situation where both are needed. Named here so a robot can assert that both are up
/// rather than infer it from two separate widgets.
pub const CONSOLES: [&str; 2] = ["serial", "graphical"];

/// The ⚒ build-thing scene.
pub struct BuildScene {
    /// The far side of the trait: where every product name lives.
    forge: Box<dyn Forge>,
    /// Step 1's field.
    artifact: String,
    /// The other door: a coordinate, typed.
    coordinate: String,
    /// The resolved home matrix. `None` until a query answers.
    menu: Option<Menu>,
    /// The last resolve failure, verbatim.
    error: Option<String>,
    /// The picked row. One at a time.
    row: Option<usize>,
    /// The picked column. One at a time, and cleared whenever the row changes.
    target: Option<usize>,
    /// Operator edits to the picked target's open settings, keyed by setting key.
    chosen: BTreeMap<String, String>,
    /// The [`PaneKind::Exec`] argv field.
    argv: String,
    /// The forge's latest answer.
    report: Report,
    /// The uploaded graphical frame. Not in `state_json` — a texture handle does not
    /// serialise, and the robot asserts the frame's dimensions instead.
    texture: Option<egui::TextureHandle>,
    /// **The two directions, one model.** Click a feature off and watch the packages
    /// it was holding up leave; click a package and watch the features that pull it
    /// go. Both are the same connection read from opposite ends, which is why there
    /// is one pane here and not two — a second table for the reverse direction is a
    /// second thing that can disagree with the first.
    galois: Option<GaloisPane>,
    /// The row [`Self::galois`] (or [`Self::axis_note`]) belongs to. While this is
    /// not the picked row, the scene keeps asking the forge; once it matches, the
    /// answer is terminal and the asking stops.
    axis_row: Option<AxisPick>,
    /// Why there is no picture, verbatim. An absent graph must never be a blank
    /// space: "still working" and "could not be derived" are different sentences.
    axis_note: Option<String>,
    /// **Which road the picture on screen came down**, classified the moment the
    /// axis lands.
    ///
    /// The graph has always carried its own provenance — but as ONE long sentence
    /// printed `.weak().small()` at the bottom of the pane, which is the same as not
    /// carrying it. A picture derived from manifests renders identically to one read
    /// out of the warehouse, so a pane that does not say which is a picture whose age
    /// nobody knows. Three states, three colours, above the graph, in the pane's own
    /// words.
    axis_road: Option<AxisRoad>,
    /// **Removable crates the graph is NOT showing, because nobody paid for their
    /// minimal-cause hunt yet.**
    ///
    /// MEASURED: the hunt is 10-16 s PER CRATE, and a real pick has 98 removable
    /// crates. Doing them all up front is 17-27 minutes, so the pane asks for none of
    /// them and buys one per click instead.
    ///
    /// They have to be listed. An unhunted crate is not in `FeatureAxis::analyses`
    /// at all, and the pane's `in_build` reads an absent crate as `false` — so
    /// leaving these silent would draw "we have not looked" as "not in the build".
    axis_unanalysed: Vec<String>,
    /// The crates the operator has clicked "hunt" on, carried into the next
    /// [`AxisPick`]. Survives a re-derive of the SAME row (that is the point) and is
    /// dropped with the rest of the axis when the row changes.
    axis_focus: BTreeSet<String>,
    /// **The forge's leaf, as it last drew** — its title and its own observable
    /// state, cached on the frame that drew it so [`Facet::state_json`] (which is
    /// `&self`) can fold it in. A leaf whose state is invisible to a driver is a
    /// leaf no test can prove is on screen.
    leaf: Value,
    /// **The settings tree as it last drew** — cached on the frame that drew it, like
    /// [`Self::leaf`], so `state_json` reports every node with its path and its flag.
    /// `None` with no recipe resolved.
    tree: Option<TreeView>,
    /// **The build graph pane** — the one DAG renderer, holding pan, zoom, folds and
    /// the selection across spec rebuilds.
    graph: DagPane,
    /// Node ids the operator has UNFOLDED — kept across rebuilds; everything else with
    /// children starts folded.
    graph_open: BTreeSet<String>,
    /// The graph's selected node id as this scene last saw it, so a canvas click is
    /// detected as a change rather than re-applied every frame.
    graph_selected: Option<String>,
    /// What the current spec is a picture OF; a different key rebuilds the spec.
    graph_key: String,
    /// The menu's subject within the picked variant's tree: a node path, or `None` for
    /// the variant itself.
    menu_node: Option<String>,
    /// The judge's last refusal (a kernel symbol that would break the boot contract),
    /// verbatim, or a note about a node that is another recipe's.
    menu_note: Option<String>,
    /// **The RUN receipt** — the second receipt (law E): the verb that ran, where, and
    /// the outcome the console measured. `None` until a verb fires.
    run_receipt: Option<crate::actions::RunReceipt>,
    /// The `run.where` val as picked: `container` (the default) or `vm`.
    run_where: String,
    /// Why a verb did not run, verbatim (a program in a vm is not wired yet).
    run_note: Option<String>,
    /// The verbs the forge last answered for the built typ, so `state_json` (`&self`)
    /// reports what the menu drew without asking the forge again.
    last_verbs: Vec<crate::actions::Verb>,
    /// **The step console** — the edge DAG the last `BYGG <kant>` planned, its steps'
    /// states, the clock, the receipt beside the build's and the RUN's.
    steps: crate::build_steps::StepConsole,
    /// **The BYGG verbs the menu drew this frame** — one per node with a consumption
    /// edge: its label, path, coordinate, typ and the CLI line it equals. Cached on
    /// draw so `state_json` (`&self`) reports what was on screen.
    build_verbs: Vec<Value>,
    /// Why a variant of a kind that COULD consume a kernel (an iso, an installer) offers
    /// no BYGG: it declares no edge — and whose decision that is.
    no_edge_why: Option<String>,
    /// **Drawn as the ⚒ scene's five LEAVES rather than as one flow.** Set by
    /// [`Self::hosted`] at mount. It changes exactly one thing: a region that has to
    /// wear its own `CollapsingHeader` when five regions share one `Slot` must NOT wear
    /// one when the scene has already given it a chromed, foldable pane with a title.
    /// Two nodes reading "Kärna" one inside the other is the duplicate-name defect this
    /// tab has now paid for twice.
    hosted: bool,
    /// The egui pass [`Self::begin_pass`] last ran on — so the once-a-frame prologue is
    /// once a frame whether one surface draws this pane or five.
    pass: Option<u64>,
}

impl BuildScene {
    /// A scene over a forge.
    #[must_use]
    pub fn new(forge: Box<dyn Forge>) -> Self {
        Self {
            forge,
            artifact: String::new(),
            coordinate: String::new(),
            menu: None,
            error: None,
            row: None,
            target: None,
            chosen: BTreeMap::new(),
            argv: String::new(),
            report: Report::default(),
            texture: None,
            galois: None,
            axis_row: None,
            axis_note: None,
            axis_road: None,
            axis_unanalysed: Vec::new(),
            axis_focus: BTreeSet::new(),
            leaf: Value::Null,
            tree: None,
            graph: DagPane::new("byggraf"),
            graph_open: BTreeSet::new(),
            graph_selected: None,
            graph_key: String::new(),
            menu_node: None,
            menu_note: None,
            run_receipt: None,
            run_where: "container".to_string(),
            run_note: None,
            last_verbs: Vec::new(),
            steps: crate::build_steps::StepConsole::new(),
            build_verbs: Vec::new(),
            no_edge_why: None,
            hosted: false,
            pass: None,
        }
    }

    /// **Mount this scene as the ⚒ tab's five LEAVES** rather than as one flow.
    ///
    /// Consuming, so the shell states the shape once where it mounts the tab and the
    /// pane cannot be half-hosted. Everything else is identical: the same forge, the
    /// same state, and the same `Facet::ui` when something boxes it whole.
    #[must_use]
    pub fn hosted(mut self) -> Self {
        self.hosted = true;
        self
    }

    /// Whether this instance is drawn as scene leaves (the host owns the chrome).
    #[must_use]
    pub fn is_hosted(&self) -> bool {
        self.hosted
    }

    // ── steps 1 and 2 ─────────────────────────────────────────────────────────

    /// Type into the `artefakt` field headlessly — the bench's and a robot's twin of the
    /// keystrokes. Pair with [`Self::resolve_artifact`].
    pub fn set_artifact(&mut self, name: &str) {
        self.artifact = name.to_string();
    }

    /// Step 1: resolve the typed artifact name.
    pub fn resolve_artifact(&mut self) {
        let name = self.artifact.trim().to_string();
        if name.is_empty() {
            self.error = Some("skriv ett artefaktnamn först".to_string());
            return;
        }
        let resolved = self.forge.resolve(&Query::Artifact(name.clone()));
        self.apply(resolved, Some(&name));
    }

    /// The other door: resolve the typed coordinate. Same state, same matrix.
    pub fn resolve_coordinate(&mut self) {
        match Coordinate::parse(self.coordinate.trim()) {
            Ok(c) => {
                let r = self.forge.resolve(&Query::At(c));
                self.apply(r, None);
            }
            Err(bad) => {
                self.menu = None;
                self.clear_selection();
                self.error = Some(format!("koordinaten {bad:?} går inte att tolka"));
            }
        }
    }

    /// Land a resolve on the one state. `preselect` picks the row whose name the
    /// operator typed — that is the name matching itself, not the scene recognising a
    /// product.
    fn apply(&mut self, result: Result<Menu, String>, preselect: Option<&str>) {
        self.clear_selection();
        match result {
            Ok(menu) => {
                self.row = preselect.and_then(|n| menu.rows.iter().position(|r| r.name == n));
                self.error = None;
                self.menu = Some(menu);
                self.load_settings();
            }
            Err(why) => {
                self.menu = None;
                self.error = Some(why);
            }
        }
    }

    // ── steps 3 and 4 ─────────────────────────────────────────────────────────

    /// Step 3. Picking a row invalidates everything downstream of it: the column, the
    /// settings, the outcome, the dependency picture and the pane below all belonged
    /// to the previous row.
    pub fn pick_row(&mut self, i: usize) {
        if self.menu.as_ref().is_some_and(|m| i < m.rows.len()) {
            self.row = Some(i);
            self.target = None;
            self.chosen.clear();
            self.report = Report::default();
            self.texture = None;
            self.forget_axis_and_hunts();
        }
    }

    /// Drop the dependency picture, so the next frame asks the forge for the picked
    /// row's own. A graph left behind after a row change would be a picture of the
    /// wrong assembly — and it would look exactly like a correct one.
    fn forget_axis(&mut self) {
        self.galois = None;
        self.axis_row = None;
        self.axis_note = None;
        // The road belongs to the graph, not to the pane. A road line left behind
        // after a row change would say "read from the warehouse" over a picture that
        // has not been derived yet — the exact confident-stale claim it exists to
        // prevent.
        self.axis_road = None;
    }

    /// [`Self::forget_axis`] **and the hunts that were bought for it.**
    ///
    /// The split is load-bearing. Buying a hunt drops the picture so the next frame
    /// re-derives it — but the crates still waiting to be hunted, and the hunts
    /// already paid for, belong to the SAME assembly and must survive that. Clearing
    /// them in `forget_axis` made the chip strip vanish for the length of a derive
    /// (so a second click landed on nothing) and made [`Self::focus_crate`]'s own
    /// guard reject every crate after the first.
    ///
    /// A row change is the other thing entirely: that assembly is gone, and spending
    /// its budget on the next row's picture would be work nobody asked for.
    fn forget_axis_and_hunts(&mut self) {
        self.forget_axis();
        self.axis_unanalysed.clear();
        self.axis_focus.clear();
    }

    /// **Buy the minimal-cause hunt for ONE crate.**
    ///
    /// The pane derives its picture with a budget of zero, because the hunt costs
    /// 10-16 s per crate and a real pick has 98 removable ones. This is how the
    /// operator spends it: name a crate, and the next derive hunts that crate and no
    /// other. Hunts already bought are kept, so a second click ADDS to the picture
    /// rather than replacing it — a replacing pane would never let anyone build one
    /// up.
    ///
    /// Only a crate on [`Self::axis_unanalysed`] can be asked for. Anything else
    /// would widen the budget past what is on screen, and the operator would be
    /// waiting on work they did not ask for.
    pub fn focus_crate(&mut self, krate: &str) {
        if !self.axis_unanalysed.iter().any(|c| c == krate) {
            return;
        }
        self.axis_focus.insert(krate.to_string());
        // Drop the PICTURE, not the hunts: the pick has changed, and a changed pick
        // is what makes the next frame re-ask the forge.
        self.forget_axis();
    }

    /// Ask the forge for the picked row's axis until it answers something terminal.
    ///
    /// The pane is built ONCE per row: rebuilding it every frame would throw away the
    /// operator's clicks, which is the whole state this picture is made of.
    ///
    /// Returns whether the forge is STILL computing — the caller needs that to keep
    /// asking for frames (see [`Facet::ui`]); a graph nobody repaints for never
    /// arrives on screen.
    fn pull_axis(&mut self) -> bool {
        let Some(pick) = self.axis_pick() else {
            return false;
        };
        if self.axis_row.as_ref() == Some(&pick) {
            return false;
        }
        match self.forge.axis(&pick) {
            Axis::Working => {
                self.axis_note = Some("beroendegrafen räknas ut …".to_string());
                return true;
            }
            Axis::Absent => {
                self.axis_row = Some(pick);
                self.galois = None;
                self.axis_note = None;
                self.axis_road = None;
            }
            Axis::Failed(why) => {
                self.axis_row = Some(pick);
                self.galois = None;
                self.axis_note = Some(why);
                self.axis_road = None;
            }
            Axis::Ready(axis) => {
                self.axis_row = Some(pick);
                self.axis_note = None;
                // Classified HERE, off the axis the pane is about to render, so the
                // road line and the graph can never be about two different reads.
                // `axis_road` derives from the provenance the pane itself prints —
                // there is no second field to drift (LAW 5).
                self.axis_road = Some(axis_road(&axis));
                // The crates nobody hunted for, carried BESIDE the graph and not
                // folded into it: the pane has no way to draw "not asked yet", so
                // the scene states it in its own words above the picture.
                self.axis_unanalysed = axis.unanalysed.clone();
                self.galois = Some(GaloisPane::new(axis.axis));
            }
        }
        false
    }

    /// What the picture should currently be OF — row, column and toggles.
    /// `None` when no row is picked, which is the only state with no picture.
    #[must_use]
    pub fn axis_pick(&self) -> Option<AxisPick> {
        Some(AxisPick {
            row: self.selected_row()?.name.clone(),
            target: self.selected_target().map(|t| t.name.clone()),
            toggles: self.toggles(),
            focus: self.axis_focus.clone(),
        })
    }

    /// **Is this pane waiting on work that happens somewhere else?**
    ///
    /// Everything slow the pane shows — the build, the boot, the guest's serial and
    /// framebuffer, the dependency graph — is produced OFF the ui thread and arrives
    /// through [`Forge::poll`]/[`Forge::axis`]. egui only draws when
    /// something asks it to, so while any of that is in flight the pane must ask for
    /// the next frame itself or the operator sits in front of a still picture.
    fn awaiting_elsewhere(&self, axis_working: bool) -> bool {
        axis_working || self.report.live || matches!(self.report.phase, Some(Phase::Building))
    }

    /// The dependency picture, when there is one. Public so a driver can assert on
    /// the SAME pane the pointer clicks rather than on a copy of its state.
    #[must_use]
    pub fn galois(&self) -> Option<&GaloisPane> {
        self.galois.as_ref()
    }

    /// Route a click on the dependency graph, by node id. The headless seam onto the
    /// exact path a pointer takes.
    pub fn click_axis(&mut self, node_id: &str) {
        if let Some(g) = self.galois.as_mut() {
            g.on_click(node_id);
        }
    }

    /// Step 4. A column that the recipe does not declare cannot be picked.
    pub fn pick_target(&mut self, i: usize) {
        let ok = self
            .selected_row()
            .and_then(|r| r.targets.get(i))
            .is_some_and(|t| t.declared);
        if ok {
            self.target = Some(i);
            self.report = Report::default();
            self.texture = None;
            self.load_settings();
        }
    }

    /// Seed the editable settings from the picked target's resolved values.
    fn load_settings(&mut self) {
        let open: Vec<(String, String)> = self
            .selected_target()
            .map(|t| {
                t.settings
                    .iter()
                    .filter(|s| !s.fixed)
                    .map(|s| (s.key.clone(), s.value.clone()))
                    .collect()
            })
            .unwrap_or_default();
        self.chosen = open.into_iter().collect();
    }

    /// **What the pane is currently showing**, whole — the phase, the console lines and
    /// the guest's captured framebuffer.
    ///
    /// `state_json` carries the frame's SIZE but not its pixels (a megabyte of RGBA in a
    /// state oracle would make every assertion on it unreadable), so a driver that wants
    /// to save the guest's real screen as proof has nowhere else to read it. It reads the
    /// SAME `Report` the pane draws from — not a copy that could tell a different story.
    #[must_use]
    pub fn report(&self) -> &Report {
        &self.report
    }

    /// **The opt-outs as they stand right now**, keyed by option name.
    ///
    /// Falls back to the DECLARATION's default for a toggle the operator has
    /// not touched — never to `false`. An opt-out that defaulted off would let a
    /// pane quietly offer an appliance stripped of capabilities nobody asked to
    /// remove.
    #[must_use]
    pub fn toggles(&self) -> BTreeMap<String, bool> {
        let Some(t) = self.selected_target() else {
            return BTreeMap::new();
        };
        t.settings
            .iter()
            .filter(|s| s.declared_option.is_some())
            .map(|s| {
                let on = match self.chosen.get(&s.key).map(String::as_str) {
                    Some("true") => true,
                    Some("false") => false,
                    _ => s.value == "true",
                };
                (s.key.clone(), on)
            })
            .collect()
    }

    /// **The feature delta this selection would build with** — `+wg-client
    /// −dhcp-client` — computed from the toggles BEFORE anything is built, so
    /// the operator sees what a tick costs while deciding rather than in the
    /// receipt afterwards.
    ///
    /// The same sentence the forge's own record carries (`BuildReport::
    /// feature_delta`, out of `ResolvedOptions::feature_delta`), down to the
    /// U+2212 MINUS SIGN — a preview that read differently from the receipt
    /// would be a second opinion about the same build.
    #[must_use]
    pub fn feature_delta(&self) -> String {
        let Some(t) = self.selected_target() else {
            return String::new();
        };
        let toggles = self.toggles();
        let parts: Vec<String> = t
            .settings
            .iter()
            .filter_map(|s| {
                let o = s.declared_option.as_ref()?;
                let on = toggles.get(&s.key).copied().unwrap_or(true);
                Some(format!(
                    "{}{}",
                    if on { '+' } else { '\u{2212}' },
                    o.feature
                ))
            })
            .collect();
        if parts.is_empty() {
            String::new()
        } else {
            format!("features: {}", parts.join(" "))
        }
    }

    /// **Every visible input that is not acceptable yet**, as `(key, why)`.
    ///
    /// Only VISIBLE inputs: a required parameter belonging to a toggle state the
    /// operator is not in has nothing to do with this build, and holding BYGG
    /// shut for a field nobody can see is a dead button with no explanation —
    /// the worst of both.
    ///
    /// The shapes come from the DECLARATION (`DeclaredParam::check`, over the
    /// kind the recipe gave — including the `ipv4` / `ipv4-cidr` an option's
    /// `param_kinds` sharpens a DHCP-off static address into), so the pane
    /// refuses exactly what the door would refuse, before paying for a build to
    /// find out.
    #[must_use]
    pub fn unmet_inputs(&self) -> Vec<(String, String)> {
        let Some(t) = self.selected_target() else {
            return Vec::new();
        };
        let toggles = self.toggles();
        t.settings
            .iter()
            .filter(|s| !s.fixed && s.visible_in(&toggles))
            .filter_map(|s| {
                let d = s.declared.as_ref()?;
                let v = self
                    .chosen
                    .get(&s.key)
                    .map(String::as_str)
                    .unwrap_or(&s.value);
                d.check(v).err().map(|why| (s.key.clone(), why))
            })
            .collect()
    }

    /// The picked row, if there is one.
    #[must_use]
    pub fn selected_row(&self) -> Option<&Row> {
        let (m, i) = (self.menu.as_ref()?, self.row?);
        m.rows.get(i)
    }

    /// The picked column, if there is one.
    #[must_use]
    pub fn selected_target(&self) -> Option<&Target> {
        self.selected_row()?.targets.get(self.target?)
    }

    /// Which pane a build would open. `None` until both selections are made — the
    /// scene refuses to guess a pane out of half a selection.
    #[must_use]
    pub fn pane(&self) -> Option<PaneKind> {
        self.selected_target().map(Target::pane)
    }

    /// Step 5 is available exactly when steps 3 and 4 both landed.
    #[must_use]
    /// **Is BYGG armed?** A picked column, and every visible declared input
    /// acceptable.
    ///
    /// Until 2026-08-22 this asked only whether a column was picked, so an
    /// installer with a REQUIRED parameter left blank armed the button, paid for
    /// a compose, and failed at the door. The refusal is the same one either
    /// way; the difference is whether the operator learns it in a second or
    /// after a build.
    pub fn can_build(&self) -> bool {
        self.selected_target().is_some() && self.unmet_inputs().is_empty()
    }

    // ── step 5 ────────────────────────────────────────────────────────────────

    /// Step 5. A no-op unless a row AND a column are picked.
    /// **The site values AS THEY STAND** — the picked column's declared settings with
    /// the operator's own edits over them. One spelling, because `BYGG` and
    /// `BYGG <kant>` must send the forge the same answer to "what did they choose";
    /// two gatherings would be two builds of two different things off one screen.
    fn settings_now(&self) -> BTreeMap<String, String> {
        let mut settings: BTreeMap<String, String> = self
            .selected_target()
            .map(|t| {
                t.settings
                    .iter()
                    .map(|s| (s.key.clone(), s.value.clone()))
                    .collect()
            })
            .unwrap_or_default();
        settings.extend(self.chosen.iter().map(|(k, v)| (k.clone(), v.clone())));
        settings
    }

    pub fn build(&mut self) {
        let Some(row) = self.selected_row().map(|r| r.name.clone()) else {
            return;
        };
        let Some(target) = self.selected_target() else {
            return;
        };
        let name = target.name.clone();
        let settings = self.settings_now();
        self.texture = None;
        self.forge.act(Act::Build {
            row,
            target: name,
            settings,
        });
    }

    /// A pane verb. Refused unless a build has produced something for it to act on —
    /// a start button that starts nothing is worse than no button.
    pub fn act(&mut self, act: Act) {
        if self.report.outcome.is_some() {
            self.forge.act(act);
        }
    }

    /// **Poll the forge, and take a re-answered matrix if it hands one up.**
    ///
    /// The one place `Forge::poll` is called from, so a matrix that changes under the
    /// scene — the recipe crate's wasm landed and the CODE now answers where the table
    /// did (gap #9) — is swapped in on every surface that polls, never on one and not
    /// the other.
    fn take_report(&mut self) {
        let mut report = self.forge.poll();
        if let Some(menu) = report.menu.take() {
            self.swap_menu(menu);
        }
        self.report = report;
        self.settle_run_receipt();
        // The chained build's steps, when a runner is reporting them.
        if let Some(steps) = self.forge.steps() {
            self.steps.apply(&steps);
        }
    }

    /// **Replace the matrix, keeping the operator's place by NAME.** The row, the
    /// column and every edit whose key the new matrix still has survive; an index
    /// would not, because the new answer may order or count its rows differently.
    fn swap_menu(&mut self, menu: Menu) {
        let row_name = self.selected_row().map(|r| r.name.clone());
        let target_name = self.selected_target().map(|t| t.name.clone());
        let kept = std::mem::take(&mut self.chosen);
        self.row = row_name.and_then(|n| menu.rows.iter().position(|r| r.name == n));
        self.target = match (self.row, target_name) {
            (Some(i), Some(t)) => menu.rows[i]
                .targets
                .iter()
                .position(|x| x.name == t && x.declared),
            _ => None,
        };
        self.menu = Some(menu);
        self.load_settings();
        for (k, v) in kept {
            if self.chosen.contains_key(&k) {
                self.chosen.insert(k, v);
            }
        }
        // The picture belonged to the previous answer's row.
        self.forget_axis_and_hunts();
    }

    /// Drop every selection, and the pane with it.
    fn clear_selection(&mut self) {
        self.row = None;
        self.target = None;
        self.chosen.clear();
        self.report = Report::default();
        self.texture = None;
        self.forget_axis_and_hunts();
    }

    // ── render ────────────────────────────────────────────────────────────────

    /// **The once-a-frame prologue — poll the forge, pull the axis, ask for the next
    /// frame — run EXACTLY once however many surfaces draw this pane.**
    ///
    /// Whole-mounted, `Facet::ui` is one call and this is trivially once. Mounted as the
    /// ⚒ scene's five LEAVES it is not: the host calls `ui_leaf` up to five times a
    /// frame, and polling a forge five times to draw one frame is five answers where
    /// there is one question — worse, `pull_axis` is a state machine that must not be
    /// stepped five times per frame. So the pass number is the guard, not the call site:
    /// folding a leaf away cannot skip the prologue and unfolding one cannot double it.
    ///
    /// ── ASK FOR THE NEXT FRAME WHILE THE WORK IS ELSEWHERE ────────────────────
    ///
    /// **MEASURED 2026-08-16 on the SHIPPED ui binary**, on a real X server: pick an
    /// installer column, click BYGG, and the pane freezes at `BYGGER 0.3s` and stays
    /// there. Three minutes later the build had packed its payload, compiled its first
    /// stage and was mastering the medium — and the window still read `0.3s`. Nothing
    /// was broken except that nobody had asked egui to draw. Nudging the mouse repainted
    /// it and the whole run appeared at once: `KLAR 237.4s`, the receipt, the serial, the
    /// guest's framebuffer.
    ///
    /// So the operator's actual experience of this pane was: press the button, watch it
    /// die, and get the result only if you happen to move the pointer. The serial and the
    /// graphical console are worse than the timer — a live install scrolls past and the
    /// frames it draws are never shown at all.
    ///
    /// **No test in this tree could see it, and none ever will.** Every drive here runs
    /// on `egui_kittest`, whose `Harness::run()` pumps frames unconditionally; a harness
    /// that always redraws cannot notice a pane that never asks to be redrawn. That is
    /// the shape of the hollow green (LAW 2) — the guard is blind to the one thing that
    /// was wrong — and the reason the check that belongs with this fix is
    /// `the_pane_asks_for_frames_while_work_is_in_flight` below, which asserts on the
    /// REQUEST rather than on a repaint nobody can observe offscreen.
    ///
    /// 120 ms is `deploy_key.rs`'s interval, and the same reasoning: fast enough that a
    /// timer looks like a timer, slow enough to cost nothing when idle. It is
    /// `request_repaint_after`, not `request_repaint`, so a settled pane sleeps.
    fn begin_pass(&mut self, ui: &Ui) {
        let pass = ui.ctx().cumulative_pass_nr();
        if self.pass == Some(pass) {
            return;
        }
        self.pass = Some(pass);
        self.take_report();
        let axis_working = self.pull_axis();
        if self.awaiting_elsewhere(axis_working) {
            ui.ctx().request_repaint_after(WORKING_REPAINT);
        }
    }

    /// Steps 1 and 2: the two doors, and what answered.
    fn draw_entry(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            // `labelled_by` is what puts the word "artefakt" on the field's AccessKit
            // node, and that is the only handle a robot has to type into it. Without it
            // the field is reachable by a human and by nothing else.
            let caption = ui.label("artefakt");
            let field = ui
                .add(
                    egui::TextEdit::singleline(&mut self.artifact)
                        .hint_text("namnet på det som ska byggas")
                        .desired_width(240.0),
                )
                .labelled_by(caption.id);
            let entered = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
            if ui.button("sök").clicked() || entered {
                self.resolve_artifact();
            }
        });
        ui.horizontal(|ui| {
            let caption = ui.label("recept");
            match &self.menu {
                Some(m) => {
                    ui.label(RichText::new(m.coordinate.to_string()).strong());
                    ui.label(RichText::new(&m.how).weak());
                }
                None => {
                    let field = ui
                        .add(
                            egui::TextEdit::singleline(&mut self.coordinate)
                                .hint_text("krate:version[:hash]")
                                .desired_width(240.0),
                        )
                        .labelled_by(caption.id);
                    let entered =
                        field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
                    if ui.button("peka ut").clicked() || entered {
                        self.resolve_coordinate();
                    }
                }
            }
        });
        if let Some(e) = &self.error {
            ui.colored_label(RED, e);
        }
    }

    /// Step 3: the home matrix, one row selectable at a time.
    fn draw_rows(&mut self, ui: &mut Ui) {
        let Some(menu) = self.menu.as_ref() else {
            return;
        };
        section(ui, "varianter");
        if menu.rows.is_empty() {
            ui.colored_label(AMBER, "receptet deklarerar inga rader");
            return;
        }
        let mut pick = None;
        // A recipe's row count is the recipe's, not the window's: a real appliance
        // recipe declares enough rows that one line each pushed the picked cell's
        // MENU below the surface (measured 2026-09-26: an iso column's tunnel toggle
        // laid out at y=1403 in a 1400 px pane). The strip is bounded and scrolls, like the
        // canvas it sits beside, so the controls it leads to stay reachable.
        egui::ScrollArea::vertical()
            .id_salt("build-scene-rows")
            .max_height(ROWS_STRIP_MAX_H)
            .auto_shrink([false, true])
            .show(ui, |ui| {
                for (i, row) in menu.rows.iter().enumerate() {
                    let selected = self.row == Some(i);
                    ui.horizontal(|ui| {
                        let r = ui.selectable_label(selected, RichText::new(&row.name).monospace());
                        if r.clicked() {
                            pick = Some(i);
                        }
                        for b in &row.badges {
                            ui.label(RichText::new(b).weak().small());
                        }
                    });
                }
            });
        if let Some(i) = pick {
            self.pick_row(i);
        }
    }

    /// Step 4: the columns of the picked row — the second half of the MATRIX.
    ///
    /// This used to run on to draw the settings, the configurator leaf, the dependency
    /// picture and BYGG as well, which is why the tab was one flow: five regions that
    /// answer five different questions, in one function, with nothing between them an
    /// operator could drag. They are separate methods now ([`Self::draw_settings`],
    /// [`Self::draw_leaf`], [`Self::draw_run`]) so each can be a LEAF of the ⚒ scene —
    /// and `Facet::ui` still calls all of them in exactly this order, so the pane
    /// mounted whole draws byte-for-byte what it drew before.
    fn draw_targets(&mut self, ui: &mut Ui) {
        let Some(row) = self.selected_row() else {
            return;
        };
        section(ui, "mål");
        let targets: Vec<(String, bool)> = row
            .targets
            .iter()
            .map(|t| (t.name.clone(), t.declared))
            .collect();
        let mut pick = None;
        ui.horizontal_wrapped(|ui| {
            for (i, (name, declared)) in targets.iter().enumerate() {
                let selected = self.target == Some(i);
                let text = RichText::new(name).monospace();
                let text = if *declared { text } else { text.color(GREY) };
                let r = ui
                    .add_enabled_ui(*declared, |ui| ui.selectable_label(selected, text))
                    .inner;
                if !*declared {
                    r.on_hover_text("receptet deklarerar ingen sådan cell för den här raden");
                } else if r.clicked() {
                    pick = Some(i);
                }
            }
        });
        if let Some(i) = pick {
            self.pick_target(i);
        }
    }

    /// Step 5: the dependency picture and the door — `▶ BYGG`, and why it is shut.
    ///
    /// Split out of [`Self::draw_targets`] so the ⚒ settings leaf can host it: what a
    /// feature costs and the button that spends it belong to the same surface, and the
    /// picture is drawn BEFORE the build precisely so the operator reads it while still
    /// deciding.
    fn draw_run(&mut self, ui: &mut Ui) {
        // The same guard `draw_targets` used to hold for this block. With no row picked
        // there is no build to arm and no picture to draw; the ⚒ matrix leaf is where
        // the operator is told to pick one, and a BYGG button that appears before there
        // is anything to build is a door to nowhere.
        if self.selected_row().is_none() {
            return;
        }
        self.draw_axis(ui);
        ui.horizontal(|ui| {
            let building = self.report.phase == Some(Phase::Building);
            let armed = self.can_build() && !building;
            if ui
                .add_enabled(armed, egui::Button::new(RichText::new("BYGG").strong()))
                .clicked()
            {
                self.build();
            }
            // ── WHY THE BUTTON IS SHUT, ALWAYS ──────────────────────────────
            //
            // A disabled button with no reason beside it is the same defect as a
            // silent empty roster: the operator can see that nothing happens and
            // cannot see why. "välj en rad och en kolumn" was the only sentence
            // here, and it was the WRONG one the moment a column was picked and
            // a required input was blank.
            if !self.can_build() {
                if self.selected_target().is_none() {
                    ui.label(RichText::new("välj en rad och en kolumn").weak());
                } else {
                    let unmet = self.unmet_inputs();
                    ui.colored_label(
                        RED,
                        unmet
                            .iter()
                            .map(|(k, why)| format!("{k}: {why}"))
                            .collect::<Vec<_>>()
                            .join(" · "),
                    );
                }
            }
        });
        // The feature delta this selection would build with — beside the button,
        // while the operator is still deciding, in the same words the receipt
        // will use afterwards.
        let delta = self.feature_delta();
        if !delta.is_empty() {
            ui.label(RichText::new(delta).monospace().weak().small());
        }
    }

    /// **The forge's own configurator, drawn where the choice is made.**
    ///
    /// **What a feature costs, drawn BEFORE the build** — above the button, not
    /// after the fact.
    ///
    /// This is the question an operator actually has at this point: click a package
    /// away and see which feature goes dark; switch a feature off and see which
    /// packages vanish. Both directions come off the ONE model this pane holds, so
    /// they cannot disagree — a second table for the reverse direction is a second
    /// thing that has to be kept true.
    ///
    /// Its height is fixed. A graph that grows with its own content pushes the build
    /// button off the bottom of a long matrix, and a button you have to scroll for is
    /// a button that is not in front of the operator.
    fn draw_axis(&mut self, ui: &mut Ui) {
        if self.selected_row().is_none() {
            return;
        }
        // ── THE FRAME IS ALWAYS HERE, AND THAT IS THE FLICKER FIX ────────────
        //
        // This used to return early whenever there was no graph, so the header
        // and its 220-420 px of space appeared and disappeared as the picture
        // arrived, failed, or was invalidated by a pick. Everything below it
        // jumped with it — including BYGG. A header that is always drawn, with
        // the reason inside it when there is nothing to draw, keeps the layout
        // still and says which of the three states this is.
        let note = self.axis_note.clone();
        let road = self.axis_road.clone();
        let unanalysed = self.axis_unanalysed.clone();
        // Which crate the operator asked to hunt, collected inside the closure and
        // acted on after it — `self` is borrowed by `pane` for the whole of the
        // body, and a hunt is a change to the PICK, not to the picture in front of
        // it.
        let mut asked: Option<String> = None;
        // ── NO SECOND GRAPH PANE (owner, 2026-08-26) ───────────────────────────
        // The dependency picture used to be its own graph widget here. It is part
        // of the ONE build graph now — every feature's crates a folded subgraph under
        // it, the emphasis edges the same — so this header carries only the words:
        // where the picture came from, what has not been hunted, and what the current
        // opt-outs cost.
        let consequence = self.galois.as_ref().map(GaloisPane::consequence);
        // The picture's nodes as accessible CHIPS — the clicks the old canvas offered
        // (a feature toggles, a crate is dropped, a conjunction breaks) survive here by
        // label, routed to the same model the graph paints.
        let chips: Vec<(String, String)> = self
            .galois
            .as_ref()
            .map(|g| {
                g.model()
                    .0
                    .nodes
                    .iter()
                    .map(|n| (n.id.clone(), n.label.clone()))
                    .collect()
            })
            .unwrap_or_default();
        let mut chip_clicked: Option<String> = None;
        egui::CollapsingHeader::new(RichText::new("beroenden").strong())
            .id_salt("build-scene-axis")
            .default_open(true)
            .show(ui, |ui| {
                draw_axis_road(ui, road.as_ref());
                if let Some(clicked) = draw_unanalysed(ui, &unanalysed) {
                    asked = Some(clicked);
                }
                if !chips.is_empty() {
                    ui.horizontal_wrapped(|ui| {
                        for (id, label) in &chips {
                            if ui
                                .selectable_label(false, RichText::new(label).monospace().small())
                                .clicked()
                            {
                                chip_clicked = Some(id.clone());
                            }
                        }
                    });
                }
                match consequence {
                    Some(c) => {
                        ui.label(
                            RichText::new("bilden är delgrafen under varje feature i byggrafen")
                                .weak()
                                .small(),
                        );
                        if !c.is_empty() {
                            ui.label(RichText::new(c).small());
                        }
                    }
                    None => {
                        ui.label(
                            RichText::new(
                                note.as_deref()
                                    .unwrap_or("ingen beroendegraf för det här valet"),
                            )
                            .weak()
                            .small(),
                        );
                    }
                }
            });
        if let Some(krate) = asked {
            self.focus_crate(&krate);
        }
        if let Some(id) = chip_clicked {
            self.click_axis(&id);
        }
    }

    /// **The build GRAPH** — the settings tree drawn as the graph it is (the owner,
    /// 2026-08-26: "byt"): a node per variant, a node per feature on it, containment
    /// and CONSUMPTION edges, a feature with children folded until clicked. Lowered by
    /// [`crate::build_graph::lower`] from the menu and the picked variant's tree
    /// ([`Forge::tree`]), painted by the ONE graph renderer this crate has
    /// ([`crate::dag_graph::DagPane`]).
    ///
    /// The spec is rebuilt only when what it is a picture OF changes (the recipe, the
    /// pick, the values) — the pane keeps its pan, zoom, folds and selection across a
    /// rebuild, because a graph that re-fits itself on every click is a graph nobody can
    /// read. A canvas click SELECTS: a variant node picks its row and column in the
    /// matrix; a feature node picks its variant and opens that node's menu; a folded
    /// node unfolds. The variant/column strips beneath stay as the accessible index —
    /// every click that existed still exists, and a robot addresses nodes by label.
    fn draw_graph(&mut self, ui: &mut Ui, canvas_first: bool) {
        let Some(menu) = self.menu.clone() else {
            self.tree = None;
            self.graph_key.clear();
            return;
        };
        // ── the picked variant's tree, with the values as they stand ─────────────
        let row = self.selected_row().map(|r| r.name.clone());
        let target = self.selected_target().map(|t| t.name.clone());
        let declared = self
            .forge
            .tree(row.as_deref(), target.as_deref(), &self.chosen);
        let symbols: BTreeMap<String, bool> = self
            .chosen
            .iter()
            .filter(|(k, _)| k.starts_with(&format!("{KERNEL_PATH}.")))
            .map(|(k, v)| (k.clone(), v == "true"))
            .collect();
        let view = declared.as_ref().map(|d| {
            TreeView::of(
                d,
                &Standing {
                    chosen: &self.chosen,
                    symbols: &symbols,
                },
            )
        });
        // ── rebuild the spec only when the picture changes ───────────────────────
        let key = format!(
            "{}|{:?}|{:?}|{}",
            menu.coordinate,
            row,
            target,
            view.as_ref()
                .map(|v| v.state_json().to_string())
                .unwrap_or_default()
        ) + &self
            .galois
            .as_ref()
            .map(|g| format!("|deps:{:?}|{}", g.disabled(), g.axis().analyses.len()))
            .unwrap_or_default();
        if key != self.graph_key {
            let mut folded = Vec::new();
            let trees: Vec<crate::build_graph::VariantTree<'_>> = declared
                .iter()
                .filter(|d| !d.variant.is_empty())
                .map(|d| crate::build_graph::VariantTree {
                    variant: &d.variant,
                    tree: d,
                })
                .collect();
            let deps = self.galois.as_ref().map(|g| g.model());
            let deps_ref = deps.as_ref().map(|(m, d)| (m, d));
            let spec = crate::build_graph::lower(&menu, &trees, deps_ref, &mut folded);
            self.graph.set_spec(&spec);
            // Folds are the operator's: a node they opened stays open across a rebuild;
            // a node they never touched starts folded.
            for id in folded {
                if !self.graph_open.contains(&id) {
                    self.graph.fold(&id);
                }
            }
            self.graph.select(self.graph_selected.clone());
            self.graph_key = key;
        }
        self.tree = view;
        // ── the strips pick ⇒ the graph follows ─────────────────────────────────
        // A pick made through the accessible strips selects the variant node, unless a
        // node of that variant's tree is the menu's subject already.
        if let Some(variant) = self.picked_variant() {
            let want = match &self.menu_node {
                Some(p) => crate::build_graph::node_id(&variant, p),
                None => crate::build_graph::variant_id(&variant),
            };
            if self.graph_selected.as_deref() != Some(want.as_str()) {
                self.graph.select(Some(want.clone()));
                self.graph_selected = Some(want);
            }
        }
        if canvas_first {
            self.draw_graph_canvas(ui);
            self.draw_rows(ui);
            self.draw_targets(ui);
        } else {
            self.draw_rows(ui);
            self.draw_targets(ui);
        }
    }

    /// **The canvas**: paint the graph, then read the click. Hosted, it sits above the
    /// strips in the graph leaf; whole-mounted it is drawn LAST, below everything the
    /// operator (and every existing robot) reaches by label — a canvas that pushes the
    /// controls below the surface is a canvas nobody can click past.
    fn draw_graph_canvas(&mut self, ui: &mut Ui) {
        if self.menu.is_none() {
            return;
        }
        let h = (ui.available_height() - 120.0).max(200.0);
        ui.allocate_ui(egui::vec2(ui.available_width(), h), |ui| self.graph.ui(ui));
        let now = self.graph.selected().map(str::to_string);
        if now != self.graph_selected {
            self.graph_selected = now.clone();
            if let Some(id) = now {
                self.on_graph_select(&id);
            }
        }
        if let Some(v) = self
            .tree
            .as_ref()
            .and_then(|t| t.kernel().map(|k| k.children.iter().any(|c| c.symbol)))
        {
            if v {
                ui.label(RichText::new(crate::kernel_scene::REFLECTS).weak().small());
            }
        }
    }

    /// **The build graph, as data** — what is selected, what the operator has opened,
    /// how big the picture is and how much of it is on screen.
    fn graph_json(&self) -> Value {
        if self.menu.is_none() {
            return Value::Null;
        }
        json!({
            "selected": self.graph_selected,
            "open": self.graph_open.iter().collect::<Vec<_>>(),
            "nodes": self.graph.node_count(),
            "edges": self.graph.edge_count(),
            "visible": self.graph.visible_ids().len(),
            "ids": self.graph_ids(),
        })
    }

    /// Every node id in the current spec, from the engine's own model.
    fn graph_ids(&self) -> Vec<String> {
        self.graph
            .state_json()
            .get("nodes")
            .and_then(|n| n.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|n| n.get("id").and_then(|i| i.as_str()).map(str::to_string))
                    .collect()
            })
            .unwrap_or_default()
    }

    /// **The menu, as data** — its subject (the picked variant, and the node within it
    /// when one is), and every control it shows with the path and flag beside it.
    fn menu_json(&self) -> Value {
        let standing = self
            .tree
            .as_ref()
            .map(|t| t.variant.clone())
            .filter(|v| !v.is_empty());
        let Some(variant) = self.picked_variant().or(standing) else {
            return Value::Null;
        };
        let view = self.tree.clone().unwrap_or_default();
        let view = &view;
        let controls: Vec<Value> = match &self.menu_node {
            // The roots, each followed by the leaf children the menu draws under it
            // (under `kärna` only the vals) — the same rule `draw_control` follows.
            None => view
                .roots
                .iter()
                .flat_map(|n| {
                    let is_kernel = n.path == KERNEL_PATH;
                    let on = n.is_on();
                    std::iter::once(control_json(n)).chain(
                        n.children
                            .iter()
                            .filter(move |c| {
                                c.when.is_none_or(|w| w == on)
                                    && c.children.is_empty()
                                    && (!is_kernel || c.kind == "val")
                            })
                            .map(control_json),
                    )
                })
                .collect(),
            Some(p) => view
                .find(p)
                .map(|n| {
                    std::iter::once(control_json(n))
                        .chain(n.children.iter().map(control_json))
                        .collect()
                })
                .unwrap_or_default(),
        };
        json!({
            "variant": variant,
            "typ": view.typ.as_str(),
            "node": self.menu_node,
            "note": self.menu_note,
            "controls": controls,
            // After a build: the receipt readable, and the verbs the typ bears.
            "buildinfo": self.buildinfo_json(),
            "actions": self.actions_json(),
            "run_note": self.run_note,
            // The BYGG verbs on the menu — exactly one per node with a consumption
            // edge, each with the CLI line it equals; and why a kernel-consuming kind
            // without an edge has none.
            "build_verbs": self.build_verbs,
            "no_edge_why": self.no_edge_why,
        })
    }

    // ── BYGG <kant>: the edge DAG (wave 3, gap #4) ────────────────────────────

    /// **Why a kernel-consuming kind offers no BYGG** — an iso or an installer whose
    /// tree carries no consumption edge. Said by name, and WHOSE decision it is: an
    /// edge on a product's medium (`[build.<kind>.kernel_donor]`) is the product
    /// owner's — the owner's — not the forge's to add; until then the medium boots the
    /// host's kernel and the refusal says so instead of offering a verb to nowhere.
    fn draw_no_edge(&mut self, ui: &mut Ui, view: &TreeView) {
        if !view.typ.takes_kernel_donor() {
            return;
        }
        fn has_edge(n: &NodeView) -> bool {
            n.edge.is_some() || n.children.iter().any(has_edge)
        }
        if view.roots.iter().any(has_edge) {
            return;
        }
        let why = format!(
            "`{}` deklarerar ingen konsumtionskant — inget BYGG <kant> här. En kärnkant på en \
             iso/installer ([build.<kind>.kernel_donor]) är produktägarens beslut, inte \
             smedjans; utan den bootar mediet värdens kärna",
            view.variant
        );
        ui.colored_label(AMBER, &why);
        self.no_edge_why = Some(why);
    }

    /// The verb's word: `BYGG kärnan` on the kärna node (the design's own spelling,
    /// `[BYGG kärnan]` ≡ `--build kernel`), `BYGG <label>` on any other edge node.
    fn edge_verb_label(n: &NodeView) -> String {
        if n.path == KERNEL_PATH {
            "BYGG kärnan".to_string()
        } else {
            format!("BYGG {}", n.label)
        }
    }

    /// **The scoped `--opt` line for an edge node** — its subtree as it stands: every
    /// val's choice (`kernel.preset`), every feature clicked OFF (a veto), every setting
    /// with a value. The strings are the tree's own flags (`flags_of` spelling) — the
    /// very ones beside the controls (law C) — so the line the menu shows is the line
    /// the CLI door judges.
    fn scoped_flags(n: &NodeView) -> Vec<String> {
        fn walk(n: &NodeView, out: &mut Vec<String>) {
            for c in &n.children {
                match (c.kind, &c.flag) {
                    ("val", Some(f)) => out.push(f.clone()),
                    ("feature", Some(f)) if !c.is_on() => out.push(f.clone()),
                    ("setting", Some(f)) if !c.value.trim().is_empty() => out.push(f.clone()),
                    _ => {}
                }
                walk(c, out);
            }
        }
        let mut out = Vec::new();
        walk(n, &mut out);
        out
    }

    /// **The BYGG verb on an edge node** — the button, and beside it the exact CLI line
    /// it equals. Disabled while something builds or runs; never absent on an edge,
    /// never present without one.
    fn draw_edge_verb(&mut self, ui: &mut Ui, n: &NodeView, variant: &str) {
        let Some(edge) = n.edge.as_deref() else {
            return;
        };
        let (coordinate, typ) = split_edge(edge);
        let label = Self::edge_verb_label(n);
        // ★ THE PRINTED LINE IS THE CLICK'S OWN PLAN, not a second spelling beside it.
        //
        // This used to call `cli_line` with `Self::scoped_flags(n)` — the flags UNDER the
        // clicked edge node — while the button sent `edge_plan`, whose consumer step
        // carries the whole tree's line. So every cell-level opt-out was printed away:
        // the line under the button named the `--build` target and the kernel subtree's
        // options and nothing else, while the click was also sending the cell's own
        // `--opt <capability>=off`. An operator who pasted that line got the capability
        // COMPILED BACK IN. Law C at this axis is path ↔ flag; a printed command that
        // does not reproduce the click breaks it worse than no command would.
        //
        // Building the plan here is the fix BY CONSTRUCTION: the line drawn and the plan
        // handed to `Forge::build_edge` are now the same object, so they cannot drift.
        // `edge_plan` resolves nothing — it reads the tree and the settings map — so it
        // is a per-frame cost of the same order as `scoped_flags` was.
        let Some(plan) = self.edge_plan(variant, n) else {
            return;
        };
        let cli = plan.cli.clone();
        self.build_verbs.push(json!({
            "label": label,
            "path": n.path,
            "coordinate": coordinate,
            "typ": typ.as_str(),
            "cli": cli,
        }));
        let busy = self.report.live || self.report.phase == Some(Phase::Building);
        if ui
            .add_enabled(!busy, egui::Button::new(RichText::new(&label).strong()))
            .clicked()
        {
            self.bygg_edge(variant, n);
        }
        ui.label(RichText::new(cli).monospace().small());
    }

    /// **`BYGG <kant>` fired.** Plan the DAG — the donor with the scoped line first, the
    /// consumer with its own flags last — stage it in the step console with the honest
    /// expectation, then hand the plan to the forge. A refusal lands on every step BY
    /// NAME and starts no clock: a forge without the runner says
    /// [`crate::build_steps::missing_runner`], never a silent no-op.
    /// The two-step this click stages BEFORE the forge answers — the picture drawn
    /// while the planner is asked. One spelling, shared by `BYGG` and its dry twin.
    fn edge_plan(&mut self, variant: &str, n: &NodeView) -> Option<crate::build_steps::StepPlan> {
        let edge = n.edge.as_deref()?;
        let (coordinate, typ) = split_edge(edge);
        let variant_typ = self.tree.as_ref().map(|t| t.typ).unwrap_or_default();
        let consumer_flags = self.tree.as_ref().map(TreeView::flags).unwrap_or_default();
        let product = self.forge.product();
        let mut plan = crate::build_steps::StepPlan::for_edge(
            product.as_deref(),
            variant,
            variant_typ,
            &n.path,
            &coordinate,
            typ,
            Self::scoped_flags(n),
            consumer_flags,
        );
        // The site values the CONSUMER's own step needs (`--param`): the same
        // gathering `BYGG` sends, so the chained road and the direct road build the
        // same artifact out of the same screen.
        plan.settings = self.settings_now();
        // The DECLARED ones among them, off the forge's own reader — the plan carries
        // them so the printed line and the sent build take one answer, not two.
        plan.params = self.forge.declared_params(variant, &plan.settings);
        plan.respell_cli();
        Some(plan)
    }

    fn bygg_edge(&mut self, variant: &str, n: &NodeView) {
        let Some(plan) = self.edge_plan(variant, n) else {
            return;
        };
        let label = Self::edge_verb_label(n);
        self.steps.stage(plan.clone());
        match self.forge.build_edge(&plan) {
            // The forge answers the plan it started — the whole DAG, not this
            // node's two steps. Restage on it, then start the clock.
            Ok(actual) => {
                self.steps.stage(actual);
                self.steps.started(&label);
            }
            Err(why) => self.steps.refused(&label, &why),
        }
    }

    /// **The chain this edge would run, planned and NOT started** — the pane's dry
    /// pass. Stages the planner's own DAG in the console with every step queued and
    /// no clock; a refusal lands on the console exactly as `BYGG` would spell it.
    fn plan_edge(&mut self, variant: &str, n: &NodeView) {
        let Some(plan) = self.edge_plan(variant, n) else {
            return;
        };
        let label = Self::edge_verb_label(n);
        self.steps.stage(plan.clone());
        match self.forge.plan_edge(&plan) {
            Ok(actual) => self.steps.stage(actual),
            Err(why) => self.steps.refused(&label, &why),
        }
    }

    /// **`BYGG <kant>` by path** — the headless twin of the button, for robots: the node
    /// at `path` in the menu's tree, when it carries an edge.
    pub fn bygg_edge_at(&mut self, path: &str) -> bool {
        let standing = self
            .tree
            .as_ref()
            .map(|t| t.variant.clone())
            .filter(|v| !v.is_empty());
        let Some(variant) = self.picked_variant().or(standing) else {
            return false;
        };
        let Some(n) = self.tree.as_ref().and_then(|t| t.find(path).cloned()) else {
            return false;
        };
        if n.edge.is_none() {
            return false;
        }
        self.bygg_edge(&variant, &n);
        true
    }

    /// **The DRY twin of [`bygg_edge_at`](Self::bygg_edge_at)** — the chain planned
    /// into the console, nothing spawned. The pane's `--list --build`.
    pub fn plan_edge_at(&mut self, path: &str) -> bool {
        let standing = self
            .tree
            .as_ref()
            .map(|t| t.variant.clone())
            .filter(|v| !v.is_empty());
        let Some(variant) = self.picked_variant().or(standing) else {
            return false;
        };
        let Some(n) = self.tree.as_ref().and_then(|t| t.find(path).cloned()) else {
            return false;
        };
        if n.edge.is_none() {
            return false;
        }
        self.plan_edge(&variant, &n);
        true
    }

    /// **Stage a step plan from data** — the fixture door: a `StepPlan` as JSON, states
    /// included, so the console renders every state without a forge.
    ///
    /// # Errors
    /// The serde error when the JSON is not a plan.
    pub fn stage_steps(&mut self, plan: &Value) -> Result<(), String> {
        self.steps.stage_json(plan)
    }

    /// The step console, as data.
    #[must_use]
    pub fn steps_json(&self) -> Value {
        self.steps.state_json()
    }

    /// The step console: the planned DAG when there is one; hosted (the console leaf),
    /// one honest line when there is none.
    fn draw_steps(&mut self, ui: &mut Ui) {
        if self.steps.plan().is_none() && !self.hosted {
            return;
        }
        self.steps.ui(ui);
        if self.steps.plan().is_some() {
            ui.separator();
        }
    }

    /// **Select a graph node by id** — the headless twin of a canvas click, for robots
    /// and benches (the graph engine's select message has the same shape). A variant
    /// node picks its row and column; a tree node picks its variant and becomes the
    /// menu's subject; a folded node unfolds.
    pub fn select_node(&mut self, id: &str) {
        self.graph.select(Some(id.to_string()));
        self.graph_selected = Some(id.to_string());
        self.on_graph_select(id);
    }

    /// Fold a graph node's subtree again (the menu's "▸" twin).
    pub fn fold_node(&mut self, id: &str) {
        self.graph.fold(id);
        self.graph_open.remove(id);
    }

    /// The build graph's pane (camera, positions, where it sits on screen) — what a
    /// robot reads to check the picture is centred and a click never moved it.
    #[must_use]
    pub fn graph_pane(&self) -> &crate::dag_graph::DagPane {
        &self.graph
    }

    /// The build graph's pane, mutable (a host or robot sets 📌 Stay / 🎯 Focus).
    pub fn graph_pane_mut(&mut self) -> &mut crate::dag_graph::DagPane {
        &mut self.graph
    }

    /// The graph pane's own measurement — nodes, edges, last frame cost.
    #[must_use]
    pub fn graph_report(&self) -> Value {
        self.graph.report()
    }

    /// The variant the pick names — the delivery variant of the picked column, else
    /// the row itself.
    fn picked_variant(&self) -> Option<String> {
        let row = self.selected_row()?;
        Some(
            match self.selected_target().and_then(|t| t.variant.clone()) {
                Some(v) => v,
                None => row.name.clone(),
            },
        )
    }

    /// **A canvas click landed on `id`.** A variant node ⇒ the matrix pick it names;
    /// a tree node ⇒ its variant picked and the node made the menu's subject; a folded
    /// node ⇒ unfolded in place. A donor's coordinate (another repo's variant) ⇒ the
    /// menu says where it is built.
    fn on_graph_select(&mut self, id: &str) {
        // A node of the dependency picture: the click goes to the feature↔crate model
        // (toggle a feature, drop a crate, break a conjunction) — the same path the
        // old picture's canvas took — and the picture re-lowers into the graph.
        if let Some((_, inner)) = id.split_once(crate::build_graph::DEPS_SEP) {
            let inner = inner.to_string();
            self.click_axis(&inner);
            if !self.graph.is_expanded(id) {
                self.graph.unfold(id);
                self.graph_open.insert(id.to_string());
            }
            return;
        }
        let (variant, path) = crate::build_graph::split_id(id);
        let (variant, path) = (variant.to_string(), path.map(str::to_string));
        // Which (row, column) names this variant.
        let pick = self.menu.as_ref().and_then(|m| {
            m.rows.iter().enumerate().find_map(|(i, r)| {
                if r.name == variant {
                    let j = r
                        .targets
                        .iter()
                        .position(|t| t.declared && t.variant.is_none());
                    return Some((i, j));
                }
                r.targets
                    .iter()
                    .position(|t| t.declared && t.variant.as_deref() == Some(variant.as_str()))
                    .map(|j| (i, Some(j)))
            })
        });
        match pick {
            Some((i, j)) => {
                if self.row != Some(i) {
                    self.pick_row(i);
                }
                if let Some(j) = j {
                    if self.target != Some(j) {
                        self.pick_target(j);
                    }
                }
                self.menu_node = path.clone();
                self.menu_note = None;
            }
            None => {
                // Not a variant of this recipe: a consumed coordinate (`<donor>/<kernel>`).
                self.menu_node = None;
                self.menu_note = Some(format!(
                    "`{variant}` byggs i sitt eget recept — sök det som rot för att välja där; \
                     här är den en konsumtionskant (--build {variant})"
                ));
            }
        }
        if path.is_some() && !self.graph.is_expanded(id) {
            self.graph.unfold(id);
            self.graph_open.insert(id.to_string());
        }
    }

    /// **The selected node's MENU** — its own settings rendered from its node (feature →
    /// checkbox, val → strip, setting → field), each with its PATH and the flag it equals
    /// (law C on screen), then the verbs the typ bears (BYGG here; RUN / BOOT on the
    /// console once something is built) and the dependency picture above ▶.
    ///
    /// A variant node's menu is its roots; a feature node's is the node and its direct
    /// children — a child with children of its own is a SUBGRAPH, named here and opened
    /// in the graph. The `kärna` node's menu wears the honesty label.
    fn draw_menu(&mut self, ui: &mut Ui) {
        if self.menu.is_none() {
            ui.label(RichText::new("sök en rot först — receptet väljer grafen").weak());
            return;
        }
        // The picked variant — or, with nothing picked, the STANDING question's variant
        // (the one that carries `kärna`), so the donor is named before a click.
        let standing = self
            .tree
            .as_ref()
            .map(|t| t.variant.clone())
            .filter(|v| !v.is_empty());
        let Some(variant) = self.picked_variant().or(standing) else {
            ui.label(RichText::new("välj en nod i grafen").weak());
            return;
        };
        let view = self.tree.clone().unwrap_or_default();
        // The BYGG verbs are re-collected by the controls that draw them this frame.
        self.build_verbs.clear();
        self.no_edge_why = None;
        ui.horizontal(|ui| {
            ui.label(RichText::new(&variant).strong().monospace());
            ui.label(RichText::new(view.typ.as_str()).weak().small());
            if let Some(p) = &self.menu_node {
                ui.label(RichText::new(format!("▸ {p}")).monospace());
            }
        });
        if let Some(n) = &self.menu_note {
            ui.colored_label(AMBER, n);
        }
        self.draw_no_edge(ui, &view);
        match self.menu_node.clone() {
            None => {
                self.draw_fixed_facts(ui);
                for n in view.roots.clone() {
                    self.draw_control(ui, &n, 0, &variant);
                }
            }
            Some(p) => match view.find(&p).cloned() {
                Some(n) => {
                    self.draw_control(ui, &n, 0, &variant);
                    if n.path == KERNEL_PATH {
                        ui.label(RichText::new(crate::kernel_scene::REFLECTS).weak().small());
                    }
                    let on = n.is_on();
                    for c in n.children.iter().filter(|c| c.when.is_none_or(|w| w == on)) {
                        self.draw_control(ui, c, 1, &variant);
                    }
                }
                None => {
                    ui.colored_label(AMBER, format!("`{p}` är inte en nod i `{variant}`s träd"));
                }
            },
        }
        self.draw_run(ui);
        self.draw_buildinfo(ui);
        self.draw_verbs(ui);
    }

    /// **The typ the last build produced**, read off the BUILD receipt (gap #7: the typ
    /// on the receipt decides the verbs) — falling back to the picked column's declared
    /// kind only for a build that produced no readable receipt. `None` before a build.
    fn built_typ(&self) -> Option<ArtifactKind> {
        let o = self.report.outcome.as_ref()?;
        if !o.ok {
            return None;
        }
        Some(match &o.receipt {
            Some(r) if !r.target.trim().is_empty() => crate::actions::typ_of(&r.target),
            _ => self.selected_target()?.kind,
        })
    }

    /// **BUILDINFO — always, for every typ.** The build receipt, readable: what was
    /// built, its sha, the settings it was built with, the edges it consumed, and what
    /// the binary's own symbol table says about the claims. A lib gets this and no verb.
    fn draw_buildinfo(&mut self, ui: &mut Ui) {
        let Some(o) = self.report.outcome.clone() else {
            return;
        };
        if !o.ok {
            return;
        }
        ui.separator();
        ui.label(RichText::new("buildinfo").strong());
        ui.horizontal_wrapped(|ui| {
            ui.label(RichText::new("kvitto").monospace().weak());
            match &o.receipt {
                Some(r) => {
                    ui.label(RichText::new(format!("typ {}", r.target)).monospace());
                    ui.label(RichText::new(receipt_line(r)).monospace().small());
                }
                None => {
                    ui.label(RichText::new("INGET KVITTO i artefakten").color(AMBER));
                }
            }
        });
        ui.horizontal_wrapped(|ui| {
            ui.label(RichText::new("artefakt").monospace().weak());
            ui.label(RichText::new(&o.artifact).monospace().small());
            if let Some(s) = &o.sha {
                ui.label(
                    RichText::new(format!("sha {}", short(s)))
                        .monospace()
                        .small(),
                );
            }
            if let Some(n) = o.size_bytes {
                ui.label(RichText::new(bytes(n)).weak().small());
            }
            if let Some(p) = &o.proof {
                ui.label(RichText::new(format!("bevis {}", p.label())).small());
            }
        });
        // The edges the variant consumed — the pins a re-reader needs beside the sha.
        let edges: Vec<String> = self
            .tree
            .as_ref()
            .map(|t| {
                t.flags()
                    .into_iter()
                    .filter(|f| f.starts_with("--build"))
                    .collect()
            })
            .unwrap_or_default();
        if !edges.is_empty() {
            ui.horizontal_wrapped(|ui| {
                ui.label(RichText::new("kanter").monospace().weak());
                for e in edges {
                    ui.label(RichText::new(e).monospace().small());
                }
            });
        }
    }

    /// **The verbs the typ bears** — from [`crate::actions::actions`], the data table,
    /// never a kind-branch here: RUN with its `run.where` val for an exe, RUN for a
    /// container, BOOT for a medium, nothing for a lib. Firing a verb writes the RUN
    /// receipt (the second receipt, law E) and hands the act to the forge; the console
    /// leaf draws the surfaces.
    fn draw_verbs(&mut self, ui: &mut Ui) {
        let Some(typ) = self.built_typ() else { return };
        let verbs: Vec<crate::actions::Verb> = self
            .forge
            .actions(typ)
            .into_iter()
            .map(Into::into)
            .collect();
        self.last_verbs = verbs.clone();
        let a = crate::actions::with_verbs(typ, verbs);
        if a.verbs.is_empty() {
            ui.label(
                RichText::new(format!(
                    "typ {} bär inga verb — bygget är klart",
                    typ.as_str()
                ))
                .weak()
                .small(),
            );
            return;
        }
        let mut fire: Option<crate::actions::Verb> = None;
        ui.horizontal_wrapped(|ui| {
            if let Some(w) = &a.where_ {
                // The `where` val — the only two places a program runs from ⚒. Its path
                // is on screen; the flag is honest: `run <koordinat>` on the CLI is not verified yet.
                ui.label(RichText::new(w.path).monospace().weak().small());
                for c in &w.choices {
                    if ui
                        .selectable_label(self.run_where == *c, RichText::new(*c).monospace())
                        .clicked()
                    {
                        self.run_where = (*c).to_string();
                    }
                }
                ui.label(
                    RichText::new("(`run <koordinat>` på CLI:t är gap #7 — ingen promptflagga än)")
                        .small()
                        .color(AMBER),
                );
            }
            if a.pane == PaneKind::Exec {
                let caption = ui.label(RichText::new("argv").monospace());
                ui.add(
                    egui::TextEdit::singleline(&mut self.argv)
                        .hint_text("argument")
                        .desired_width(240.0),
                )
                .labelled_by(caption.id);
            }
            let live = self.report.live;
            for v in &a.verbs {
                if ui
                    .add_enabled(!live, egui::Button::new(RichText::new(v.label()).strong()))
                    .clicked()
                {
                    fire = Some(*v);
                }
            }
        });
        if let Some(n) = &self.run_note {
            ui.colored_label(AMBER, n);
        }
        let Some(v) = fire else { return };
        self.run_note = None;
        let where_ = a.where_.as_ref().map(|_| self.run_where.clone());
        // A program in a VM is not wired yet: the val offers it because the containers
        // rule names it, and the pane says so rather than running it on the host.
        if where_.as_deref() == Some("vm") && a.pane == PaneKind::Exec {
            self.run_note = Some(
                "exe i vm: inte kopplat än — kör i container, eller vänta på vm-vägen".to_string(),
            );
            return;
        }
        self.run_receipt = Some(crate::actions::RunReceipt {
            typ: typ.as_str().to_string(),
            verb: v.label().to_string(),
            where_,
            artifact_sha: self.report.outcome.as_ref().and_then(|o| o.sha.clone()),
            outcome: "running".to_string(),
        });
        match (v, a.pane) {
            (crate::actions::Verb::Run, PaneKind::Exec) => {
                let argv = self.argv.clone();
                self.act(Act::Run(argv));
            }
            (crate::actions::Verb::Run, _) | (crate::actions::Verb::Boot, _) => {
                self.act(Act::Start)
            }
            (crate::actions::Verb::Unpack, _) => {
                self.run_note = Some(
                    "UNPACK: ingen typ bär det än (arkivtypen finns inte som artefakttyp)"
                        .to_string(),
                );
            }
        }
    }

    /// The RUN receipt's outcome follows the console: `running` while the subject is
    /// up, then the last measured line — an exit code, INSTALL-OK, a stop.
    fn settle_run_receipt(&mut self) {
        let Some(r) = self.run_receipt.as_mut() else {
            return;
        };
        if self.report.live {
            r.outcome = "running".to_string();
        } else if r.outcome == "running" {
            r.outcome = self
                .report
                .lines
                .iter()
                .rev()
                .find(|l| !l.trim().is_empty())
                .cloned()
                .unwrap_or_else(|| "stopped".to_string());
        }
    }

    /// The verbs and the where, as data — what the menu offers after a build.
    fn actions_json(&self) -> Value {
        match self.built_typ() {
            Some(t) => {
                let mut j = crate::actions::with_verbs(t, self.last_verbs.clone()).state_json();
                if let Value::Object(o) = &mut j {
                    o.insert("run_where".into(), json!(self.run_where));
                }
                j
            }
            None => Value::Null,
        }
    }

    /// The buildinfo, as data.
    fn buildinfo_json(&self) -> Value {
        match self.report.outcome.as_ref().filter(|o| o.ok) {
            Some(o) => json!({
                "typ": self.built_typ().map(|t| t.as_str()),
                "receipt": o.receipt.as_ref().map(receipt_line),
                "artifact": o.artifact,
                "sha": o.sha,
                "proof": o.proof.as_ref().map(|p| p.label()),
                "edges": self.tree.as_ref().map(|t| t.flags().into_iter().filter(|f| f.starts_with("--build")).collect::<Vec<_>>()),
            }),
            None => Value::Null,
        }
    }

    /// The facts the recipe FIXED for the picked variant — kind, profile, target,
    /// version, the donor line — read-only, above the controls. Not nodes: nothing here
    /// is a choice.
    fn draw_fixed_facts(&mut self, ui: &mut Ui) {
        let fixed: Vec<Setting> = self
            .selected_target()
            .map(|t| t.settings.iter().filter(|s| s.fixed).cloned().collect())
            .unwrap_or_default();
        for s in &fixed {
            ui.horizontal(|ui| {
                ui.label(RichText::new(&s.key).monospace());
                ui.label(RichText::new(&s.value).monospace().weak());
                if let Some(n) = &s.note {
                    ui.label(RichText::new(n).weak().small());
                }
            });
        }
    }

    /// **One node's control**, its path and its flag on the same line — and, for a node
    /// with children, the subgraph named (opened in the graph, not unrolled here).
    fn draw_control(&mut self, ui: &mut Ui, n: &NodeView, depth: usize, variant: &str) {
        let is_kernel = n.path == KERNEL_PATH;
        ui.horizontal(|ui| {
            ui.add_space(depth as f32 * 18.0);
            match n.kind {
                "feature" if is_kernel => {
                    ui.label(RichText::new(&n.label).strong());
                    if let Some(e) = &n.edge {
                        ui.label(RichText::new(format!("→ {e}")).monospace());
                        self.draw_edge_verb(ui, n, variant);
                    }
                }
                "feature" if n.edge.is_some() => {
                    // Any node with a consumption edge (wave 2's typed edge) bears BYGG
                    // for its edge target — the same road as the kärna node's.
                    ui.label(RichText::new(&n.label).strong());
                    ui.label(RichText::new(format!("→ {}", n.edge.as_deref().unwrap_or_default())).monospace());
                    self.draw_edge_verb(ui, n, variant);
                }
                "feature" if n.symbol => {
                    // Through the tree's own judge — cascade and contract lock included —
                    // the same door the CLI's `--opt kernel.<symbol>=off` goes through.
                    let mut on = n.is_on();
                    let cb = ui.checkbox(&mut on, RichText::new(&n.label).monospace());
                    if !n.locked_by.is_empty() {
                        cb.on_hover_text(format!("LÅST — bryter {}", n.locked_by.join(", ")));
                    }
                    if on != n.is_on() {
                        self.toggle_symbol(variant, &n.path, on);
                    }
                }
                "feature" if n.fixed => {
                    ui.label(RichText::new(&n.label).monospace());
                    ui.label(RichText::new("på").monospace().weak());
                }
                "feature" => {
                    let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
                    let mut on = cur == "true";
                    let cb = ui.checkbox(&mut on, RichText::new(&n.label).strong());
                    if let Some(f) = &n.feature {
                        cb.on_hover_text(format!(
                            "av ⇒ {f} byggs INTE in i den packade binären (--no-default-features utan {f}), \
                             inte bara avstängd vid körning"
                        ));
                        // The cost line rides only on a feature the prompt can name (an
                        // opt-out); an open cargo feature already says it reaches nothing.
                        if n.flag.is_some() {
                            ui.label(
                                RichText::new(format!("{}{f}", if on { '+' } else { '\u{2212}' }))
                                    .monospace()
                                    .weak()
                                    .small(),
                            );
                        }
                    }
                    if on != (cur == "true") {
                        *cur = on.to_string();
                    }
                }
                "val" => {
                    ui.label(RichText::new(&n.label).monospace());
                    let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
                    let mut want: Option<String> = None;
                    for c in &n.choices {
                        if ui.selectable_label(cur == c, RichText::new(c).monospace()).clicked() {
                            want = Some(c.clone());
                        }
                    }
                    if let Some(v) = want {
                        *cur = v;
                    }
                }
                _ => {
                    // The KEY is the caption a field is labelled by — the name a robot types
                    // into and the alias the prompt accepts; the declaration's own label
                    // rides beside it.
                    let caption = ui.label(RichText::new(&n.key).monospace());
                    if n.label != n.key {
                        ui.label(RichText::new(&n.label).weak().small());
                    }
                    let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
                    draw_param_field(ui, &n.key, n.declared.as_ref(), cur, caption.id);
                }
            }
            // ── LAW C, ON SCREEN: the path, and the flag it equals ───────────────
            // The path is drawn when it says more than the control's own caption — a
            // root's path IS its name, and two atoms with one name make both unreachable.
            if n.path != n.key && n.path != n.label {
                ui.label(RichText::new(&n.path).monospace().weak().small());
            }
            if let Some(f) = &n.flag {
                ui.label(RichText::new(f).monospace().small());
            }
            if !n.children.is_empty() && depth > 0 {
                ui.label(RichText::new(format!("▸ delgraf · {} noder", n.children.len())).weak().small());
            }
        });
        // The node's note goes BELOW its row, wrapping: inside the row it was one
        // line of a horizontal layout and got CLIPPED ("— klicket n", on the
        // owner's screen 2026-10-16).
        if let (None, Some(why)) = (&n.flag, &n.no_flag_why) {
            node_note(ui, why, depth);
        }
        // A root's children with children are subgraphs in the graph; its leaf
        // children draw here so a variant's whole flat surface is one menu. Under
        // `kärna` only the VALS draw here (preset, profile — the choices); the symbol
        // features are the subgraph, reached by selecting the node.
        if depth == 0 {
            let on = n.is_on();
            for c in n.children.iter().filter(|c| {
                c.when.is_none_or(|w| w == on)
                    && c.children.is_empty()
                    && (!is_kernel || c.kind == "val")
            }) {
                self.draw_control(ui, c, 1, variant);
            }
        }
    }

    /// **A `kernel.<symbol>` click, judged.** Through the model crate's `SettingsTree::try_toggle`
    /// on the picked variant's tree — cascade and contract lock included — the SAME
    /// door the CLI's `--opt kernel.<symbol>=off` goes through. An accepted decision
    /// lands in [`Self::chosen`] under the full path (so `Act::Build` carries
    /// `kernel.<symbol>=off` and the flag on screen is the flag the door gets), its
    /// cascade with it; a refusal is shown, verbatim, and turns nothing.
    fn toggle_symbol(&mut self, variant: &str, path: &str, on: bool) {
        // No tree for the picked row/target: nothing to judge the click against.
        if self
            .forge
            .tree(
                self.selected_row().map(|r| r.name.clone()).as_deref(),
                self.selected_target().map(|t| t.name.clone()).as_deref(),
                &self.chosen,
            )
            .is_none()
        {
            return;
        }
        // ONE spelling, the model crate's (lane AK, 2026-08-30). A kernel VARIANT's
        // roots used to be BARE in the settings tree (`preset`, `virtio-net`) while the
        // view, the graph and the prompt all spelled them `kernel.*`, so this pane
        // carried a spell/unspell layer to cross between them on every click.
        // `SettingsTree::of_kernel` scopes the row itself now — the same spelling the
        // CLI accepts and `--list-options` prints — so the crossing is gone and the
        // path the pane holds IS the path the judge resolves.
        let vals: [String; 2] = [
            format!("{KERNEL_PATH}{PATH_SEP}preset"),
            format!("{KERNEL_PATH}{PATH_SEP}profile"),
        ];
        let mut req = ToggleRequest::new(variant, path, on);
        for (k, v) in &self.chosen {
            if k.starts_with(&format!("{KERNEL_PATH}.")) && !vals.contains(k) {
                req.state.insert(k.clone(), v == "true");
            }
        }
        for p in &vals {
            // A choice recorded before the scope existed (or by a door that spells a
            // val bare) is still honoured, under the tree's spelling.
            let bare = p.rsplit(PATH_SEP).next().unwrap_or(p);
            if let Some(v) = self.chosen.get(p).or_else(|| self.chosen.get(bare)) {
                req.vals.insert(p.clone(), v.clone());
            }
        }
        // The judge is the forge's: the build thing over wasm when loaded, the derived
        // tree otherwise — never a second judge in the pane.
        let d = self.forge.try_toggle(&req);
        if !d.verdict.ok {
            self.menu_note = Some(d.verdict.line());
            return;
        }
        self.menu_note = None;
        self.chosen.insert(d.path.clone(), on.to_string());
        for c in &d.cascade {
            self.chosen.insert(c.clone(), "false".to_string());
        }
    }

    /// The result header and the artifact/receipt lines the sketches show.
    fn draw_result_head(&mut self, ui: &mut Ui) {
        let (Some(row), Some(target)) = (self.selected_row(), self.selected_target()) else {
            return;
        };
        let Some(phase) = self.report.phase.clone() else {
            return;
        };
        let head = format!("{} · {}", row.name, target.name);
        let secs = self.report.elapsed_ms as f64 / 1000.0;
        ui.add_space(6.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new(head).strong().monospace());
            let colour = match &phase {
                Phase::Failed(_) => RED,
                Phase::Done => TEAL,
                _ => AMBER,
            };
            ui.label(RichText::new(format!("{} {secs:.1}s", phase.label())).color(colour));
        });
        if let Phase::Failed(why) = &phase {
            ui.colored_label(RED, why);
        }
        if let Some(o) = &self.report.outcome {
            ui.horizontal(|ui| {
                ui.label(RichText::new("artefakt").weak());
                // The path is the way to the FILE, so it has to leave the pane:
                // click it (or the button) and it lands on the clipboard, through
                // the shared clip seam rather than a second copy path.
                // Showing a path the operator can only read is what made an ISO
                // that had been built for hours feel unreachable.
                let on_disk = artifact_on_disk(&o.artifact);
                let text = RichText::new(&o.artifact).monospace();
                let text = if on_disk.is_some() {
                    text
                } else {
                    text.color(RED)
                };
                let hit = ui
                    .add(egui::Label::new(text).sense(egui::Sense::click()))
                    .on_hover_text(match &on_disk {
                        Some(m) => format!(
                            "klicka för att kopiera sökvägen\n{}\n{} · byggd {}",
                            o.artifact,
                            bytes(m.0),
                            m.1
                        ),
                        None => format!(
                            "{}\nFILEN FINNS INTE PÅ DISK just nu — utfallet är från en \
                             tidigare körning, eller så städades filen bort",
                            o.artifact
                        ),
                    });
                if hit.clicked() {
                    put_typed(ui.ctx(), ClipPayload::Text(o.artifact.clone()));
                }
                if ui
                    .small_button("📋")
                    .on_hover_text("kopiera artefaktens sökväg")
                    .clicked()
                {
                    put_typed(ui.ctx(), ClipPayload::Text(o.artifact.clone()));
                }
                if let Some(dir) = std::path::Path::new(&o.artifact).parent() {
                    let dir = dir.display().to_string();
                    if ui
                        .small_button("📁")
                        .on_hover_text(format!("kopiera katalogen\n{dir}"))
                        .clicked()
                    {
                        put_typed(ui.ctx(), ClipPayload::Text(dir));
                    }
                }
                if on_disk.is_none() {
                    ui.label(RichText::new("saknas på disk").color(RED));
                }
                if let Some(b) = o.size_bytes {
                    ui.label(RichText::new(bytes(b)).monospace().weak());
                }
                if let Some(s) = &o.sha {
                    ui.label(RichText::new(short(s)).monospace().weak());
                }
                if let Some(n) = &o.note {
                    ui.label(RichText::new(n).weak());
                }
            });
            ui.horizontal(|ui| {
                ui.label(RichText::new("kvitto  ").weak());
                match &o.receipt {
                    Some(r) => ui.label(RichText::new(receipt_line(r)).monospace()),
                    None => {
                        ui.label(RichText::new("inget kvitto skrivet i artefakten").color(AMBER))
                    }
                };
            });
            // The per-site record the build APPLIED — the forge's own sentence per
            // value, so a fixture-filled one reads "← fixture wg-hub (…)" here, in
            // the CLI log, and in the attested descriptor alike.
            for line in &o.applied {
                ui.horizontal(|ui| {
                    ui.label(RichText::new("param   ").weak());
                    ui.label(RichText::new(line).monospace());
                });
            }
            // Which cargo features the artifact was built with — the record's own.
            if let Some(f) = &o.features {
                ui.add(egui::Label::new(RichText::new(f).monospace()).wrap());
            }
            // ── WHERE THE RECORD LANDED ──────────────────────────────────────
            //
            // ALWAYS drawn once there is an outcome, never only on failure: a row
            // that appears when something is wrong is a row nobody learns to read,
            // and "filed nowhere" has to be as visible as "filed". Three colours
            // for the three states the writer already tells apart — RED for a
            // configured server that took nothing, TEAL for rows that landed,
            // AMBER for a build that never tried. Never green-by-default, and
            // never absent.
            {
                let colour = if o.warehouse_red {
                    RED
                } else if o.warehouse_landed {
                    TEAL
                } else {
                    AMBER
                };
                ui.horizontal(|ui| {
                    ui.label(RichText::new("register").weak());
                    ui.label(RichText::new(&o.warehouse_line).color(colour).monospace());
                });
            }
            // The third level. Shown whenever there was an artifact to measure,
            // including when the measurement could not be made: "not measured" is
            // an outcome an operator has to see, not a row that disappears.
            if let Some(p) = &o.proof {
                let colour = match p.verdict {
                    ReceiptVerdict::Proven => TEAL,
                    ReceiptVerdict::Disproven => RED,
                    _ => AMBER,
                };
                ui.horizontal(|ui| {
                    ui.label(RichText::new("bevis   ").weak());
                    ui.label(RichText::new(p.label()).color(colour).monospace().strong());
                });
                for line in &p.lines {
                    ui.label(RichText::new(line).color(colour).monospace().weak());
                }
            }
        }
    }

    /// **The pane below.** One `match`, three arms, and no name of anything.
    fn draw_pane(&mut self, ui: &mut Ui) {
        if self.report.outcome.is_none() {
            return;
        }
        let Some(kind) = self.pane() else { return };
        ui.add_space(4.0);
        match kind {
            PaneKind::None => {
                ui.label(RichText::new("den här artefakten går inte att köra").weak());
            }
            PaneKind::Exec => self.draw_exec(ui),
            PaneKind::Log => self.draw_log(ui),
            PaneKind::Console => self.draw_console(ui),
        }
        if let Some(n) = &self.report.note {
            ui.colored_label(AMBER, n);
        }
    }

    /// [`PaneKind::Exec`] — an argv field and stdout. What an executable offers.
    fn draw_exec(&mut self, ui: &mut Ui) {
        let mut run = false;
        ui.horizontal(|ui| {
            let caption = ui.label(RichText::new("argv").monospace());
            ui.add(
                egui::TextEdit::singleline(&mut self.argv)
                    .hint_text("argument")
                    .desired_width(360.0),
            )
            .labelled_by(caption.id);
            run = ui.button("KÖR").clicked();
        });
        if run {
            let argv = self.argv.clone();
            self.act(Act::Run(argv));
        }
        self.draw_lines(ui, "stdout");
    }

    /// [`PaneKind::Log`] — start, stop, the log, and the ports the recipe declared.
    fn draw_log(&mut self, ui: &mut Ui) {
        let live = self.report.live;
        let ports = self
            .report
            .outcome
            .as_ref()
            .map(|o| o.ports.clone())
            .unwrap_or_default();
        let mut act = None;
        ui.horizontal(|ui| {
            if ui
                .add_enabled(!live, egui::Button::new("▶ START"))
                .clicked()
            {
                act = Some(Act::Start);
            }
            if ui.add_enabled(live, egui::Button::new("✕ STOPP")).clicked() {
                act = Some(Act::Stop);
            }
            for p in &ports {
                ui.label(RichText::new(format!(":{p}")).monospace().color(TEAL));
            }
        });
        if let Some(a) = act {
            self.act(a);
        }
        self.draw_lines(ui, "logg");
    }

    /// [`PaneKind::Console`] — run, reboot, stop, and BOTH consoles, one above the other.
    ///
    /// The serial and the framebuffer are not two modes of one view; they answer two
    /// different questions about the same guest and an installation needs both answers
    /// at the same time. The same pane serves every bootable kind.
    fn draw_console(&mut self, ui: &mut Ui) {
        let live = self.report.live;
        let mut act = None;
        ui.horizontal(|ui| {
            if ui.add_enabled(!live, egui::Button::new("▶ KÖR")).clicked() {
                act = Some(Act::Start);
            }
            if ui
                .add_enabled(live, egui::Button::new("↻ BOOTA OM"))
                .clicked()
            {
                act = Some(Act::Reboot);
            }
            if ui.add_enabled(live, egui::Button::new("✕ STOPP")).clicked() {
                act = Some(Act::Stop);
            }
        });
        if let Some(a) = act {
            self.act(a);
        }
        self.draw_lines(ui, "serial");
        ui.label(RichText::new("grafisk").weak().small());
        egui::Frame::group(ui.style()).show(ui, |ui| self.draw_screen(ui));
    }

    /// The text console the three panes share.
    ///
    /// ── WHY THIS IS ONE WIDGET AND NOT N LABELS ──────────────────────────────
    ///
    /// **MEASURED live by the owner 2026-08-20** (stinky `kopiera-1`): Ctrl+C over this
    /// block pasted the PREVIOUS clipboard contents — intermittently, and reliably
    /// only while a build was actually running.
    ///
    /// It was `N` separate `ui.label`s in this scroll area. egui 0.35 holds a
    /// cross-label selection in ONE context-wide plugin, and
    /// `label_text_selection.rs:220-224` says verbatim "let's just deselect" the moment
    /// either end of the selection leaves the visible rect. `stick_to_bottom(true)` plus
    /// the 120 ms `WORKING_REPAINT` above meant the viewport was yanked on every new
    /// line, so the selection was dropped, `text_to_copy` was emptied, and `copy_text`
    /// was never called at all — leaving the clipboard holding whatever it had before.
    /// That is why the symptom read as "copied the WRONG text" and not "copied nothing",
    /// and why it went away as soon as the run settled and stopped scrolling.
    ///
    /// [`CopyableBlock`] is the shared cure (LAW 5 — the same widget
    /// serves every log/console block in the constellation): ONE read-only
    /// `TextEdit::multiline`, whose selection lives in its own `TextEditState` that
    /// `LabelSelectionState` never touches, plus an explicit button that hands the whole
    /// block over in a call no viewport movement can interrupt. `follow` keeps the
    /// newest line in view but yields while the pointer is down.
    ///
    /// The scroll area is salted per `label` too: this used to be the one `ScrollArea`
    /// in the pane with no `id_salt` at all, so `stdout`, `logg` and `serial` shared a
    /// scroll offset (stinky `kopiera-3`).
    fn draw_lines(&mut self, ui: &mut Ui, label: &str) {
        ui.label(RichText::new(label).weak().small());
        egui::Frame::group(ui.style()).show(ui, |ui| {
            if self.report.lines.is_empty() {
                ui.label(RichText::new("(inget ännu)").weak());
                return;
            }
            let text = CopyableBlock::join(&self.report.lines);
            CopyableBlock::new(label, &text)
                .max_height(220.0)
                .follow(true)
                .show(ui);
        });
    }

    /// The graphical console. A console with no frame yet SAYS so — it never draws a
    /// placeholder that could be mistaken for the guest's screen.
    fn draw_screen(&mut self, ui: &mut Ui) {
        let Some(s) = self.report.screen.clone() else {
            ui.colored_label(AMBER, "ingen bildruta ännu");
            return;
        };
        if s.rgba.len() != s.width * s.height * 4 {
            ui.colored_label(RED, "bildrutan har fel storlek och ritas inte");
            return;
        }
        let image = egui::ColorImage::from_rgba_unmultiplied([s.width, s.height], &s.rgba);
        let tex = self.texture.get_or_insert_with(|| {
            // NEAREST, not the default linear: this is a guest CONSOLE, and upscaling
            // text with a linear filter turns crisp glyphs into grey mush. A 2x nearest
            // upscale of an 80x25 VGA console is readable; a 2x linear one is not.
            ui.ctx().load_texture(
                "build-scene-console",
                image.clone(),
                egui::TextureOptions::NEAREST,
            )
        });
        tex.set(image, egui::TextureOptions::NEAREST);
        let size = tex.size_vec2();
        let avail = ui.available_size();
        ui.image((tex.id(), size * console_scale(size, avail)));
    }
}

/// **How big the guest's console is drawn** — a pure function so the rule can be proved
/// without a GPU, a window or a running VM.
///
/// It used to be `(available_width / width).min(1.0)`, and that `.min(1.0)` is the whole
/// defect: it caps the scale at NATIVE SIZE, so the console can only ever be shrunk. A
/// guest that boots at 800x600 into a 1600-pixel-wide pane drew at 800x600 with half the
/// pane empty beside it — the owner's *"that window has to be made bigger"*. Nothing about
/// a framebuffer makes its own pixel count the right size on someone else's display.
///
/// The rule now: fill the space, keep the aspect, and be bounded by BOTH axes. Bounding
/// on width alone is why a cap felt necessary in the first place — an upscale that only
/// watches width will run a 4:3 console straight off the bottom of a wide pane, and the
/// operator loses the part they were reading.
///
/// [`MAX_CONSOLE_SCALE`] stops a tiny frame from being blown into abstract art: an early
/// boot frame can legitimately be a few hundred pixels, and 8x of that is a wall of
/// squares, not a console. Degenerate inputs (a zero-sized texture, a pane with no room)
/// fall back to 1.0 rather than producing an infinity or a zero, because a console drawn
/// at native size is a recoverable mistake and one drawn at NaN is a panic.
fn console_scale(size: egui::Vec2, avail: egui::Vec2) -> f32 {
    if size.x <= 0.0 || size.y <= 0.0 || avail.x <= 0.0 || avail.y <= 0.0 {
        return 1.0;
    }
    (avail.x / size.x)
        .min(avail.y / size.y)
        .min(MAX_CONSOLE_SCALE)
}

/// The ceiling on [`console_scale`]. Chosen so a 320x200 early-boot frame reaches a
/// legible ~1280x800 and stops there, rather than being stretched until the pixels are
/// the size of the text they are made of.
const MAX_CONSOLE_SCALE: f32 = 4.0;

// ── the facet contract ────────────────────────────────────────────────────────

impl Facet for BuildScene {
    fn title(&self) -> &str {
        "⚒ Build Thing"
    }

    fn ui(&mut self, ui: &mut Ui) {
        self.begin_pass(ui);
        self.draw_entry(ui);
        self.draw_graph(ui, false);
        self.draw_menu(ui);
        self.draw_steps(ui);
        self.draw_result_head(ui);
        self.draw_pane(ui);
        self.draw_graph_canvas(ui);
    }

    fn component(&self) -> &'static str {
        COMPONENT
    }

    fn severity(&self) -> Severity {
        match (&self.error, &self.report.phase) {
            (Some(_), _) | (_, Some(Phase::Failed(_))) => Severity::Error,
            (None, None) if self.menu.is_none() => Severity::Warning,
            _ => Severity::Info,
        }
    }

    fn state_json(&self) -> Value {
        let rows: Vec<Value> = self
            .menu
            .iter()
            .flat_map(|m| m.rows.iter())
            .map(|r| {
                json!({
                    "name": r.name,
                    "badges": r.badges,
                    "targets": r.targets.iter().map(|t| json!({
                        "name": t.name,
                        "kind": t.kind.as_str(),
                        "pane": t.pane(),
                        "declared": t.declared,
                        "settings": t.settings.iter().map(|s| json!({
                            "key": s.key, "value": s.value, "fixed": s.fixed,
                            // The closed vocabulary, when there is one. `[]` on
                            // every free-text setting — a driver that wants to
                            // know whether a control can be TURNED asks here.
                            "choices": s.choices.iter().map(|c| c.value.clone()).collect::<Vec<_>>(),
                            "fixture": s.declared.as_ref().and_then(|d| d.fixture.clone()),
                            "multiline": s.declared.as_ref().is_some_and(|d| d.multiline),
                            // The opt-out axis: what this setting IS, and what
                            // it answers to. Null on every setting that is
                            // neither, which is every setting a pre-options
                            // recipe produces.
                            "option": s.declared_option.as_ref().map(|o| json!({
                                "label": o.label, "feature": o.feature,
                            })),
                            "governed_by": s.governed.as_ref().map(|g| json!({
                                "option": g.option, "shown_when": g.shown_when,
                            })),
                        })).collect::<Vec<_>>(),
                    })).collect::<Vec<_>>(),
                })
            })
            .collect();
        json!({
            "title": self.title(),
            "component": COMPONENT,
            "artifact": self.artifact,
            "coordinate_field": self.coordinate,
            "recipe": self.menu.as_ref().map(|m| m.coordinate.to_string()),
            "how": self.menu.as_ref().map(|m| m.how.clone()),
            "error": self.error,
            "rows": rows,
            "row": self.selected_row().map(|r| r.name.clone()),
            "target": self.selected_target().map(|t| t.name.clone()),
            "pane": self.pane(),
            "can_build": self.can_build(),
            // WHY it is shut, so a driver asserts on the reason rather than on
            // the bare `false` a dozen different faults all produce.
            "unmet": self.unmet_inputs()
                .into_iter()
                .map(|(k, why)| json!({"key": k, "why": why}))
                .collect::<Vec<_>>(),
            // The opt-outs as they stand, and the cargo-feature delta they
            // resolve to. `feature_delta` is the PREVIEW, in the same words the
            // receipt uses once the forge has answered.
            "toggles": self.toggles(),
            "feature_delta": self.feature_delta(),
            // Which inputs the toggles currently ask for — the list the operator
            // is actually looking at, so a hidden field cannot be asserted as
            // present.
            "visible_settings": self.selected_target().map(|t| {
                let toggles = self.toggles();
                t.settings
                    .iter()
                    .filter(|s| s.visible_in(&toggles))
                    .map(|s| s.key.clone())
                    .collect::<Vec<_>>()
            }),
            // The dependency picture, whole. Everything a driver can assert about
            // BOTH directions is in the pane's own state — there is no second copy
            // here that could tell a different story than the graph on screen.
            "axis": self.galois.as_ref().map(GaloisPane::state_json),
            // What the picture on screen is a picture OF — row, column and the
            // toggles that were in force when it was derived. A driver asserts
            // on THIS to know the graph followed a tick, rather than on the
            // graph's own contents alone.
            "axis_row": self.axis_row.as_ref().map(|p| p.row.clone()),
            "axis_pick": self.axis_row.as_ref().map(|p| json!({
                "row": p.row, "target": p.target, "toggles": p.toggles,
            })),
            "axis_note": self.axis_note,
            // WHICH ROAD the picture came down, as three distinguishable words plus
            // the warehouse's own excuse. A driver asserts on this rather than on a
            // substring of the provenance sentence — the whole point of the split is
            // that a fallback must not be mistakable for a warehouse read, and a test
            // that greps prose is one rewording away from asserting nothing.
            "axis_road": self.axis_road.as_ref().map(|r| match r {
                AxisRoad::Warehouse { .. } => "warehouse",
                AxisRoad::Manifests { .. } => "manifests",
                AxisRoad::Unlocated { .. } => "unlocated",
            }),
            // The crates the graph is NOT showing and why a driver may click them.
            // Both halves are observable because both halves are claims: the list is
            // "we have not looked at these", and the focus set is "these were asked
            // for". A test that could only see the graph could not tell a crate that
            // was never hunted from one hunted and found absent.
            "axis_unanalysed": self.axis_unanalysed,
            "axis_focus": self.axis_focus.iter().collect::<Vec<_>>(),
            "axis_road_detail": self.axis_road.as_ref().map(|r| match r {
                AxisRoad::Warehouse { path } => path.clone(),
                AxisRoad::Manifests { why } | AxisRoad::Unlocated { why } => why.clone(),
            }),
            // The forge's own leaf, whole: its facet state when it is up, its
            // refusal when it is not, and null when this selection has no such
            // surface. A driver asserts on THIS rather than on a widget it hopes
            // is somewhere below the settings block.
            "leaf": self.leaf,
            // The picked variant's TREE as the graph and the menu draw it: every node with
            // its path and the flag it equals (law C), the `kärna` node where the variant
            // has one. Null with no recipe resolved.
            "tree": self.tree.as_ref().map(TreeView::state_json),
            // The build graph: what is selected, what is folded, how big.
            "graph": self.graph_json(),
            // The selected node's menu: its subject and its controls with their flags.
            "menu": self.menu_json(),
            // The second receipt: what happened when it ran.
            "run_receipt": self.run_receipt.as_ref().map(crate::actions::RunReceipt::state_json),
            // The step console: the edge DAG the last `BYGG <kant>` planned, every step's
            // state word, the clock — and its receipt, beside the other two.
            "steps": self.steps.state_json(),
            "edge_build_receipt": self.steps.receipt().map(crate::build_steps::EdgeBuildReceipt::state_json),
            "settings": self.chosen,
            "argv": self.argv,
            // Every console this pane is showing, at once. A toggle would put one
            // string here; the design puts both.
            "consoles": if self.pane() == Some(PaneKind::Console) { CONSOLES.to_vec() } else { Vec::new() },
            "phase": self.report.phase.as_ref().map(Phase::label),
            "failed": matches!(self.report.phase, Some(Phase::Failed(_))),
            "elapsed_ms": self.report.elapsed_ms,
            "live": self.report.live,
            "lines": self.report.lines,
            "line_count": self.report.lines.len(),
            "note": self.report.note,
            "screen": self.report.screen.as_ref().map(|s| json!({
                "width": s.width, "height": s.height, "bytes": s.rgba.len(),
            })),
            "outcome": self.report.outcome.as_ref().map(|o| json!({
                "ok": o.ok,
                "artifact": o.artifact,
                // Whether the named file is THERE, asked of the filesystem at read
                // time — an Outcome outlives its artifact, and a path to nothing
                // must not read as a delivered file.
                "artifact_on_disk": artifact_on_disk(&o.artifact).is_some(),
                "artifact_dir": std::path::Path::new(&o.artifact)
                    .parent()
                    .map(|p| p.display().to_string()),
                "size_bytes": o.size_bytes,
                "sha": o.sha,
                "ports": o.ports,
                "has_receipt": o.receipt.is_some(),
                "receipt": o.receipt.as_ref().map(receipt_line),
                "applied": o.applied,
                "features": o.features,
                // `proven` is deliberately not `!failed`: NotMeasured is false
                // here and must stay false, or a stripped binary reads as a pass.
                "proof": o.proof.as_ref().map(|p| p.label()),
                "proven": o.proof.as_ref().is_some_and(Proof::holds),
                "proof_lines": o.proof.as_ref().map(|p| p.lines.clone()).unwrap_or_default(),
                // Where the provenance record landed. `landed` is NOT `!red` —
                // a build that never tried is false in both, and a driver that
                // asserted on one alone could not tell it from a success.
                "warehouse_line": o.warehouse_line,
                "warehouse_red": o.warehouse_red,
                "warehouse_landed": o.warehouse_landed,
            })),
        })
    }

    /// The headless seam. One message per step of the flow, named after the step.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<Value>(msg_json) else {
            return;
        };
        if let Some(s) = v.get("artifact").and_then(Value::as_str) {
            self.artifact = s.to_string();
        }
        if let Some(s) = v.get("coordinate").and_then(Value::as_str) {
            self.coordinate = s.to_string();
        }
        if let Some(s) = v.get("argv").and_then(Value::as_str) {
            self.argv = s.to_string();
        }
        if let Some(s) = v.get("set_setting").and_then(Value::as_object) {
            for (k, val) in s {
                if let Some(val) = val.as_str() {
                    self.chosen.insert(k.clone(), val.to_string());
                }
            }
        }
        if v.get("resolve").is_some() {
            self.resolve_artifact();
        }
        if v.get("resolve_coordinate").is_some() {
            self.resolve_coordinate();
        }
        if let Some(name) = v.get("pick_row").and_then(Value::as_str) {
            if let Some(i) = self
                .menu
                .as_ref()
                .and_then(|m| m.rows.iter().position(|r| r.name == name))
            {
                self.pick_row(i);
            }
        }
        if let Some(name) = v.get("pick_target").and_then(Value::as_str) {
            if let Some(i) = self
                .selected_row()
                .and_then(|r| r.targets.iter().position(|t| t.name == name))
            {
                self.pick_target(i);
            }
        }
        // The two directions, through the SAME entry point a pointer click takes:
        // `feat:<name>` for a feature, `crate:<name>` for a package, `and:…` for a
        // conjunction. A message per direction would be two code paths for one
        // model, which is the drift this pane exists to not have.
        // Buy one crate's minimal-cause hunt — the same entry point the chip's
        // pointer click takes, so a driver exercises the shipped path and not a
        // parallel one.
        if let Some(krate) = v.get("focus_crate").and_then(Value::as_str) {
            self.focus_crate(krate);
        }
        if let Some(node) = v.get("click_axis").and_then(Value::as_str) {
            let node = node.to_string();
            self.click_axis(&node);
        }
        if v.get("build").is_some() {
            self.build();
        }
        if v.get("run").is_some() {
            let argv = self.argv.clone();
            self.act(Act::Run(argv));
        }
        if v.get("start").is_some() {
            self.act(Act::Start);
        }
        if v.get("stop").is_some() {
            self.act(Act::Stop);
        }
        if v.get("reboot").is_some() {
            self.act(Act::Reboot);
        }
        // `BYGG <kant>` by node path — the button's headless twin.
        if let Some(path) = v.get("bygg_edge").and_then(Value::as_str) {
            let path = path.to_string();
            if !self.bygg_edge_at(&path) {
                self.menu_note = Some(format!(
                    "`{path}` bär ingen konsumtionskant — inget BYGG att fyra av"
                ));
            }
        }
        // The dry pass by node path — plans the chain, spawns nothing.
        if let Some(path) = v.get("plan_edge").and_then(Value::as_str) {
            let path = path.to_string();
            if !self.plan_edge_at(&path) {
                self.menu_note = Some(format!(
                    "`{path}` bär ingen konsumtionskant — ingen kedja att planera"
                ));
            }
        }
        // A step plan as data — the fixture door for the console's five states.
        if let Some(plan) = v.get("stage_steps") {
            if let Err(e) = self.stage_steps(plan) {
                self.menu_note = Some(format!("stage_steps: {e}"));
            }
        }
        if v.get("poll").is_some() {
            self.take_report();
        }
    }
}

/// **The ⚒ body DECOMPOSES into five named leaves** — the tree
/// [`crate::build_thing_scene::body`] has declared since it was written, and which had
/// no caller until this impl existed.
///
/// It is a DECOMPOSITION, not a rewrite: every method here is one of the same
/// `draw_*` calls `Facet::ui` makes, in the same order, so a harness that boxes this
/// pane whole draws exactly what it drew before. What changes is that the five regions
/// are now five panes with four draggable dividers between them, which is the whole
/// point — *"fönstret går inte att förstora"* had a precise cause, and it was that the
/// body was one `Slot` with no divider anywhere inside it.
impl crate::tabbed::LeafPanel for BuildScene {
    // No `leaf_absent` override: every ⚒ leaf has a subject whenever the tab does. The
    // kernel used to be the one conditional leaf, gated through here on
    // `Forge::kernel_declared`; it is the `kärna` NODE of the settings tree now (gap #6),
    // and a node needs no presence overlay — it is in the tree or it is not.

    fn ui_leaf(&mut self, leaf: &str, ui: &mut Ui) -> Value {
        use crate::build_thing_scene as tree;
        // Once a frame, however many leaves draw — see `begin_pass`.
        self.begin_pass(ui);
        match leaf {
            // WHICH ROOT (the two doors: an artifact name, or a coordinate), then the
            // build graph, then the accessible strips. Picking here re-resolves
            // everything to the right of the first divider.
            tree::GRAPH => {
                self.draw_entry(ui);
                self.draw_graph(ui, true);
                json!({
                    "artifact": self.artifact,
                    "recipe": self.menu.as_ref().map(|m| m.coordinate.to_string()),
                    "error": self.error,
                    "rows": self.menu.as_ref().map(|m| m.rows.len()).unwrap_or(0),
                    "row": self.selected_row().map(|r| r.name.clone()),
                    "target": self.selected_target().map(|t| t.name.clone()),
                    "graph": self.graph_json(),
                })
            }
            // The selected node's menu: its settings with their flags, the picture, ▶.
            tree::MENU => {
                self.draw_menu(ui);
                json!({
                    "menu": self.menu_json(),
                    "tree": self.tree.as_ref().map(TreeView::state_json),
                    "can_build": self.can_build(),
                    "unmet": self.unmet_inputs()
                        .into_iter()
                        .map(|(k, why)| json!({"key": k, "why": why}))
                        .collect::<Vec<_>>(),
                    "feature_delta": self.feature_delta(),
                })
            }
            // The step log and the guest's framebuffer.
            tree::CONSOLE => {
                // The step console first: the DAG a `BYGG <kant>` planned, before and
                // while it runs; then the build's head and the run surfaces.
                self.draw_steps(ui);
                self.draw_result_head(ui);
                self.draw_pane(ui);
                json!({
                    "phase": self.report.phase.as_ref().map(Phase::label),
                    "pane": self.pane(),
                    "live": self.report.live,
                    "steps": self.steps.state_json(),
                })
            }
            // A name nothing declares. SAY it — a leaf that renders nothing because the
            // host asked for a pane that does not exist reads exactly like a leaf whose
            // content happens to be empty, and the operator cannot tell them apart.
            other => {
                ui.colored_label(RED, format!("⚒ deklarerar inget blad som heter `{other}`"));
                json!({ "unknown_leaf": other })
            }
        }
    }
}

// ── small shared render bits ──────────────────────────────────────────────────

/// The tallest the ⚒ variant strip grows before it scrolls (about twelve rows).
const ROWS_STRIP_MAX_H: f32 = 280.0;

/// An edge as the view spells it — `<repo>/<variant> : <typ>` — split back into the
/// coordinate and the typ. A string without the ` : ` is the coordinate alone.
fn split_edge(edge: &str) -> (String, ArtifactKind) {
    let e = Edge::from_spelled(edge);
    (e.coordinate, e.typ)
}

/// One menu control as data: the node's path, kind, value and the flag it equals.
fn control_json(n: &NodeView) -> Value {
    json!({
        "path": n.path,
        "key": n.key,
        "kind": n.kind,
        "value": n.value,
        "flag": n.flag,
        "no_flag_why": n.no_flag_why,
        "edge": n.edge,
        "children": n.children.len(),
    })
}

/// **A declared parameter's field** — the fixture affordance ONLY where the recipe
/// declares one, multiline where the declaration stages a file, a single line
/// otherwise. The checkbox carries the key in its own label so a robot (and a screen
/// reader) can address THIS parameter's toggle; ticking it supplies the CLI's own
/// `@fixture` word, and the editor yields to one line saying so — the operator is never
/// shown a field that looks typeable but is about to be overwritten.
fn draw_param_field(
    ui: &mut Ui,
    key: &str,
    declared: Option<&DeclaredParam>,
    cur: &mut String,
    caption: egui::Id,
) {
    if let Some(fx) = declared.and_then(|d| d.fixture.as_deref()) {
        let mut use_fixture = cur.as_str() == FIXTURE_VALUE;
        if ui
            .checkbox(&mut use_fixture, RichText::new(format!("{key}: använd fixtur ({fx})")).small())
            .on_hover_text(
                "smedjan präglar fixturen själv vid bygget — värdet blir fixturens offentliga del, \
                 och kvittot säger att det kom därifrån",
            )
            .changed()
        {
            *cur = if use_fixture { FIXTURE_VALUE.to_string() } else { String::new() };
        }
        if use_fixture {
            ui.label(
                RichText::new(format!(
                    "{FIXTURE_VALUE} → fylls av fixturen {fx} vid bygget"
                ))
                .monospace()
                .weak(),
            );
            return;
        }
    }
    if declared.is_some_and(|d| d.multiline) {
        ui.add(

The file goes on; the console shows all of it.