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;
10pub mod interface;
11/// **Barnes–Hut N-body repulsion** — the shared O(n log n) force-layout kernel
12/// (dimension-generic quadtree/octree, gatling-parallel) that every facett
13/// force-directed layout consumes so a 30 000-node graph lays out without the
14/// O(n²)-per-iteration hang. See [`barnes_hut`].
15pub mod barnes_hut;
16/// The shared **action bus** (BUS-1) — a typed, deterministic outbox facets use
17/// to hand side work (a drag move, a time-scrub, a command) to the host. The
18/// common carrier [`dragdrop`] / [`time_axis`] `Effect`s flow through.
19pub mod action_bus;
20pub mod caps;
21pub mod chrome;
22pub mod clip;
23pub mod clipboard;
24pub mod deckfx;
25/// The shared **drag-and-drop** primitive (DND-1) — the one reducer behind
26/// items-dragged-onto-zones (cards→columns, bars→lanes). Engine-agnostic sibling
27/// of [`nav::Navigable`]; the caps flag [`FacetCaps::draggable`] points here.
28pub mod dragdrop;
29pub mod edges;
30pub mod effects;
31/// **THE unified view core** (ENGINE-1) — the one *cull · project · label-collide ·
32/// pick* spine that `facett-map`, `facett-map3d`, `facett-geomap`, the L1 overlay
33/// and every graph view collapse onto. Only THREE things differ between a map and
34/// a graph, and they are the three traits
35/// ([`PositionSource`](engine::PositionSource),
36/// [`ElevationSource`](engine::ElevationSource), [`Hierarchy`](engine::Hierarchy)).
37/// See [`engine`].
38pub mod engine;
39/// The **UI error-code scheme** (`facet-<component>-<n>`) — the stable, unique code
40/// every facett UI part carries so tests + consumers react to the CODE, not a matched
41/// string. See [`errcode::FacetError`], the [`facet_err!`] macro, the canonical
42/// [`errcode::REGISTRY`], and the pink [`errcode::code_color`] renderer.
43pub mod devid;
44pub mod errcode;
45/// The canonical **Elm contract** (FC-2 / FC-9) — the [`Elm`] trait +
46/// [`impl_facet_via_elm!`] bridge macro that lift `facett-security`'s hand-rolled
47/// Model/Msg/Effect/pure-view pattern into reusable infra. Pair with
48/// [`harness`] to drive an `Elm` component headlessly.
49pub mod elm;
50pub mod focus;
51/// **The golden-image guard** (feature `golden`) — [`golden::assert_golden`], the
52/// one comparison every facett screenshot proof runs against its committed PNG.
53/// Exact match, failure artifacts under `target/`, a **missing golden FAILS**, and
54/// blessing is the explicit opt-in `UPDATE_SNAPSHOTS=1`. Enabled from the
55/// `[dev-dependencies]` of the crates that own goldens; absent from release builds.
56#[cfg(feature = "golden")]
57pub mod golden;
58pub mod imgscan; // image-analysis oracle (SCAN-THE-PIXELS law): spoke/high-freq/
59                 // coverage/centroid features computed FROM the rendered pixels.
60pub mod labels3d;
61/// **ROOT LAW #0 — the rayon-free law**, as machinery a test can actually fail
62/// on ([`law`]). ONE writer for korp and facett so the two repos hold the same
63/// line: our calls and our manifests are always red, a *normal* dependency edge
64/// to rayon is red, and the known dev-only third-party edge (image→ravif inside
65/// the screenshot-diff harness) is reported as context and never fails.
66pub mod law;
67/// **The label-declutter contract** — the order-independent spatial-grid collision
68/// rule shared by the CPU painter and the WGSL compute passes (GFX_V2 item 4). One
69/// writer, two executions; see the module docs for why [`legibility::place_labels`]
70/// (greedy, order-dependent, displaces into alternative slots) could not be the one.
71pub mod label_grid;
72/// **Legibility at scale** — the shared screen-space toolbox (spatial hover index,
73/// label collision avoidance, density-aware edge thinning) that makes a 100 000-node
74/// graph or a 1 000 000-pin map readable. Pure, deterministic, headless-testable; the
75/// legibility half of the story whose speed half is [`render::gpu::graphcloud`].
76pub mod legibility;
77pub mod harness;
78pub mod look;
79pub mod nav;
80pub mod overlay;
81/// The constellation-wide **`Panel` trait** (Phase 0 foundation) — the primary
82/// UI-pane seam, with backend (1) native in-process (the blanket
83/// `impl<T: Facet> Panel for T`) and backend (4) headless ([`panel::drive`]).
84/// See `.nornir/wasm-ui-panels-design.md` §1.
85pub mod panel;
86pub mod rabbit;
87/// The L0 shared render kernel (CONS-CORE) — shared `Camera`, z-ordered
88/// `LayerStack`, the CPU rect scissor, and (feature `wgpu`) the extracted GPU
89/// scaffold. Map skins + `facett-graphview` draw through this.
90pub mod render;
91pub mod runtrace; // in-memory, wasm-safe "what RAN" ledger (no FS) — folded into
92                  // state_json["trace"]["ran"], read via the JS hook on wasm.
93pub mod scroll_engine;
94pub mod severity; // STRUCTURAL error signal for the Robot-UI HARD GATE — each
95                  // pane/atom reports Severity{Info,Warning,Error}; the gate asserts
96                  // on this instead of scanning rendered text (RESOLVED decision (a)).
97pub mod testmatrix; // functional-status → nornir test-matrix bridge (feature
98                    // `testmatrix`); no-op in release.
99pub mod theme;
100/// The shared **pan/zoom-in-time axis** (TIME-1) — the one time-window model
101/// behind gantt/CFD/calendar/timeline, linkable across facets. Temporal sibling
102/// of [`nav::Navigable`]; the caps flag [`FacetCaps::time_scrollable`] points here.
103pub mod time_axis;
104pub mod trace; // structured IN/OUT/END event stream ($FACETT_TRACE) — the
105               // machine-readable data a facet actually rendered.
106pub use a11y::{Semantics, node as a11y_node, stable_id};
107pub use errcode::{FacetError, MountedFacetError};
108pub use action_bus::{ActionBus, BusAction, BusMsg};
109pub use caps::FacetCaps;
110pub use dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove};
111pub use clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
112pub use clipboard::ClipAction;
113pub use deckfx::{DeckFx, DeckRaven};
114pub use elm::Elm;
115pub use imgscan::{BBox, Rgba, ScanReport, coverage, high_freq_ratio, painted_centroid_and_bbox, scan, spoke_score};
116pub use look::{Action, KeyMap, Palette};
117pub use nav::{Dir4, Navigable, nearest_in_direction};
118pub use panel::Panel;
119pub use rabbit::{Rabbit, RabbitMesh, rabbit_mesh, rabbit_outline};
120pub use scroll_engine::SmoothScroll;
121pub use severity::{Severity, worst as worst_severity};
122pub use theme::{Theme, set_theme, theme};
123pub use time_axis::{TimeAxis, TimeEffect, TimeMsg};
124
125// The rich look-&-feel `Theme` (the work-order architecture) is re-exported under
126// an unambiguous alias so it coexists with the legacy flat palette `Theme` above.
127pub use look::Theme as LookTheme;
128
129/// A node: a label + a colour (the *consumer* picks the colour policy — hash by
130/// label, by status, …).
131#[derive(Clone)]
132pub struct Node {
133    pub label: String,
134    pub color: Color32,
135}
136
137/// A directed edge between node indices.
138#[derive(Clone, Copy)]
139pub struct Edge {
140    pub src: usize,
141    pub dst: usize,
142}
143
144/// A drawable graph: nodes + edges (edges index into `nodes`).
145#[derive(Default, Clone)]
146pub struct Scene {
147    pub nodes: Vec<Node>,
148    pub edges: Vec<Edge>,
149}
150
151impl Scene {
152    pub fn new() -> Self {
153        Self::default()
154    }
155    /// Push a node, returning its index.
156    pub fn node(&mut self, label: impl Into<String>, color: Color32) -> usize {
157        self.nodes.push(Node { label: label.into(), color });
158        self.nodes.len() - 1
159    }
160    pub fn edge(&mut self, src: usize, dst: usize) {
161        self.edges.push(Edge { src, dst });
162    }
163    pub fn is_empty(&self) -> bool {
164        self.nodes.is_empty()
165    }
166}
167
168/// Node placement strategy.
169#[derive(Clone, Copy, PartialEq, Eq, Default)]
170pub enum Layout {
171    #[default]
172    Circular,
173    /// Deterministic Fruchterman–Reingold (edges pull, all nodes repel). O(n²)
174    /// per iteration — best for small/medium graphs.
175    Force,
176}
177
178/// Draw a `Scene` into `ui` — the reusable render primitive. Empty scenes show
179/// `empty_hint`. Labels render when the node count is small enough to read.
180pub fn draw(ui: &mut Ui, scene: &Scene, layout: Layout, empty_hint: &str) {
181    let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
182    let n = scene.nodes.len();
183    if n == 0 {
184        let th = theme(ui);
185        ui.painter_at(rect).text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
186        return;
187    }
188    let pos = positions(layout, scene, rect);
189    draw_positions(ui, scene, &pos, rect, empty_hint);
190}
191
192/// Paint a `Scene` into `rect` using **pre-computed** node positions — the drawing
193/// half of [`draw`], split out so a stateful host can **freeze** the layout: compute
194/// the O(n log n) force positions once (cached until the graph structure changes, see
195/// [`ForceCache`]) and paint them every frame WITHOUT re-running the layout. `pos`
196/// must be indexed like `scene.nodes`; short/empty falls back to the empty hint.
197pub fn draw_positions(ui: &mut Ui, scene: &Scene, pos: &[Pos2], rect: Rect, empty_hint: &str) {
198    let th = theme(ui);
199    let painter = ui.painter_at(rect);
200    let n = scene.nodes.len();
201    if n == 0 || pos.len() != n {
202        painter.text(rect.center(), Align2::CENTER_CENTER, empty_hint, FontId::proportional(13.0), th.text_dim);
203        return;
204    }
205    for e in &scene.edges {
206        if e.src < n && e.dst < n {
207            painter.line_segment([pos[e.src], pos[e.dst]], Stroke::new(0.6_f32, th.edge));
208        }
209    }
210    for (i, node) in scene.nodes.iter().enumerate() {
211        painter.circle_filled(pos[i], 5.0, node.color);
212    }
213    if n <= 60 {
214        for (i, node) in scene.nodes.iter().enumerate() {
215            painter.text(pos[i] + vec2(7.0, 0.0), Align2::LEFT_CENTER, &node.label, FontId::proportional(10.0), th.text);
216        }
217    }
218}
219
220/// **Converge-once-then-freeze** layout cache. A force layout is a pure function of
221/// the graph structure (node count + edge set) and the paint `rect`, so this holds the
222/// last-computed positions and only re-runs [`layout_positions`] when the structure or
223/// rect actually changes. Embed it on a stateful host (e.g. a `GraphView`) and call
224/// [`ForceCache::positions`] from the render path — the O(n log n) (with Barnes–Hut)
225/// or O(n²) (small graphs) layout stops running every frame, which is the other half
226/// (besides Barnes–Hut) of killing the 30 000-node hang.
227#[derive(Default, Clone)]
228pub struct ForceCache {
229    sig: u64,
230    rect: [u32; 4],
231    pos: Vec<Pos2>,
232}
233
234impl ForceCache {
235    /// The node positions for `scene` under `layout` in `rect`, computed on the first
236    /// call and on any structural/rect change, and returned from cache otherwise.
237    pub fn positions(&mut self, layout: Layout, scene: &Scene, rect: Rect) -> &[Pos2] {
238        let n = scene.nodes.len();
239        let key = self.structure_key(layout, scene);
240        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
241        if self.sig != key || self.rect != rb || self.pos.len() != n {
242            self.pos = positions(layout, scene, rect);
243            self.sig = key;
244            self.rect = rb;
245        }
246        &self.pos
247    }
248
249    /// Whether the next [`positions`](Self::positions) call for this `layout`/`scene`
250    /// in this `rect` will be a cache hit (a no-op relayout) — the *frozen/settled*
251    /// signal a `tick` can assert.
252    #[must_use]
253    pub fn is_settled(&self, layout: Layout, scene: &Scene, rect: Rect) -> bool {
254        let rb = [rect.min.x.to_bits(), rect.min.y.to_bits(), rect.max.x.to_bits(), rect.max.y.to_bits()];
255        self.sig == self.structure_key(layout, scene) && self.rect == rb && self.pos.len() == scene.nodes.len()
256    }
257
258    /// Force a recompute on the next [`positions`](Self::positions) call.
259    pub fn invalidate(&mut self) {
260        self.sig = 0;
261        self.rect = [0; 4];
262        self.pos.clear();
263    }
264
265    fn structure_key(&self, layout: Layout, scene: &Scene) -> u64 {
266        let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
267        let key = match layout {
268            Layout::Circular => "circular",
269            Layout::Force => "force",
270        };
271        crate::barnes_hut::structure_sig(key, scene.nodes.len(), &edges)
272    }
273}
274
275/// **Test/host hook (additive).** The public, return-asserted view of the
276/// private [`positions`] layout node — the exact node centres [`draw`] paints for
277/// `scene` under `layout` inside `rect`. Exposed so the graph-skin call-chain
278/// matrix can assert the *layout* stage (finite, in-rect, count == nodes,
279/// circular radius, force-fit normalisation) without a painter. Calls the **same**
280/// private fn `draw` uses, so it IS the layout the pixels come from — additive,
281/// no behaviour change.
282pub fn layout_positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
283    positions(layout, scene, rect)
284}
285
286fn positions(layout: Layout, scene: &Scene, rect: Rect) -> Vec<Pos2> {
287    let n = scene.nodes.len();
288    let center = rect.center();
289    let radius = rect.size().min_elem() * 0.42;
290    let circular = |i: usize| {
291        let a = std::f32::consts::TAU * (i as f32) / (n as f32);
292        vec2(a.cos(), a.sin())
293    };
294    match layout {
295        Layout::Circular => (0..n).map(|i| center + radius * circular(i)).collect(),
296        Layout::Force => {
297            // Deterministic Fruchterman–Reingold from a circular seed (unit space).
298            let mut p: Vec<egui::Vec2> = (0..n).map(circular).collect();
299            let k = (1.0 / (n.max(1) as f32).sqrt()).clamp(0.05, 1.0);
300            // Above the threshold the O(n²) all-pairs repulsion is the 30 000-node
301            // hang — swap it for the shared Barnes–Hut O(n log n) kernel (parallel,
302            // deterministic). Below the threshold the exact loop runs unchanged, so
303            // every small-graph golden is byte-for-byte identical (additive).
304            let use_bh = n >= crate::barnes_hut::BH_THRESHOLD;
305            for _ in 0..120 {
306                let mut disp = vec![egui::Vec2::ZERO; n];
307                if use_bh {
308                    let pts: Vec<[f32; 2]> = p.iter().map(|v| [v.x, v.y]).collect();
309                    let rep = crate::barnes_hut::repulsion_forces::<2>(&pts, k, crate::barnes_hut::BH_THETA);
310                    for i in 0..n {
311                        disp[i] = egui::vec2(rep[i][0], rep[i][1]);
312                    }
313                } else {
314                    for i in 0..n {
315                        for j in (i + 1)..n {
316                            let d = p[i] - p[j];
317                            let dist = d.length().max(1e-3);
318                            let f = k * k / dist;
319                            let dir = d / dist;
320                            disp[i] += dir * f;
321                            disp[j] -= dir * f;
322                        }
323                    }
324                }
325                for e in &scene.edges {
326                    if e.src < n && e.dst < n {
327                        let d = p[e.src] - p[e.dst];
328                        let dist = d.length().max(1e-3);
329                        let f = dist * dist / k;
330                        let dir = d / dist;
331                        disp[e.src] -= dir * f;
332                        disp[e.dst] += dir * f;
333                    }
334                }
335                for i in 0..n {
336                    let dl = disp[i].length().max(1e-3);
337                    p[i] += disp[i] / dl * dl.min(0.04); // capped step (cooling-free, deterministic)
338                }
339            }
340            // Normalise to fit the rect.
341            let (mut mn, mut mx) = (egui::vec2(f32::MAX, f32::MAX), egui::vec2(f32::MIN, f32::MIN));
342            for v in &p {
343                mn.x = mn.x.min(v.x);
344                mn.y = mn.y.min(v.y);
345                mx.x = mx.x.max(v.x);
346                mx.y = mx.y.max(v.y);
347            }
348            let span = (mx - mn).max(egui::vec2(1e-3, 1e-3));
349            p.iter()
350                .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))
351                .collect()
352        }
353    }
354}
355
356/// The facett **component contract**. Every facet — graph, map, pipeline, table,
357/// the ported nornir viewers — implements this, so consumers (korp, nornir, …)
358/// compose them uniformly *and* get headless robot-testing for free.
359///
360/// The things a component owes its host:
361/// 1. a **title** (tab label / panel heading),
362/// 2. how to **draw** itself into egui,
363/// 3. its **observable state** as JSON — dumped to `$APP_STATE` for headless
364///    assertions. **Rule:** every visible list/status/count goes in `state_json`.
365/// 4. (**defaulted**) [`update_json`](Facet::update_json) — the Elm mutation path,
366///    a no-op by default so it costs existing facets nothing; overriding it (and
367///    the three above) makes a facet interchangeable with a [`Panel`](crate::Panel).
368pub trait Facet {
369    fn title(&self) -> &str;
370    fn ui(&mut self, ui: &mut Ui);
371    fn state_json(&self) -> serde_json::Value;
372
373    /// The **Elm mutation path** — apply one message (JSON). This is the fourth
374    /// member of the component contract (`title`/`ui`/`state_json`/`update_json`),
375    /// and it is **defaulted to a no-op** so it is purely ADDITIVE: every existing
376    /// `impl Facet` keeps compiling unchanged, while a facet that wants the writable
377    /// input surface overrides it (an Elm-backed facet routes the JSON through its
378    /// `update`). With this default in place `Facet` and [`Panel`](crate::Panel)
379    /// share the same four-method shape, which is what lets the blanket
380    /// `impl<T: Facet> Panel for T` (see [`panel`](crate::panel)) make **every**
381    /// `Facet` a `Panel` for free. Unknown/undriven messages are ignored — the
382    /// default simply does nothing.
383    fn update_json(&mut self, _msg_json: &str) {}
384
385    /// The pane's **STRUCTURAL severity** — the RESOLVED Robot-UI error signal
386    /// (decision (a)). A facet returns the WORST [`Severity`](crate::Severity) over
387    /// its currently-rendered atoms (compute it with
388    /// [`worst_severity`](crate::worst_severity)): [`Severity::Info`] when it is
389    /// showing real data, [`Severity::Warning`] when degraded, and
390    /// [`Severity::Error`] when it surfaced a failure (a load error, an unavailable
391    /// backend, an empty data-bearing surface).
392    ///
393    /// **Defaulted to [`Severity::Info`]** so it is purely ADDITIVE — every existing
394    /// `impl Facet` stays green and keeps compiling. The Robot-UI HARD GATE reads
395    /// THIS (surfaced through the [`Panel`](crate::Panel) seam the headless robot
396    /// drives), not the `nornir_robotui::error_atoms()` substring scan, which is kept
397    /// only as a MIGRATION FALLBACK for panes that have not yet overridden this.
398    /// A pane that returns [`Severity::Error`] is RED and FAILS the gate.
399    /// **This pane's stable DEV-ID** — the [`errcode`](crate::errcode) *component*
400    /// string (the `<component>` in `facet-<component>-<n>`), so the identity a pane
401    /// shows and the identity its errors carry are the SAME vocabulary rather than two
402    /// naming schemes that drift.
403    ///
404    /// Shown as a click-to-copy chip in non-release builds ([`devid::badge`](crate::devid::badge)),
405    /// because a screenshot cannot tell you which crate drew a pane and a title like
406    /// "Map" appears in four of them. Pasting the chip resolves to a crate dir and a
407    /// source file via [`devid::resolve`](crate::devid::resolve).
408    ///
409    /// **Defaulted to `""`** so it is purely ADDITIVE — every existing `impl Facet`
410    /// keeps compiling. A pane that has not overridden it renders a visibly-different
411    /// `⟨unregistered⟩` marker instead of an id, never a plausible-looking string that
412    /// resolves to nothing (the `facet-map-99` hole that
413    /// `facett-core/tests/errcode_raise_sites.rs` documents). So the badge is also the
414    /// to-do list: every marker is a pane still to declare this.
415    fn component(&self) -> &'static str {
416        ""
417    }
418
419    fn severity(&self) -> crate::Severity {
420        crate::Severity::Info
421    }
422
423    // --- uniform capability surface (all defaulted; see caps.rs / clipboard.rs) ---
424
425    /// What this facet can do. Override to opt into capabilities.
426    fn caps(&self) -> FacetCaps {
427        FacetCaps::NONE
428    }
429
430    /// Current uniform scale (1.0 = native). Override if `caps().scalable`.
431    fn scale(&self) -> f32 {
432        1.0
433    }
434    /// Set the uniform scale; clamp internally. Default no-op (not scalable).
435    fn set_scale(&mut self, _scale: f32) {}
436
437    /// **The band `[min, max]`, relative to native (`1.0`), that this facet's uniform
438    /// scale may be driven over.** [`FacetDeck::scale_active`] clamps into it.
439    ///
440    /// The default `[0.25, 4.0]` is a *document viewer's* range — a quarter size to
441    /// quadruple size, which is all a table, a form or a text pane ever wants. It was
442    /// for years the deck's only vocabulary, hardcoded in `scale_active`, and that is
443    /// a bug for any facet whose native range is wider. A MAP's is: `OsmView`'s camera
444    /// spans `[ZOOM_MIN, ZOOM_MAX]` = a 1.25-million-fold range, so a hardcoded 16-fold
445    /// clamp both **stalls** zoom-in a couple of clicks past the fit *and* — because
446    /// the wheel and pinch drive the camera directly, never through [`Self::set_scale`]
447    /// — makes the next `+` click **saturate the clamp and slam the camera backwards**,
448    /// a zoom-IN button that visibly zooms OUT. Guarded by
449    /// `facett-geomap/tests/osm2d_deck_scale.rs`.
450    ///
451    /// Override alongside [`Self::scale`]/[`Self::set_scale`] when native has no fixed
452    /// pixel size. Must contain `1.0` — [`FacetDeck::reset_scale`] writes it.
453    fn scale_range(&self) -> (f32, f32) {
454        (0.25, 4.0)
455    }
456
457    /// The current selection as JSON (also folded into `state_json` by
458    /// convention). `Null` when nothing/none selectable.
459    fn selection_json(&self) -> serde_json::Value {
460        serde_json::Value::Null
461    }
462
463    /// Clipboard hooks — see clipboard.rs. Defaults: nothing to give/take.
464    /// Returns the text to place on the clipboard (None = nothing copyable now).
465    fn copy(&mut self) -> Option<String> {
466        None
467    }
468    /// Like `copy`, but also removes the selection. Default delegates to `copy`.
469    fn cut(&mut self) -> Option<String> {
470        self.copy()
471    }
472    /// Accept pasted text. Returns true if consumed.
473    fn paste(&mut self, _text: &str) -> bool {
474        false
475    }
476
477    /// Optional downcast handle for hosts that need typed access to a specific
478    /// facet living inside a [`FacetDeck`] (e.g. a robot-UI driver clicking an
479    /// app-level control that must forward to a concrete component's own API).
480    /// Defaulted to `None` so no existing facet has to change; a component opts in
481    /// by returning `Some(self)`.
482    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
483        None
484    }
485
486    // --- cross-instance state clone (copy/paste BETWEEN same-component instances) ---
487    // See `.nornir/design/copy-paste-between-instances.md` + `clipboard.rs`. The trio
488    // below is the type-tagged STATE layer on top of the text clipboard: two
489    // instances with the SAME `kind()` exchange `portable_state()` via the OS
490    // clipboard envelope (`clipboard::encode_component`/`decode_component`). Each is
491    // defaulted to the opt-OUT floor — a component that doesn't implement all three
492    // neither copies nor accepts cross-instance state, and nothing panics.
493
494    /// Stable component-type id (e.g. `"jobview"`, `"graphpan"`, `"table"`). Two
495    /// instances with the **same** kind can exchange portable state; the empty
496    /// default `""` means **opted out** of cross-instance clone.
497    fn kind(&self) -> &'static str {
498        ""
499    }
500
501    /// The **portable** subset of this facet's state — the fields a same-kind
502    /// sibling can adopt. `None` = not cloneable. Kept SEPARATE from
503    /// [`state_json`](Self::state_json) (the introspection dump, which may carry
504    /// derived / render-only data) so this stays round-trippable through
505    /// [`load_state`](Self::load_state).
506    fn portable_state(&self) -> Option<serde_json::Value> {
507        None
508    }
509
510    /// Adopt a portable state produced by [`portable_state`](Self::portable_state)
511    /// on a same-kind sibling. Returns `true` if accepted. Default `false`
512    /// (opt-in per component).
513    fn load_state(&mut self, _state: &serde_json::Value) -> bool {
514        false
515    }
516}
517
518/// A tabbed set of [`Facet`]s — the reusable multi-component shell. Draws a tab
519/// bar + the active facet, and composes **every** facet's `state_json` under its
520/// title, so the whole-app introspection contract is free. korp/nornir can build
521/// their window from a `FacetDeck` instead of hand-rolling tabs + the state dump.
522pub struct FacetDeck {
523    facets: Vec<Box<dyn Facet>>,
524    active: usize,
525    /// Opt-in deck effects (palette override + glow). `Default` = all off, so a
526    /// deck that never opts in is unchanged and pays nothing.
527    fx: DeckFx,
528    /// A raven summoned through the deck, in flight or perched (or `None`).
529    raven: Option<DeckRaven>,
530    /// A transient, themed component-clone toast (message + the `ctx.input.time`
531    /// it was raised at), shown briefly after a Copy-/Paste-component gesture —
532    /// chiefly the type-mismatch rejection ("clipboard holds a `table`, not a
533    /// `graphpan`"). `None` = nothing to show. See [`Self::component_toast`].
534    toast: Option<(String, f64)>,
535    /// **Responsive MENU collapse.** Host-driven: on a narrow (phone-class) viewport
536    /// the long tab bar (one selectable per facet — a deck can hold 60+) wraps into
537    /// many rows and buries the active facet below the screen fold. When
538    /// `menu_collapsed` is set (by the host, classifying the viewport — e.g.
539    /// `facett_app::scene::Device`) the tab bar renders as a compact HAMBURGER header
540    /// (`≡` + the active choice's label) with the full choice list behind a drawer
541    /// (canvas-first). On a wide viewport it stays the inline wrapped strip. See
542    /// [`set_menu_collapsed`](Self::set_menu_collapsed).
543    menu_collapsed: bool,
544    /// The hamburger drawer's open state (deck-owned). Default **closed** ⇒ the
545    /// collapsed header is a single row and the canvas gets the screen; opening lists
546    /// every choice, and picking one switches the tab AND shuts the drawer.
547    menu_open: bool,
548    /// Last frame's tab-bar (menu header) rect — the observable geometry a headless
549    /// test reads (`state_json.menu.bar_rect`) to prove the collapsed menu is ONE
550    /// compact row, not a multi-row wrap that eats the viewport.
551    menu_bar_rect: Rect,
552    /// Last frame's active-facet content rect — the "canvas" the collapse hands the
553    /// screen to (`state_json.menu.content_rect`).
554    content_rect: Rect,
555}
556
557/// How long a component-clone [`toast`](FacetDeck::toast) stays on screen.
558const TOAST_SECS: f64 = 2.6;
559
560impl FacetDeck {
561    pub fn new(facets: Vec<Box<dyn Facet>>) -> Self {
562        Self {
563            facets,
564            active: 0,
565            fx: DeckFx::OFF,
566            raven: None,
567            toast: None,
568            menu_collapsed: false,
569            menu_open: false,
570            menu_bar_rect: Rect::ZERO,
571            content_rect: Rect::ZERO,
572        }
573    }
574    /// Append a facet (the incremental form of [`new`](Self::new)). Lets a host
575    /// build a deck pane-by-pane as it discovers what to show (e.g. one pane per
576    /// warehouse table it finds).
577    pub fn push(&mut self, facet: Box<dyn Facet>) {
578        self.facets.push(facet);
579    }
580    pub fn active(&self) -> usize {
581        self.active
582    }
583
584    /// The title of the currently-active facet (the deck's `state_json["active"]`),
585    /// or `None` if the deck is empty.
586    pub fn active_title(&self) -> Option<&str> {
587        self.facets.get(self.active).map(|f| f.title())
588    }
589
590    /// The titles of every tabbed facet, in tab order — the discoverable surface a
591    /// host (or a robot-UI control channel) enumerates to know which tabs exist.
592    pub fn titles(&self) -> Vec<&str> {
593        self.facets.iter().map(|f| f.title()).collect()
594    }
595
596    /// Make the facet titled `title` the active tab — the programmatic (headless,
597    /// robot-addressable) equivalent of clicking its tab header. Returns `true` if a
598    /// facet with that title exists (and is now active), `false` otherwise. This is
599    /// the named boundary a control channel switches tabs through (the deck analogue
600    /// of the viz's `Tab::from_name`), so a driver needn't replay a pointer click.
601    pub fn set_active_by_title(&mut self, title: &str) -> bool {
602        match self.facets.iter().position(|f| f.title() == title) {
603            Some(i) => {
604                self.active = i;
605                true
606            }
607            None => false,
608        }
609    }
610
611    // ── responsive menu (hamburger) — the reusable narrow-viewport tab bar ───────
612
613    /// Host-driven: render the tab bar as a compact **hamburger menu** (`≡` + the
614    /// active choice's label, the full list behind a drawer) instead of the inline
615    /// wrapped strip. The host classifies the viewport (e.g.
616    /// `facett_app::scene::Device::from_width`) and calls this each frame; on a
617    /// narrow (phone-class) width the long tab bar would otherwise wrap into many
618    /// rows and bury the active facet below the screen fold. Setting it `false` (a
619    /// wide viewport) restores the inline bar AND shuts the drawer. This is the deck
620    /// analogue of the demo shell's `⚙` control-strip fold — the SAME canvas-first
621    /// pattern applied to the MAIN menu, living once here so every deck host
622    /// (korp/nornir/…) inherits it.
623    pub fn set_menu_collapsed(&mut self, collapsed: bool) {
624        if !collapsed {
625            self.menu_open = false;
626        }
627        self.menu_collapsed = collapsed;
628    }
629    /// Whether the tab bar is currently rendered as the collapsed hamburger menu.
630    pub fn menu_collapsed(&self) -> bool {
631        self.menu_collapsed
632    }
633    /// Whether the hamburger drawer is open (the full choice list is showing).
634    pub fn menu_open(&self) -> bool {
635        self.menu_open
636    }
637    /// Open / shut the hamburger drawer explicitly (the programmatic equivalent of
638    /// tapping `≡`). No visible effect while the menu is not collapsed.
639    pub fn set_menu_open(&mut self, open: bool) {
640        self.menu_open = open;
641    }
642    /// Toggle the hamburger drawer; returns the new open state.
643    pub fn toggle_menu(&mut self) -> bool {
644        self.menu_open = !self.menu_open;
645        self.menu_open
646    }
647
648    /// Typed mutable access to the facet with `title`, downcast to `T` — `None` if
649    /// no such facet, or it doesn't opt into [`Facet::as_any_mut`], or the type
650    /// mismatches. Lets a host drive a concrete component's own API (e.g. a
651    /// robot-UI control forwarding a node selection to a `SystemChart`).
652    pub fn facet_mut<T: std::any::Any>(&mut self, title: &str) -> Option<&mut T> {
653        self.facets
654            .iter_mut()
655            .find(|f| f.title() == title)
656            .and_then(|f| f.as_any_mut())
657            .and_then(|a| a.downcast_mut::<T>())
658    }
659
660    /// Replace the facet whose `title` matches with `facet` (the box's own title is
661    /// what the deck enumerates afterwards). Returns `true` if a facet was replaced.
662    /// Used by hosts that **reload** a tab's data in place — e.g. the OSM region
663    /// picker rebuilds the `OSM 2D` / `OSM 3D` views from a freshly clipped region
664    /// and swaps them in, keeping the same tab slots (and the active selection).
665    pub fn replace_facet(&mut self, title: &str, facet: Box<dyn Facet>) -> bool {
666        if let Some(slot) = self.facets.iter_mut().find(|f| f.title() == title) {
667            *slot = facet;
668            true
669        } else {
670            false
671        }
672    }
673
674    // ── opt-in effects + theming (see deckfx.rs) ─────────────────────────────
675
676    /// Enable deck effects up front (builder form of [`fx_mut`](Self::fx_mut)).
677    pub fn with_fx(mut self, fx: DeckFx) -> Self {
678        self.fx = fx;
679        self
680    }
681    /// The current deck-effects config (read-only).
682    pub fn fx(&self) -> &DeckFx {
683        &self.fx
684    }
685    /// Mutate the deck-effects config (toggle glow, pin a palette, …).
686    pub fn fx_mut(&mut self) -> &mut DeckFx {
687        &mut self.fx
688    }
689    /// Override the deck theme with palette index `i` (wraps); enables the
690    /// override. Convenience over `fx_mut().set_palette(i)`.
691    pub fn set_palette(&mut self, i: usize) {
692        self.fx.set_palette(i);
693    }
694    /// Advance to the next palette in [`Theme::ALL`] (wrapping); returns the new
695    /// index. Convenience over `fx_mut().cycle_palette()`.
696    pub fn cycle_palette(&mut self) -> usize {
697        self.fx.cycle_palette()
698    }
699
700    /// **Summon the raven** to perch on `target` — any rect a facet/host hands us
701    /// (a table row, a node, a header). Replaces any raven already in flight. The
702    /// body is tinted from the deck's current palette (or the host theme). Logs an
703    /// activity trail entry. Drive/paint happens automatically inside
704    /// [`ui`](Self::ui).
705    pub fn send_raven(&mut self, target: Rect) {
706        let theme = self.effective_theme();
707        self.raven = Some(DeckRaven::new(target, &theme));
708        harness::trail(
709            harness::Kind::Render,
710            format!("raven launched → perch ({:.0},{:.0})", target.center().x, target.top()),
711        );
712    }
713    /// True while a raven is present (flying or perched).
714    pub fn has_raven(&self) -> bool {
715        self.raven.is_some()
716    }
717    /// True once the summoned raven has landed (false if none).
718    pub fn raven_perched(&self) -> bool {
719        self.raven.as_ref().map(|r| r.is_perched()).unwrap_or(false)
720    }
721    /// Dismiss any raven.
722    pub fn clear_raven(&mut self) {
723        self.raven = None;
724    }
725
726    /// The theme the deck paints with: the fx palette override if set, else
727    /// [`Theme::default`] (the host's own `set_theme` still applies its visuals;
728    /// this is just the colour source for deck-owned effects/picker).
729    fn effective_theme(&self) -> Theme {
730        self.fx.theme().unwrap_or_default()
731    }
732
733    /// Draw a one-line **palette picker** — a switcher over [`Theme::ALL`] the
734    /// host can place anywhere (toolbar, menu). Selecting a palette pins the fx
735    /// override; `ui()` then applies it each frame. Returns the chosen index if it
736    /// changed this frame.
737    ///
738    /// The override stays **off until the user actually clicks** a palette: merely
739    /// drawing the picker must not pin index 0, otherwise a host that drives its
740    /// own theme (e.g. the rich [`crate::look::Theme`]) would be silently clobbered
741    /// every frame by the legacy `set_theme` in [`ui`](Self::ui) — size still
742    /// changing (spacing) but colour frozen on `Theme::ALL[0]`. So we only pin when
743    /// the selection genuinely changed this frame.
744    pub fn palette_picker(&mut self, ui: &mut Ui) -> Option<usize> {
745        let mut sel = self.fx.palette().unwrap_or(0);
746        let before = sel;
747        ui.horizontal_wrapped(|ui| {
748            ui.label("Palette:");
749            for (i, ctor) in Theme::ALL.iter().enumerate() {
750                ui.selectable_value(&mut sel, i, ctor().name);
751            }
752        });
753        if sel != before {
754            self.fx.set_palette(sel);
755        }
756        (sel != before).then_some(sel)
757    }
758
759    /// The capabilities of the currently-active facet (or `NONE` if empty).
760    pub fn active_caps(&self) -> FacetCaps {
761        self.facets.get(self.active).map(|f| f.caps()).unwrap_or(FacetCaps::NONE)
762    }
763
764    /// Number of facets in the deck.
765    pub fn len(&self) -> usize {
766        self.facets.len()
767    }
768    /// True when the deck holds no facets.
769    pub fn is_empty(&self) -> bool {
770        self.facets.is_empty()
771    }
772
773    /// **Wall layout** — render **every** facet at once in a wrapping grid of
774    /// `cols` columns, instead of the tabbed one-at-a-time [`ui`](Self::ui). This is
775    /// the "multiple components visible simultaneously" mode a dashboard host wants
776    /// (e.g. several `Graph3D` panes side-by-side). Each cell is a titled group; the
777    /// fx palette override (if set) still applies, and every rendered facet is logged
778    /// to the runtrace ledger keyed `deck.wall:<title>` (mirrors the tab path's
779    /// `deck.render:<title>`), so `state_json`'s `trace.ran` proves each pane drew.
780    pub fn wall_ui(&mut self, ui: &mut Ui, cols: usize) {
781        if let Some(theme) = self.fx.theme() {
782            set_theme(ui.ctx(), theme);
783        }
784        if self.facets.is_empty() {
785            ui.weak("empty deck");
786            return;
787        }
788        let cols = cols.max(1);
789        let spacing = ui.spacing().item_spacing.x;
790        let total_w = ui.available_width();
791        let cell_w = ((total_w - spacing * (cols as f32 - 1.0)) / cols as f32).max(160.0);
792        // A generous cell so 3D graphs have room; the inner facet fills it.
793        let cell_h = 300.0_f32;
794        egui::ScrollArea::vertical()
795            .auto_shrink([false; 2])
796            .show(ui, |ui| {
797                ui.horizontal_wrapped(|ui| {
798                    for f in self.facets.iter_mut() {
799                        ui.allocate_ui(egui::vec2(cell_w, cell_h), |ui| {
800                            let cell = ui.group(|ui| {
801                                ui.set_min_size(egui::vec2(cell_w - 12.0, cell_h - 12.0));
802                                ui.vertical(|ui| {
803                                    ui.strong(f.title());
804                                    ui.separator();
805                                    runtrace::ran(&format!("deck.wall:{}", f.title()));
806                                    f.ui(ui);
807                                });
808                            });
809                            // The wall shows every pane at once, which is exactly when
810                            // "which crate drew THAT one?" is hardest to answer.
811                            devid::badge(ui, cell.response.rect, f.component(), f.title());
812                        });
813                    }
814                });
815            });
816    }
817
818    /// **Render ONE facet into the caller's `Ui`** — the seam a scene host needs to
819    /// make a deck out of REAL panes instead of emulating one.
820    ///
821    /// [`wall_ui`](Self::wall_ui) owns its own grid: it decides the columns, the cell
822    /// size and the scrolling, and draws every facet inside one `Ui`. That is right for
823    /// a self-contained dashboard and wrong for `facett_app::scene`, which already has
824    /// a pane tree, allots each pane its own rect, and gives each one chrome and a
825    /// published address. A host with a scene could only mount the WHOLE deck into ONE
826    /// pane, so N panes' worth of content ended up sharing a single allotment — which
827    /// is precisely the shape that lets one pane's growth displace another.
828    ///
829    /// This renders facet `idx` and nothing else, into whatever rect the caller has
830    /// already decided. No grid, no scroll area, no title strip — the scene's chrome
831    /// bar carries the title, and a second one would be a second header row.
832    ///
833    /// Returns `false` when `idx` is out of range, so a host whose pane tree has
834    /// drifted from the deck's contents gets a value it can assert on rather than a
835    /// silently blank pane.
836    ///
837    /// The runtrace key is `deck.pane:<title>`, alongside `deck.wall:` and
838    /// `deck.render:`, so `state_json`'s `trace.ran` still proves the pane drew.
839    pub fn pane_ui(&mut self, idx: usize, ui: &mut Ui) -> bool {
840        if let Some(theme) = self.fx.theme() {
841            set_theme(ui.ctx(), theme);
842        }
843        let Some(f) = self.facets.get_mut(idx) else {
844            return false;
845        };
846        runtrace::ran(&format!("deck.pane:{}", f.title()));
847        // The pane's own rect, captured BEFORE it draws: a pane that consumes the whole
848        // `Ui` leaves the cursor somewhere unhelpful, so reading it afterwards would
849        // anchor the chip to wherever the content happened to end.
850        let rect = ui.max_rect();
851        let (component, title) = (f.component(), f.title().to_string());
852        f.ui(ui);
853        // The DEV-ID chip — one draw site, so EVERY deck pane gets it for free (LAW 5)
854        // rather than 70-odd panes each remembering to paint their own. No-op in release.
855        devid::badge(ui, rect, component, &title);
856        true
857    }
858
859    /// The title of facet `idx`, for a host naming its panes from the deck.
860    pub fn pane_title(&self, idx: usize) -> Option<&str> {
861        self.facets.get(idx).map(|f| f.title())
862    }
863
864    /// The active facet's current scale (1.0 if none / not scalable).
865    fn active_scale(&self) -> f32 {
866        self.facets.get(self.active).map(|f| f.scale()).unwrap_or(1.0)
867    }
868
869    /// Multiply the active facet's scale by `k`, clamped to **the facet's own**
870    /// [`Facet::scale_range`] — not to a hardcoded document-viewer band. A facet whose
871    /// native range is wider (a map: 1.25-million-fold) would otherwise saturate the
872    /// clamp on the first click and have its camera written backwards.
873    fn scale_active(&mut self, k: f32) {
874        if let Some(f) = self.facets.get_mut(self.active) {
875            let (lo, hi) = f.scale_range();
876            let s = (f.scale() * k).clamp(lo, hi);
877            f.set_scale(s);
878        }
879    }
880
881    /// Reset the active facet's scale to native.
882    fn reset_scale(&mut self) {
883        if let Some(f) = self.facets.get_mut(self.active) {
884            f.set_scale(1.0);
885        }
886    }
887
888    /// Draw the tab bar + capability toolbar + the active facet, and route
889    /// capability-gated shortcuts (Ctrl-+/-/0 for scale; Ctrl-C/X/V for clipboard).
890    pub fn ui(&mut self, ui: &mut Ui) {
891        // Opt-in palette override: apply the chosen Theme::ALL palette + its
892        // egui Visuals each frame so the whole deck (and every facet that reads
893        // `theme(ui)`) follows. No override → the host's own theme stays.
894        if let Some(theme) = self.fx.theme() {
895            set_theme(ui.ctx(), theme);
896        }
897
898        let titles: Vec<String> = self.facets.iter().map(|f| f.title().to_string()).collect();
899        // The tab bar has TWO forms (responsive, host-driven via `set_menu_collapsed`):
900        //   • WIDE viewport → the inline wrapped strip (every tab visible + clickable).
901        //   • NARROW (phone) → a compact `≡` HAMBURGER header + a drawer of choices,
902        //     so the long list doesn't wrap into many rows and bury the canvas.
903        // Capture the bar's rect either way so a headless test can prove the collapsed
904        // menu is one compact row, not a viewport-eating wrap.
905        let bar = ui.scope(|ui| {
906            if self.menu_collapsed {
907                self.draw_menu_drawer(&titles, ui);
908            } else {
909                // Wrap the tab bar: with many facets a single non-wrapping row overflows
910                // the panel width and the trailing tabs become unreachable (off-screen,
911                // unclickable for a robot driver / pointer). Wrapping keeps every tab
912                // visible + clickable no matter how many facets the deck holds.
913                ui.horizontal_wrapped(|ui| {
914                    for (i, t) in titles.iter().enumerate() {
915                        ui.selectable_value(&mut self.active, i, t);
916                    }
917                });
918            }
919        });
920        self.menu_bar_rect = bar.response.rect;
921
922        let caps = self.active_caps();
923
924        // Capability-driven toolbar: only show controls the active facet honors.
925        if caps.scalable {
926            ui.horizontal(|ui| {
927                if ui.button("−").on_hover_text("Zoom out (Ctrl-−)").clicked() {
928                    self.scale_active(1.0 / 1.1);
929                }
930                ui.label(format!("{:.0}%", self.active_scale() * 100.0));
931                if ui.button("+").on_hover_text("Zoom in (Ctrl-+)").clicked() {
932                    self.scale_active(1.1);
933                }
934                if ui.button("Reset").on_hover_text("Reset zoom (Ctrl-0)").clicked() {
935                    self.reset_scale();
936                }
937            });
938        }
939
940        // Capability-gated scale shortcuts. egui has no semantic event for these,
941        // so we hand-detect the key combos (clipboard uses semantic events below).
942        if caps.scalable {
943            let (cmd, plus, minus, zero) = ui.input(|i| {
944                (
945                    i.modifiers.command,
946                    i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
947                    i.key_pressed(egui::Key::Minus),
948                    i.key_pressed(egui::Key::Num0),
949                )
950            });
951            if cmd {
952                if plus {
953                    self.scale_active(1.1);
954                }
955                if minus {
956                    self.scale_active(1.0 / 1.1);
957                }
958                if zero {
959                    self.reset_scale();
960                }
961            }
962        }
963
964        // Clipboard routing: drain semantic events and dispatch to the active
965        // facet, gated by its caps. A focused TextEdit already consumed its own.
966        self.route_clipboard(ui.ctx());
967
968        // Cross-instance component clone (DISTINCT gesture): the Ctrl+Shift+C/V
969        // accelerators + any pending envelope paste, drained the same frame.
970        self.route_component_clipboard(ui);
971
972        ui.separator();
973        // Optionally wrap the active facet in the shared glass/card chrome
974        // (`chrome` module), gated by the active EffectsPolicy. Reserve a paint slot
975        // BEFORE the content so the glass fill sits behind it; the glow + border
976        // edge is painted on top afterwards.
977        let chrome_on = self.fx.chrome;
978        let chrome_slot = chrome_on.then(|| ui.painter().add(egui::Shape::Noop));
979        // Render the active facet, capturing the rect it occupied so the deck can
980        // bloom it (opt-in glow) without the facet knowing.
981        let content = ui.scope(|ui| {
982            if let Some(f) = self.facets.get_mut(self.active) {
983                // Render-trace: this facet's `ui()` RAN this frame (the wasm-safe
984                // "what ran" ledger — folded into state_json, read via the JS hook
985                // on wasm). Keyed by tab title so the ran-list maps tab → ran?.
986                runtrace::ran(&format!("deck.render:{}", f.title()));
987                f.ui(ui);
988            }
989        });
990        let content_rect = content.response.rect;
991        // The DEV-ID chip, on the rect the facet actually occupied. This is the draw
992        // path the apps really use (`Deck::ui`); `pane_ui` below is the host-driven
993        // one. Both paint it, because a badge wired only into the path nobody calls is
994        // a feature that ships green and never appears (facett-demo goes through HERE).
995        if let Some(f) = self.facets.get(self.active) {
996            let (component, title) = (f.component(), f.title().to_string());
997            devid::badge(ui, content_rect, component, &title);
998        }
999        // Remember the "canvas" rect — the screen the collapsed menu hands to the
1000        // active facet (read back via `state_json.menu.content_rect`).
1001        self.content_rect = content_rect;
1002
1003        // Paint the card chrome around the facet's content rect.
1004        if let Some(slot) = chrome_slot
1005            && content_rect.is_positive()
1006        {
1007            let theme = self.effective_theme();
1008            let policy = crate::look::effects_policy(ui);
1009            let style = chrome::ChromeStyle::default().for_policy(policy);
1010            let card = content_rect.expand(6.0);
1011            ui.painter().set(slot, chrome::fill_shape(card, &theme, policy, style));
1012            chrome::edge(ui.painter(), card, &theme, policy, style);
1013        }
1014
1015        // Right-click the active facet body → the Copy/Paste-component menu (the
1016        // discoverable affordance for the cross-instance clone gesture). The
1017        // text clipboard's own copy/paste is unaffected (different gesture).
1018        if !self.active_kind().is_empty() {
1019            content.response.context_menu(|ui| self.component_menu(ui));
1020        }
1021
1022        // Opt-in glow on the active facet's content rect, pulsing.
1023        if self.fx.glow && content_rect.is_positive() {
1024            let theme = self.effective_theme();
1025            let time = ui.input(|i| i.time);
1026            let painter = ui.painter_at(content_rect);
1027            deckfx::paint_active_glow(&painter, content_rect.shrink(2.0), &theme, &self.fx, time);
1028            ui.ctx().request_repaint(); // keep the pulse animating
1029        }
1030
1031        // Drive + paint a summoned raven on a foreground layer above everything.
1032        self.drive_raven(ui.ctx());
1033
1034        // Paint the component-clone toast (mismatch / rejection feedback) on top.
1035        self.paint_component_toast(ui);
1036    }
1037
1038    /// Draw the collapsed **hamburger menu** for a narrow viewport: a compact header
1039    /// (`≡` / `✕` toggle + the active choice's label) and, when the drawer is open, the
1040    /// full choice list as a vertical, scrollable drawer. Picking a choice switches the
1041    /// active facet AND shuts the drawer (canvas-first again). Mirrors the demo shell's
1042    /// `⚙` control-strip fold, applied to the MAIN menu.
1043    fn draw_menu_drawer(&mut self, titles: &[String], ui: &mut Ui) {
1044        let active_label = titles.get(self.active).cloned().unwrap_or_default();
1045        ui.horizontal(|ui| {
1046            // The hamburger toggle. `≡` closed, `✕` open — a single tappable glyph.
1047            let glyph = if self.menu_open { "✕" } else { "≡" };
1048            let hint = if self.menu_open { "close the menu — give the canvas the screen" } else { "choose a view" };
1049            if ui.selectable_label(self.menu_open, glyph).on_hover_text(hint).clicked() {
1050                self.menu_open = !self.menu_open;
1051            }
1052            // The current choice's label, so the collapsed header still says WHAT is shown.
1053            ui.label(&active_label);
1054        });
1055        if self.menu_open {
1056            // The full choice list as a vertical DRAWER (bounded + scrollable so 60+
1057            // choices never blow past the viewport). Selecting one switches + closes.
1058            egui::Frame::group(ui.style()).show(ui, |ui| {
1059                egui::ScrollArea::vertical().max_height(420.0).auto_shrink([false, true]).show(ui, |ui| {
1060                    for (i, t) in titles.iter().enumerate() {
1061                        if ui.selectable_label(self.active == i, t.as_str()).clicked() {
1062                            self.active = i;
1063                            self.menu_open = false;
1064                        }
1065                    }
1066                });
1067            });
1068        }
1069    }
1070
1071    /// Advance + paint the summoned raven (if any) on a foreground layer. Pins its
1072    /// launch time on the first frame and keeps repainting while it flies.
1073    fn drive_raven(&mut self, ctx: &egui::Context) {
1074        let Some(raven) = self.raven.as_mut() else { return };
1075        raven.sprite.update(ctx);
1076        let painter =
1077            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_raven")));
1078        raven.sprite.paint(&painter);
1079    }
1080
1081    /// Route this frame's clipboard events to the active facet, gated by caps.
1082    /// The single OS-touching write (`clipboard::put`) lives here.
1083    fn route_clipboard(&mut self, ctx: &egui::Context) {
1084        let caps = self.active_caps();
1085        if !(caps.copyable || caps.cuttable || caps.pasteable) {
1086            return;
1087        }
1088        for action in clipboard::poll(ctx) {
1089            let Some(f) = self.facets.get_mut(self.active) else { continue };
1090            match action {
1091                ClipAction::Copy if caps.copyable => {
1092                    if let Some(t) = f.copy() {
1093                        clipboard::put(ctx, t);
1094                    }
1095                }
1096                ClipAction::Cut if caps.cuttable => {
1097                    if let Some(t) = f.cut() {
1098                        clipboard::put(ctx, t);
1099                    }
1100                }
1101                ClipAction::Paste(s) if caps.pasteable => {
1102                    f.paste(&s);
1103                }
1104                // Capability not declared → ignore (event may belong to a focused
1105                // sub-widget egui already handled).
1106                _ => {}
1107            }
1108        }
1109    }
1110
1111    // ── cross-instance component clone (Copy/Paste component) ────────────────
1112    //
1113    // A DISTINCT gesture from the text clipboard above: it transfers a facet's
1114    // type-tagged PORTABLE state (not a text selection) to a same-kind sibling.
1115    // See `.nornir/design/copy-paste-between-instances.md`. Surfaced two ways —
1116    // the context menu in `component_menu` (right-click the body) and the
1117    // `Ctrl+Shift+C / Ctrl+Shift+V` accelerators routed in `ui`.
1118
1119    /// The active facet's [`Facet::kind`] (`""` if empty / opted out).
1120    pub fn active_kind(&self) -> &'static str {
1121        self.facets.get(self.active).map(|f| f.kind()).unwrap_or("")
1122    }
1123
1124    /// **Copy component** — encode the active facet's [`Facet::portable_state`]
1125    /// into the tagged clipboard envelope and place it on the OS clipboard.
1126    /// Returns the envelope text on success, or `None` if the active facet opts
1127    /// out (empty `kind()` or no `portable_state()`). This is the data half the
1128    /// gesture handlers + tests drive; the OS write is the caller's via
1129    /// [`clipboard::put`] (done for them in [`copy_component`](Self::copy_component)).
1130    pub fn copy_component_envelope(&self) -> Option<String> {
1131        let f = self.facets.get(self.active)?;
1132        let kind = f.kind();
1133        if kind.is_empty() {
1134            return None;
1135        }
1136        let state = f.portable_state()?;
1137        Some(clipboard::encode_component(kind, &state))
1138    }
1139
1140    /// Copy the active facet's portable state to the OS clipboard (the full
1141    /// gesture). Returns `true` if something was copied.
1142    pub fn copy_component(&mut self, ctx: &egui::Context) -> bool {
1143        match self.copy_component_envelope() {
1144            Some(env) => {
1145                clipboard::put(ctx, env);
1146                true
1147            }
1148            None => false,
1149        }
1150    }
1151
1152    /// **Paste component** — decode a clipboard `text` envelope and, **only if its
1153    /// kind matches the active facet's** [`Facet::kind`], hand the state to
1154    /// [`Facet::load_state`]. Returns `true` if the active facet adopted it.
1155    /// A kind mismatch (or a non-envelope / wrong-version text) is a no-op that
1156    /// raises a themed mismatch [`toast`](Self::toast) — the type-match guard is
1157    /// the whole point: a `table` envelope NEVER loads into a `graphpan`.
1158    pub fn paste_component(&mut self, text: &str, now: f64) -> bool {
1159        let Some((kind, state)) = clipboard::decode_component(text) else {
1160            // Not a component envelope at all — leave it for the text path; no toast.
1161            return false;
1162        };
1163        let active_kind = self.active_kind();
1164        if active_kind.is_empty() {
1165            self.toast = Some(("this view doesn't accept a pasted component".to_string(), now));
1166            return false;
1167        }
1168        if kind != active_kind {
1169            self.toast = Some((format!("clipboard holds a `{kind}`, not a `{active_kind}`"), now));
1170            return false;
1171        }
1172        let Some(f) = self.facets.get_mut(self.active) else { return false };
1173        let accepted = f.load_state(&state);
1174        if !accepted {
1175            self.toast = Some((format!("this `{active_kind}` could not adopt the clipboard state"), now));
1176        }
1177        accepted
1178    }
1179
1180    /// The current component-clone toast message (if one is live), for tests /
1181    /// hosts that want to surface it themselves.
1182    pub fn component_toast(&self) -> Option<&str> {
1183        self.toast.as_ref().map(|(m, _)| m.as_str())
1184    }
1185
1186    /// Right-click context-menu entries for the cross-instance clone gesture —
1187    /// **Copy component** / **Paste component** — themed by the active style. A
1188    /// host attaches these to the facet body (or its tab) via
1189    /// `response.context_menu(|ui| deck.component_menu(ui))`. Greys out when the
1190    /// active facet opts out (empty `kind()`).
1191    pub fn component_menu(&mut self, ui: &mut egui::Ui) {
1192        let km = look::keymap(ui);
1193        let kind = self.active_kind();
1194        let can_clone = !kind.is_empty();
1195        ui.add_enabled_ui(can_clone && self.copy_component_envelope().is_some(), |ui| {
1196            let label = format!("Copy component  {}", km.label(Action::Copy, ui.ctx()));
1197            if ui.button(label).clicked() {
1198                self.copy_component(ui.ctx());
1199                ui.close();
1200            }
1201        });
1202        ui.add_enabled_ui(can_clone, |ui| {
1203            let label = format!("Paste component  {}", km.label(Action::Paste, ui.ctx()));
1204            if ui.button(label).clicked() {
1205                // Pull the OS clipboard via egui's paste request; the actual text
1206                // arrives next frame as an Event::Paste, routed in `route_component_clipboard`.
1207                ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1208                ui.close();
1209            }
1210        });
1211    }
1212
1213    /// Route the `Ctrl+Shift+C / Ctrl+Shift+V` component-clone accelerators +
1214    /// drain any pending paste envelope. Called once per frame from [`ui`](Self::ui),
1215    /// AFTER the text clipboard so a focused TextEdit's plain Ctrl+C/V is untouched
1216    /// (the Shift discriminates this gesture from text copy/paste).
1217    fn route_component_clipboard(&mut self, ui: &mut egui::Ui) {
1218        let now = ui.input(|i| i.time);
1219        // Accelerators: Ctrl+Shift+C copies; Ctrl+Shift+V triggers an OS-clipboard
1220        // paste request (the text lands next frame as Event::Paste, decoded below).
1221        let copy_shift = egui::KeyboardShortcut::new(
1222            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1223            egui::Key::C,
1224        );
1225        let paste_shift = egui::KeyboardShortcut::new(
1226            egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
1227            egui::Key::V,
1228        );
1229        if ui.input_mut(|i| i.consume_shortcut(&copy_shift)) {
1230            self.copy_component(ui.ctx());
1231        }
1232        if ui.input_mut(|i| i.consume_shortcut(&paste_shift)) {
1233            ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste);
1234        }
1235        // Drain any Paste events that look like a component envelope (a plain text
1236        // paste decodes to None here and is left for the text clipboard path).
1237        let pastes: Vec<String> = ui.input(|i| {
1238            i.events
1239                .iter()
1240                .filter_map(|e| match e {
1241                    egui::Event::Paste(s) if clipboard::decode_component(s).is_some() => Some(s.clone()),
1242                    _ => None,
1243                })
1244                .collect()
1245        });
1246        for text in pastes {
1247            self.paste_component(&text, now);
1248        }
1249        // Age out the toast.
1250        if let Some((_, raised)) = self.toast {
1251            if now - raised > TOAST_SECS {
1252                self.toast = None;
1253            }
1254        }
1255    }
1256
1257    /// Paint the live component-clone toast on a foreground layer (themed, spacious),
1258    /// if one is set. A no-op when there is no toast.
1259    fn paint_component_toast(&self, ui: &mut egui::Ui) {
1260        let Some((msg, _)) = self.toast.as_ref() else { return };
1261        let th = theme(ui);
1262        let ctx = ui.ctx();
1263        let painter =
1264            ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("facett_deck_component_toast")));
1265        let screen = ctx.content_rect();
1266        let font = FontId::proportional(14.0);
1267        let galley = painter.layout_no_wrap(msg.clone(), font.clone(), th.text);
1268        let pad = vec2(14.0, 10.0); // spacious preset padding
1269        let size = galley.size() + pad * 2.0;
1270        // Bottom-centre, lifted off the edge.
1271        let center = Pos2::new(screen.center().x, screen.max.y - size.y * 0.5 - 18.0);
1272        let rect = Rect::from_center_size(center, size);
1273        painter.rect_filled(rect, 8.0, th.panel_bg);
1274        painter.rect_stroke(rect, 8.0, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Inside);
1275        painter.galley(rect.min + pad, galley, th.text);
1276        ctx.request_repaint(); // keep ticking so the toast ages out on time
1277    }
1278
1279    /// The whole-app observable state: the active facet + each facet's
1280    /// `state_json`, plus an **additive** sibling `caps` map (title → caps JSON)
1281    /// so the existing flat `facets[title]` shape is unchanged for consumers.
1282    pub fn state_json(&self) -> serde_json::Value {
1283        let mut facets = serde_json::Map::new();
1284        let mut caps = serde_json::Map::new();
1285        for f in &self.facets {
1286            facets.insert(f.title().to_string(), f.state_json());
1287            caps.insert(f.title().to_string(), f.caps().to_json());
1288        }
1289        let mr = self.menu_bar_rect;
1290        let cr = self.content_rect;
1291        serde_json::json!({
1292            "active": self.facets.get(self.active).map(|f| f.title()),
1293            "facets": facets,
1294            "caps": caps,
1295            // The RESPONSIVE menu (#39-follow): whether the tab bar is the collapsed
1296            // hamburger (phone-class) and, if so, whether its drawer is open — plus the
1297            // menu-header rect and the active-facet ("canvas") rect, so a headless test
1298            // proves the collapsed menu is one compact row and the canvas gets the screen.
1299            "menu": {
1300                "collapsed": self.menu_collapsed,
1301                "open": self.menu_open,
1302                "active": self.facets.get(self.active).map(|f| f.title()),
1303                "count": self.facets.len(),
1304                "bar_rect": [mr.min.x, mr.min.y, mr.width(), mr.height()],
1305                "content_rect": [cr.min.x, cr.min.y, cr.width(), cr.height()],
1306            },
1307            // The deck's opt-in effects (DeckFx) as data: whether the shared glass/
1308            // card `chrome` wrap is on, whether the active-facet bloom glow is on, and
1309            // the active palette override. A headless driver reads this to PROVE the
1310            // T1.3 showcase rendering (glass + bloom) is actually wired, not eyeballed.
1311            "fx": {
1312                "chrome": self.fx.chrome,
1313                "glow": self.fx.glow,
1314                "glow_layers": self.fx.glow_layers,
1315                "palette": self.fx.palette(),
1316            },
1317            // The wasm-safe "what RAN" ledger — every facet render + every traced
1318            // control handler that has executed this session (the readable proof
1319            // the shipped artifact actually ran each surface). See `runtrace`.
1320            "trace": { "ran": runtrace::snapshot(), "distinct": runtrace::distinct() },
1321            // The DEV-ID gate — enabled/env/profile/source. Reports whether the chips
1322            // are SWITCHED ON, not whether one is on screen; see `devid::gate_json`.
1323            "devid": devid::gate_json(),
1324        })
1325    }
1326}
1327
1328/// A stable, bright-ish colour from a string (FNV-1a). Handy default node colour.
1329pub fn hash_color(s: &str) -> Color32 {
1330    let mut h: u32 = 2166136261;
1331    for b in s.bytes() {
1332        h = (h ^ b as u32).wrapping_mul(16777619);
1333    }
1334    Color32::from_rgb((h & 0xFF) as u8 | 0x60, ((h >> 8) & 0xFF) as u8 | 0x60, ((h >> 16) & 0xFF) as u8 | 0x60)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340
1341    #[test]
1342    fn scene_builds() {
1343        let mut s = Scene::new();
1344        let a = s.node("Person", hash_color("Person"));
1345        let b = s.node("Company", hash_color("Company"));
1346        s.edge(a, b);
1347        assert_eq!(s.nodes.len(), 2);
1348        assert_eq!(s.edges.len(), 1);
1349        assert!(!s.is_empty());
1350    }
1351
1352    #[test]
1353    fn force_layout_produces_finite_bounded_positions() {
1354        let mut scene = Scene::new();
1355        for i in 0..12 { scene.node(format!("n{i}"), hash_color("n")); }
1356        for i in 0..12 { scene.edge(i, (i + 1) % 12); }
1357        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
1358        let pos = positions(Layout::Force, &scene, rect);
1359        assert_eq!(pos.len(), 12);
1360        for p in &pos {
1361            assert!(p.x.is_finite() && p.y.is_finite(), "finite");
1362            assert!(rect.expand(50.0).contains(*p), "roughly within the rect");
1363        }
1364    }
1365
1366    /// **Scale (RED-when-broken):** above the Barnes–Hut threshold a large force
1367    /// graph must still lay out — every node finite + inside the rect — under a sane
1368    /// wall-clock budget. The old O(n²) all-pairs loop blows this budget (and the
1369    /// 30 000-node viewer hung); the O(n log n) Barnes–Hut path meets it. A regression
1370    /// back to quadratic repulsion trips the time bound.
1371    #[test]
1372    fn force_layout_scales_to_ten_thousand_nodes() {
1373        let n = 10_000;
1374        assert!(n >= crate::barnes_hut::BH_THRESHOLD, "n is on the Barnes–Hut path");
1375        let mut scene = Scene::new();
1376        for i in 0..n {
1377            scene.node(format!("n{i}"), hash_color("n"));
1378        }
1379        // A sparse ring + chords: O(n) edges (attraction stays O(edges)).
1380        for i in 0..n {
1381            scene.edge(i, (i + 1) % n);
1382            if i % 7 == 0 {
1383                scene.edge(i, (i + 137) % n);
1384            }
1385        }
1386        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1024.0, 1024.0));
1387        let t0 = std::time::Instant::now();
1388        let pos = positions(Layout::Force, &scene, rect);
1389        let ms = t0.elapsed().as_secs_f64() * 1000.0;
1390        assert_eq!(pos.len(), n);
1391        for p in &pos {
1392            assert!(p.x.is_finite() && p.y.is_finite(), "every node finite at scale");
1393            assert!(rect.expand(50.0).contains(*p), "every node bounded within the rect");
1394        }
1395        // Generous ceiling for a debug build on a slow box; the O(n²) loop is far over.
1396        assert!(ms < 20_000.0, "10k-node force layout must be sub-quadratic fast; took {ms:.0} ms");
1397    }
1398
1399    #[test]
1400    fn hash_color_is_stable() {
1401        assert_eq!(hash_color("Person"), hash_color("Person"));
1402        assert_ne!(hash_color("Person"), hash_color("Company"));
1403    }
1404
1405    /// A minimal facet for deck tests.
1406    struct Stub(&'static str);
1407    impl Facet for Stub {
1408        fn title(&self) -> &str {
1409            self.0
1410        }
1411        fn ui(&mut self, ui: &mut Ui) {
1412            ui.label(self.0);
1413        }
1414        fn state_json(&self) -> serde_json::Value {
1415            serde_json::json!({ "t": self.0 })
1416        }
1417    }
1418
1419    /// A component-clone-capable stub: a `kind`, a JSON `payload` it round-trips
1420    /// through `portable_state`/`load_state`.
1421    struct CloneStub {
1422        kind: &'static str,
1423        payload: serde_json::Value,
1424    }
1425    impl Facet for CloneStub {
1426        fn title(&self) -> &str {
1427            self.kind
1428        }
1429        fn ui(&mut self, _ui: &mut Ui) {}
1430        fn state_json(&self) -> serde_json::Value {
1431            serde_json::json!({ "kind": self.kind })
1432        }
1433        fn kind(&self) -> &'static str {
1434            self.kind
1435        }
1436        fn portable_state(&self) -> Option<serde_json::Value> {
1437            Some(self.payload.clone())
1438        }
1439        fn load_state(&mut self, state: &serde_json::Value) -> bool {
1440            self.payload = state.clone();
1441            true
1442        }
1443    }
1444
1445    /// **The deck reports the DEV-ID gate too.** `facett_app::scene` is korp's container,
1446    /// but facett-demo and the wasm surfaces go through `FacetDeck`, and an oracle asking
1447    /// "are the chips on?" must get the same answer from either. A gate readable from only
1448    /// one of the two containers is exactly the shape of miss #2.
1449    #[test]
1450    fn the_deck_state_reports_the_devid_gate() {
1451        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1452        let gate = deck.state_json()["devid"].clone();
1453        assert!(!gate.is_null(), "the deck's state_json carries no `devid` gate");
1454        assert_eq!(gate["enabled"], devid::enabled(), "deck reports a gate badge() disobeys");
1455        assert_eq!(gate["debug_assertions"], cfg!(debug_assertions), "wrong profile: {gate}");
1456    }
1457
1458    #[test]
1459    fn component_clone_round_trips_through_the_deck() {
1460        // Instance A (configured) → envelope → instance B adopts it.
1461        let a = CloneStub { kind: "graphpan", payload: serde_json::json!({ "zoom": 2.0, "pan": [3, 4] }) };
1462        let mut deck_a = FacetDeck::new(vec![Box::new(a)]);
1463        let env = deck_a.copy_component_envelope().expect("A copies its portable state");
1464
1465        let mut deck_b = FacetDeck::new(vec![Box::new(CloneStub {
1466            kind: "graphpan",
1467            payload: serde_json::json!({ "zoom": 1.0, "pan": [0, 0] }),
1468        })]);
1469        assert!(deck_b.paste_component(&env, 0.0), "same-kind paste is accepted");
1470        // B now equals A's portable state.
1471        assert_eq!(
1472            deck_b.copy_component_envelope(),
1473            deck_a.copy_component_envelope(),
1474            "B adopted A's portable state exactly"
1475        );
1476        assert!(deck_b.component_toast().is_none(), "a successful paste raises no toast");
1477    }
1478
1479    #[test]
1480    fn component_clone_rejects_a_type_mismatch_with_a_toast() {
1481        // A `table` envelope handed to a `graphpan` → load_state NOT called.
1482        let table_env = clipboard::encode_component("table", &serde_json::json!({ "rows": 3 }));
1483        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1484            kind: "graphpan",
1485            payload: serde_json::json!({ "zoom": 1.0 }),
1486        })]);
1487        let before = deck.copy_component_envelope();
1488        assert!(!deck.paste_component(&table_env, 0.0), "cross-type paste returns false");
1489        assert_eq!(deck.copy_component_envelope(), before, "graphpan state untouched");
1490        let toast = deck.component_toast().expect("mismatch raises a toast");
1491        assert!(toast.contains("table") && toast.contains("graphpan"), "toast names both kinds: {toast}");
1492    }
1493
1494    #[test]
1495    fn component_clone_version_guard_rejects_unknown_v() {
1496        let bad = serde_json::json!({ "facett.kind": "graphpan", "v": 7, "state": { "zoom": 9.0 } }).to_string();
1497        let mut deck = FacetDeck::new(vec![Box::new(CloneStub {
1498            kind: "graphpan",
1499            payload: serde_json::json!({ "zoom": 1.0 }),
1500        })]);
1501        let before = deck.copy_component_envelope();
1502        // An unknown-version text decodes to None → it's a no-op (left for the text path).
1503        assert!(!deck.paste_component(&bad, 0.0), "unknown version is not adopted");
1504        assert_eq!(deck.copy_component_envelope(), before, "state untouched by a bad-version paste");
1505    }
1506
1507    #[test]
1508    fn component_clone_opt_out_floor_neither_copies_nor_accepts() {
1509        // The plain Stub does NOT implement the trio → empty kind, no copy.
1510        let mut deck = FacetDeck::new(vec![Box::new(Stub("plain"))]);
1511        assert_eq!(deck.active_kind(), "", "opted out");
1512        assert!(deck.copy_component_envelope().is_none(), "opt-out facet never copies a component");
1513        // A real envelope handed to an opt-out facet is refused (no panic, no state).
1514        let env = clipboard::encode_component("table", &serde_json::json!({ "rows": 1 }));
1515        assert!(!deck.paste_component(&env, 0.0), "opt-out facet never adopts a component");
1516        assert!(deck.component_toast().is_some(), "the refusal is surfaced");
1517    }
1518
1519    #[test]
1520    fn deck_fx_is_off_by_default() {
1521        let deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1522        assert_eq!(*deck.fx(), DeckFx::OFF, "no effects until the host opts in");
1523        assert!(!deck.has_raven());
1524        assert!(!deck.fx().glow);
1525        assert!(deck.fx().palette().is_none());
1526    }
1527
1528    #[test]
1529    fn deck_state_json_reports_fx_as_data() {
1530        // The deck's opt-in effects must be observable as data (a robot proof reads
1531        // `fx.chrome` / `fx.glow` to know the showcase rendering is wired ON).
1532        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1533        let off = deck.state_json();
1534        assert_eq!(off["fx"]["chrome"].as_bool(), Some(false), "chrome off by default");
1535        assert_eq!(off["fx"]["glow"].as_bool(), Some(false), "glow off by default");
1536        assert!(off["fx"]["palette"].is_null(), "no palette override by default");
1537
1538        deck.fx_mut().chrome = true;
1539        deck.fx_mut().glow = true;
1540        deck.set_palette(2);
1541        let on = deck.state_json();
1542        assert_eq!(on["fx"]["chrome"].as_bool(), Some(true), "chrome wired ON shows in state");
1543        assert_eq!(on["fx"]["glow"].as_bool(), Some(true), "glow wired ON shows in state");
1544        assert_eq!(on["fx"]["palette"].as_u64(), Some(2), "palette override shows in state");
1545    }
1546
1547    #[test]
1548    fn deck_cycle_palette_walks_theme_all() {
1549        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1550        let first = deck.cycle_palette();
1551        assert_eq!(first, 0);
1552        assert_eq!(deck.fx().theme().map(|t| t.name), Some(Theme::ALL[0]().name));
1553        // walks forward and wraps
1554        for _ in 1..Theme::ALL.len() {
1555            deck.cycle_palette();
1556        }
1557        assert_eq!(deck.cycle_palette(), 0, "wraps back to the first palette");
1558    }
1559
1560    #[test]
1561    fn deck_send_raven_launches_and_perches_after_a_full_flight() {
1562        use crate::effects::RAVEN_FLIGHT_SECS;
1563        let mut deck = FacetDeck::new(vec![Box::new(Stub("rows"))]);
1564        assert!(!deck.has_raven());
1565        let target = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(200.0, 28.0));
1566        deck.send_raven(target);
1567        assert!(deck.has_raven(), "raven summoned");
1568        assert!(!deck.raven_perched(), "not perched at launch");
1569
1570        // Drive the sprite headlessly past the flight duration → it perches.
1571        if let Some(r) = deck.raven.as_mut() {
1572            r.sprite.advance(RAVEN_FLIGHT_SECS + 0.1);
1573        }
1574        assert!(deck.raven_perched(), "perched after the flight duration");
1575
1576        deck.clear_raven();
1577        assert!(!deck.has_raven());
1578    }
1579
1580    /// REGRESSION (inject-assert): merely *drawing* the palette picker without a
1581    /// user click must NOT pin a palette override. The bug: the picker auto-pinned
1582    /// index 0 on the first passive frame, turning the legacy `set_theme` override
1583    /// permanently on and clobbering a host's own theme (the rich `look::Theme`)
1584    /// every frame. We render one frame with no interaction and assert the override
1585    /// is still `None` (host theme wins).
1586    #[test]
1587    fn palette_picker_does_not_pin_without_a_user_click() {
1588        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1589        assert!(deck.fx().palette().is_none(), "starts with no override");
1590        let ctx = egui::Context::default();
1591        let mut chosen = Some(7usize);
1592        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
1593            egui::CentralPanel::default().show_inside(ui, |ui| {
1594                // No synthetic click is fed → the picker is drawn but not used.
1595                chosen = deck.palette_picker(ui);
1596            });
1597        });
1598        assert_eq!(chosen, None, "drawing the picker reports no selection without a click");
1599        assert!(
1600            deck.fx().palette().is_none(),
1601            "drawing the picker must not pin index 0 — that would clobber the host's own theme each frame"
1602        );
1603    }
1604
1605    #[test]
1606    fn deck_palette_override_applies_theme_in_a_ui_pass() {
1607        let mut deck = FacetDeck::new(vec![Box::new(Stub("a"))]);
1608        deck.set_palette(1); // sci-fi
1609        let ctx = egui::Context::default();
1610        let mut seen = "";
1611        let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
1612            egui::CentralPanel::default().show_inside(ui, |ui| {
1613                deck.ui(ui);
1614                seen = theme(ui).name;
1615            });
1616        });
1617        assert_eq!(seen, Theme::ALL[1]().name, "deck applied its palette override");
1618    }
1619}