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

nordisk/facett

integrations/nornir-flow.patch

diff --git a/Cargo.toml b/Cargo.toml
index b6f67b3..90b4b1d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -465,7 +465,7 @@ docs-export = ["nornir-docgen/export", "dep:nornir-docbook"]
 # dep-graph edges as woven threads between lanes. Pure Rust
 # (wgpu/glow back-end); no JS, no C deps beyond the platform's own
 # windowing libs that eframe pulls in.
-viz = ["wire-client", "dep:eframe", "dep:egui", "dep:image", "dep:egui-snarl", "dep:facett-jobview", "dep:facett-docview", "dep:facett-graphview", "dep:facett-graph3d", "dep:facett-plandag", "dep:facett-warehousedeck", "dep:facett-helix", "dep:facett-core", "dep:facett-brokkr", "dep:facett-app", "dep:facett-graphpan", "dep:facett-matrix"]
+viz = ["wire-client", "dep:eframe", "dep:egui", "dep:image", "dep:egui-snarl", "dep:facett-jobview", "dep:facett-docview", "dep:facett-graphview", "dep:facett-graph3d", "dep:facett-plandag", "dep:facett-warehousedeck", "dep:facett-helix", "dep:facett-core", "dep:facett-flow", "dep:facett-brokkr", "dep:facett-app", "dep:facett-graphpan", "dep:facett-matrix"]
 # Semantic-search embedders. The `vector::Embedder` trait is the interface;
 # two interchangeable backends run the SELECTED model's ONNX export (the model
 # is chosen from `src/vector/embed_registry.rs` via $NORNIR_EMBED_MODEL —
@@ -1089,6 +1089,7 @@ facett-helix = { version = "0.2", path = "../facett/facett-helix", optional = tr
 # snapshot-asserted surfaces". Pulled directly (not just transitively) so the
 # `autonom::facett_probe` drive code can name `facett_core::harness`; same
 # egui 0.34 / facett-core 0.1 stack as every embed above.
+facett-flow = { path = "../facett/facett-flow", optional = true, default-features = false, features = ["layered", "status", "labels", "hit-test", "compact-state", "wgpu"] }
 facett-core = { version = "0.2", path = "../facett/facett-core", optional = true }
 # ⚒ Brokkr tab pipeline BUILDER: the reusable, nornir-agnostic facett component
 # `facett_brokkr::PipelineBuilder` lets a user AUTHOR a `brokkr.toml` in the
diff --git a/src/bin/urdr-threads.rs b/src/bin/urdr-threads.rs
index 4b5d28c..8ec5f63 100644
--- a/src/bin/urdr-threads.rs
+++ b/src/bin/urdr-threads.rs
@@ -419,6 +419,9 @@ fn main() -> Result<()> {
         "Urðr Threads",
         native_options,
         Box::new(move |cc| {
+            if let Some(render_state) = &cc.wgpu_render_state {
+                facett_flow::gpu::install_renderer(render_state);
+            }
             // Force dark mode: the timeline/graphs are painted on a dark canvas,
             // but egui otherwise follows the system theme (light on some displays)
             // → dark/black UI text in the side panel + labels. Pin it dark.
diff --git a/src/viz/app.rs b/src/viz/app.rs
index 6fdc256..0e183dc 100644
--- a/src/viz/app.rs
+++ b/src/viz/app.rs
@@ -2814,7 +2814,10 @@ impl eframe::App for UrdrThreadsApp {
     // `update(ctx, frame)` is deprecated). The given `ui` is a bare root with no
     // CentralPanel, so the existing ctx-panel layout in `draw_ui` works unchanged
     // when driven off `ui.ctx()`.
-    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
+        if let Some(render_state) = frame.wgpu_render_state() {
+            facett_flow::gpu::evict_idle(render_state, ui.ctx().cumulative_frame_nr());
+        }
         self.draw_ui(ui);
     }
 }
diff --git a/src/viz/build_tab.rs b/src/viz/build_tab.rs
index 91b2212..9c8de24 100644
--- a/src/viz/build_tab.rs
+++ b/src/viz/build_tab.rs
@@ -12,7 +12,7 @@
 //!
 //! The observable state lives entirely in [`BuildThing`](crate::build::elm::BuildThing)
 //! (the facett Elm pure core: Model / Msg / update / Effect). `draw` is a pure
-//! projection + input collector: it emits [`BuildThingMsg`]s and lets
+//! projection + input collector: it emits [`BuildTabMsg`]s and lets
 //! [`BuildThing::update`] mutate the model and hand back [`BuildThingEffect`]s. The
 //! only side work — actually running the build — is done by a [`BuildRunner`] seam
 //! OFF the paint thread; its result is fed back as a `BuildSucceeded` / `BuildFailed`
@@ -28,10 +28,25 @@ use std::sync::{Arc, Mutex};
 
 use eframe::egui::{self};
 
+use super::build_flow::{BuildResult, build_plan_graph, declared_plans};
 use crate::build::elm::{BuildThing, BuildThingEffect, BuildThingMsg};
 use crate::build::surface::BuildSurface;
 use crate::build::variant::BuildVariantSet;
 use crate::jobs::JobSink;
+use facett_core::{Elm, Facet};
+use nornir_build_thing::plugin::BuildPlan;
+
+/// Inputs for the build cards and the cached, independently navigable plan graph.
+#[derive(Clone, Debug)]
+pub enum BuildTabMsg {
+    Build(BuildThingMsg),
+    Flow(facett_flow::Msg),
+}
+impl From<BuildThingMsg> for BuildTabMsg {
+    fn from(value: BuildThingMsg) -> Self {
+        Self::Build(value)
+    }
+}
 
 use super::facett_theme::{GREEN, RED, Theme, ThemeExt};
 use super::test_boot::{BootRunner, BootStatus, BootTarget, TestBootState};
@@ -132,6 +147,8 @@ struct InFlight {
 pub struct BuildTabState {
     /// The Elm pure core (all observable state).
     thing: BuildThing,
+    plans: Vec<BuildPlan>,
+    flow: facett_flow::FlowGraph,
     /// The reflected surface (choosable rows: binary × features × profile × target).
     surface: BuildSurface,
     /// The repo the builds run in.
@@ -235,8 +252,17 @@ impl BuildTabState {
             .unwrap_or_else(|| "<repo>".to_string());
         let surface = BuildSurface::from_set(repo.clone(), &set);
         let thing = BuildThing::from_surface(&surface);
+        let plans = declared_plans(&repo, &set);
+        let parts = build_plan_graph(&plans, &Default::default());
+        let mut flow = facett_flow::FlowGraph::new("build-plan", parts.graph)
+            .expect("valid declared build graph");
+        flow.set_labels(true);
+        flow.set_compact_state(true);
+        flow.update(facett_flow::Msg::Running(false));
         Self {
             thing,
+            plans,
+            flow,
             surface,
             repo_root,
             runner,
@@ -254,6 +280,61 @@ impl BuildTabState {
         }
     }
 
+    /// Supply the exact plans materialized by a plugin (including custom steps).
+    /// Existing card/runner state is retained; only the graph topology is replaced.
+    pub fn set_build_plans(&mut self, plans: Vec<BuildPlan>) {
+        let parts = build_plan_graph(&plans, &Default::default());
+        self.plans = plans;
+        self.flow
+            .update(facett_flow::Msg::Replace(Arc::new(parts.graph)));
+        self.sync_flow_status();
+    }
+
+    fn sync_flow_status(&mut self) {
+        use crate::build::elm::CardStatus;
+        use facett_flow::attr::Status;
+        let results = self
+            .thing
+            .state()
+            .cards
+            .iter()
+            .map(|card| {
+                let variant = match &card.status {
+                    CardStatus::Built { .. } => Some(Status::Ok),
+                    CardStatus::Failed { .. } => Some(Status::Error),
+                    CardStatus::Idle | CardStatus::Building => None,
+                };
+                (
+                    card.name.clone(),
+                    BuildResult {
+                        variant,
+                        ..Default::default()
+                    },
+                )
+            })
+            .collect();
+        // Outcomes change independently of positions and camera. Do not replace
+        // the scene on a build transition or guess which unreported step failed.
+        let status = super::build_flow::build_plan_status(&self.plans, &results);
+        self.flow.update(facett_flow::Msg::Status(Arc::new(status)));
+    }
+
+    fn update_build(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
+        let selected = if let BuildThingMsg::Select(variant) = &msg {
+            self.plans.iter().position(|plan| &plan.variant == variant)
+        } else {
+            None
+        };
+        let effects = self.thing.update(msg);
+        if let Some(index) = selected {
+            self.flow.update(facett_flow::Msg::Select(Some(format!(
+                "plan{index}:variant"
+            ))));
+        }
+        self.sync_flow_status();
+        effects
+    }
+
     /// Point the test-boot jobs at a durable job ledger `sink` (the app wires the
     /// server's `jobs.redb` sink so boot jobs show in the Jobs panel too).
     pub fn set_boot_sink(&mut self, sink: JobSink, workspace: impl Into<String>) {
@@ -328,6 +409,7 @@ impl BuildTabState {
             "building": !self.inflight.is_empty(),
             // The Elm model (cards, statuses, log) — the full pure-core state.
             "model": self.thing.state_json(),
+            "flow": Facet::state_json(&self.flow),
             // The reflected choosable rows (binary × features × profile × target).
             "rows": self.surface.state_json()["variants"].clone(),
             "provenance": serde_json::Value::Object(provenance),
@@ -400,7 +482,7 @@ impl BuildTabState {
     /// Send one message through the Elm core and run any effects it hands back
     /// (spawning the build off the paint thread). The ONE mutation path.
     fn dispatch(&mut self, msg: BuildThingMsg) {
-        for effect in self.thing.update(msg) {
+        for effect in self.update_build(msg) {
             match effect {
                 BuildThingEffect::RunBuild(variant) => self.spawn_build(variant),
             }
@@ -479,7 +561,7 @@ impl BuildTabState {
     /// tab uses (no worker thread). The test seam — asserts the full BUILD wiring
     /// (select → `nornir build --attest --seal` → provenance card) deterministically.
     pub fn run_build_blocking(&mut self, variant: &str) {
-        let effects = self.thing.update(BuildThingMsg::Build(variant.to_string()));
+        let effects = self.update_build(BuildThingMsg::Build(variant.to_string()));
         for effect in effects {
             let BuildThingEffect::RunBuild(v) = effect;
             match self.runner.run(&self.repo_root, &v) {
@@ -519,7 +601,10 @@ impl BuildTabState {
         // The pure Elm view paints + emits the frame's Msgs; dispatch runs them (and
         // their effects — the real build spawn) — the ONE live mutation path.
         for msg in <Self as facett_core::Elm>::view(self, ui) {
-            self.dispatch(msg);
+            for effect in <Self as Elm>::update(self, msg) {
+                let BuildThingEffect::RunBuild(variant) = effect;
+                self.spawn_build(variant);
+            }
         }
         // Drain the boot/kill presses the pure view recorded and act on them (the
         // live-only side work — a headless deck render never drains these, so it
@@ -543,7 +628,7 @@ impl BuildTabState {
     /// interactions produced — it never mutates the model or does I/O. Kept as the
     /// [`Elm::view`](facett_core::Elm::view) impl below; [`Self::draw`] (live) and the
     /// facett `Facet::ui` bridge both drive it.
-    fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
+    fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildTabMsg> {
         ui.heading(format!("🔨 Build — {}", self.surface.repo));
         ui.label(
             "Choose a build target (binary × features × profile × target), then press \
@@ -552,6 +637,14 @@ impl BuildTabState {
         ui.add_space(6.0);
 
         // Collect messages during the render, apply after (keep the borrow clean).
+        let mut flow_messages = Vec::new();
+        egui::CollapsingHeader::new("Build plan graph")
+            .default_open(true)
+            .show(ui, |ui| {
+                ui.allocate_ui(egui::vec2(ui.available_width(), 220.0), |ui| {
+                    flow_messages.extend(self.flow.view(ui).into_iter().map(BuildTabMsg::Flow));
+                });
+            });
         let mut pending: Vec<BuildThingMsg> = Vec::new();
         let selected = self.thing.state().selected.clone();
 
@@ -759,7 +852,8 @@ impl BuildTabState {
                 }
             });
 
-        pending
+        flow_messages.extend(pending.into_iter().map(BuildTabMsg::Build));
+        flow_messages
     }
 
     /// Paint the per-card status chip (idle / building / built digest / failed).
@@ -803,14 +897,14 @@ impl BuildTabState {
 ///
 /// - `Model` = [`BuildThingModel`](crate::build::elm::BuildThingModel) (the whole
 ///   pure-core observable state);
-/// - `Msg` = [`BuildThingMsg`] / `Effect` = [`BuildThingEffect`];
+/// - `Msg` = [`BuildTabMsg`] (cards or graph) / `Effect` = [`BuildThingEffect`];
 /// - `update` is the pure mutation path (delegates to the core `BuildThing::update`)
 ///   and returns the effects as DATA — the live [`BuildTabState::draw`] runs them
 ///   (`spawn_build`), while the macro-generated `Facet::ui` applies them without
 ///   side work so a headless render never spawns a real build.
 impl facett_core::Elm for BuildTabState {
     type Model = crate::build::elm::BuildThingModel;
-    type Msg = BuildThingMsg;
+    type Msg = BuildTabMsg;
     type Effect = BuildThingEffect;
 
     fn title(&self) -> &str {
@@ -821,11 +915,28 @@ impl facett_core::Elm for BuildTabState {
         self.thing.state()
     }
 
-    fn update(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
-        self.thing.update(msg)
+    fn update(&mut self, msg: BuildTabMsg) -> Vec<BuildThingEffect> {
+        match msg {
+            BuildTabMsg::Build(msg) => self.update_build(msg),
+            BuildTabMsg::Flow(msg) => {
+                if let facett_flow::Msg::Select(Some(id)) = &msg
+                    && let Some(index) = id
+                        .split(':')
+                        .next()
+                        .and_then(|p| p.strip_prefix("plan"))
+                        .and_then(|p| p.parse::<usize>().ok())
+                    && let Some(plan) = self.plans.get(index)
+                {
+                    self.thing
+                        .update(BuildThingMsg::Select(plan.variant.clone()));
+                }
+                self.flow.update(msg);
+                Vec::new()
+            }
+        }
     }
 
-    fn view(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
+    fn view(&self, ui: &mut egui::Ui) -> Vec<BuildTabMsg> {
         self.view_pure(ui)
     }
 }
@@ -908,6 +1019,50 @@ profile = "release"
         BuildTabState::from_toml(PathBuf::from("/tmp/holger"), DEMO_FIXTURE, runner)
     }
 
+    #[test]
+    fn build_outcome_changes_only_attributes_and_preserves_navigation() {
+        let mut tab = tab_with(Arc::new(MockRunner::default()));
+        let graph = Arc::clone(&tab.flow.state().graph);
+        let camera = facett_flow::Camera {
+            centre: [123.0, 456.0],
+            zoom: 1.7,
+        };
+        Elm::update(
+            &mut tab,
+            BuildTabMsg::Flow(facett_flow::Msg::Camera(camera.clone())),
+        );
+        tab.run_build_blocking("mini");
+        assert!(Arc::ptr_eq(&graph, &tab.flow.state().graph));
+        assert_eq!(tab.flow.state().camera, camera);
+        let status = tab.flow.state().status.as_ref().unwrap();
+        let root = graph
+            .nodes
+            .iter()
+            .position(|n| n.id == "plan1:variant")
+            .unwrap();
+        assert_eq!(status.nodes[root], Some(facett_flow::attr::Status::Ok));
+        assert_eq!(
+            status.nodes[root + 1],
+            None,
+            "runner reports no individual step outcome"
+        );
+        assert!(tab.state_json()["flow"].is_object());
+    }
+
+    #[test]
+    fn graph_and_cards_share_selection_without_starting_a_build() {
+        let runner = Arc::new(MockRunner::default());
+        let mut tab = tab_with(runner.clone());
+        Elm::update(
+            &mut tab,
+            BuildTabMsg::Flow(facett_flow::Msg::Select(Some("plan2:step0".into()))),
+        );
+        assert_eq!(tab.thing.state().selected.as_deref(), Some("full"));
+        Elm::update(&mut tab, BuildThingMsg::Select("mini".into()).into());
+        assert_eq!(tab.flow.state().selected.as_deref(), Some("plan1:variant"));
+        assert!(runner.calls.lock().unwrap().is_empty());
+    }
+
     /// ★ RED-WHEN-BROKEN: the live Build tab reflects **THIS repo's** declared
     /// atoms, not a fixture. Before 2026-08-01 it rendered a frozen copy of
     /// holger's `embedded`/`mini`/`full` in every repo, so the assertion that
@@ -1194,7 +1349,7 @@ profile = "release"
         // core Model (BuildThingModel) — `update` is the only way it changed.
         let snap = harness::snapshot_json(
             &mut tab,
-            vec![crate::build::elm::BuildThingMsg::Select("full".to_string())],
+            vec![crate::build::elm::BuildThingMsg::Select("full".to_string()).into()],
         );
         assert!(snap.is_object(), "snapshot is the serializable Model");
         assert_eq!(
@@ -1230,7 +1385,9 @@ profile = "release"
         // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
         ctx.run_ui(egui::RawInput::default(), |ui| {
             egui::CentralPanel::default().show(ui, |ui| tab.draw(ui));
-        }).textures_delta.clear();
+        })
+        .textures_delta
+        .clear();
         let j = tab.state_json();
         assert!(!j.as_object().unwrap().is_empty());
         assert_eq!(j["variant_count"], 3);
diff --git a/src/viz/depgraph_layout.rs b/src/viz/depgraph_layout.rs
index 0050dcb..0e3bee1 100644
--- a/src/viz/depgraph_layout.rs
+++ b/src/viz/depgraph_layout.rs
@@ -1,8 +1,84 @@
-//! Re-export of the pure dependency-graph layout.
-//!
-//! The layout core moved to the shared `nornir-depgraph` edda leaf (so nornir AND
-//! dwarves render the same suite dependency view). It stays reachable as
-//! `viz::depgraph_layout` so the 🔗 Dep Graph tab's renderer (`super::graph`) and
-//! the introspection call sites are unchanged.
+//! Nornir's CrossRepoEdge facade over Facett's shared-core dependency layout.
+//! The old implementation was extracted to edda. Keep this public viz surface
+//! while using the same iterative core ranks as Falsett and the other graph panes.
+pub use facett_graphview::{EdgeClass, LaidEdge, LaidNode};
+use nornir_depgraph::CrossRepoEdge;
+use std::collections::BTreeSet;
+use std::ops::{Deref, DerefMut};
 
-pub use nornir_depgraph::layout::*;
+#[derive(Debug, Clone)]
+pub struct DepGraphLayout(facett_graphview::DepGraphLayout);
+impl Deref for DepGraphLayout {
+    type Target = facett_graphview::DepGraphLayout;
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+impl DerefMut for DepGraphLayout {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+fn edges(input: &[CrossRepoEdge]) -> Vec<facett_graphview::DepEdge> {
+    input
+        .iter()
+        .map(|e| facett_graphview::DepEdge {
+            from: e.from.clone(),
+            to: e.to.clone(),
+            via: e.via.iter().cloned().collect(),
+        })
+        .collect()
+}
+impl DepGraphLayout {
+    pub fn build(
+        repos: &[String],
+        input: &[CrossRepoEdge],
+        deep: bool,
+        collapsed: &BTreeSet<String>,
+    ) -> Self {
+        Self(facett_graphview::DepGraphLayout::build(
+            repos,
+            &edges(input),
+            deep,
+            collapsed,
+        ))
+    }
+    pub fn transitive_closure(repo: &str, input: &[CrossRepoEdge]) -> BTreeSet<String> {
+        facett_graphview::DepGraphLayout::transitive_closure(repo, &edges(input))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[test]
+    fn legacy_dag_layout_json_and_collapse_are_preserved() {
+        let repos = ["app", "core", "util", "other"].map(String::from);
+        let input = [("app", "core"), ("core", "util"), ("app", "other")]
+            .map(|(a, b)| CrossRepoEdge::normal(a, b, BTreeSet::from(["dependency".into()])));
+        for deep in [false, true] {
+            for collapsed in [BTreeSet::new(), BTreeSet::from(["core".into()])] {
+                let old = nornir_depgraph::layout::DepGraphLayout::build(
+                    &repos, &input, deep, &collapsed,
+                );
+                let shared = DepGraphLayout::build(&repos, &input, deep, &collapsed);
+                assert_eq!(shared.state_json(), old.state_json());
+                assert_eq!(shared.visible(), old.visible());
+            }
+        }
+        assert_eq!(
+            DepGraphLayout::transitive_closure("app", &input),
+            nornir_depgraph::layout::DepGraphLayout::transitive_closure("app", &input)
+        );
+    }
+    #[test]
+    fn cycle_guard_stays_bounded_to_the_real_nodes() {
+        let repos = ["a", "b"].map(String::from);
+        let input = [
+            CrossRepoEdge::normal("a", "b", BTreeSet::new()),
+            CrossRepoEdge::normal("b", "a", BTreeSet::new()),
+        ];
+        let shared = DepGraphLayout::build(&repos, &input, false, &BTreeSet::new());
+        assert!(shared.nodes.iter().all(|n| n.col < 2));
+    }
+}
diff --git a/src/viz/mod.rs b/src/viz/mod.rs
index e9bd539..a1e36ed 100644
--- a/src/viz/mod.rs
+++ b/src/viz/mod.rs
@@ -134,6 +134,8 @@ mod security_tab; // 🛰 the Warehouse (Constellation) Navigator MODEL — rend
 // (recency/bugs/release-wave/blast/bottleneck/deepest-chain), consumed by facett's
 // Code Vault view via `NavigatorModel::state_json`. Extends the release_depdag seed.
 #[cfg(feature = "viz")]
+pub mod build_flow; // BuildPlan → cached Falsett graph adapter
+#[cfg(feature = "viz")]
 pub mod build_tab; // 🔨 build runner (now a sub-pane of ⚒ Dwarves — N5)
 // egui/facett-bound (eframe + facett_brokkr/facett_theme/facett_ui) — only the viz app
 // (dwarves_tab) uses it, so gate on `viz`; a headless `server`-only build omits it.
diff --git a/src/viz/build_flow.rs b/src/viz/build_flow.rs
new file mode 100644
index 0000000..95eb12f
--- /dev/null
+++ b/src/viz/build_flow.rs
@@ -0,0 +1,251 @@
+//! Build plans as Falsett graphs. Commands stay data; drawing never executes a plan.
+use std::collections::BTreeMap;
+
+use facett_flow::{
+    Edge, Graph, Node,
+    attr::{Status, StatusTable},
+};
+use nornir_build_thing::plugin::{BuildPlan, BuildThing, Manifest};
+use nornir_build_thing::variant::BuildVariantSet;
+
+/// Only outcomes actually reported by the forge. A variant-level failure does
+/// not identify the failing step; leave unreported steps/artifacts uncoloured.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BuildResult {
+    pub variant: Option<Status>,
+    pub steps: Vec<Option<Status>>,
+    pub artifacts: BTreeMap<String, Status>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct FlowParts {
+    pub graph: Graph,
+    pub status: StatusTable,
+}
+
+/// Variant → ordered command steps → expected artifacts. Index-qualified IDs
+/// distinguish repeated step labels and artifacts in different variant plans.
+/// `results` are keyed by variant; missing entries carry no terminal status.
+pub fn build_plan_graph(plans: &[BuildPlan], results: &BTreeMap<String, BuildResult>) -> FlowParts {
+    let mut graph = Graph {
+        nodes: Vec::new(),
+        edges: Vec::new(),
+    };
+    for (p, plan) in plans.iter().enumerate() {
+        let group = if plan.variant.trim().is_empty() {
+            format!("plan-{p}")
+        } else {
+            plan.variant.clone()
+        };
+        let root = format!("plan{p}:variant");
+        graph.nodes.push(Node {
+            id: root.clone(),
+            label: plan.variant.clone(),
+            position: [0.0; 2],
+            group: group.clone(),
+            kind: 0,
+        });
+        let mut previous = root;
+        for (s, step) in plan.steps.iter().enumerate() {
+            let id = format!("plan{p}:step{s}");
+            graph.nodes.push(Node {
+                id: id.clone(),
+                label: step.name.clone(),
+                position: [0.0; 2],
+                group: group.clone(),
+                kind: 1,
+            });
+            graph.edges.push(Edge {
+                from: previous,
+                to: id.clone(),
+            });
+            previous = id;
+        }
+        for (a, artifact) in plan.expected_artifacts.iter().enumerate() {
+            let id = format!("plan{p}:artifact{a}");
+            graph.nodes.push(Node {
+                id: id.clone(),
+                label: artifact.clone(),
+                position: [0.0; 2],
+                group: group.clone(),
+                kind: 2,
+            });
+            graph.edges.push(Edge {
+                from: previous.clone(),
+                to: id,
+            });
+        }
+    }
+    let pitch = 9_000_000.0 / graph.nodes.len().saturating_sub(1).max(1) as f32;
+    graph
+        .layered(facett_flow::LayeredOpts {
+            pitch: [240.0_f32.min(pitch), 120.0_f32.min(pitch)],
+            ..Default::default()
+        })
+        .expect("build plans within Falsett graph budget");
+    FlowParts {
+        graph,
+        status: build_plan_status(plans, results),
+    }
+}
+
+/// Attribute-only projection for live outcomes; no topology rebuild or layout.
+pub fn build_plan_status(
+    plans: &[BuildPlan],
+    results: &BTreeMap<String, BuildResult>,
+) -> StatusTable {
+    let mut status = StatusTable {
+        nodes: Vec::new(),
+        edges: Vec::new(),
+    };
+    for plan in plans {
+        let result = results.get(&plan.variant);
+        status.nodes.push(result.and_then(|r| r.variant));
+        for i in 0..plan.steps.len() {
+            let state = result.and_then(|r| r.steps.get(i).copied().flatten());
+            status.nodes.push(state);
+            status.edges.push(state);
+        }
+        for artifact in &plan.expected_artifacts {
+            let state = result.and_then(|r| r.artifacts.get(artifact).copied());
+            status.nodes.push(state);
+            status.edges.push(state);
+        }
+    }
+    status
+}
+
+/// The Build tab still supports descriptor-only hosts. Reuse the build thing's
+/// default materializer instead of duplicating argv/artifact/cfg conventions.
+/// A host with plugin-specific plans calls `BuildTabState::set_build_plans`.
+pub fn declared_plans(repo: &str, set: &BuildVariantSet) -> Vec<BuildPlan> {
+    struct Declared<'a> {
+        repo: &'a str,
+        set: &'a BuildVariantSet,
+    }
+    impl BuildThing for Declared<'_> {
+        fn manifest(&self) -> Manifest {
+            Manifest {
+                abi: nornir_build_thing::ABI_VERSION,
+                repo: self.repo.into(),
+                source_crate: "declared-build-surface".into(),
+                summary: "Declared cargo build plans".into(),
+            }
+        }
+        fn variants(&self) -> BuildVariantSet {
+            self.set.clone()
+        }
+    }
+    let declared = Declared { repo, set };
+    set.variants
+        .iter()
+        .filter_map(|v| declared.plan(&v.name))
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nornir_build_thing::plugin::PlanStep;
+    fn plan() -> BuildPlan {
+        BuildPlan {
+            variant: "server".into(),
+            steps: ["codegen", "compile"]
+                .into_iter()
+                .map(|name| PlanStep {
+                    name: name.into(),
+                    program: "cargo".into(),
+                    args: vec!["build".into()],
+                    env: Vec::new(),
+                })
+                .collect(),
+            env: Vec::new(),
+            expected_artifacts: vec!["target/release/server".into()],
+            rationale: None,
+        }
+    }
+    #[test]
+    fn plan_chain_and_artifact_fanout_have_valid_unique_ids_and_direction() {
+        let mut other = plan();
+        other.variant = "client".into();
+        other
+            .expected_artifacts
+            .push("target/release/client".into());
+        let parts = build_plan_graph(&[plan(), other], &BTreeMap::new());
+        parts.graph.validate().unwrap();
+        assert_eq!((parts.graph.nodes.len(), parts.graph.edges.len()), (9, 7));
+        let positions: BTreeMap<_, _> = parts
+            .graph
+            .nodes
+            .iter()
+            .map(|n| (&n.id, n.position))
+            .collect();
+        for edge in &parts.graph.edges {
+            assert!(positions[&edge.from][0] < positions[&edge.to][0]);
+        }
+        assert!(parts.status.nodes.iter().all(Option::is_none));
+    }
+    #[test]
+    fn results_change_attributes_without_geometry_or_fabricated_step_outcomes() {
+        let plans = [plan()];
+        let plain = build_plan_graph(&plans, &BTreeMap::new());
+        let mut results = BTreeMap::from([(
+            "server".into(),
+            BuildResult {
+                variant: Some(Status::Error),
+                ..Default::default()
+            },
+        )]);
+        let failed = build_plan_graph(&plans, &results);
+        assert_eq!(plain.graph, failed.graph);
+        assert_eq!(failed.status.nodes, [Some(Status::Error), None, None, None]);
+        let result = results.get_mut("server").unwrap();
+        result.steps = vec![Some(Status::Ok), Some(Status::Error)];
+        let explicit = build_plan_graph(&plans, &results);
+        assert_eq!(
+            explicit.status.nodes[1..3],
+            [Some(Status::Ok), Some(Status::Error)]
+        );
+        assert_eq!(explicit.status.nodes[3], None);
+    }
+    #[test]
+    fn descriptor_plans_use_the_existing_materializer() {
+        let set = BuildVariantSet::from_toml_str(
+            r#"[build]
+[[build.variant]]
+name = "server"
+bin = "server"
+profile = "release"
+"#,
+        )
+        .unwrap();
+        let plans = declared_plans("fixture", &set);
+        assert_eq!(plans.len(), 1);
+        assert_eq!(plans[0].steps[0].args, set.variants[0].cargo_args());
+        assert_eq!(
+            plans[0].expected_artifacts,
+            vec![
+                set.variants[0]
+                    .artifact_rel_path()
+                    .unwrap()
+                    .to_string_lossy()
+                    .into_owned()
+            ]
+        );
+        let parts = build_plan_graph(&plans, &BTreeMap::new());
+        parts.graph.validate().unwrap();
+        assert_eq!(parts.graph.nodes.len(), 3);
+    }
+    #[test]
+    fn empty_plans_and_artifacts_without_steps_remain_valid() {
+        build_plan_graph(&[], &BTreeMap::new())
+            .graph
+            .validate()
+            .unwrap();
+        let mut p = plan();
+        p.steps.clear();
+        let parts = build_plan_graph(&[p], &BTreeMap::new());
+        parts.graph.validate().unwrap();
+        assert_eq!((parts.graph.nodes.len(), parts.graph.edges.len()), (2, 1));
+    }
+}