Skip to main content

facett_core/
lib.rs

1//! **facett-core** — the visual kernel. Render a node/edge **`Scene`** into egui.
2//! Source-agnostic: build a `Scene` from anything (Arrow rows, a graph, a DAG),
3//! hand it here, get pixels. The CPU painter is the reference; a **wgpu** fast
4//! path (GPU viewport-cull + indirect draw, seeded from katana-osm's
5//! `osm-viewer`) lands behind this same `draw()` call — consumers don't change.
6
7use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
8
9pub mod a11y;
10/// The bounded WebGPU adapter retry + the plain "no GPU" messages, shared by every
11/// wasm entry point (facet-wrapped mounts, facett-demo's pages bundle).
12pub mod adapter_retry;
13/// Embedded-asset integrity: the `const fn` every `include_bytes!` site in this
14/// workspace checks itself with, so a Git-LFS pointer standing in for a font fails the
15/// BUILD instead of shipping as a font that draws nothing.
16pub mod asset;
17pub mod interface;
18pub mod layered;
19/// **Barnes–Hut N-body repulsion** — the shared O(n log n) force-layout kernel
20/// (dimension-generic quadtree/octree, gatling-parallel) that every facett
21/// force-directed layout consumes so a 30 000-node graph lays out without the
22/// O(n²)-per-iteration hang. See [`barnes_hut`].
23pub mod barnes_hut;
24/// The shared **action bus** (BUS-1) — a typed, deterministic outbox facets use
25/// to hand side work (a drag move, a time-scrub, a command) to the host. The
26/// common carrier [`dragdrop`] / [`time_axis`] `Effect`s flow through.
27pub mod action_bus;
28pub mod caps;
29pub mod chrome;
30pub mod clip;
31pub mod clipboard;
32pub mod deckfx;
33/// The shared **drag-and-drop** primitive (DND-1) — the one reducer behind
34/// items-dragged-onto-zones (cards→columns, bars→lanes). Engine-agnostic sibling
35/// of [`nav::Navigable`]; the caps flag [`FacetCaps::draggable`] points here.
36pub mod dragdrop;
37pub mod edges;
38pub mod effects;
39/// **THE unified view core** (ENGINE-1) — the one *cull · project · label-collide ·
40/// pick* spine that `facett-map`, `facett-map3d`, `facett-geomap`, the L1 overlay
41/// and every graph view collapse onto. Only THREE things differ between a map and
42/// a graph, and they are the three traits
43/// ([`PositionSource`](engine::PositionSource),
44/// [`ElevationSource`](engine::ElevationSource), [`Hierarchy`](engine::Hierarchy)).
45/// See [`engine`].
46pub mod engine;
47/// The **UI error-code scheme** (`facet-<component>-<n>`) — the stable, unique code
48/// every facett UI part carries so tests + consumers react to the CODE, not a matched
49/// string. See [`errcode::FacetError`], the [`facet_err!`] macro, the canonical
50/// [`errcode::REGISTRY`], and the pink [`errcode::code_color`] renderer.
51pub mod devid;
52/// **The NAMED empty state** — what a pane draws when its feed has nothing in it:
53/// the feed's own name, why it is empty (`no-rows` / `unreachable` / `not-configured`),
54/// and optionally what would fill it. The alternative a 2026-09-10 audit found in the
55/// wild was a *fixture* — plausible hand-written rows behind a `source: "fixture"`
56/// tag — and rows read as data no matter what the tag says. See [`empty::EmptyState`].
57pub mod empty;
58pub mod errcode;
59/// The canonical **Elm contract** (FC-2 / FC-9) — the [`Elm`] trait +
60/// [`impl_facet_via_elm!`] bridge macro that lift `facett-security`'s hand-rolled
61/// Model/Msg/Effect/pure-view pattern into reusable infra. Pair with
62/// [`harness`] to drive an `Elm` component headlessly.
63pub mod elm;
64pub mod fly;
65pub mod focus;
66pub mod graph_split;
67/// **The golden-image guard** (feature `golden`) — [`golden::assert_golden`], the
68/// one comparison every facett screenshot proof runs against its committed PNG.
69/// Exact match, failure artifacts under `target/`, a **missing golden FAILS**, and
70/// blessing is the explicit opt-in `UPDATE_SNAPSHOTS=1`. Enabled from the
71/// `[dev-dependencies]` of the crates that own goldens; absent from release builds.
72#[cfg(feature = "golden")]
73pub mod golden;
74pub mod imgscan; // image-analysis oracle (SCAN-THE-PIXELS law): spoke/high-freq/
75                 // coverage/centroid features computed FROM the rendered pixels.
76pub mod labels3d;
77/// **ROOT LAW #0 — the rayon-free law**, as machinery a test can actually fail
78/// on ([`law`]). ONE writer for korp and facett so the two repos hold the same
79/// line: our calls and our manifests are always red, a *normal* dependency edge
80/// to rayon is red, and the known dev-only third-party edge (image→ravif inside
81/// the screenshot-diff harness) is reported as context and never fails.
82pub mod law;
83/// **The label-declutter contract** — the order-independent spatial-grid collision
84/// rule shared by the CPU painter and the WGSL compute passes (GFX_V2 item 4). One
85/// writer, two executions; see the module docs for why [`legibility::place_labels`]
86/// (greedy, order-dependent, displaces into alternative slots) could not be the one.
87pub mod label_grid;
88/// **Legibility at scale** — the shared screen-space toolbox (spatial hover index,
89/// label collision avoidance, density-aware edge thinning) that makes a 100 000-node
90/// graph or a 1 000 000-pin map readable. Pure, deterministic, headless-testable; the
91/// legibility half of the story whose speed half is [`render::gpu::graphcloud`].
92pub mod legibility;
93pub mod harness;
94pub mod look;
95pub mod nav;
96pub mod navgraph; // dev-mode numbered navigation graph + overlay (navgraph-design.md phase 1)
97pub mod overlay;
98/// The constellation-wide **`Panel` trait** (Phase 0 foundation) — the primary
99/// UI-pane seam, with backend (1) native in-process (the blanket
100/// `impl<T: Facet> Panel for T`) and backend (4) headless ([`panel::drive`]).
101/// See `.nornir/wasm-ui-panels-design.md` §1.
102pub mod panel;
103pub mod rabbit;
104/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
105/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
106/// scaffold. Map skins + `facett-graphview` draw through this.
107pub mod render;
108pub mod runtrace; // in-memory, wasm-safe "what RAN" ledger (no FS) — folded into
109                  // state_json["trace"]["ran"], read via the JS hook on wasm.
110pub mod scroll_engine;
111pub mod severity; // STRUCTURAL error signal for the Robot-UI HARD GATE — each
112                  // pane/atom reports Severity{Info,Warning,Error}; the gate asserts
113                  // on this instead of scanning rendered text (RESOLVED decision (a)).
114pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
115                    // `testmatrix`); no-op in release.
116pub mod theme;
117/// The shared **pan/zoom-in-time axis** (TIME-1) — the one time-window model
118/// behind gantt/CFD/calendar/timeline, linkable across facets. Temporal sibling
119/// of [`nav::Navigable`]; the caps flag [`FacetCaps::time_scrollable`] points here.
120pub mod time_axis;
121pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
122               // machine-readable data a facet actually rendered.
123pub use a11y::{Semantics, node as a11y_node, stable_id};
124pub use errcode::{FacetError, MountedFacetError};
125pub use action_bus::{ActionBus, BusAction, BusMsg};
126pub use caps::FacetCaps;
127pub use dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove};
128pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
129pub use clipboard::ClipAction;
130pub use deckfx::{DeckFx, DeckRaven};
131pub use elm::Elm;
132pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
133pub use look::{Action, KeyMap, Palette};
134pub use nav::{Dir4, Navigable, nearest_in_direction};
135pub use panel::Panel;
136pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
137pub use scroll_engine::SmoothScroll;
138pub use severity::{Severity, worst as worst_severity};
139pub use theme::{Theme, set_theme, theme};
140pub use time_axis::{TimeAxis, TimeEffect, TimeMsg};
141
142// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
143// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
144pub use look::Theme as LookTheme;
145
146/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
147/// label, by status, …).
148#[derive(Clone)]
149pub struct Node {
150    pub label: String,
151    pub color: Color32,
152}
153
154/// A directed edge between node indices.
155#[derive(Clone, Copy)]
156pub struct Edge {
157    pub src: usize,
158    pub dst: usize,
159}
160
161/// A drawable graph: nodes + edges (edges index into `nodes`).
162#[derive(Default, Clone)]
163pub struct Scene {
164    pub nodes: Vec<Node>,
165    pub edges: Vec<Edge>,
166}
167
168impl Scene {
169    pub fn new() -> Self {
170        Self::default()
171    }
172    /// Push a node, returning its index.
173    pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
174        self.nodes.push(Node { label: label.into(), color });
175        self.nodes.len() - 1
176    }
177    pub fn edge(&mut self, src: usize, dst: usize) {
178        self.edges.push(Edge { src, dst });
179    }
180    pub fn is_empty(&self) -> bool {
181        self.nodes.is_empty()
182    }
183}
184
185/// Node placement strategy.
186#[derive(Clone, Copy, PartialEq, Eq, Default)]
187pub enum Layout {
188    #[default]
189    Circular,
190    /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
191    /// per iteration — best for small/medium graphs.
192    Force,
193}
194
195/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
196/// `empty_hint`. Labels render when the node count is small enough to read.
197pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
198    let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
199    let n = scene.nodes.len();
200    if n == 0 {
201        let th = theme(ui);
202        ui.painter_at(rect).text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
203        return;
204    }
205    let pos = positions(layout, scene, rect);
206    draw_positions(ui, scene, &pos, rect, empty_hint);
207}
208
209/// Paint a `Scene` into `rect` using **pre-computed** node positions — the drawing
210/// half of [`draw`], split out so a stateful host can **freeze** the layout: compute
211/// the O(n log n) force positions once (cached until the graph structure changes, see
212/// [`ForceCache`]) and paint them every frame WITHOUT re-running the layout. `pos`
213/// must be indexed like `scene.nodes`; short/empty falls back to the empty hint.
214pub fn draw_positions(ui: &mut Ui, scene: &Scene, pos: &[Pos2], rect: Rect, empty_hint: &str) {
215    let th = theme(ui);
216    let painter = ui.painter_at(rect);
217    let n = scene.nodes.len();
218    if n == 0 || pos.len() != n {
219        painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
220        return;
221    }
222    for e in &scene.edges {
223        if e.src < n && e.dst < n {
224            painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6_f32, th.edge));
225        }
226    }
227    for (i, node) in scene.nodes.iter().enumerate() {
228        painter.circle_filled(pos[i], 5.0, node.color);
229    }
230    draw_position_labels(ui, scene, pos, rect);
231}
232
233/// The label half of [`draw_positions`] alone: a host whose node/edge cloud went to the
234/// GPU (`graphcloud::cloud::draw_cloud`) still paints the text through egui, with the
235/// same font, offset and `n <= 60` cut as the CPU painter.
236pub fn draw_position_labels(ui: &Ui, scene: &Scene, pos: &[Pos2], rect: Rect) {
237    let th = theme(ui);
238    let painter = ui.painter_at(rect);
239    let n = scene.nodes.len();
240    if n <= 60 && pos.len() == n {
241        for (i, node) in scene.nodes.iter().enumerate() {
242            painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
243        }
244    }
245}
246
247/// **Converge-once-then-freeze** layout cache. A force layout is a pure function of
248/// the graph structure (node count + edge set) and the paint `rect`, so this holds the
249/// last-computed positions and only re-runs [`layout_positions`] when the structure or
250/// rect actually changes. Embed it on a stateful host (e.g. a `GraphView`) and call
251/// [`ForceCache::positions`] from the render path — the O(n log n) (with Barnes–Hut)
252/// or O(n²) (small graphs) layout stops running every frame, which is the other half
253/// (besides Barnes–Hut) of killing the 30 000-node hang.
254#[derive(Default, Clone)]
255pub struct ForceCache {
256    sig: u64,
257    rect: [u32; 4],
258    pos: Vec<Pos2>,
259}
260
261impl ForceCache {
262    /// The node positions for `scene` under `layout` in `rect`, computed on the first
263    /// call and on any structural/rect change, and returned from cache otherwise.
264    pub fn positions(&mut self, layout: Layout, scene: &Scene, rect: Rect) -> &[Pos2] {
265        let n = scene.nodes.len();
266        let key = self.structure_key(layout, scene);
267        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
268        if self.sig != key || self.rect != rb || self.pos.len() != n {
269            self.pos = positions(layout, scene, rect);
270            self.sig = key;
271            self.rect = rb;
272        }
273        &self.pos
274    }
275
276    /// Whether the next [`positions`](Self::positions) call for this `layout`/`scene`
277    /// in this `rect` will be a cache hit (a no-op relayout) — the *frozen/settled*
278    /// signal a `tick` can assert.
279    #[must_use]
280    pub fn is_settled(&self, layout: Layout, scene: &Scene, rect: Rect) -> bool {
281        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
282        self.sig == self.structure_key(layout, scene) && self.rect == rb && self.pos.len() == scene.nodes.len()
283    }
284
285    /// Force a recompute on the next [`positions`](Self::positions) call.
286    pub fn invalidate(&mut self) {
287        self.sig = 0;
288        self.rect = [0; 4];
289        self.pos.clear();
290    }
291
292    fn structure_key(&self, layout: Layout, scene: &Scene) -> u64 {
293        let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
294        let key = match layout {
295            Layout::Circular => "circular",
296            Layout::Force => "force",
297        };
298        crate::barnes_hut::structure_sig(key, scene.nodes.len(), &edges)
299    }
300}
301
302/// **Test/host hook (additive).** The public, return-asserted view of the
303/// private [`positions`] layout node — the exact node centres [`draw`] paints for
304/// `scene` under `layout` inside `rect`. Exposed so the graph-skin call-chain
305/// matrix can assert the *layout* stage (finite, in-rect, count == nodes,
306/// circular radius, force-fit normalisation) without a painter. Calls the **same**
307/// private fn `draw` uses, so it IS the layout the pixels come from — additive,
308/// no behaviour change.
309pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
310    positions(layout, scene, rect)
311}
312
313fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
314    let n = scene.nodes.len();
315    let center = rect.center();
316    let radius = rect.size().min_elem() * 0.42;
317    let circular = |i: usize| {
318        let a = std::f32::consts::TAU * (i as f32) / (n as f32);
319        vec2(a.cos(), a.sin())
320    };
321    match layout {
322        Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
323        Layout::Force => {
324            // Deterministic Fruchterman–Reingold from a circular seed (unit space).
325            let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
326            let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
327            // Above the threshold the O(n²) all-pairs repulsion is the 30 000-node
328            // hang — swap it for the shared Barnes–Hut O(n log n) kernel (parallel,
329            // deterministic). Below the threshold the exact loop runs unchanged, so
330            // every small-graph golden is byte-for-byte identical (additive).
331            let use_bh = n >= crate::barnes_hut::BH_THRESHOLD;
332            for _ in 0..120 {
333                let mut disp = vec![egui::Vec2::ZERO; n];
334                if use_bh {
335                    let pts: Vec<[f32; 2]> = p.iter().map(|v| [v.x, v.y]).collect();
336                    let rep = crate::barnes_hut::repulsion_forces::<2>(&pts, k, crate::barnes_hut::BH_THETA);
337                    for i in 0..n {
338                        disp[i] = egui::vec2(rep[i][0], rep[i][1]);
339                    }
340                } else {
341                    for i in 0..n {
342                        for j in (i + 1)..n {
343                            let d = p[i] - p[j];
344                            let dist = d.length().max(1e-3);
345                            let f = k * k / dist;
346                            let dir = d / dist;
347                            disp[i] += dir * f;
348                            disp[j] -= dir * f;
349                        }
350                    }
351                }
352                for e in &scene.edges {
353                    if e.src < n && e.dst < n {
354                        let d = p[e.src] - p[e.dst];
355                        let dist = d.length().max(1e-3);
356                        let f = dist * dist / k;
357                        let dir = d / dist;
358                        disp[e.src] -= dir * f;
359                        disp[e.dst] += dir * f;
360                    }
361                }
362                for i in 0..n {
363                    let dl = disp[i].length().max(1e-3);
364                    p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
365                }
366            }
367            // Normalise to fit the rect.
368            let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
369            for v in &p {
370                mn.x = mn.x.min(v.x);
371                mn.y = mn.y.min(v.y);
372                mx.x = mx.x.max(v.x);
373                mx.y = mx.y.max(v.y);
374            }
375            let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
376            p.iter()
377                .map(|v| center + egui::vec2(((v.x - mn.x) / span.x - 0.5) * 2.0 * radius, ((v.y - mn.y) / span.y - 0.5) * 2.0 * radius))
378                .collect()
379        }
380    }
381}
382
383/// The facett **component contract**. Every facet — graph, map, pipeline, table,
384/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
385/// compose them uniformly *and* get headless robot-testing for free.
386///
387/// The things a component owes its host:
388/// 1. a **title** (tab label / panel heading),
389/// 2. how to **draw** itself into egui,
390/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
391///    assertions. **Rule:** every visible list/status/count goes in `state_json`.
392/// 4. (**defaulted**) [`update_json`](Facet::update_json) — the Elm mutation path,
393///    a no-op by default so it costs existing facets nothing; overriding it (and
394///    the three above) makes a facet interchangeable with a [`Panel`](crate::Panel).
395pub trait Facet {
396    fn title(&self) -> &str;
397    fn ui(&mut self, ui: &mut Ui);
398    fn state_json(&self) -> serde_json::Value;
399
400    /// The **Elm mutation path** — apply one message (JSON). This is the fourth
401    /// member of the component contract (`title`/`ui`/`state_json`/`update_json`),
402    /// and it is **defaulted to a no-op** so it is purely ADDITIVE: every existing
403    /// `impl Facet` keeps compiling unchanged, while a facet that wants the writable
404    /// input surface overrides it (an Elm-backed facet routes the JSON through its
405    /// `update`). With this default in place `Facet` and [`Panel`](crate::Panel)
406    /// share the same four-method shape, which is what lets the blanket
407    /// `impl<T: Facet> Panel for T` (see [`panel`](crate::panel)) make **every**
408    /// `Facet` a `Panel` for free. Unknown/undriven messages are ignored — the
409    /// default simply does nothing.
410    fn update_json(&mut self, _msg_json: &str) {}
411
412    /// The pane's **STRUCTURAL severity** — the RESOLVED Robot-UI error signal
413    /// (decision (a)). A facet returns the WORST [`Severity`](crate::Severity) over
414    /// its currently-rendered atoms (compute it with
415    /// [`worst_severity`](crate::worst_severity)): [`Severity::Info`] when it is
416    /// showing real data, [`Severity::Warning`] when degraded, and
417    /// [`Severity::Error`] when it surfaced a failure (a load error, an unavailable
418    /// backend, an empty data-bearing surface).
419    ///
420    /// **Defaulted to [`Severity::Info`]** so it is purely ADDITIVE — every existing
421    /// `impl Facet` stays green and keeps compiling. The Robot-UI HARD GATE reads
422    /// THIS (surfaced through the [`Panel`](crate::Panel) seam the headless robot
423    /// drives), not the `nornir_robotui::error_atoms()` substring scan, which is kept
424    /// only as a MIGRATION FALLBACK for panes that have not yet overridden this.
425    /// A pane that returns [`Severity::Error`] is RED and FAILS the gate.
426    /// **This pane's stable DEV-ID** — the [`errcode`](crate::errcode) *component*
427    /// string (the `<component>` in `facet-<component>-<n>`), so the identity a pane
428    /// shows and the identity its errors carry are the SAME vocabulary rather than two
429    /// naming schemes that drift.
430    ///
431    /// Shown as a click-to-copy chip in non-release builds ([`devid::badge`](crate::devid::badge)),
432    /// because a screenshot cannot tell you which crate drew a pane and a title like
433    /// "Map" appears in four of them. Pasting the chip resolves to a crate dir and a
434    /// source file via [`devid::resolve`](crate::devid::resolve).
435    ///
436    /// **Defaulted to `""`** so it is purely ADDITIVE — every existing `impl Facet`
437    /// keeps compiling. A pane that has not overridden it renders a visibly-different
438    /// `⟨unregistered⟩` marker instead of an id, never a plausible-looking string that
439    /// resolves to nothing (the `facet-map-99` hole that
440    /// `facett-core/tests/errcode_raise_sites.rs` documents). So the badge is also the
441    /// to-do list: every marker is a pane still to declare this.
442    fn component(&self) -> &'static str {
443        ""
444    }
445
446    fn severity(&self) -> crate::Severity {
447        crate::Severity::Info
448    }
449
450    // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
451
452    /// What this facet can do. Override to opt into capabilities.
453    fn caps(&self) -> FacetCaps {
454        FacetCaps::NONE
455    }
456
457    /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
458    fn scale(&self) -> f32 {
459        1.0
460    }
461    /// Set the uniform scale; clamp internally. Default no-op (not scalable).
462    fn set_scale(&mut self, _scale: f32) {}
463
464    /// **The band `[min, max]`, relative to native (`1.0`), that this facet's uniform
465    /// scale may be driven over.** [`FacetDeck::scale_active`] clamps into it.
466    ///
467    /// The default `[0.25, 4.0]` is a *document viewer's* range — a quarter size to
468    /// quadruple size, which is all a table, a form or a text pane ever wants. It was
469    /// for years the deck's only vocabulary, hardcoded in `scale_active`, and that is
470    /// a bug for any facet whose native range is wider. A MAP's is: `OsmView`'s camera
471    /// spans `[ZOOM_MIN, ZOOM_MAX]` = a 1.25-million-fold range, so a hardcoded 16-fold
472    /// clamp both **stalls** zoom-in a couple of clicks past the fit *and* — because
473    /// the wheel and pinch drive the camera directly, never through [`Self::set_scale`]
474    /// — makes the next `+` click **saturate the clamp and slam the camera backwards**,
475    /// a zoom-IN button that visibly zooms OUT. Guarded by
476    /// `facett-geomap/tests/osm2d_deck_scale.rs`.
477    ///
478    /// Override alongside [`Self::scale`]/[`Self::set_scale`] when native has no fixed
479    /// pixel size. Must contain `1.0` — [`FacetDeck::reset_scale`] writes it.
480    fn scale_range(&self) -> (f32, f32) {
481        (0.25, 4.0)
482    }
483
484    /// The current selection as JSON (also folded into `state_json` by
485    /// convention). `Null` when nothing/none selectable.
486    fn selection_json(&self) -> serde_json::Value {
487        serde_json::Value::Null
488    }
489
490    /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
491    /// Returns the text to place on the clipboard (None = nothing copyable now).
492    fn copy(&mut self) -> Option<String> {
493        None
494    }
495    /// Like `copy`, but also removes the selection. Default delegates to `copy`.
496    fn cut(&mut self) -> Option<String> {
497        self.copy()
498    }
499    /// Accept pasted text. Returns true if consumed.
500    fn paste(&mut self, _text: &str) -> bool {
501        false
502    }
503
504    /// Optional downcast handle for hosts that need typed access to a specific
505    /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
506    /// app-level control that must forward to a concrete component's own API).
507    /// Defaulted to `None` so no existing facet has to change; a component opts in
508    /// by returning `Some(self)`.
509    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
510        None
511    }
512
513    // --- cross-instance state clone (copy/paste BETWEEN same-component instances) ---
514    // See `.nornir/design/copy-paste-between-instances.md` + `clipboard.rs`. The trio
515    // below is the type-tagged STATE layer on top of the text clipboard: two
516    // instances with the SAME `kind()` exchange `portable_state()` via the OS
517    // clipboard envelope (`clipboard::encode_component`/`decode_component`). Each is
518    // defaulted to the opt-OUT floor — a component that doesn't implement all three
519    // neither copies nor accepts cross-instance state, and nothing panics.
520
521    /// Stable component-type id (e.g. `"jobview"`, `"graphpan"`, `"table"`). Two
522    /// instances with the **same** kind can exchange portable state; the empty
523    /// default `""` means **opted out** of cross-instance clone.
524    fn kind(&self) -> &'static str {
525        ""
526    }
527
528    /// The **portable** subset of this facet's state — the fields a same-kind
529    /// sibling can adopt. `None` = not cloneable. Kept SEPARATE from
530    /// [`state_json`](Self::state_json) (the introspection dump, which may carry
531    /// derived / render-only data) so this stays round-trippable through
532    /// [`load_state`](Self::load_state).
533    fn portable_state(&self) -> Option<serde_json::Value> {
534        None
535    }
536
537    /// Adopt a portable state produced by [`portable_state`](Self::portable_state)
538    /// on a same-kind sibling. Returns `true` if accepted. Default `false`
539    /// (opt-in per component).
540    fn load_state(&mut self, _state: &serde_json::Value) -> bool {
541        false
542    }
543}
544
545/// The per-facet states [`FacetDeck::state_json_rolling`] serves inactive tabs from.
546/// One per publishing host; `Default` = empty (the first call builds everything).
547///
548/// The map is MOVED into the returned state, not cloned (a clone of 80 tabs' JSON trees
549/// cost as much as building them), so the host hands it back with
550/// [`FacetDeck::return_rolling`] after serialising — or the next call rebuilds every tab
551/// (correct, only slow).
552#[derive(Default, Debug)]
553pub struct DeckStateCache {
554    facets: serde_json::Map<String, serde_json::Value>,
555    /// Each facet's state as JSON text ([`FacetDeck::serialize_rolling`]); dropped when
556    /// that facet's state is rebuilt, so it is re-serialised exactly then.
557    text: std::collections::HashMap<String, String>,
558    next: usize,
559    /// Facet `state_json` builds made through this cache (the honesty counter).
560    pub built: u64,
561}
562
563/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
564/// bar + the active facet, and composes **every** facet's `state_json` under its
565/// title, so the whole-app introspection contract is free. korp/nornir can build
566/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
567pub struct FacetDeck {
568    facets: Vec<Box<dyn Facet>>,
569    active: usize,
570    /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
571    /// deck that never opts in is unchanged and pays nothing.
572    fx: DeckFx,
573    /// A raven summoned through the deck, in flight or perched (or `None`).
574    raven: Option<DeckRaven>,
575    /// A transient, themed component-clone toast (message + the `ctx.input.time`
576    /// it was raised at), shown briefly after a Copy-/Paste-component gesture —
577    /// chiefly the type-mismatch rejection ("clipboard holds a `table`, not a
578    /// `graphpan`"). `None` = nothing to show. See [`Self::component_toast`].
579    toast: Option<(String, f64)>,
580    /// **Responsive MENU collapse.** Host-driven: on a narrow (phone-class) viewport
581    /// the long tab bar (one selectable per facet — a deck can hold 60+) wraps into
582    /// many rows and buries the active facet below the screen fold. When
583    /// `menu_collapsed` is set (by the host, classifying the viewport — e.g.
584    /// `facett_app::scene::Device`) the tab bar renders as a compact HAMBURGER header
585    /// (`≡` + the active choice's label) with the full choice list behind a drawer
586    /// (canvas-first). On a wide viewport it stays the inline wrapped strip. See
587    /// [`set_menu_collapsed`](Self::set_menu_collapsed).
588    menu_collapsed: bool,
589    /// The hamburger drawer's open state (deck-owned). Default **closed** ⇒ the
590    /// collapsed header is a single row and the canvas gets the screen; opening lists
591    /// every choice, and picking one switches the tab AND shuts the drawer.
592    menu_open: bool,
593    /// Last frame's tab-bar (menu header) rect — the observable geometry a headless
594    /// test reads (`state_json.menu.bar_rect`) to prove the collapsed menu is ONE
595    /// compact row, not a multi-row wrap that eats the viewport.
596    menu_bar_rect: Rect,
597    /// Last frame's active-facet content rect — the "canvas" the collapse hands the
598    /// screen to (`state_json.menu.content_rect`).
599    content_rect: Rect,
600    /// **Tab groups** (opt-in, [`with_groups`](Self::with_groups)). Empty = the one flat
601    /// strip, exactly as before. Set = a TWO-LEVEL bar: a row of group names, then the
602    /// active group's tabs. Every facet sits in exactly one group (unlisted ones land in
603    /// a trailing group), and the active group is DERIVED from the active facet, so a
604    /// programmatic switch ([`set_active_by_title`](Self::set_active_by_title), a deep
605    /// link, a tour) can never show a tab row that does not hold the active tab.
606    groups: Vec<TabGroup>,
607}
608
609/// One named group of deck tabs: its label and its members as facet indices, in deck
610/// order. Built by [`FacetDeck::with_groups`].
611#[derive(Clone, Debug, PartialEq, Eq)]
612pub struct TabGroup {
613    /// The group's label in the group row (also its navgraph field key).
614    pub name: String,
615    /// Facet indices, ascending (deck order).
616    pub members: Vec<usize>,
617    /// The member last shown, so re-entering a group returns to where the user was.
618    last: usize,
619}
620
621impl TabGroup {
622    /// The member index re-entering this group opens (the last one shown).
623    pub fn entry(&self) -> usize {
624        if self.members.contains(&self.last) { self.last } else { self.members.first().copied().unwrap_or(0) }
625    }
626}
627
628/// How long a component-clone [`toast`](FacetDeck::toast) stays on screen.
629const TOAST_SECS: f64 = 2.6;
630
631impl FacetDeck {
632    pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
633        Self {
634            facets,
635            active: 0,
636            fx: DeckFx::OFF,
637            raven: None,
638            toast: None,
639            menu_collapsed: false,
640            menu_open: false,
641            menu_bar_rect: Rect::ZERO,
642            content_rect: Rect::ZERO,
643            groups: Vec::new(),
644        }
645    }
646
647    /// **Group the tab bar** into named groups — `(name, tab titles)` in display order.
648    ///
649    /// Tab titles are matched exactly; a title the deck does not hold is skipped (a
650    /// native-only tab named in a list a wasm build shares). A facet no group names is
651    /// put in a trailing group called `rest` (created only when needed), so no tab can
652    /// become unreachable by being forgotten. A facet named by two groups belongs to the
653    /// first. Groups left empty are dropped. Inside a group the tabs keep DECK order, so
654    /// the tab strip's navgraph draw order is the deck order filtered, as before.
655    ///
656    /// Must be called after the facets are in place (it indexes them); a later
657    /// [`push`](Self::push) lands in the `rest` group on the next call only.
658    #[must_use]
659    pub fn with_groups(mut self, groups: &[(&str, &[&str])], rest: &str) -> Self {
660        self.set_groups(groups, rest);
661        self
662    }
663
664    /// The in-place form of [`with_groups`](Self::with_groups).
665    pub fn set_groups(&mut self, groups: &[(&str, &[&str])], rest: &str) {
666        let mut owner: Vec<Option<usize>> = vec![None; self.facets.len()];
667        for (g, (_, titles)) in groups.iter().enumerate() {
668            for t in titles.iter() {
669                if let Some(i) = self.facets.iter().position(|f| f.title() == *t)
670                    && owner[i].is_none()
671                {
672                    owner[i] = Some(g);
673                }
674            }
675        }
676        let mut out: Vec<TabGroup> = groups
677            .iter()
678            .enumerate()
679            .map(|(g, (name, _))| TabGroup {
680                name: (*name).to_string(),
681                members: (0..self.facets.len()).filter(|&i| owner[i] == Some(g)).collect(),
682                last: usize::MAX,
683            })
684            .collect();
685        let orphans: Vec<usize> = (0..self.facets.len()).filter(|&i| owner[i].is_none()).collect();
686        if !orphans.is_empty() {
687            // A `rest` group the caller already declared takes the orphans too.
688            match out.iter_mut().find(|g| g.name == rest) {
689                Some(g) => {
690                    g.members.extend(orphans);
691                    g.members.sort_unstable();
692                }
693                None => out.push(TabGroup { name: rest.to_string(), members: orphans, last: usize::MAX }),
694            }
695        }
696        out.retain(|g| !g.members.is_empty());
697        self.groups = out;
698    }
699
700    /// The tab groups (empty when the bar is one flat strip).
701    pub fn groups(&self) -> &[TabGroup] {
702        &self.groups
703    }
704
705    /// The index of the group holding the active facet, or `None` when ungrouped.
706    pub fn active_group(&self) -> Option<usize> {
707        self.groups.iter().position(|g| g.members.contains(&self.active))
708    }
709
710    /// The name of the group holding the active facet.
711    pub fn active_group_name(&self) -> Option<&str> {
712        self.active_group().map(|g| self.groups[g].name.as_str())
713    }
714
715    /// Switch to group `name`: the active facet becomes the group's last-shown member
716    /// (its first, the first time). Returns `false` for an unknown group. The
717    /// programmatic twin of clicking the group's label.
718    pub fn set_active_group(&mut self, name: &str) -> bool {
719        match self.groups.iter().find(|g| g.name == name) {
720            Some(g) => {
721                self.active = g.entry();
722                true
723            }
724            None => false,
725        }
726    }
727
728    /// Remember the active facet as its group's last-shown member.
729    fn note_group_entry(&mut self) {
730        let active = self.active;
731        if let Some(g) = self.groups.iter_mut().find(|g| g.members.contains(&active)) {
732            g.last = active;
733        }
734    }
735
736    /// The group row of a grouped bar: one selectable per group, a draw-order-declared
737    /// navgraph `groups` strip (each label a `Field`). Clicking a group opens its entry.
738    /// Returns `true` when a group was picked this frame.
739    fn draw_group_row(&mut self, ui: &mut Ui) -> bool {
740        let active_group = self.active_group();
741        let mut pick: Option<usize> = None;
742        let nav = navgraph::begin(ui, "groups", navgraph::NavKind::Group, "groups");
743        navgraph::declare_draw_order(ui, nav);
744        let row = ui.horizontal_wrapped(|ui| {
745            for (gi, g) in self.groups.iter().enumerate() {
746                let r = ui
747                    .selectable_label(active_group == Some(gi), g.name.as_str())
748                    .on_hover_text(format!("{} tabs", g.members.len()));
749                navgraph::field(ui, &g.name, &r);
750                if r.clicked() {
751                    pick = Some(gi);
752                }
753            }
754        });
755        navgraph::end(ui, nav, row.response.rect);
756        if let Some(gi) = pick {
757            self.active = self.groups[gi].entry();
758        }
759        pick.is_some()
760    }
761    /// Append a facet (the incremental form of [`new`](Self::new)). Lets a host
762    /// build a deck pane-by-pane as it discovers what to show (e.g. one pane per
763    /// warehouse table it finds).
764    pub fn push(&mut self, facet: Box<dyn Facet>) {
765        self.facets.push(facet);
766    }
767    pub fn active(&self) -> usize {
768        self.active
769    }
770
771    /// The title of the currently-active facet (the deck's `state_json["active"]`),
772    /// or `None` if the deck is empty.
773    pub fn active_title(&self) -> Option<&str> {
774        self.facets.get(self.active).map(|f| f.title())
775    }
776
777    /// The titles of every tabbed facet, in tab order — the discoverable surface a
778    /// host (or a robot-UI control channel) enumerates to know which tabs exist.
779    pub fn titles(&self) -> Vec<&str> {
780        self.facets.iter().map(|f| f.title()).collect()
781    }
782
783    /// Make the facet titled `title` the active tab — the programmatic (headless,
784    /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
785    /// facet with that title exists (and is now active), `false` otherwise. This is
786    /// the named boundary a control channel switches tabs through (the deck analogue
787    /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
788    pub fn set_active_by_title(&mut self, title: &str) -> bool {
789        match self.facets.iter().position(|f| f.title() == title) {
790            Some(i) => {
791                self.active = i;
792                true
793            }
794            None => false,
795        }
796    }
797
798    // ── responsive menu (hamburger) — the reusable narrow-viewport tab bar ───────
799
800    /// Host-driven: render the tab bar as a compact **hamburger menu** (`≡` + the
801    /// active choice's label, the full list behind a drawer) instead of the inline
802    /// wrapped strip. The host classifies the viewport (e.g.
803    /// `facett_app::scene::Device::from_width`) and calls this each frame; on a
804    /// narrow (phone-class) width the long tab bar would otherwise wrap into many
805    /// rows and bury the active facet below the screen fold. Setting it `false` (a
806    /// wide viewport) restores the inline bar AND shuts the drawer. This is the deck
807    /// analogue of the demo shell's `⚙` control-strip fold — the SAME canvas-first
808    /// pattern applied to the MAIN menu, living once here so every deck host
809    /// (korp/nornir/…) inherits it.
810    pub fn set_menu_collapsed(&mut self, collapsed: bool) {
811        if !collapsed {
812            self.menu_open = false;
813        }
814        self.menu_collapsed = collapsed;
815    }
816    /// Whether the tab bar is currently rendered as the collapsed hamburger menu.
817    pub fn menu_collapsed(&self) -> bool {
818        self.menu_collapsed
819    }
820    /// Whether the hamburger drawer is open (the full choice list is showing).
821    pub fn menu_open(&self) -> bool {
822        self.menu_open
823    }
824    /// Open / shut the hamburger drawer explicitly (the programmatic equivalent of
825    /// tapping `≡`). No visible effect while the menu is not collapsed.
826    pub fn set_menu_open(&mut self, open: bool) {
827        self.menu_open = open;
828    }
829    /// Toggle the hamburger drawer; returns the new open state.
830    pub fn toggle_menu(&mut self) -> bool {
831        self.menu_open = !self.menu_open;
832        self.menu_open
833    }
834
835    /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
836    /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
837    /// mismatches. Lets a host drive a concrete component's own API (e.g. a
838    /// robot-UI control forwarding a node selection to a `SystemChart`).
839    pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
840        self.facets
841            .iter_mut()
842            .find(|f| f.title() == title)
843            .and_then(|f| f.as_any_mut())
844            .and_then(|a| a.downcast_mut::<T>())
845    }
846
847    /// Forward one [`Facet::update_json`] message to the facet titled `title` — the
848    /// untyped twin of [`facet_mut`](Self::facet_mut) for a host that holds a message,
849    /// not a type (a deep link, a control channel). `false` when no such facet.
850    pub fn update_json_for(&mut self, title: &str, msg_json: &str) -> bool {
851        match self.facets.iter_mut().find(|f| f.title() == title) {
852            Some(f) => {
853                f.update_json(msg_json);
854                true
855            }
856            None => false,
857        }
858    }
859
860    /// Replace the facet whose `title` matches with `facet` (the box's own title is
861    /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
862    /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
863    /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
864    /// and swaps them in, keeping the same tab slots (and the active selection).
865    pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
866        if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
867            *slot = facet;
868            true
869        } else {
870            false
871        }
872    }
873
874    // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
875
876    /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
877    pub fn with_fx(mut self, fx: DeckFx) -> Self {
878        self.fx = fx;
879        self
880    }
881    /// The current deck-effects config (read-only).
882    pub fn fx(&self) -> &DeckFx {
883        &self.fx
884    }
885    /// Mutate the deck-effects config (toggle glow, pin a palette, …).
886    pub fn fx_mut(&mut self) -> &mut DeckFx {
887        &mut self.fx
888    }
889    /// Override the deck theme with palette index `i` (wraps); enables the
890    /// override. Convenience over `fx_mut().set_palette(i)`.
891    pub fn set_palette(&mut self, i: usize) {
892        self.fx.set_palette(i);
893    }
894    /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
895    /// index. Convenience over `fx_mut().cycle_palette()`.
896    pub fn cycle_palette(&mut self) -> usize {
897        self.fx.cycle_palette()
898    }
899
900    /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
901    /// (a table row, a node, a header). Replaces any raven already in flight. The
902    /// body is tinted from the deck's current palette (or the host theme). Logs an
903    /// activity trail entry. Drive/paint happens automatically inside
904    /// [`ui`](Self::ui).
905    pub fn send_raven(&mut self, target: Rect) {
906        let theme = self.effective_theme();
907        self.raven = Some(DeckRaven::new(target, &theme));
908        harness::trail(
909            harness::Kind::Render,
910            format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
911        );
912    }
913    /// True while a raven is present (flying or perched).
914    pub fn has_raven(&self) -> bool {
915        self.raven.is_some()
916    }
917    /// True once the summoned raven has landed (false if none).
918    pub fn raven_perched(&self) -> bool {
919        self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
920    }
921    /// Dismiss any raven.
922    pub fn clear_raven(&mut self) {
923        self.raven = None;
924    }
925
926    /// The theme the deck paints with: the fx palette override if set, else
927    /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
928    /// this is just the colour source for deck-owned effects/picker).
929    fn effective_theme(&self) -> Theme {
930        self.fx.theme().unwrap_or_default()
931    }
932
933    /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
934    /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
935    /// override; `ui()` then applies it each frame. Returns the chosen index if it
936    /// changed this frame.
937    ///
938    /// The override stays **off until the user actually clicks** a palette: merely
939    /// drawing the picker must not pin index 0, otherwise a host that drives its
940    /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
941    /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
942    /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
943    /// the selection genuinely changed this frame.
944    pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
945        let mut sel = self.fx.palette().unwrap_or(0);
946        let before = sel;
947        ui.horizontal_wrapped(|ui| {
948            ui.label("Palette:");
949            for (i, ctor) in Theme::ALL.iter().enumerate() {
950                ui.selectable_value(&mut sel, i, ctor().name);
951            }
952        });
953        if sel != before {
954            self.fx.set_palette(sel);
955        }
956        (sel != before).then_some(sel)
957    }
958
959    /// The capabilities of the currently-active facet (or `NONE` if empty).
960    pub fn active_caps(&self) -> FacetCaps {
961        self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
962    }
963
964    /// Number of facets in the deck.
965    pub fn len(&self) -> usize {
966        self.facets.len()
967    }
968    /// True when the deck holds no facets.
969    pub fn is_empty(&self) -> bool {
970        self.facets.is_empty()
971    }
972
973    /// **Wall layout** — render **every** facet at once in a wrapping grid of
974    /// `cols` columns, instead of the tabbed one-at-a-time [`ui`](Self::ui). This is
975    /// the "multiple components visible simultaneously" mode a dashboard host wants
976    /// (e.g. several `Graph3D` panes side-by-side). Each cell is a titled group; the
977    /// fx palette override (if set) still applies, and every rendered facet is logged
978    /// to the runtrace ledger keyed `deck.wall:<title>` (mirrors the tab path's
979    /// `deck.render:<title>`), so `state_json`'s `trace.ran` proves each pane drew.
980    pub fn wall_ui(&mut self, ui: &mut Ui, cols: usize) {
981        if let Some(theme) = self.fx.theme() {
982            set_theme(ui.ctx(), theme);
983        }
984        if self.facets.is_empty() {
985            ui.weak("empty deck");
986            return;
987        }
988        let cols = cols.max(1);
989        let spacing = ui.spacing().item_spacing.x;
990        let total_w = ui.available_width();
991        let cell_w = ((total_w - spacing * (cols as f32 - 1.0)) / cols as f32).max(160.0);
992        // A generous cell so 3D graphs have room; the inner facet fills it.
993        let cell_h = 300.0_f32;
994        egui::ScrollArea::vertical()
995            .auto_shrink([false; 2])
996            .show(ui, |ui| {
997                ui.horizontal_wrapped(|ui| {
998                    for f in self.facets.iter_mut() {
999                        ui.allocate_ui(egui::vec2(cell_w, cell_h), |ui| {
1000                            let cell = ui.group(|ui| {
1001                                ui.set_min_size(egui::vec2(cell_w - 12.0, cell_h - 12.0));
1002                                ui.vertical(|ui| {
1003                                    ui.strong(f.title());
1004                                    ui.separator();
1005                                    runtrace::ran(&format!("deck.wall:{}", f.title()));
1006                                    f.ui(ui);
1007                                });
1008                            });
1009                            // The wall shows every pane at once, which is exactly when
1010                            // "which crate drew THAT one?" is hardest to answer.
1011                            devid::badge(ui, cell.response.rect, f.component(), f.title());
1012                        });
1013                    }
1014                });
1015            });
1016    }
1017
1018    /// **Render ONE facet into the caller's `Ui`** — the seam a scene host needs to
1019    /// make a deck out of REAL panes instead of emulating one.
1020    ///
1021    /// [`wall_ui`](Self::wall_ui) owns its own grid: it decides the columns, the cell
1022    /// size and the scrolling, and draws every facet inside one `Ui`. That is right for
1023    /// a self-contained dashboard and wrong for `facett_app::scene`, which already has
1024    /// a pane tree, allots each pane its own rect, and gives each one chrome and a
1025    /// published address. A host with a scene could only mount the WHOLE deck into ONE
1026    /// pane, so N panes' worth of content ended up sharing a single allotment — which
1027    /// is precisely the shape that lets one pane's growth displace another.
1028    ///
1029    /// This renders facet `idx` and nothing else, into whatever rect the caller has
1030    /// already decided. No grid, no scroll area, no title strip — the scene's chrome
1031    /// bar carries the title, and a second one would be a second header row.
1032    ///
1033    /// Returns `false` when `idx` is out of range, so a host whose pane tree has
1034    /// drifted from the deck's contents gets a value it can assert on rather than a
1035    /// silently blank pane.
1036    ///
1037    /// The runtrace key is `deck.pane:<title>`, alongside `deck.wall:` and
1038    /// `deck.render:`, so `state_json`'s `trace.ran` still proves the pane drew.
1039    pub fn pane_ui(&mut self, idx: usize, ui: &mut Ui) -> bool {
1040        if let Some(theme) = self.fx.theme() {
1041            set_theme(ui.ctx(), theme);
1042        }
1043        let Some(f) = self.facets.get_mut(idx) else {
1044            return false;
1045        };
1046        runtrace::ran(&format!("deck.pane:{}", f.title()));
1047        // The pane's own rect, captured BEFORE it draws: a pane that consumes the whole
1048        // `Ui` leaves the cursor somewhere unhelpful, so reading it afterwards would
1049        // anchor the chip to wherever the content happened to end.
1050        let rect = ui.max_rect();
1051        let (component, title) = (f.component(), f.title().to_string());
1052        let nav = navgraph::collecting(ui.ctx())
1053            .then(|| devid::badge_text(component, &title).0)
1054            .and_then(|key| navgraph::begin(ui, &key, navgraph::NavKind::Pane, &title));
1055        f.ui(ui);
1056        navgraph::end(ui, nav, rect);
1057        // The DEV-ID chip — one draw site, so EVERY deck pane gets it for free (LAW 5)
1058        // rather than 70-odd panes each remembering to paint their own. No-op in release.
1059        devid::badge(ui, rect, component, &title);
1060        true
1061    }
1062
1063    /// Open the active facet's navgraph Pane (dev mode, collecting only — the key is a
1064    /// `format!`, so it is not built otherwise).
1065    fn nav_pane_begin(&self, ui: &Ui) -> Option<navgraph::Token> {
1066        if !navgraph::collecting(ui.ctx()) {
1067            return None;
1068        }
1069        let f = self.facets.get(self.active)?;
1070        let (key, _) = devid::badge_text(f.component(), f.title());
1071        navgraph::begin(ui, &key, navgraph::NavKind::Pane, f.title())
1072    }
1073
1074    /// The title of facet `idx`, for a host naming its panes from the deck.
1075    pub fn pane_title(&self, idx: usize) -> Option<&str> {
1076        self.facets.get(idx).map(|f| f.title())
1077    }
1078
1079    /// The active facet's current scale (1.0 if none / not scalable).
1080    fn active_scale(&self) -> f32 {
1081        self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
1082    }
1083
1084    /// Multiply the active facet's scale by `k`, clamped to **the facet's own**
1085    /// [`Facet::scale_range`] — not to a hardcoded document-viewer band. A facet whose
1086    /// native range is wider (a map: 1.25-million-fold) would otherwise saturate the
1087    /// clamp on the first click and have its camera written backwards.
1088    fn scale_active(&mut self, k: f32) {
1089        if let Some(f) = self.facets.get_mut(self.active) {
1090            let (lo, hi) = f.scale_range();
1091            let s = (f.scale() * k).clamp(lo, hi);
1092            f.set_scale(s);
1093        }
1094    }
1095
1096    /// Reset the active facet's scale to native.
1097    fn reset_scale(&mut self) {
1098        if let Some(f) = self.facets.get_mut(self.active) {
1099            f.set_scale(1.0);
1100        }
1101    }
1102
1103    /// Draw the tab bar + capability toolbar + the active facet, and route
1104    /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
1105    pub fn ui(&mut self, ui: &mut Ui) {
1106        // Opt-in palette override: apply the chosen Theme::ALL palette + its
1107        // egui Visuals each frame so the whole deck (and every facet that reads
1108        // `theme(ui)`) follows. No override → the host's own theme stays.
1109        if let Some(theme) = self.fx.theme() {
1110            set_theme(ui.ctx(), theme);
1111        }
1112
1113        let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
1114        // The tab bar has TWO forms (responsive, host-driven via `set_menu_collapsed`):
1115        //   • WIDE viewport → the inline wrapped strip (every tab visible + clickable).
1116        //   • NARROW (phone) → a compact `≡` HAMBURGER header + a drawer of choices,
1117        //     so the long list doesn't wrap into many rows and bury the canvas.
1118        // Capture the bar's rect either way so a headless test can prove the collapsed
1119        // menu is one compact row, not a viewport-eating wrap.
1120        let bar = ui.scope(|ui| {
1121            if self.menu_collapsed {
1122                self.draw_menu_drawer(&titles, ui);
1123            } else {
1124                // Wrap the tab bar: with many facets a single non-wrapping row overflows
1125                // the panel width and the trailing tabs become unreachable (off-screen,
1126                // unclickable for a robot driver / pointer). Wrapping keeps every tab
1127                // visible + clickable no matter how many facets the deck holds.
1128                // navgraph: the strip is a Group whose draw order IS its declared order
1129                // (the deck's facet order), and each tab is a focusable Field.
1130                //
1131                // GROUPED (opt-in `with_groups`): a group row first, then ONLY the active
1132                // group's tabs in the same `tabs` strip — so a tab's navgraph id
1133                // (`…tabs.<title>`) and its AccessKit label are the ones the flat strip had.
1134                let shown: Vec<usize> = match self.active_group() {
1135                    Some(g) => {
1136                        self.draw_group_row(ui);
1137                        // The group row may have switched the active facet.
1138                        let g = self.active_group().unwrap_or(g);
1139                        self.groups[g].members.clone()
1140                    }
1141                    None => (0..titles.len()).collect(),
1142                };
1143                let nav = navgraph::begin(ui, "tabs", navgraph::NavKind::Group, "tabs");
1144                navgraph::declare_draw_order(ui, nav);
1145                let strip = ui.horizontal_wrapped(|ui| {
1146                    for &i in &shown {
1147                        let t = &titles[i];
1148                        let r = ui.selectable_value(&mut self.active, i, t);
1149                        navgraph::field(ui, t, &r);
1150                    }
1151                });
1152                navgraph::end(ui, nav, strip.response.rect);
1153                self.note_group_entry();
1154            }
1155        });
1156        self.menu_bar_rect = bar.response.rect;
1157
1158        let caps = self.active_caps();
1159
1160        // Capability-driven toolbar: only show controls the active facet honors.
1161        if caps.scalable {
1162            ui.horizontal(|ui| {
1163                if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
1164                    self.scale_active(1.0 / 1.1);
1165                }
1166                ui.label(format!("{:.0}%", self.active_scale() * 100.0));
1167                if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
1168                    self.scale_active(1.1);
1169                }
1170                if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
1171                    self.reset_scale();
1172                }
1173            });
1174        }
1175
1176        // Capability-gated scale shortcuts. egui has no semantic event for these,
1177        // so we hand-detect the key combos (clipboard uses semantic events below).
1178        if caps.scalable {
1179            let (cmd, plus, minus, zero) = ui.input(|i| {
1180                (
1181                    i.modifiers.command,
1182                    i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
1183                    i.key_pressed(egui::Key::Minus),
1184                    i.key_pressed(egui::Key::Num0),
1185                )
1186            });
1187            if cmd {
1188                if plus {
1189                    self.scale_active(1.1);
1190                }
1191                if minus {
1192                    self.scale_active(1.0 / 1.1);
1193                }
1194                if zero {
1195                    self.reset_scale();
1196                }
1197            }
1198        }
1199
1200        // Clipboard routing: drain semantic events and dispatch to the active
1201        // facet, gated by its caps. A focused TextEdit already consumed its own.
1202        self.route_clipboard(ui.ctx());
1203
1204        // Cross-instance component clone (DISTINCT gesture): the Ctrl+Shift+C/V
1205        // accelerators + any pending envelope paste, drained the same frame.
1206        self.route_component_clipboard(ui);
1207
1208        ui.separator();
1209        // Optionally wrap the active facet in the shared glass/card chrome
1210        // (`chrome` module), gated by the active EffectsPolicy. Reserve a paint slot
1211        // BEFORE the content so the glass fill sits behind it; the glow + border
1212        // edge is painted on top afterwards.
1213        let chrome_on = self.fx.chrome;
1214        let chrome_slot = chrome_on.then(|| ui.painter().add(egui::Shape::Noop));
1215        // navgraph: the active facet is a Pane keyed like its dev-id chip, opened
1216        // BEFORE it draws so its atoms hang under it; the badge below merges into it.
1217        let nav = self.nav_pane_begin(ui);
1218        // Render the active facet, capturing the rect it occupied so the deck can
1219        // bloom it (opt-in glow) without the facet knowing.
1220        let content = ui.scope(|ui| {
1221            if let Some(f) = self.facets.get_mut(self.active) {
1222                // Render-trace: this facet's `ui()` RAN this frame (the wasm-safe
1223                // "what ran" ledger — folded into state_json, read via the JS hook
1224                // on wasm). Keyed by tab title so the ran-list maps tab → ran?.
1225                runtrace::ran(&format!("deck.render:{}", f.title()));
1226                f.ui(ui);
1227            }
1228        });
1229        let content_rect = content.response.rect;
1230        navgraph::end(ui, nav, content_rect);
1231        // The DEV-ID chip, on the rect the facet actually occupied. This is the draw
1232        // path the apps really use (`Deck::ui`); `pane_ui` below is the host-driven
1233        // one. Both paint it, because a badge wired only into the path nobody calls is
1234        // a feature that ships green and never appears (facett-demo goes through HERE).
1235        if let Some(f) = self.facets.get(self.active) {
1236            let (component, title) = (f.component(), f.title().to_string());
1237            devid::badge(ui, content_rect, component, &title);
1238        }
1239        // Remember the "canvas" rect — the screen the collapsed menu hands to the
1240        // active facet (read back via `state_json.menu.content_rect`).
1241        self.content_rect = content_rect;
1242
1243        // Paint the card chrome around the facet's content rect.
1244        if let Some(slot) = chrome_slot
1245            && content_rect.is_positive()
1246        {
1247            let theme = self.effective_theme();
1248            let policy = crate::look::effects_policy(ui);
1249            let style = chrome::ChromeStyle::default().for_policy(policy);
1250            let card = content_rect.expand(6.0);
1251            ui.painter().set(slot, chrome::fill_shape(card, &theme, policy, style));
1252            chrome::edge(ui.painter(), card, &theme, policy, style);
1253        }
1254
1255        // Right-click the active facet body → the Copy/Paste-component menu (the
1256        // discoverable affordance for the cross-instance clone gesture). The
1257        // text clipboard's own copy/paste is unaffected (different gesture).
1258        if !self.active_kind().is_empty() {
1259            content.response.context_menu(|ui| self.component_menu(ui));
1260        }
1261
1262        // Opt-in glow on the active facet's content rect, pulsing.
1263        if self.fx.glow && content_rect.is_positive() {
1264            let theme = self.effective_theme();
1265            let time = ui.input(|i| i.time);
1266            let painter = ui.painter_at(content_rect);
1267            deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
1268            ui.ctx().request_repaint(); // keep the pulse animating
1269        }
1270
1271        // Drive + paint a summoned raven on a foreground layer above everything.
1272        self.drive_raven(ui.ctx());
1273
1274        // Paint the component-clone toast (mismatch / rejection feedback) on top.
1275        self.paint_component_toast(ui);
1276    }
1277
1278    /// Draw the collapsed **hamburger menu** for a narrow viewport: a compact header
1279    /// (`≡` / `✕` toggle + the active choice's label) and, when the drawer is open, the
1280    /// full choice list as a vertical, scrollable drawer. Picking a choice switches the
1281    /// active facet AND shuts the drawer (canvas-first again). Mirrors the demo shell's
1282    /// `⚙` control-strip fold, applied to the MAIN menu.
1283    fn draw_menu_drawer(&mut self, titles: &[String], ui: &mut Ui) {
1284        // Grouped deck: the group row stays visible on a phone too (a handful of short
1285        // names), so every group — Grafer, Kartor, … — is one tap away and the drawer
1286        // below lists only the active group's tabs. Picking a group opens its drawer.
1287        if self.active_group().is_some() && self.draw_group_row(ui) {
1288            self.menu_open = true;
1289        }
1290        let active_label = titles.get(self.active).cloned().unwrap_or_default();
1291        ui.horizontal(|ui| {
1292            // The hamburger toggle. `≡` closed, `✕` open — a single tappable glyph.
1293            let glyph = if self.menu_open { "✕" } else { "≡" };
1294            let hint = if self.menu_open { "close the menu — give the canvas the screen" } else { "choose a view" };
1295            if ui.selectable_label(self.menu_open, glyph).on_hover_text(hint).clicked() {
1296                self.menu_open = !self.menu_open;
1297            }
1298            // The current choice's label, so the collapsed header still says WHAT is shown.
1299            ui.label(&active_label);
1300        });
1301        if self.menu_open {
1302            // The full choice list as a vertical DRAWER (bounded + scrollable so 60+
1303            // choices never blow past the viewport). Selecting one switches + closes.
1304            // Bounded by the screen too: a phone in landscape is shorter than 420 pt.
1305            let max_h = (ui.ctx().content_rect().height() * 0.6).clamp(160.0, 420.0);
1306            egui::Frame::group(ui.style()).show(ui, |ui| {
1307                egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
1308                    // Grouped deck: the active group's tabs (the group row above picks
1309                    // the group). Ungrouped: the one flat list.
1310                    let members: Vec<usize> = match self.active_group() {
1311                        Some(g) => self.groups[g].members.clone(),
1312                        None => (0..titles.len()).collect(),
1313                    };
1314                    for i in members {
1315                        let Some(t) = titles.get(i) else { continue };
1316                        if ui.selectable_label(self.active == i, t.as_str()).clicked() {
1317                            self.active = i;
1318                            self.menu_open = false;
1319                        }
1320                    }
1321                });
1322            });
1323        }
1324    }
1325
1326    /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
1327    /// launch time on the first frame and keeps repainting while it flies.
1328    fn drive_raven(&mut self, ctx: &egui::Context) {
1329        let Some(raven) = self.raven.as_mut() else { return };
1330        raven.sprite.update(ctx);
1331        let painter =
1332            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
1333        raven.sprite.paint(&painter);
1334    }
1335
1336    /// Route this frame's clipboard events to the active facet, gated by caps.
1337    /// The single OS-touching write (`clipboard::put`) lives here.
1338    fn route_clipboard(&mut self, ctx: &egui::Context) {
1339        let caps = self.active_caps();
1340        if !(caps.copyable || caps.cuttable || caps.pasteable) {
1341            return;
1342        }
1343        for action in clipboard::poll(ctx) {
1344            let Some(f) = self.facets.get_mut(self.active) else { continue };
1345            match action {
1346                ClipAction::Copy if caps.copyable => {
1347                    if let Some(t) = f.copy() {
1348                        clipboard::put(ctx, t);
1349                    }
1350                }
1351                ClipAction::Cut if caps.cuttable => {
1352                    if let Some(t) = f.cut() {
1353                        clipboard::put(ctx, t);
1354                    }
1355                }
1356                ClipAction::Paste(s) if caps.pasteable => {
1357                    f.paste(&s);
1358                }
1359                // Capability not declared → ignore (event may belong to a focused
1360                // sub-widget egui already handled).
1361                _ => {}
1362            }
1363        }
1364    }
1365
1366    // ── cross-instance component clone (Copy/Paste component) ────────────────
1367    //
1368    // A DISTINCT gesture from the text clipboard above: it transfers a facet's
1369    // type-tagged PORTABLE state (not a text selection) to a same-kind sibling.
1370    // See `.nornir/design/copy-paste-between-instances.md`. Surfaced two ways —
1371    // the context menu in `component_menu` (right-click the body) and the
1372    // `Ctrl+Shift+C / Ctrl+Shift+V` accelerators routed in `ui`.
1373
1374    /// The active facet's [`Facet::kind`] (`""` if empty / opted out).
1375    pub fn active_kind(&self) -> &'static str {
1376        self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
1377    }
1378
1379    /// **Copy component** — encode the active facet's [`Facet::portable_state`]
1380    /// into the tagged clipboard envelope and place it on the OS clipboard.
1381    /// Returns the envelope text on success, or `None` if the active facet opts
1382    /// out (empty `kind()` or no `portable_state()`). This is the data half the
1383    /// gesture handlers + tests drive; the OS write is the caller's via
1384    /// [`clipboard::put`] (done for them in [`copy_component`](Self::copy_component)).
1385    pub fn copy_component_envelope(&self) -> Option<String> {
1386        let f = self.facets.get(self.active)?;
1387        let kind = f.kind();
1388        if kind.is_empty() {
1389            return None;
1390        }
1391        let state = f.portable_state()?;
1392        Some(clipboard::encode_component(kind, &state))
1393    }
1394
1395    /// Copy the active facet's portable state to the OS clipboard (the full
1396    /// gesture). Returns `true` if something was copied.
1397    pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
1398        match self.copy_component_envelope() {
1399            Some(env) => {
1400                clipboard::put(ctx, env);
1401                true
1402            }
1403            None => false,
1404        }
1405    }
1406
1407    /// **Paste component** — decode a clipboard `text` envelope and, **only if its
1408    /// kind matches the active facet's** [`Facet::kind`], hand the state to
1409    /// [`Facet::load_state`]. Returns `true` if the active facet adopted it.
1410    /// A kind mismatch (or a non-envelope / wrong-version text) is a no-op that
1411    /// raises a themed mismatch [`toast`](Self::toast) — the type-match guard is
1412    /// the whole point: a `table` envelope NEVER loads into a `graphpan`.
1413    pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
1414        let Some((kind, state)) = clipboard::decode_component(text) else {
1415            // Not a component envelope at all — leave it for the text path; no toast.
1416            return false;
1417        };
1418        let active_kind = self.active_kind();
1419        if active_kind.is_empty() {
1420            self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
1421            return false;
1422        }
1423        if kind != active_kind {
1424            self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
1425            return false;
1426        }
1427        let Some(f) = self.facets.get_mut(self.active) else { return false };
1428        let accepted = f.load_state(&state);
1429        if !accepted {
1430            self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
1431        }
1432        accepted
1433    }
1434
1435    /// The current component-clone toast message (if one is live), for tests /
1436    /// hosts that want to surface it themselves.
1437    pub fn component_toast(&self) -> Option<&str> {
1438        self.toast.as_ref().map(|(m, _)| m.as_str())
1439    }
1440
1441    /// Right-click context-menu entries for the cross-instance clone gesture —
1442    /// **Copy component** / **Paste component** — themed by the active style. A
1443    /// host attaches these to the facet body (or its tab) via
1444    /// `response.context_menu(|ui| deck.component_menu(ui))`. Greys out when the
1445    /// active facet opts out (empty `kind()`).
1446    pub fn component_menu(&mut self, ui: &mut egui::Ui) {
1447        let km = look::keymap(ui);
1448        let kind = self.active_kind();
1449        let can_clone = !kind.is_empty();
1450        ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
1451            let label = format!("Copy component  {}", km.label(Action::Copy, ui.ctx()));
1452            if ui.button(label).clicked() {
1453                self.copy_component(ui.ctx());
1454                ui.close();
1455            }
1456        });
1457        ui.add_enabled_ui(can_clone, |ui| {
1458            let label = format!("Paste component  {}", km.label(Action::Paste, ui.ctx()));
1459            if ui.button(label).clicked() {
1460                // Pull the OS clipboard via egui's paste request; the actual text
1461                // arrives next frame as an Event::Paste, routed in `route_component_clipboard`.
1462                ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1463                ui.close();
1464            }
1465        });
1466    }
1467
1468    /// Route the `Ctrl+Shift+C / Ctrl+Shift+V` component-clone accelerators +
1469    /// drain any pending paste envelope. Called once per frame from [`ui`](Self::ui),
1470    /// AFTER the text clipboard so a focused TextEdit's plain Ctrl+C/V is untouched
1471    /// (the Shift discriminates this gesture from text copy/paste).
1472    fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
1473        let now = ui.input(|i| i.time);
1474        // Accelerators: Ctrl+Shift+C copies; Ctrl+Shift+V triggers an OS-clipboard
1475        // paste request (the text lands next frame as Event::Paste, decoded below).
1476        let copy_shift = egui::KeyboardShortcut::new(
1477            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1478            egui::Key::C,
1479        );
1480        let paste_shift = egui::KeyboardShortcut::new(
1481            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1482            egui::Key::V,
1483        );
1484        if ui.input_mut(|i| i.consume_shortcut(&copy_shift)) {
1485            self.copy_component(ui.ctx());
1486        }
1487        if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
1488            ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1489        }
1490        // Drain any Paste events that look like a component envelope (a plain text
1491        // paste decodes to None here and is left for the text clipboard path).
1492        let pastes: Vec<String> = ui.input(|i| {
1493            i.events
1494                .iter()
1495                .filter_map(|e| match e {
1496                    egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
1497                    _ => None,
1498                })
1499                .collect()
1500        });
1501        for text in pastes {
1502            self.paste_component(&text, now);
1503        }
1504        // Age out the toast.
1505        if let Some((_, raised)) = self.toast && now - raised > TOAST_SECS {
1506            self.toast = None;
1507        }
1508    }
1509
1510    /// Paint the live component-clone toast on a foreground layer (themed, spacious),
1511    /// if one is set. A no-op when there is no toast.
1512    fn paint_component_toast(&self, ui: &mut egui::Ui) {
1513        let Some((msg, _)) = self.toast.as_ref() else { return };
1514        let th = theme(ui);
1515        let ctx = ui.ctx();
1516        let painter =
1517            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
1518        let screen = ctx.content_rect();
1519        let font = FontId::proportional(14.0);
1520        let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
1521        let pad = vec2(14.0, 10.0); // spacious preset padding
1522        let size = galley.size() + pad * 2.0;
1523        // Bottom-centre, lifted off the edge.
1524        let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
1525        let rect = Rect::from_center_size(center, size);
1526        painter.rect_filled(rect, 8.0, th.panel_bg);
1527        painter.rect_stroke(rect, 8.0, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Inside);
1528        painter.galley(rect.min + pad, galley, th.text);
1529        ctx.request_repaint(); // keep ticking so the toast ages out on time
1530    }
1531
1532    /// The whole-app observable state: the active facet + each facet's
1533    /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
1534    /// so the existing flat `facets[title]` shape is unchanged for consumers.
1535    pub fn state_json(&self) -> serde_json::Value {
1536        self.state_json_inner(None)
1537    }
1538
1539    /// [`state_json`](Self::state_json) for a host that publishes it **every frame**
1540    /// (the wasm demo's `window.__facett_state()`): the ACTIVE facet's state is built
1541    /// every call, ONE inactive facet's in turn (round-robin), and every other inactive
1542    /// facet's is the copy `cache` holds from its last turn — so a frame pays for the
1543    /// tab on screen plus 1/N of the rest instead of all N.
1544    ///
1545    /// MEASURED (lane M, 2026-10-02): the pages demo serialised all 81 tabs every frame;
1546    /// with `lineage` at 100k on screen that included the inactive `Graphs` tab's full
1547    /// demo-data state (its population graph: 102 kB, 1.2 ms per frame natively on
1548    /// dwarf, vs lineage's demo 34 µs when `Graphs` is the one on screen) — the whole
1549    /// gap between lineage's 56.8 fps and Graphs' 60.2 on loki's RTX 5080. An inactive
1550    /// tab's state is at most one round (N frames) old; a tab is fresh from the frame it
1551    /// becomes active. The shape is `state_json`'s exactly.
1552    pub fn state_json_rolling(&self, cache: &mut DeckStateCache) -> serde_json::Value {
1553        self.state_json_inner(Some(cache))
1554    }
1555
1556    /// Hand the facets map of a [`state_json_rolling`](Self::state_json_rolling) result
1557    /// (`deck` = that value, or the `"deck"` subtree a host embedded it in) back to `cache`.
1558    pub fn return_rolling(cache: &mut DeckStateCache, mut deck: serde_json::Value) {
1559        if let Some(serde_json::Value::Object(m)) = deck.get_mut("facets").map(serde_json::Value::take) {
1560            cache.facets = m;
1561        }
1562    }
1563
1564    /// **Serialise a state that embeds a rolling deck state** (at `deck_pointer`, e.g.
1565    /// `"/deck"`, or `""` for the deck state itself) and hand its facets back to `cache`.
1566    /// Each facet's JSON text is reused unless its state was rebuilt this call, so a
1567    /// frame serialises the tab on screen + one other, not every tab's tree — the bytes
1568    /// are `serde_json::to_string`'s exactly.
1569    pub fn serialize_rolling(cache: &mut DeckStateCache, mut state: serde_json::Value, deck_pointer: &str) -> String {
1570        const MARK: &str = "\u{0}facett-rolling-facets\u{0}";
1571        let slot = state.pointer_mut(&format!("{deck_pointer}/facets"));
1572        let Some(slot) = slot else { return serde_json::to_string(&state).unwrap_or_default() };
1573        let serde_json::Value::Object(facets) = std::mem::replace(slot, serde_json::Value::String(MARK.into())) else {
1574            return serde_json::to_string(&state).unwrap_or_default();
1575        };
1576        let head = serde_json::to_string(&state).unwrap_or_default();
1577        cache.text.retain(|k, _| facets.contains_key(k));
1578        let mut body = String::with_capacity(cache.text.values().map(|t| t.len() + 32).sum::<usize>() + 2);
1579        body.push('{');
1580        for (i, (k, v)) in facets.iter().enumerate() {
1581            if i > 0 {
1582                body.push(',');
1583            }
1584            body.push_str(&serde_json::to_string(k).unwrap_or_default());
1585            body.push(':');
1586            match cache.text.get(k) {
1587                Some(t) => body.push_str(t),
1588                None => {
1589                    let t = serde_json::to_string(v).unwrap_or_default();
1590                    body.push_str(&t);
1591                    cache.text.insert(k.clone(), t);
1592                }
1593            }
1594        }
1595        body.push('}');
1596        cache.facets = facets;
1597        let mark = serde_json::to_string(MARK).unwrap_or_default();
1598        head.replacen(&mark, &body, 1)
1599    }
1600
1601    fn state_json_inner(&self, cache: Option<&mut DeckStateCache>) -> serde_json::Value {
1602        let mut caps = serde_json::Map::new();
1603        for f in &self.facets {
1604            caps.insert(f.title().to_string(), f.caps().to_json());
1605        }
1606        let facets = match cache {
1607            None => self.facets.iter().map(|f| (f.title().to_string(), f.state_json())).collect(),
1608            Some(c) => {
1609                let n = self.facets.len();
1610                // A map that does not hold exactly this deck's titles is rebuilt whole.
1611                if c.facets.len() != n || self.facets.iter().any(|f| !c.facets.contains_key(f.title())) {
1612                    c.facets.clear();
1613                    c.text.clear();
1614                }
1615                // This call's round-robin turn: the next inactive facet after the last one.
1616                let turn = (0..n).map(|k| (c.next + k) % n.max(1)).find(|&i| i != self.active);
1617                c.next = turn.map_or(0, |i| i + 1);
1618                for (i, f) in self.facets.iter().enumerate() {
1619                    if i == self.active || Some(i) == turn || !c.facets.contains_key(f.title()) {
1620                        c.built += 1;
1621                        c.facets.insert(f.title().to_string(), f.state_json());
1622                        c.text.remove(f.title());
1623                    }
1624                }
1625                std::mem::take(&mut c.facets)
1626            }
1627        };
1628        let mr = self.menu_bar_rect;
1629        let cr = self.content_rect;
1630        serde_json::json!({
1631            "active": self.facets.get(self.active).map(|f| f.title()),
1632            "facets": facets,
1633            "caps": caps,
1634            // The RESPONSIVE menu (#39-follow): whether the tab bar is the collapsed
1635            // hamburger (phone-class) and, if so, whether its drawer is open — plus the
1636            // menu-header rect and the active-facet ("canvas") rect, so a headless test
1637            // proves the collapsed menu is one compact row and the canvas gets the screen.
1638            "menu": {
1639                "collapsed": self.menu_collapsed,
1640                "open": self.menu_open,
1641                "active": self.facets.get(self.active).map(|f| f.title()),
1642                "count": self.facets.len(),
1643                // The two-level bar (`with_groups`): the active group + every group's
1644                // name and size, in display order. `null` / `[]` when ungrouped.
1645                "group": self.active_group_name(),
1646                "groups": self.groups.iter().map(|g| serde_json::json!({
1647                    "name": g.name,
1648                    "tabs": g.members.len(),
1649                })).collect::<Vec<_>>(),
1650                "bar_rect": [mr.min.x, mr.min.y, mr.width(), mr.height()],
1651                "content_rect": [cr.min.x, cr.min.y, cr.width(), cr.height()],
1652            },
1653            // The deck's opt-in effects (DeckFx) as data: whether the shared glass/
1654            // card `chrome` wrap is on, whether the active-facet bloom glow is on, and
1655            // the active palette override. A headless driver reads this to PROVE the
1656            // T1.3 showcase rendering (glass + bloom) is actually wired, not eyeballed.
1657            "fx": {
1658                "chrome": self.fx.chrome,
1659                "glow": self.fx.glow,
1660                "glow_layers": self.fx.glow_layers,
1661                "palette": self.fx.palette(),
1662            },
1663            // The wasm-safe "what RAN" ledger — every facet render + every traced
1664            // control handler that has executed this session (the readable proof
1665            // the shipped artifact actually ran each surface). See `runtrace`.
1666            "trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
1667            // The DEV-ID gate — enabled/env/profile/source. Reports whether the chips
1668            // are SWITCHED ON, not whether one is on screen; see `devid::gate_json`.
1669            "devid": devid::gate_json(),
1670        })
1671    }
1672}
1673
1674/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
1675pub fn hash_color(s: &str) -> Color32 {
1676    let mut h: u32 = 2166136261;
1677    for b in s.bytes() {
1678        h = (h ^ b as u32).wrapping_mul(16777619);
1679    }
1680    Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686
1687    #[test]
1688    fn scene_builds() {
1689        let mut s = Scene::new();
1690        let a = s.node("Person", hash_color("Person"));
1691        let b = s.node("Company", hash_color("Company"));
1692        s.edge(a, b);
1693        assert_eq!(s.nodes.len(), 2);
1694        assert_eq!(s.edges.len(), 1);
1695        assert!(!s.is_empty());
1696    }
1697
1698    #[test]
1699    fn force_layout_produces_finite_bounded_positions() {
1700        let mut scene = Scene::new();
1701        for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
1702        for i in 0..12 { scene.edge(i, (i + 1) % 12); }
1703        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
1704        let pos = positions(Layout::Force, &scene, rect);
1705        assert_eq!(pos.len(), 12);
1706        for p in &pos {
1707            assert!(p.x.is_finite() && p.y.is_finite(), "finite");
1708            assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
1709        }
1710    }
1711
1712    /// **Scale (RED-when-broken): the repulsion is sub-quadratic, measured as a
1713    /// GROWTH RATIO and not as a wall clock.**
1714    ///
1715    /// This was `assert!(ms < 20_000.0)` on one 10 000-node run, which is two defects
1716    /// in one line (stinky `mapgpu-2`): on a loaded box a *correct* implementation
1717    /// fails (MEASURED 2026-08-03: 39 616 ms at load average 120 on 32 cores), and on
1718    /// an idle box an O(n²) implementation *passes*. A wall clock cannot answer a
1719    /// complexity question — it answers a hardware question.
1720    ///
1721    /// What is measured instead is `t(4n) / t(n)`, which is ~16 for a quadratic
1722    /// repulsion and ~2–4 for `O(n log n)`. A ratio is immune to machine load because
1723    /// both halves of it contend for the same cores in the same run, and the **4×**
1724    /// spacing (rather than 2×) is deliberate: it puts the two classes an order of
1725    /// magnitude apart, so load only has to be survived, not eliminated.
1726    ///
1727    /// **ARM 1 is a red probe that runs every time.** Below
1728    /// [`BH_THRESHOLD`](crate::barnes_hut::BH_THRESHOLD) `positions` deliberately keeps
1729    /// the exact all-pairs loop, so the same instrument is pointed at a *known
1730    /// quadratic* and required to say so against the *same gate*. If arm 1 cannot see
1731    /// quadratic growth where quadratic growth demonstrably lives, arm 2's green is not
1732    /// evidence and the test fails at arm 1 instead of passing blind.
1733    ///
1734    /// MEASURED on oden 2026-08-22, debug build, load average 91 on 32 cores:
1735    /// quadratic arm `n=250 194.4 ms → n=1000 3 625.7 ms = 18.651`; Barnes–Hut arm
1736    /// `n=2000 1 541.5 ms → n=8000 3 043.7 ms = 1.975`. That is a **9.4× separation**,
1737    /// and the single gate below sits in the empty middle of it.
1738    ///
1739    /// **SEEN RED**: an earlier 2×-spaced revision of this test was run with `use_bh`
1740    /// forced to `false` in [`positions`] — the exact regression it exists to catch —
1741    /// and failed with `26 656.7 ms → 68 839.0 ms = 2.582` against a correct 1.502 on
1742    /// the same box. That run also showed *why* the spacing was widened to 4×: at load
1743    /// average 121 a 2× step compresses a true quadratic from 3.976 to 3.151 and the
1744    /// regressed Barnes–Hut arm to 2.582, which is far too little daylight.
1745    #[test]
1746    fn force_layout_repulsion_is_sub_quadratic() {
1747        /// The gate BOTH arms are judged against. Quadratic growth over a 4× step is
1748        /// ~16; `O(n log n)` is ~2–4. One number, so the red probe and the claim
1749        /// cannot drift apart.
1750        const QUADRATIC_GATE: f64 = 8.0;
1751
1752        /// A sparse ring + chords: O(n) edges, so the only super-linear term left in
1753        /// the layout is the repulsion this test is about.
1754        fn ring_scene(n: usize) -> Scene {
1755            let mut scene = Scene::new();
1756            for i in 0..n {
1757                scene.node(format!("n{i}"), hash_color("n"));
1758            }
1759            for i in 0..n {
1760                scene.edge(i, (i + 1) % n);
1761                if i % 7 == 0 {
1762                    scene.edge(i, (i + 137) % n);
1763                }
1764            }
1765            scene
1766        }
1767        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1024.0, 1024.0));
1768        // Best of two: the MINIMUM is the estimator least polluted by a scheduler
1769        // steal, and a steal can only ever make a run slower, never faster.
1770        let run = |n: usize| -> f64 {
1771            let scene = ring_scene(n);
1772            let mut best = f64::INFINITY;
1773            for _ in 0..2 {
1774                let t0 = std::time::Instant::now();
1775                let pos = positions(Layout::Force, &scene, rect);
1776                let ms = t0.elapsed().as_secs_f64() * 1000.0;
1777                assert_eq!(pos.len(), n);
1778                for p in &pos {
1779                    assert!(p.x.is_finite() && p.y.is_finite(), "every node finite at n={n}");
1780                    assert!(rect.expand(50.0).contains(*p), "every node bounded at n={n}");
1781                }
1782                best = best.min(ms);
1783            }
1784            best
1785        };
1786
1787        // ── ARM 1 (RED PROBE): the instrument sees a quadratic when there IS one ──
1788        const QUAD_N: usize = 250;
1789        const _: () = assert!(
1790            4 * QUAD_N < crate::barnes_hut::BH_THRESHOLD,
1791            "arm 1 must stay entirely on the exact all-pairs path"
1792        );
1793        let q1 = run(QUAD_N);
1794        let q4 = run(4 * QUAD_N);
1795        let quad_ratio = q4 / q1;
1796        assert!(
1797            quad_ratio >= QUADRATIC_GATE,
1798            "RED PROBE FAILED: the exact all-pairs repulsion below BH_THRESHOLD grew only \
1799             {quad_ratio:.3}x from n={QUAD_N} ({q1:.1} ms) to n={} ({q4:.1} ms), against the \
1800             {QUADRATIC_GATE} this test calls quadratic (measured 18.651 on oden). This box cannot \
1801             currently tell quadratic from n log n, so the Barnes-Hut arm below would be blind — \
1802             re-run it somewhere less contended rather than trusting a green.",
1803            4 * QUAD_N
1804        );
1805
1806        // ── ARM 2: the claim ──────────────────────────────────────────────────
1807        const BH_N: usize = 2_000;
1808        const _: () = assert!(BH_N >= crate::barnes_hut::BH_THRESHOLD, "arm 2 must be on the Barnes-Hut path");
1809        let b1 = run(BH_N);
1810        let b4 = run(4 * BH_N);
1811        let bh_ratio = b4 / b1;
1812        assert!(
1813            bh_ratio < QUADRATIC_GATE,
1814            "the force layout is not sub-quadratic: t(4n)/t(n) = {bh_ratio:.3} \
1815             ({b1:.1} ms at n={BH_N} -> {b4:.1} ms at n={}), against {quad_ratio:.3} for the \
1816             all-pairs loop the same instrument measured seconds ago. A regression to quadratic \
1817             repulsion above BH_THRESHOLD lands exactly here.",
1818            4 * BH_N
1819        );
1820    }
1821
1822    #[test]
1823    fn hash_color_is_stable() {
1824        assert_eq!(hash_color("Person"), hash_color("Person"));
1825        assert_ne!(hash_color("Person"), hash_color("Company"));
1826    }
1827
1828    /// A minimal facet for deck tests.
1829    struct Stub(&'static str);
1830    impl Facet for Stub {
1831        fn title(&self) -> &str {
1832            self.0
1833        }
1834        fn ui(&mut self, ui: &mut Ui) {
1835            ui.label(self.0);
1836        }
1837        fn state_json(&self) -> serde_json::Value {
1838            serde_json::json!({ "t": self.0 })
1839        }
1840    }
1841
1842    /// A facet whose state counts its own builds (`Cell` — `state_json` takes `&self`).
1843    struct Counted(&'static str, std::rc::Rc<std::cell::Cell<u32>>);
1844    impl Facet for Counted {
1845        fn title(&self) -> &str {
1846            self.0
1847        }
1848        fn ui(&mut self, _ui: &mut Ui) {}
1849        fn state_json(&self) -> serde_json::Value {
1850            self.1.set(self.1.get() + 1);
1851            serde_json::json!({ "t": self.0, "n": self.1.get() })
1852        }
1853    }
1854
1855    /// The page publishes the deck's state every frame: the rolling form builds the
1856    /// active tab every call and ONE inactive tab per call, serves the rest from the
1857    /// cache, keeps `state_json`'s shape, and a newly active tab is fresh at once.
1858    #[test]
1859    fn rolling_state_builds_the_active_tab_and_one_other_per_frame() {
1860        let counts: Vec<_> = (0..5).map(|_| std::rc::Rc::new(std::cell::Cell::new(0u32))).collect();
1861        let names = ["a", "b", "c", "d", "e"];
1862        let mut deck = FacetDeck::new(
1863            names.iter().zip(&counts).map(|(n, c)| Box::new(Counted(n, c.clone())) as Box<dyn Facet>).collect(),
1864        );
1865        let mut cache = DeckStateCache::default();
1866        let first = deck.state_json_rolling(&mut cache);
1867        assert_eq!(cache.built, 5, "the first call builds everything");
1868        let full = deck.state_json();
1869        let keys = |v: &serde_json::Value| v.as_object().unwrap().keys().cloned().collect::<Vec<_>>();
1870        assert_eq!(keys(&first), keys(&full), "same shape as state_json");
1871        assert_eq!(keys(&first["facets"]), keys(&full["facets"]));
1872        FacetDeck::return_rolling(&mut cache, first);
1873        for _ in 0..8 {
1874            let v = deck.state_json_rolling(&mut cache);
1875            FacetDeck::return_rolling(&mut cache, v);
1876        }
1877        // 8 frames: the active tab 8 more times, the four others twice each (one per frame).
1878        assert_eq!(cache.built, 5 + 8 * 2);
1879        let after: Vec<u32> = counts.iter().map(|c| c.get()).collect();
1880        // +1 each for the plain `state_json` above.
1881        assert_eq!(after, vec![1 + 1 + 8, 1 + 1 + 2, 1 + 1 + 2, 1 + 1 + 2, 1 + 1 + 2]);
1882        // A tab made active is built on the very next call.
1883        assert!(deck.set_active_by_title("d"));
1884        let before = counts[3].get();
1885        let v = deck.state_json_rolling(&mut cache);
1886        assert_eq!(counts[3].get(), before + 1);
1887        assert_eq!(v["facets"]["d"]["n"], before + 1, "the active tab's state is this frame's");
1888        assert_eq!(v["active"], "d");
1889        // The spliced serialisation is byte-for-byte serde's, and reuses the text.
1890        let v = deck.state_json_rolling(&mut cache);
1891        let plain = serde_json::to_string(&v).unwrap();
1892        let spliced = FacetDeck::serialize_rolling(&mut cache, serde_json::json!({ "deck": v, "x": 1 }), "/deck");
1893        assert_eq!(spliced, format!("{{\"deck\":{plain},\"x\":1}}"));
1894        let v = deck.state_json_rolling(&mut cache);
1895        let plain = serde_json::to_string(&v).unwrap();
1896        assert_eq!(FacetDeck::serialize_rolling(&mut cache, v, ""), plain, "the deck state itself");
1897        // A host that never hands the map back still gets a whole, correct state.
1898        drop(deck.state_json_rolling(&mut cache));
1899        let built = cache.built;
1900        let w = deck.state_json_rolling(&mut cache);
1901        assert_eq!(cache.built, built + 5, "nothing to serve from: every tab rebuilt");
1902        assert_eq!(keys(&w["facets"]), keys(&full["facets"]));
1903    }
1904
1905    /// A component-clone-capable stub: a `kind`, a JSON `payload` it round-trips
1906    /// through `portable_state`/`load_state`.
1907    struct CloneStub {
1908        kind: &'static str,
1909        payload: serde_json::Value,
1910    }
1911    impl Facet for CloneStub {
1912        fn title(&self) -> &str {
1913            self.kind
1914        }
1915        fn ui(&mut self, _ui: &mut Ui) {}
1916        fn state_json(&self) -> serde_json::Value {
1917            serde_json::json!({ "kind": self.kind })
1918        }
1919        fn kind(&self) -> &'static str {
1920            self.kind
1921        }
1922        fn portable_state(&self) -> Option<serde_json::Value> {
1923            Some(self.payload.clone())
1924        }
1925        fn load_state(&mut self, state: &serde_json::Value) -> bool {
1926            self.payload = state.clone();
1927            true
1928        }
1929    }
1930
1931    /// **The deck reports the DEV-ID gate too.** `facett_app::scene` is korp's container,
1932    /// but facett-demo and the wasm surfaces go through `FacetDeck`, and an oracle asking
1933    /// "are the chips on?" must get the same answer from either. A gate readable from only
1934    /// one of the two containers is exactly the shape of miss #2.
1935    #[test]
1936    fn the_deck_state_reports_the_devid_gate() {
1937        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1938        let gate = deck.state_json()["devid"].clone();
1939        assert!(!gate.is_null(), "the deck's state_json carries no `devid` gate");
1940        assert_eq!(gate["enabled"], devid::enabled(), "deck reports a gate badge() disobeys");
1941        assert_eq!(gate["debug_assertions"], cfg!(debug_assertions), "wrong profile: {gate}");
1942    }
1943
1944    #[test]
1945    fn component_clone_round_trips_through_the_deck() {
1946        // Instance A (configured) → envelope → instance B adopts it.
1947        let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
1948        let deck_a = FacetDeck::new(vec![Box::new(a)]);
1949        let env = deck_a.copy_component_envelope().expect("A copies its portable state");
1950
1951        let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
1952            kind: "graphpan",
1953            payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
1954        })]);
1955        assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
1956        // B now equals A's portable state.
1957        assert_eq!(
1958            deck_b.copy_component_envelope(),
1959            deck_a.copy_component_envelope(),
1960            "B adopted A's portable state exactly"
1961        );
1962        assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
1963    }
1964
1965    #[test]
1966    fn component_clone_rejects_a_type_mismatch_with_a_toast() {
1967        // A `table` envelope handed to a `graphpan` → load_state NOT called.
1968        let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
1969        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1970            kind: "graphpan",
1971            payload: serde_json::json!({ "zoom": 1.0 }),
1972        })]);
1973        let before = deck.copy_component_envelope();
1974        assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
1975        assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
1976        let toast = deck.component_toast().expect("mismatch raises a toast");
1977        assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
1978    }
1979
1980    #[test]
1981    fn component_clone_version_guard_rejects_unknown_v() {
1982        let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
1983        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1984            kind: "graphpan",
1985            payload: serde_json::json!({ "zoom": 1.0 }),
1986        })]);
1987        let before = deck.copy_component_envelope();
1988        // An unknown-version text decodes to None → it's a no-op (left for the text path).
1989        assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
1990        assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
1991    }
1992
1993    #[test]
1994    fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
1995        // The plain Stub does NOT implement the trio → empty kind, no copy.
1996        let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
1997        assert_eq!(deck.active_kind(), "", "opted out");
1998        assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
1999        // A real envelope handed to an opt-out facet is refused (no panic, no state).
2000        let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
2001        assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
2002        assert!(deck.component_toast().is_some(), "the refusal is surfaced");
2003    }
2004
2005    #[test]
2006    fn deck_fx_is_off_by_default() {
2007        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
2008        assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
2009        assert!(!deck.has_raven());
2010        assert!(!deck.fx().glow);
2011        assert!(deck.fx().palette().is_none());
2012    }
2013
2014    #[test]
2015    fn deck_state_json_reports_fx_as_data() {
2016        // The deck's opt-in effects must be observable as data (a robot proof reads
2017        // `fx.chrome` / `fx.glow` to know the showcase rendering is wired ON).
2018        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
2019        let off = deck.state_json();
2020        assert_eq!(off["fx"]["chrome"].as_bool(), Some(false), "chrome off by default");
2021        assert_eq!(off["fx"]["glow"].as_bool(), Some(false), "glow off by default");
2022        assert!(off["fx"]["palette"].is_null(), "no palette override by default");
2023
2024        deck.fx_mut().chrome = true;
2025        deck.fx_mut().glow = true;
2026        deck.set_palette(2);
2027        let on = deck.state_json();
2028        assert_eq!(on["fx"]["chrome"].as_bool(), Some(true), "chrome wired ON shows in state");
2029        assert_eq!(on["fx"]["glow"].as_bool(), Some(true), "glow wired ON shows in state");
2030        assert_eq!(on["fx"]["palette"].as_u64(), Some(2), "palette override shows in state");
2031    }
2032
2033    #[test]
2034    fn deck_cycle_palette_walks_theme_all() {
2035        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
2036        let first = deck.cycle_palette();
2037        assert_eq!(first, 0);
2038        assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
2039        // walks forward and wraps
2040        for _ in 1..Theme::ALL.len() {
2041            deck.cycle_palette();
2042        }
2043        assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
2044    }
2045
2046    #[test]
2047    fn deck_send_raven_launches_and_perches_after_a_full_flight() {
2048        use crate::effects::RAVEN_FLIGHT_SECS;
2049        let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
2050        assert!(!deck.has_raven());
2051        let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
2052        deck.send_raven(target);
2053        assert!(deck.has_raven(), "raven summoned");
2054        assert!(!deck.raven_perched(), "not perched at launch");
2055
2056        // Drive the sprite headlessly past the flight duration → it perches.
2057        if let Some(r) = deck.raven.as_mut() {
2058            r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
2059        }
2060        assert!(deck.raven_perched(), "perched after the flight duration");
2061
2062        deck.clear_raven();
2063        assert!(!deck.has_raven());
2064    }
2065
2066    /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
2067    /// user click must NOT pin a palette override. The bug: the picker auto-pinned
2068    /// index 0 on the first passive frame, turning the legacy `set_theme` override
2069    /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
2070    /// every frame. We render one frame with no interaction and assert the override
2071    /// is still `None` (host theme wins).
2072    #[test]
2073    fn palette_picker_does_not_pin_without_a_user_click() {
2074        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
2075        assert!(deck.fx().palette().is_none(), "starts with no override");
2076        let ctx = egui::Context::default();
2077        let mut chosen = Some(7usize);
2078        ctx.run_ui(egui::RawInput::default(), |ui| {
2079            egui::CentralPanel::default().show(ui, |ui| {
2080                // No synthetic click is fed → the picker is drawn but not used.
2081                chosen = deck.palette_picker(ui);
2082            });
2083        }).textures_delta.clear();
2084        assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
2085        assert!(
2086            deck.fx().palette().is_none(),
2087            "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
2088        );
2089    }
2090
2091    #[test]
2092    fn deck_palette_override_applies_theme_in_a_ui_pass() {
2093        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
2094        deck.set_palette(1); // sci-fi
2095        let ctx = egui::Context::default();
2096        let mut seen = "";
2097        ctx.run_ui(egui::RawInput::default(), |ui| {
2098            egui::CentralPanel::default().show(ui, |ui| {
2099                deck.ui(ui);
2100                seen = theme(ui).name;
2101            });
2102        }).textures_delta.clear();
2103        assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
2104    }
2105
2106    // ── tab groups (the two-level bar) ─────────────────────────────────────────
2107
2108    fn grouped_deck() -> FacetDeck {
2109        FacetDeck::new(vec![
2110            Box::new(Stub("graph")),
2111            Box::new(Stub("grid")),
2112            Box::new(Stub("Graphs")),
2113            Box::new(Stub("map")),
2114            Box::new(Stub("loose")),
2115        ])
2116        .with_groups(&[("Grafer", &["Graphs", "graph", "not-here"]), ("Kartor", &["map"]), ("Tom", &[])], "Övrigt")
2117    }
2118
2119    #[test]
2120    fn every_facet_lands_in_exactly_one_group_in_deck_order() {
2121        let deck = grouped_deck();
2122        let names: Vec<&str> = deck.groups().iter().map(|g| g.name.as_str()).collect();
2123        // The empty group is dropped; the unlisted facets fall into the rest group.
2124        assert_eq!(names, ["Grafer", "Kartor", "Övrigt"]);
2125        assert_eq!(deck.groups()[0].members, vec![0, 2], "deck order, not list order; unknown titles skipped");
2126        assert_eq!(deck.groups()[2].members, vec![1, 4], "grid + loose are orphans");
2127        let mut all: Vec<usize> = deck.groups().iter().flat_map(|g| g.members.clone()).collect();
2128        all.sort_unstable();
2129        assert_eq!(all, (0..deck.len()).collect::<Vec<_>>(), "each facet once");
2130    }
2131
2132    #[test]
2133    fn the_active_group_follows_the_active_tab_and_remembers_its_entry() {
2134        let mut deck = grouped_deck();
2135        assert_eq!(deck.active_group_name(), Some("Grafer"));
2136        // A programmatic switch (deep link / tour) moves the group with it.
2137        assert!(deck.set_active_by_title("map"));
2138        assert_eq!(deck.active_group_name(), Some("Kartor"));
2139        // Entering a group opens its first member the first time …
2140        assert!(deck.set_active_group("Övrigt"));
2141        assert_eq!(deck.active_title(), Some("grid"));
2142        assert!(deck.set_active_by_title("loose"));
2143        // … and the last shown member when re-entered (the bar notes it while drawing).
2144        let ctx = egui::Context::default();
2145        ctx.run_ui(egui::RawInput::default(), |ui| {
2146            egui::CentralPanel::default().show(ui, |ui| deck.ui(ui));
2147        }).textures_delta.clear();
2148        assert!(deck.set_active_group("Grafer"));
2149        assert!(deck.set_active_group("Övrigt"));
2150        assert_eq!(deck.active_title(), Some("loose"));
2151        assert!(!deck.set_active_group("nope"));
2152        let j = deck.state_json();
2153        assert_eq!(j["menu"]["group"], "Övrigt");
2154        assert_eq!(j["menu"]["groups"].as_array().unwrap().len(), 3);
2155    }
2156
2157    #[test]
2158    fn an_ungrouped_deck_keeps_the_flat_strip() {
2159        let deck = FacetDeck::new(vec![Box::new(Stub("a")), Box::new(Stub("b"))]);
2160        assert!(deck.groups().is_empty());
2161        assert_eq!(deck.active_group_name(), None);
2162        assert_eq!(deck.state_json()["menu"]["groups"], serde_json::json!([]));
2163    }
2164}