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

nordisk/facett

facett-helix/src/lib.rs

//! **facett-helix** — the **TIME-HELIX DAG view**. A plan / dependency DAG rendered
//! as a **helix that advances ALONG a time axis**: each node is a plan item (idea /
//! task / test / crate) tied to a `system` (its angular lane / strand); the DAG winds
//! *forward through time* rather than spinning in place. Edges are **soft rounded
//! rubberband splines** (Catmull-Rom, elastic settle); coils can be **wound onto a
//! pin** (`wind`); many coils coexist in one scene with **inter-helix links**; an
//! **animated traverse** flies a time-cursor along the axis. GPU-rendered with the
//! emissive **bloom** lane reused from `facett-graph3d`.
//!
//! ## Portability contract (the reason this crate exists)
//! [`model`] + [`layout`] depend on **nothing but `serde` + `std`** — no egui, no
//! facett, no nornir/modgunn. They are the seam that `git mv`s into **ordning**. Only
//! [`HelixView`] (this file) binds to facett's [`Facet`]/egui/`facett-graph3d`. Hosts
//! (nornir's funnel, modgunn) just *populate* a [`model::Scene`] through the
//! [`model::PlanDag::add_idea`] API; facett stays generic.
//!
//! ## FC contract
//! - **Serializable state** ([`state_json`](HelixView::state_json)): every node's
//!   helix coordinates (along-axis `t`, angular lane, system) + the wind / level /
//!   cursor / tension — the picture asserted as DATA.
//! - **[`Msg`] + [`update`](HelixView::update)**: the robot-/CLI-addressable surface
//!   (seek the time cursor, wind a coil, filter dep levels, select) — FC-3.
//! - **Injected clock** ([`set_clock`](HelixView::set_clock) / [`Msg::Tick`]): the
//!   traverse + the elastic wobble are pure functions of it (FC-7, determinism).
//! - **AccessKit**: each node rides into the tree as a queryable Button (FC-4).

pub mod layout;
pub mod model;
pub mod stability;
/// The real 3D **tube/pipe** field ([`TubeField`]) — data smeared on the cylinder
/// walls + data along the centre axis. Dependency-free (serde only), like [`model`].
pub mod tube;
/// Builds a [`facett_map3d::Mesh3`] from a [`tube::TubeField`] (cylinder wall +
/// centre-axis core tube).
pub mod mesh;
/// The [`TubeView`](tubeview::TubeView) Facet — orbits + lights + paints the tube mesh.
pub mod tubeview;

pub use tube::TubeField;
pub use tubeview::{Face, TubeMsg, TubeView};
pub use layout::{HelixLayout, HelixParams, SceneLayout};
// Mental-map stability for dynamic graphs (#4): anchor constraints bound a node's
// per-step travel so structural change is obvious without teleporting; the time axis
// maps to helix depth so the user flies through history. See [`stability`].
pub use stability::{anchor_sequence, snapshot_from_dag, stability, trajectory_world, Snapshot, Stability, Target};
pub use model::{Coil, Dep, Edge, Event, EventKind, HelixRef, InterEdge, Kind, Node, NodeId, PlanDag, Scene, Status};

use egui::{Align2, Color32, CornerRadius, FontId, Pos2, Sense, Stroke, vec2};
use facett_core::effects::easing;
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Facet, FacetCaps, a11y, theme};
use serde::{Deserialize, Serialize};

/// How fast a full traverse (cursor 0 → 1) runs, in seconds (live mode).
const TRAVERSE_PERIOD: f64 = 8.0;
/// Spin rate (rad/s) for the secondary tube rotation.
const SPIN_RATE: f32 = 0.35;
/// Elastic wobble cycles per second.
const WOBBLE_RATE: f64 = 0.6;
/// Horizontal compression of the world time-axis when projected to screen.
const AXIS_COMPRESS: f32 = 0.62;

/// A robot-/CLI-addressable control message. Applied by [`HelixView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Advance the injected clock by `dt` seconds (drives traverse + wobble).
    Tick(f64),
    /// Pin the injected clock to `seconds`.
    SetClock(f64),
    /// Seek the time cursor to `u ∈ [0,1]` along the axis (also stops auto-traverse).
    Seek(f32),
    /// Start / stop the animated traverse along the time axis.
    ToggleTraverse(bool),
    /// Set the tube-spin yaw (radians).
    SetYaw(f32),
    /// Start / stop the secondary tube spin.
    ToggleSpin(bool),
    /// Wind coil `helix` onto its pin (`1.0`) or splay it out (`0.0`).
    SetWind(u32, f32),
    /// Show only dependency edges at or below this depth (1 = direct only).
    SetLevel(u8),
    /// Elastic-edge tension (rubberband wobble amplitude).
    SetTension(f32),
    /// Select a node (scene-global ref).
    Select(HelixRef),
    /// Clear the selection.
    ClearSelection,
}

/// Side work as data (FC-8). The helix does **no** I/O — every input is folded into
/// the [`Model`](facett_core::Elm::Model) synchronously by [`update`](HelixView::update),
/// so this is intentionally **uninhabited**: the honest statement that there is nothing
/// for a host to run after a `Msg`.
#[derive(Debug)]
pub enum Effect {}

/// The TIME-HELIX DAG [`Facet`].
///
/// **FC-1 / FC-3 (Elm `Model`)**: every field is observable state that
/// `Serialize`/`Deserialize`/`Clone`/`PartialEq` round-trip — there is no ephemeral
/// render cache — so `HelixView` **is** its own [`Elm::Model`](facett_core::Elm::Model)
/// (`state(&self) -> &Self`). Mutation flows only through [`update`](Self::update); the
/// view ([`render`](Self::render)) is a pure function of `&self`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HelixView {
    pub title: String,
    pub scene: Scene,
    pub params: HelixParams,
    /// Time cursor along the axis, `u ∈ [0,1]`.
    cursor_u: f32,
    /// Auto-advance the cursor along the time axis.
    traverse: bool,
    /// Secondary tube-spin yaw (radians) + whether it spins.
    yaw: f32,
    spin: bool,
    /// Dependency-depth filter (edges with `level ≤ max_level` are shown).
    max_level: u8,
    /// Selected node (scene-global).
    selected: Option<HelixRef>,
    /// Emissive peak for the GPU bloom lane (read by the `wgpu` `gpu_scene`).
    #[cfg_attr(not(feature = "wgpu"), allow(dead_code))]
    emissive: f32,
    /// **Injected clock** (seconds; FC-7). `Some` ⇒ traverse + wobble are pure
    /// functions of it. `None` ⇒ follow egui's ambient time (live UX).
    clock_s: Option<f64>,
}

impl HelixView {
    /// Build a view over a [`Scene`].
    pub fn new(title: impl Into<String>, scene: Scene) -> Self {
        let max_level = scene.max_level();
        Self {
            title: title.into(),
            scene,
            params: HelixParams::default(),
            cursor_u: 0.0,
            traverse: true,
            yaw: 0.5,
            spin: true,
            max_level,
            selected: None,
            emissive: 3.0,
            clock_s: None,
        }
    }

    /// Build a single-coil view from one [`PlanDag`] (the common plan-DAG case).
    pub fn from_dag(title: impl Into<String>, dag: PlanDag) -> Self {
        Self::new(title, Scene::single(dag))
    }

    /// A self-contained **demo scene**: two crate-coils (modgunn-style) winding through
    /// release time, with intra-coil deps at mixed depth levels, a cross-coil
    /// (system-level) link, and a CVE event — exercises every capability with no host.
    pub fn demo() -> Self {
        // Coil A — a crate evolving across four releases, two subsystems (lanes).
        let mut a = PlanDag::new();
        let a0 = a.add_idea("init", "core", 0.0, Kind::Crate);
        let a1 = a.add_idea("api", "core", 1.0, Kind::Task);
        let a2 = a.add_idea("io", "net", 2.0, Kind::Task);
        let a3 = a.add_idea("v1.0", "core", 3.0, Kind::Test);
        a.add_dep(a1, a0);
        a.add_dep(a2, a1);
        a.add_dep_at(a3, a0, 2); // a transitive (level-2) dep
        a.add_dep(a3, a2);
        a.set_status(a3, Status::Active);
        a.add_event(a2, 2.4, EventKind::Cve, "RUSTSEC-2025-0007");
        // Coil B — a dependent crate, offset in Z so it shares the time (X) axis.
        let mut b = PlanDag::new();
        let b0 = b.add_idea("init", "lib", 0.5, Kind::Crate);
        let b1 = b.add_idea("v0.9", "lib", 2.5, Kind::Task);
        b.add_dep(b1, b0);
        b.add_event(b1, 2.6, EventKind::Delta, "bumped a-crate");

        let mut scene = Scene::new();
        scene.tension = 0.6;
        let ha = scene.add_coil(Coil::new(a).at([0.0, 0.0, 0.0]));
        let hb = scene.add_coil(Coil::new(b).at([0.0, 0.0, 3.2]));
        // System-level link: B's init depends on A's api (cross-coil).
        scene.add_link(HelixRef { helix: hb, node: b0 }, HelixRef { helix: ha, node: a1 }, 1);
        Self::new("time-helix", scene)
    }

    /// Pin the animation clock (seconds; FC-7).
    pub fn set_clock(&mut self, seconds: f64) {
        self.clock_s = Some(seconds);
    }
    /// Builder form of [`set_clock`](Self::set_clock).
    #[must_use]
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.clock_s = Some(seconds);
        self
    }

    /// The effective time cursor `u ∈ [0,1]` for an ambient clock value — auto-traverse
    /// advances it (looping); a manual [`Msg::Seek`] pins it. Pure in the clock.
    fn cursor_for(&self, time_s: f64) -> f32 {
        if self.traverse {
            ((self.cursor_u as f64 + time_s / TRAVERSE_PERIOD).rem_euclid(1.0)) as f32
        } else {
            self.cursor_u
        }
    }

    /// The elastic wobble phase `∈ [0,1)` for an ambient clock value (FC-7).
    fn wobble_phase(&self, time_s: f64) -> f32 {
        (time_s * WOBBLE_RATE).rem_euclid(1.0) as f32
    }

    /// The spin yaw for an ambient clock value.
    fn yaw_for(&self, time_s: f64) -> f32 {
        if self.spin { self.yaw + time_s as f32 * SPIN_RATE } else { self.yaw }
    }

    /// Apply a [`Msg`]. Returns `true` if it changed observable state.
    pub fn update(&mut self, msg: Msg) -> bool {
        match msg {
            Msg::Tick(dt) => {
                self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
                true
            }
            Msg::SetClock(s) => {
                let changed = self.clock_s != Some(s);
                self.clock_s = Some(s);
                changed
            }
            Msg::Seek(u) => {
                let u = u.clamp(0.0, 1.0);
                let changed = self.cursor_u != u || self.traverse;
                self.cursor_u = u;
                self.traverse = false;
                changed
            }
            Msg::ToggleTraverse(on) => {
                let changed = self.traverse != on;
                self.traverse = on;
                changed
            }
            Msg::SetYaw(y) => {
                let changed = self.yaw != y;
                self.yaw = y;
                changed
            }
            Msg::ToggleSpin(on) => {
                let changed = self.spin != on;
                self.spin = on;
                changed
            }
            Msg::SetWind(helix, w) => self.scene.set_wind(helix, w),
            Msg::SetLevel(l) => {
                let l = l.clamp(1, self.scene.max_level());
                let changed = self.max_level != l;
                self.max_level = l;
                changed
            }
            Msg::SetTension(t) => {
                let t = t.clamp(0.0, 3.0);
                let changed = (self.scene.tension - t).abs() > f32::EPSILON;
                self.scene.tension = t;
                changed
            }
            Msg::Select(r) => {
                let valid = self.scene.coils.get(r.helix as usize).is_some_and(|c| r.node.index() < c.dag.len());
                if valid && self.selected != Some(r) {
                    self.selected = Some(r);
                    true
                } else {
                    false
                }
            }
            Msg::ClearSelection => self.selected.take().is_some(),
        }
    }

    // Read accessors.
    #[must_use]
    pub fn level(&self) -> u8 {
        self.max_level
    }
    #[must_use]
    pub fn cursor(&self) -> f32 {
        self.cursor_u
    }
    #[must_use]
    pub fn selected(&self) -> Option<HelixRef> {
        self.selected
    }

    /// The base colour of a node by kind, tinted by status.
    fn node_color(kind: &Kind, status: Status) -> Color32 {
        let base = match kind {
            Kind::Idea => Color32::from_rgb(60, 200, 230),
            Kind::Task => Color32::from_rgb(90, 215, 130),
            Kind::Test => Color32::from_rgb(230, 190, 70),
            Kind::Crate => Color32::from_rgb(180, 130, 235),
            Kind::Other(_) => Color32::from_rgb(170, 175, 190),
        };
        match status {
            Status::Blocked => Color32::from_rgb(228, 86, 76),
            Status::Done => base,
            Status::Active => base.gamma_multiply(1.0),
            Status::Planned => base.gamma_multiply(0.7),
        }
    }

    /// Project a **world** helix point: pan the time axis (X) by the cursor, rotate the
    /// cross-section by `yaw`, orthographic to screen. Returns `(screen, depth)`; depth
    /// (rotated Z) drives size/brightness. A pure function (no egui state).
    fn project(world: [f32; 3], cam_x: f32, yaw: f32, center: Pos2, scale: f32) -> (Pos2, f32) {
        let x = world[0] - cam_x;
        let (s, c) = yaw.sin_cos();
        let y = world[1] * c + world[2] * s;
        let z = -world[1] * s + world[2] * c;
        (center + vec2(x * AXIS_COMPRESS, -y) * scale, z)
    }

    /// The scene's world-X span (the time-axis extent the cursor sweeps).
    fn world_x_span(lay: &SceneLayout) -> (f32, f32) {
        let mut lo = f32::INFINITY;
        let mut hi = f32::NEG_INFINITY;
        for coil in &lay.coils {
            for pl in &coil.placed {
                lo = lo.min(pl.point[0]);
                hi = hi.max(pl.point[0]);
            }
        }
        if lo.is_finite() { (lo, hi) } else { (0.0, 1.0) }
    }

    /// The number of timeline events whose `t` falls within a small window of the time
    /// cursor — what the traverse is "passing through" right now (folded into state).
    fn active_events(&self, cursor_u: f32) -> usize {
        let mut count = 0;
        for coil in &self.scene.coils {
            let (t_min, t_max) = coil.dag.t_span();
            let span = (t_max - t_min) as f32;
            for n in coil.dag.nodes() {
                for e in &n.events {
                    let eu = if span.abs() < f32::EPSILON { 0.0 } else { ((e.t - t_min) as f32 / span).clamp(0.0, 1.0) };
                    if (eu - cursor_u).abs() < 0.06 {
                        count += 1;
                    }
                }
            }
        }
        count
    }
}

/// The elastic easing the rubberband edges settle with — **facett-core's**
/// `easing::elastic` (the reuse seam). `f(1) = 1` ⇒ the wobble rests to zero.
fn elastic(t: f32) -> f32 {
    easing::elastic(t)
}

impl HelixView {
    /// The copyable text: the selected node's `label (system)` when a node is
    /// selected, else every node label across all coils, one per line.
    pub fn copy_text(&self) -> Option<String> {
        if let Some(r) = self.selected && let Some(n) = self.scene.coil(r.helix).and_then(|c| c.dag.node(r.node)) {
            return Some(if n.system.is_empty() { n.label.clone() } else { format!("{} ({})", n.label, n.system) });
        }
        let labels: Vec<String> =
            self.scene.coils.iter().flat_map(|c| c.dag.nodes().iter().map(|n| n.label.clone())).collect();
        (!labels.is_empty()).then(|| labels.join("\n"))
    }
}

// ── typed copy (§16) — read-only: selected node label or the label list ───────
impl CopySource for HelixView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        self.copy_text().map(ClipPayload::Text)
    }
}

impl Facet for HelixView {
    fn title(&self) -> &str {
        &self.title
    }

    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().selectable().resizable().copyable()
    }

    /// **FC-9 bridge.** The view is pure ([`render`](HelixView::render) paints + emits
    /// `Msg`s); FC-2: mutation flows ONLY through [`update`](HelixView::update). The
    /// immediate-mode trait just bridges the two — it does not itself mutate on paint.
    fn ui(&mut self, ui: &mut egui::Ui) {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        for m in self.render(ui) {
            self.update(m);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let lay = layout::layout_scene(&self.scene, &self.params);
        let clock = self.clock_s.unwrap_or(0.0);
        let cursor_u = self.cursor_for(clock);

        let coils: Vec<serde_json::Value> = self
            .scene
            .coils
            .iter()
            .zip(&lay.coils)
            .enumerate()
            .map(|(ci, (coil, hl))| {
                let nodes: Vec<serde_json::Value> = coil
                    .dag
                    .nodes()
                    .iter()
                    .zip(&hl.placed)
                    .map(|(n, pl)| {
                        serde_json::json!({
                            "id": n.id.0,
                            "label": n.label,
                            "system": n.system,
                            "kind": n.kind.key(),
                            "status": n.status.key(),
                            "t": n.t,
                            // the helix coordinates, AS DATA (the asserted picture):
                            "along": pl.along,
                            "theta": pl.theta,
                            "lane": pl.lane,
                            "events": n.events.len(),
                        })
                    })
                    .collect();
                serde_json::json!({
                    "helix": ci,
                    "wind": coil.wind,
                    "lanes": hl.lanes,
                    "node_count": coil.dag.len(),
                    "edge_count": coil.dag.edges_at_level(self.max_level).len(),
                    "nodes": nodes,
                })
            })
            .collect();

        // `winds_forward`: the helix angle advances monotonically with t on coil 0.
        let winds_forward = lay
            .coils
            .first()
            .map(|c| c.placed.windows(2).all(|w| w[1].theta >= w[0].theta - 1e-4))
            .unwrap_or(true);

        serde_json::json!({
            "title": self.title,
            "coil_count": self.scene.coils.len(),
            "link_count": self.scene.links.iter().filter(|l| l.level <= self.max_level).count(),
            "total_nodes": self.scene.coils.iter().map(|c| c.dag.len()).sum::<usize>(),
            "tension": self.scene.tension,
            "max_level": self.max_level,
            "scene_max_level": self.scene.max_level(),
            "cursor_u": cursor_u,
            "traverse": self.traverse,
            "spin": self.spin,
            "clock_s": self.clock_s,
            "active_events": self.active_events(cursor_u),
            "event_total": self.scene.coils.iter().map(|c| c.dag.event_count()).sum::<usize>(),
            "winds_forward": winds_forward,
            "selected": self.selected.map(|r| serde_json::json!({ "helix": r.helix, "node": r.node.0 })),
            "coils": coils,
        })
    }

    fn selection_json(&self) -> serde_json::Value {
        match self.selected.and_then(|r| self.scene.coil(r.helix).and_then(|c| c.dag.node(r.node)).map(|n| (r, n))) {
            Some((r, n)) => serde_json::json!({
                "helix": r.helix, "node": n.id.0, "label": n.label, "system": n.system, "kind": n.kind.key(), "t": n.t,
            }),
            None => serde_json::Value::Null,
        }
    }
}

impl HelixView {
    /// **FC-9 — the PURE view.** Paints the whole time-helix and *returns* the [`Msg`]s
    /// the frame produced (a checkbox/slider toggle, a node click), mutating **nothing**
    /// on `&self`. [`Facet::ui`] (and [`Elm::view`](facett_core::Elm::view)) apply the
    /// returned messages through [`update`](Self::update) — so the render pass is a pure
    /// function of the [`Model`](facett_core::Elm::Model) and never edits it mid-paint.
    ///
    /// The one-frame latency this introduces (an interaction is observed on frame *n*
    /// and folded into the model for frame *n+1*) is the standard, intended Elm tradeoff.
    pub fn render(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs = Vec::new();
        let th = theme(ui);
        // ── header controls (traverse / level / wind) ───────────────────────────
        ui.horizontal_wrapped(|ui| {
            let mut trav = self.traverse;
            if ui.checkbox(&mut trav, "traverse").changed() {
                msgs.push(Msg::ToggleTraverse(trav));
            }
            let mut spin = self.spin;
            if ui.checkbox(&mut spin, "spin").changed() {
                msgs.push(Msg::ToggleSpin(spin));
            }
            if self.scene.max_level() > 1 {
                ui.separator();
                ui.label(egui::RichText::new("dep level:").color(th.text_dim));
                let mut lvl = self.max_level;
                if ui.add(egui::Slider::new(&mut lvl, 1..=self.scene.max_level())).changed() {
                    msgs.push(Msg::SetLevel(lvl));
                }
            }
            if self.scene.coils.len() > 1 || self.scene.coils.first().is_some_and(|c| c.wind > 0.0) {
                ui.separator();
                ui.label(egui::RichText::new("wind:").color(th.text_dim));
                for i in 0..self.scene.coils.len() {
                    let mut w = self.scene.coils[i].wind;
                    if ui.add(egui::Slider::new(&mut w, 0.0..=1.0).text(format!("c{i}"))).changed() {
                        msgs.push(Msg::SetWind(i as u32, w));
                    }
                }
            }
        });
        ui.separator();

        // ── lay out the scene + resolve the animation clock ──────────────────────
        let lay = layout::layout_scene(&self.scene, &self.params);
        let ambient = ui.input(|i| i.time);
        // FC-7: with an injected clock everything is a pure function of it; without one
        // we follow ambient time and keep repainting for the live animation.
        let clock = self.clock_s.unwrap_or(ambient);
        if self.clock_s.is_none() && (self.traverse || self.spin || self.scene.tension > 0.0) {
            ui.ctx().request_repaint();
        }
        let cursor_u = self.cursor_for(clock);
        let yaw = self.yaw_for(clock);
        let phase = self.wobble_phase(clock);

        let (rect, _resp) = ui.allocate_exact_size(ui.available_size().max(vec2(320.0, 280.0)), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
        let center = rect.center();
        let scale = rect.size().min_elem() * 0.20;
        // A soft radial vignette so the dark ground has depth (the CPU stand-in for
        // the GPU bloom's ambient lift): a few faint, widening haloes toward center.
        for k in 0..4 {
            let rr = rect.size().min_elem() * (0.30 + k as f32 * 0.16);
            painter.circle_filled(center, rr, th.accent.gamma_multiply(0.012));
        }

        let (x_min, x_max) = Self::world_x_span(&lay);
        let cam_x = x_min + (x_max - x_min) * cursor_u;
        let half_w = (rect.width() * 0.42).max(1.0);

        // ── the TUBE the strands wrap around ─────────────────────────────────────
        // Faint cross-section RINGS (the ribs of the cylinder) + the glowing CENTRAL
        // TIME AXIS down the core. Drawn first, dim, as a backdrop so the bright
        // strands/nodes read as a 3D tunnel with time running through its middle —
        // the fix that turns the thin spring into a Doctor-Who time-vortex.
        for ci in 0..self.scene.coils.len() {
            let hl = &lay.coils[ci];
            let rib = th.text_dim.gamma_multiply(0.5);
            for ring in hl.rings(9, 40) {
                paint_spline(&painter, &ring, cam_x, yaw, center, scale, rib, 0.8);
            }
            // The timeline through the core of the axis (the strands wind around it).
            let spine = hl.time_axis(48);
            paint_spline(&painter, &spine, cam_x, yaw, center, scale, th.accent.gamma_multiply(0.7), 1.2);
        }

        // ── the HELIX BACKBONE — the glowing spiral tube itself ──────────────────
        // Sample each lane's continuous strand across the coil's t-span and paint it
        // as a multi-pass emissive ribbon. This is what makes the scene read as a
        // *helix* (before, only the dep edges drew, so the coil was invisible between
        // nodes). Depth-sorted brightness + a traverse-sweep lift near the "now" plane.
        for (ci, _coil) in self.scene.coils.iter().enumerate() {
            let hl = &lay.coils[ci];
            let steps = 88usize;
            for lane in 0..hl.lanes {
                let mut poly: Vec<[f32; 3]> = Vec::with_capacity(steps + 1);
                for s in 0..=steps {
                    let u = s as f32 / steps as f32;
                    poly.push(layout::place_in(u, lane, hl.lanes, &hl.params, &hl.frame).point);
                }
                paint_strand(&painter, &poly, cam_x, yaw, center, scale, strand_color(ci, lane, hl.lanes), half_w);
            }
        }

        // ── intra-coil rubberband edges (level-filtered) ─────────────────────────
        let tension = self.scene.tension;
        for (ci, coil) in self.scene.coils.iter().enumerate() {
            let hl = &lay.coils[ci];
            for e in coil.dag.edges_at_level(self.max_level) {
                let ctrl = hl.intra_controls(e.from, e.to, 6);
                if ctrl.len() < 2 {
                    continue;
                }
                let poly = layout::elastic_spline(&ctrl, tension, phase, elastic, 5);
                let col = Self::node_color(&coil.dag.node(e.to).map(|n| n.kind.clone()).unwrap_or(Kind::Idea), Status::Done);
                paint_spline(&painter, &poly, cam_x, yaw, center, scale, col, 1.4);
            }
        }
        // ── inter-helix (system-level) links — same soft rubberband ──────────────
        for link in &self.scene.links {
            if link.level > self.max_level {
                continue;
            }
            if let Some(ctrl) = lay.link_controls(link, 6, 1.1) {
                let poly = layout::elastic_spline(&ctrl, tension.max(0.4), phase, elastic, 5);
                paint_spline(&painter, &poly, cam_x, yaw, center, scale, Color32::from_rgb(235, 170, 90), 1.8);
            }
        }

        // ── nodes (depth-sorted) + AccessKit ─────────────────────────────────────
        let base_id = ui.id().with("helix-nodes");
        let mut draw: Vec<(usize, usize, Pos2, f32)> = Vec::new();
        for (ci, coil) in self.scene.coils.iter().enumerate() {
            for (ni, _n) in coil.dag.nodes().iter().enumerate() {
                let world = lay.coils[ci].placed[ni].point;
                let (p, z) = Self::project(world, cam_x, yaw, center, scale);
                draw.push((ci, ni, p, z));
            }
        }
        draw.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
        let mut clicked: Option<HelixRef> = None;
        for (ci, ni, p, z) in draw {
            let coil = &self.scene.coils[ci];
            let n = &coil.dag.nodes()[ni];
            // traverse-sweep proximity: 1 at the "now" plane, 0 at the pane edge —
            // a node blooms as the time cursor passes over it.
            let prox = (1.0 - ((p.x - center.x).abs() / half_w).min(1.0)).max(0.0);
            let flare = prox * prox; // sharpen the sweep
            let r = (3.5 + (z + 1.0) * 2.2) * (1.0 + 0.45 * flare);
            let col = Self::node_color(&n.kind, n.status);
            let bright = ((0.5 + (z + 1.0) * 0.25) * (1.0 + 0.5 * flare)).clamp(0.35, 1.0);
            let rf = HelixRef { helix: ci as u32, node: n.id };
            let is_sel = self.selected == Some(rf);
            // layered soft glow under the node (the CPU bloom stand-in) — lifts as
            // the traverse sweep crosses it.
            painter.circle_filled(p, r * 3.0, col.gamma_multiply(0.05 + 0.14 * flare));
            painter.circle_filled(p, r * 2.2, col.gamma_multiply(0.10 + 0.12 * flare));
            painter.circle_filled(p, r * 1.5, col.gamma_multiply(0.16));
            painter.circle_filled(p, r, col.gamma_multiply(bright));
            if is_sel {
                painter.circle_stroke(p, r + 3.0, Stroke::new(2.0_f32, th.accent));
            }
            // event marker pip if the node carries timeline events — pulses out as
            // the sweep crosses it (a CVE / delta announcing itself in time).
            if !n.events.is_empty() {
                let ring = Color32::from_rgb(235, 120, 90);
                painter.circle_stroke(p, r + 1.5, Stroke::new(1.0_f32, ring));
                if flare > 0.02 {
                    let pr = r + 2.0 + phase * 7.0 * flare;
                    painter.circle_stroke(p, pr, Stroke::new(1.5_f32, ring.gamma_multiply((1.0 - phase) * flare)));
                }
            }
            // AccessKit node (FC-4): queryable Button carrying the along-axis t as value.
            let nrect = egui::Rect::from_center_size(p, vec2(r * 2.0 + 6.0, r * 2.0 + 6.0));
            let sem = a11y::Semantics::button(format!("{} · {} · {}", n.label, n.system, n.kind.key()))
                .selected(is_sel)
                .value(n.t);
            let resp = a11y::node(ui, base_id, (ci, ni), Sense::click(), nrect, sem);
            if resp.clicked() {
                clicked = Some(rf);
            }
            // labels for small scenes only.
            if coil.dag.len() + self.scene.coils.len() <= 24 {
                painter.text(p + vec2(0.0, -(r + 3.0)), Align2::CENTER_BOTTOM, &n.label, FontId::proportional(10.0), th.text.gamma_multiply(bright));
            }
        }
        // FC-9: the click is COLLECTED, not applied — the toggle is computed against the
        // model this frame observes, and folded in by `update` on the next.
        if let Some(rf) = clicked {
            if self.selected == Some(rf) {
                msgs.push(Msg::ClearSelection);
            } else {
                msgs.push(Msg::Select(rf));
            }
        }

        // ── the time-cursor "now" plane — a glowing vertical sweep band ──────────
        let top = Pos2::new(center.x, rect.top() + 8.0);
        let bot = Pos2::new(center.x, rect.bottom() - 8.0);
        for &(w, a) in &[(11.0f32, 0.045f32), (5.0, 0.10), (1.5, 0.65)] {
            painter.line_segment([top, bot], Stroke::new(w, th.accent.gamma_multiply(a)));
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-helix::HelixView::ui",
            "ui_render",
            !self.scene.coils.is_empty(),
            &format!("coils={} cursor={cursor_u:.3} level={} tension={tension:.2}", self.scene.coils.len(), self.max_level),
        );

        msgs
    }
}

// ── FC-2 / FC-9 canonical `Elm` trait ────────────────────────────────────────
//
// `HelixView` already carries the Model/Msg/Effect/pure-view split, so it implements
// `facett_core::Elm` by delegating to its inherent methods — no behaviour change.
// NOTE: the hand-written `impl Facet` above is intentionally **KEPT** (not replaced by
// `impl_facet_via_elm!`) for the same reason `facett-security` keeps its own: this view
// publishes a **richer** `state_json` (per-node helix coordinates, `winds_forward`,
// active-events, level-filtered edge counts) than the macro's hardcoded
// `serde(state())`, and the macro cannot override `state_json` (it would be a duplicate
// method). The Elm impl + the pure `render` are what the migration needs; the macro is
// the shortcut only for components with no custom `state_json`.
impl facett_core::Elm for HelixView {
    /// `HelixView` **is** its own model — every field is round-trippable observable state.
    type Model = HelixView;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &HelixView {
        self
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        // Delegate to the inherent `update` (returns a `changed` dirty-bit); the helix
        // does no I/O, so there are never any effects to hand back.
        HelixView::update(self, msg);
        Vec::new()
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        self.render(ui)
    }
}

/// Project a world-space spline polyline and paint it as a connected emissive-ish
/// strip (the soft rounded edge). A free helper so the borrow of `self` stays simple.
#[allow(clippy::too_many_arguments)]
fn paint_spline(painter: &egui::Painter, poly: &[[f32; 3]], cam_x: f32, yaw: f32, center: Pos2, scale: f32, color: Color32, width: f32) {
    if poly.len() < 2 {
        return;
    }
    let pts: Vec<(Pos2, f32)> = poly.iter().map(|&w| HelixView::project(w, cam_x, yaw, center, scale)).collect();
    for seg in pts.windows(2) {
        let depth = (seg[0].1 + seg[1].1) * 0.5;
        let a = ((110.0 + depth * 90.0).clamp(30.0, 220.0)) as u8;
        painter.line_segment([seg[0].0, seg[1].0], Stroke::new(width, color.gamma_multiply(a as f32 / 255.0)));
    }
}

/// A soft neon strand tint for coil `ci`, lane `lane` — hues spread around a wheel so
/// multiple coils / lanes stay visually distinct without a host-supplied palette.
fn strand_color(ci: usize, lane: usize, lanes: usize) -> Color32 {
    const WHEEL: [Color32; 6] = [
        Color32::from_rgb(80, 200, 245),  // cyan
        Color32::from_rgb(150, 130, 245), // violet
        Color32::from_rgb(90, 225, 165),  // teal-green
        Color32::from_rgb(240, 170, 90),  // amber
        Color32::from_rgb(235, 110, 160), // magenta-rose
        Color32::from_rgb(120, 210, 110), // green
    ];
    WHEEL[(ci.wrapping_mul(lanes.max(1)).wrapping_add(lane)) % WHEEL.len()]
}

/// Paint a helix strand as a **multi-pass emissive ribbon** — a wide faint halo, a mid
/// glow, and a thin bright core — each depth- and sweep-attenuated so the tube glows
/// brighter as it nears the "now" plane at `center.x` (± `half_w`). The CPU stand-in
/// for the GPU bloom lane; the strand spans the coil's full t-span.
#[allow(clippy::too_many_arguments)]
fn paint_strand(painter: &egui::Painter, poly: &[[f32; 3]], cam_x: f32, yaw: f32, center: Pos2, scale: f32, color: Color32, half_w: f32) {
    if poly.len() < 2 {
        return;
    }
    let pts: Vec<(Pos2, f32)> = poly.iter().map(|&w| HelixView::project(w, cam_x, yaw, center, scale)).collect();
    for &(width, base_a) in &[(6.5f32, 0.05f32), (3.0, 0.11), (1.3, 0.55)] {
        for seg in pts.windows(2) {
            let depth = (seg[0].1 + seg[1].1) * 0.5;
            let db = (0.45 + (depth + 1.0) * 0.28).clamp(0.3, 1.1);
            let mx = (seg[0].0.x + seg[1].0.x) * 0.5;
            let prox = (1.0 - ((mx - center.x).abs() / half_w).min(1.0)).max(0.0);
            let a = (base_a * db * (0.7 + 0.6 * prox)).clamp(0.0, 1.0);
            painter.line_segment([seg[0].0, seg[1].0], Stroke::new(width, color.gamma_multiply(a)));
        }
    }
}

// ─── GPU bloom lane (feature `wgpu`) — reuse facett-graph3d's offscreen + bloom ────
#[cfg(feature = "wgpu")]
mod gpu {
    use super::*;
    use facett_graph3d::graph_gpu::{EdgeInstance, GraphScene, NodeInstance};

    impl HelixView {
        /// **Build the GPU-lane scene** — project every node + every (level-filtered)
        /// rubberband spline into a `w×h` px pane and emit instanced emissive node discs
        /// + edge filaments for [`facett_graph3d::graph_gpu`]. Node/edge radiance is
        /// scaled **above 1.0** so the additive HDR pass + bloom catch the glow. Pure
        /// projection (no egui), so it is unit-testable headless.
        #[must_use]
        pub fn gpu_scene(&self, w: f32, h: f32) -> GraphScene {
            let lay = layout::layout_scene(&self.scene, &self.params);
            let clock = self.clock_s.unwrap_or(0.0);
            let cursor_u = self.cursor_for(clock);
            let yaw = self.yaw_for(clock);
            let phase = self.wobble_phase(clock);
            let center = Pos2::new(w * 0.5, h * 0.5);
            let scale = w.min(h) * 0.20;
            let (x_min, x_max) = Self::world_x_span(&lay);
            let cam_x = x_min + (x_max - x_min) * cursor_u;

            let radiance = |c: Color32, depth: f32, mul: f32| -> [f32; 4] {
                let lin = |b: u8| {
                    let s = b as f32 / 255.0;
                    s * s
                };
                let dbright = (0.55 + (depth + 1.0) * 0.35).clamp(0.4, 1.3);
                let k = self.emissive * dbright * mul;
                [lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
            };

            let mut nodes = Vec::new();
            for (ci, coil) in self.scene.coils.iter().enumerate() {
                for (ni, n) in coil.dag.nodes().iter().enumerate() {
                    let (p, z) = Self::project(lay.coils[ci].placed[ni].point, cam_x, yaw, center, scale);
                    nodes.push(NodeInstance {
                        center: [p.x, p.y],
                        radius: 3.0 + (z + 1.0) * 2.5,
                        _pad: 0.0,
                        color: radiance(Self::node_color(&n.kind, n.status), z, 1.0),
                    });
                }
            }

            let mut edges = Vec::new();
            let mut push_spline = |poly: &[[f32; 3]], col: Color32| {
                let pts: Vec<(Pos2, f32)> = poly.iter().map(|&wld| Self::project(wld, cam_x, yaw, center, scale)).collect();
                for seg in pts.windows(2) {
                    let depth = (seg[0].1 + seg[1].1) * 0.5;
                    edges.push(EdgeInstance {
                        a: [seg[0].0.x, seg[0].0.y],
                        b: [seg[1].0.x, seg[1].0.y],
                        width: 1.5,
                        _pad: 0.0,
                        color: radiance(col, depth, 0.6),
                    });
                }
            };
            for (ci, coil) in self.scene.coils.iter().enumerate() {
                for e in coil.dag.edges_at_level(self.max_level) {
                    let ctrl = lay.coils[ci].intra_controls(e.from, e.to, 6);
                    if ctrl.len() < 2 {
                        continue;
                    }
                    let poly = layout::elastic_spline(&ctrl, self.scene.tension, phase, elastic, 5);
                    let col = Self::node_color(&coil.dag.node(e.to).map(|n| n.kind.clone()).unwrap_or(Kind::Idea), Status::Done);
                    push_spline(&poly, col);
                }
            }
            for link in &self.scene.links {
                if link.level > self.max_level {
                    continue;
                }
                if let Some(ctrl) = lay.link_controls(link, 6, 1.1) {
                    let poly = layout::elastic_spline(&ctrl, self.scene.tension.max(0.4), phase, elastic, 5);
                    push_spline(&poly, Color32::from_rgb(235, 170, 90));
                }
            }

            GraphScene { background: [0.01, 0.01, 0.02, 1.0], nodes, edges }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn typed_copy_is_selected_node_or_all_node_labels() {
        use facett_core::clip::{ClipKind, CopySource};
        let v = HelixView::demo();
        // No selection -> every node label across coils (multi-line).
        let p = v.copy_payload().expect("a demo scene copies");
        assert_eq!(p.kind(), ClipKind::Text);
        assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
        assert_eq!(p.as_text().lines().count() as u64, v.state_json()["total_nodes"].as_u64().unwrap());
    }

    #[test]
    fn demo_builds_a_multi_coil_scene() {
        let v = HelixView::demo();
        let st = v.state_json();
        assert_eq!(st["coil_count"], 2);
        assert!(st["total_nodes"].as_u64().unwrap() >= 6);
        assert_eq!(st["winds_forward"], true);
        assert!(st["link_count"].as_u64().unwrap() >= 1, "the cross-coil system link is present");
    }

    #[test]
    fn seek_moves_the_cursor_and_stops_traverse() {
        let mut v = HelixView::demo();
        assert!(v.update(Msg::Seek(0.5)));
        assert!((v.cursor() - 0.5).abs() < 1e-6);
        assert_eq!(v.state_json()["traverse"], false);
        assert_eq!(v.state_json()["cursor_u"], 0.5);
    }

    #[test]
    fn injected_clock_makes_the_traverse_deterministic() {
        let v = HelixView::demo().with_clock(2.0);
        let a = v.state_json()["cursor_u"].as_f64().unwrap();
        let b = HelixView::demo().with_clock(2.0).state_json()["cursor_u"].as_f64().unwrap();
        assert_eq!(a, b, "same state + clock ⇒ same cursor (FC-7)");
        let c = HelixView::demo().with_clock(5.0).state_json()["cursor_u"].as_f64().unwrap();
        assert_ne!(a, c, "a different clock advances the traverse");
    }

    #[test]
    fn level_filter_thins_the_dependency_edges() {
        let mut v = HelixView::demo();
        let full = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
        v.update(Msg::SetLevel(1));
        let direct = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
        assert!(direct < full, "direct-only ({direct}) shows fewer edges than all levels ({full})");
    }

    #[test]
    fn winding_a_coil_is_observable() {
        let mut v = HelixView::demo();
        assert_eq!(v.state_json()["coils"][1]["wind"], 0.0);
        assert!(v.update(Msg::SetWind(1, 1.0)));
        assert_eq!(v.state_json()["coils"][1]["wind"], 1.0);
    }

    #[test]
    fn nodes_carry_helix_coordinates_as_data() {
        let v = HelixView::demo();
        let st = v.state_json();
        for coil in st["coils"].as_array().unwrap() {
            let nodes = coil["nodes"].as_array().unwrap();
            // along-axis t is monotone in node t (sorted insertion), lanes are indices.
            for n in nodes {
                assert!(n["along"].is_number() && n["theta"].is_number());
                assert!(n["lane"].as_u64().is_some());
            }
        }
    }

    #[test]
    fn selection_round_trips_through_msg() {
        let mut v = HelixView::demo();
        let r = HelixRef { helix: 0, node: NodeId(0) };
        assert!(v.update(Msg::Select(r)));
        assert_eq!(v.selected(), Some(r));
        assert_eq!(v.selection_json()["helix"], 0);
        assert!(v.update(Msg::ClearSelection));
        assert!(v.selected().is_none());
    }

    // ── FC-2 → FC-3 via the headless Elm harness (no egui / no GPU) ──────────────
    //
    // `facett_core::harness::{drive,snapshot}` feed a `Vec<Msg>` through `Elm::update`
    // and read back the resulting `Model` (= `HelixView`) — exactly FC-2 (writable Msg
    // surface) proven as a property against FC-3 (observable state), with `view()` (the
    // only egui-touching part) never called. These run anywhere, deterministically.
    use facett_core::harness::{drive, snapshot};

    #[test]
    fn harness_seek_stops_traverse_and_moves_cursor() {
        let mut v = HelixView::demo();
        assert!(v.state_json()["traverse"].as_bool().unwrap(), "demo auto-traverses");
        let snap = snapshot(&mut v, [Msg::Seek(0.5)]);
        assert!((snap.cursor() - 0.5).abs() < 1e-6, "Seek folds into the cursor");
        assert_eq!(snap.state_json()["traverse"], false, "Seek stops the auto-traverse");
        assert_eq!(snap.state_json()["cursor_u"], 0.5);
    }

    #[test]
    fn harness_level_and_wind_fold_into_state() {
        let mut v = HelixView::demo();
        let full = v.state_json()["coils"][0]["edge_count"].as_u64().unwrap();
        let snap = snapshot(&mut v, [Msg::SetLevel(1), Msg::SetWind(1, 1.0)]);
        assert_eq!(snap.level(), 1, "SetLevel(1) is observable as the dep-level filter");
        assert!(
            snap.state_json()["coils"][0]["edge_count"].as_u64().unwrap() < full,
            "direct-only thins the edges",
        );
        assert_eq!(snap.state_json()["coils"][1]["wind"], 1.0, "SetWind reels the coil onto its pin");
    }

    #[test]
    fn harness_selection_round_trips_through_msgs() {
        let mut v = HelixView::demo();
        let r = HelixRef { helix: 0, node: NodeId(0) };
        let snap = snapshot(&mut v, [Msg::Select(r)]);
        assert_eq!(snap.selected(), Some(r), "Select folds the ref into the model");
        // The same component, now cleared.
        let snap = snapshot(&mut v, [Msg::ClearSelection]);
        assert!(snap.selected().is_none(), "ClearSelection empties it");
    }

    #[test]
    fn harness_tick_advances_the_traverse_cursor() {
        // With an injected clock the traverse is a pure function of it: ticking the clock
        // forward advances the observable cursor (FC-7 determinism through the Msg surface).
        let mut v = HelixView::demo().with_clock(0.0);
        let c0 = v.state_json()["cursor_u"].as_f64().unwrap();
        let snap = snapshot(&mut v, [Msg::Tick(2.0)]);
        let c1 = snap.state_json()["cursor_u"].as_f64().unwrap();
        assert!(c1 > c0, "Tick advances the traverse cursor along the time axis ({c0} → {c1})");
    }

    #[test]
    fn harness_drive_reports_no_effects_fc8() {
        // FC-8: the helix does no I/O — every Msg produces zero effects.
        let mut v = HelixView::demo();
        let effects = drive(
            &mut v,
            [Msg::Tick(1.0), Msg::Seek(0.3), Msg::SetLevel(1), Msg::SetTension(1.5), Msg::ClearSelection],
        );
        assert!(effects.is_empty(), "an idle-by-construction component yields no Effects");
    }

    #[test]
    fn model_round_trips_through_serde_fc3() {
        // FC-3: the driven Model serialises and deserialises back to an equal value.
        let mut v = HelixView::demo();
        let snap = snapshot(&mut v, [Msg::Seek(0.25), Msg::SetWind(1, 0.8), Msg::SetTension(1.2), Msg::ToggleSpin(false)]);
        let json = serde_json::to_string(&snap).expect("Model serialises");
        let back: HelixView = serde_json::from_str(&json).expect("Model deserialises");
        assert_eq!(back, snap, "the observable Model round-trips through serde");
    }

    #[test]
    fn proptest_style_msg_sequences_keep_state_wellformed() {
        // Dependency-free deterministic LCG: throw many random Msg sequences at fresh
        // views and assert the model stays well-formed — no panic, `winds_forward` holds,
        // levels stay in range, and the Model round-trips through serde (contract §5.1).
        let mut rng: u64 = 0x1234_5678_9ABC_DEF0;
        let mut next = || {
            rng ^= rng << 13;
            rng ^= rng >> 7;
            rng ^= rng << 17;
            rng
        };
        for _ in 0..200 {
            let mut v = HelixView::demo();
            let scene_max = v.state_json()["scene_max_level"].as_u64().unwrap() as u8;
            let len = (next() % 10) as usize;
            let msgs: Vec<Msg> = (0..len)
                .map(|_| match next() % 8 {
                    0 => Msg::Tick((next() % 100) as f64 / 10.0),
                    1 => Msg::Seek((next() % 100) as f32 / 100.0),
                    2 => Msg::ToggleTraverse(next() % 2 == 0),
                    3 => Msg::ToggleSpin(next() % 2 == 0),
                    4 => Msg::SetLevel((next() % 4) as u8),
                    5 => Msg::SetWind((next() % 2) as u32, (next() % 100) as f32 / 100.0),
                    6 => Msg::SetTension((next() % 300) as f32 / 100.0),
                    _ => Msg::ClearSelection,
                })
                .collect();
            let snap = snapshot(&mut v, msgs);
            let st = snap.state_json();
            // Invariant 1: the level filter stays clamped into the valid range.
            let lvl = st["max_level"].as_u64().unwrap() as u8;
            assert!((1..=scene_max.max(1)).contains(&lvl), "level {lvl} out of range");
            // Invariant 2: the helix still winds forward through time.
            assert_eq!(st["winds_forward"], true, "the helix must keep winding forward");
            // Invariant 3: the serde round-trip is a STABLE FIXPOINT (FC-3). We assert a
            // fixpoint rather than raw `back == snap` because an accumulated `Tick` can
            // land on an f64 like 15.399999999999999 whose shortest decimal serde_json
            // re-parses to the adjacent bits (15.4) — a one-time normalisation, after
            // which the value is stable. Both directions must agree once normalised.
            let back: HelixView = serde_json::from_str(&serde_json::to_string(&snap).unwrap()).unwrap();
            let back2: HelixView = serde_json::from_str(&serde_json::to_string(&back).unwrap()).unwrap();
            assert_eq!(back, back2, "the observable Model is a serde round-trip fixpoint");
        }
    }
}