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

nordisk/facett

facett-jobview/src/lib.rs

//! **facett-jobview** — a job/task-list viewer, in the spirit of a desktop task
//! manager: one row per long-running job (kind · target · status · elapsed ·
//! detail), themeable, sortable, with a kind filter and a live counts header.
//! It renders a **hierarchical, streaming job tree** — parents fold/unfold, any
//! row selects, and children stream in over successive refreshes.
//!
//! **Host-agnostic by construction.** This crate knows nothing about any
//! particular backend (no nornir, no warehouse, no gRPC). The host hands it a
//! `Vec<`[`JobEntry`]`>` each refresh (push, via [`JobList::set_jobs`]) or
//! implements [`JobSource`] for the list to pull (via [`JobList::set_source`]).
//! [`JobEntry`] is the one simple datatype the list renders, carrying free-form
//! `meta` key/values so a host can attach whatever extra info it has.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`JobState`]** — the complete observable, serializable, round-trippable
//!   state: the job rows plus the view controls (sort key/direction, kind filter,
//!   the selected row, and the folded-parent set). Expand/collapse (`folded`) and
//!   selection (`selected`) key on **stable job ids** (FC-5), never tree-position
//!   indices — the tree streams and reorders, so a positional key would drift.
//! - **[`Msg`] + [`JobList::update`]** — the single mutation path (FC-2): fold a
//!   parent, select a row, click a sort header, set the kind filter, refresh the
//!   rows. The same gesture the canvas drives, so live == headless.
//! - **[`JobList::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   tree, and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//!   The only `&self`-interior mutation is the ephemeral scroll/fade animation
//!   cache (FC-7), deliberately kept OUT of the observable Model.
//! - **Rich `state_json`** — the fold-aware display *tree* (parents then children,
//!   `depth` + ASCII branch guides), the hierarchy summary, and the motion state a
//!   headless driver reads are *derived* from the Model, so the macro is invoked in
//!   **form 3** (`custom_state_json`) to publish those keys (which facett-demo's
//!   mega_matrix reads for the `"jobs"` cell) instead of a plain `serde(state())`.

use std::cell::{Cell, RefCell};

use facett_core::effects::FadeTrack;
use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
use facett_core::{Elm, FacetCaps, Theme, theme};
use serde::{Deserialize, Serialize};

/// Hover / selection cross-fade length (seconds) for a row highlight.
const ROW_FADE_SECS: f32 = 0.12;
/// Fold expand/collapse tween length (seconds) — the subtree-height animation.
const FOLD_SECS: f32 = 0.16;

// Re-export the canonical Facet trait so a host can drive [`JobList`] (call
// `.ui(..)` / `.state_json()`) without taking its own facett-core dependency.
pub use facett_core::Facet;

/// Lifecycle status of a job. The list colours each row by this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
    /// Accepted, not started yet.
    Queued,
    /// In flight.
    Running,
    /// Finished successfully.
    Done,
    /// Finished with an error (or abandoned).
    Failed,
}

impl JobStatus {
    /// Stable lowercase tag (matches a host's `status` string and `state_json`).
    pub fn as_str(self) -> &'static str {
        match self {
            JobStatus::Queued => "queued",
            JobStatus::Running => "running",
            JobStatus::Done => "done",
            JobStatus::Failed => "failed",
        }
    }

    /// Parse a host status string; unknown ⇒ [`JobStatus::Queued`].
    pub fn parse(s: &str) -> Self {
        match s {
            "running" => JobStatus::Running,
            "done" => JobStatus::Done,
            "failed" => JobStatus::Failed,
            _ => JobStatus::Queued,
        }
    }

    /// `true` while the job may still change (queued or running).
    pub fn is_active(self) -> bool {
        matches!(self, JobStatus::Queued | JobStatus::Running)
    }

    /// A short status glyph for the row.
    pub fn glyph(self) -> &'static str {
        match self {
            JobStatus::Queued => "…",
            JobStatus::Running => "▶",
            JobStatus::Done => "✓",
            JobStatus::Failed => "✗",
        }
    }

    /// Row colour — semantic ongoing/done/failed traffic-light, with queued dim:
    /// running = 🟡 amber-yellow (ongoing), done = 🟢 the theme's `point` (green-ish),
    /// failed = 🔴 a fixed warm red, queued = dim. Running is a fixed yellow rather
    /// than the theme accent so "in progress" reads unambiguously against any palette.
    pub fn color(self, th: &Theme) -> egui::Color32 {
        match self {
            JobStatus::Queued => th.text_dim,
            JobStatus::Running => egui::Color32::from_rgb(232, 193, 42),
            JobStatus::Done => th.point,
            JobStatus::Failed => facett_core::look::RED,
        }
    }
}

/// One job row — the simple datatype the list renders. Owned + cheap to clone so
/// a host can rebuild the list each refresh without lifetime juggling.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JobEntry {
    /// Stable id (used as the row key / selection identity).
    pub id: String,
    /// Op family, e.g. `"docs_book_svg"`, `"bench_run"` — the host's vocabulary.
    pub kind: String,
    /// What the job acts on (repo / workspace / artifact).
    pub target: String,
    pub status: JobStatus,
    /// Start time, microseconds since the unix epoch (drives newest-first sort).
    pub started_micros: i64,
    /// Wall-clock duration once terminal; `None` while running/queued.
    pub elapsed_ms: Option<i64>,
    /// One-line detail (args / counts / error), shown in the last column.
    pub detail: String,
    /// Arbitrary extra key/values the host wants to surface (shown on the
    /// selected row's expanded line). Empty is fine.
    pub meta: Vec<(String, String)>,
    /// **Hierarchy** — the id of this job's PARENT job, when it is a sub-job of a
    /// larger op (`None` = a top-level job). A host emitting a multi-phase /
    /// multi-member op (e.g. a workspace *populate*: one parent job + one child
    /// per member clone + per build stage) sets this to the parent's [`id`]; the
    /// list then renders the parent as a FOLDABLE row whose children appear
    /// indented underneath when unfolded. Host-agnostic: korp/njord/nornir all
    /// reuse it — a child is simply any entry whose `parent_id` matches another
    /// entry's `id`. Children stream in over successive [`set_jobs`](JobList::set_jobs)
    /// refreshes as the op progresses (running → done), so the tree fills live.
    pub parent_id: Option<String>,
}

impl JobEntry {
    /// Minimal constructor; fill the rest with struct-update syntax.
    pub fn new(id: impl Into<String>, kind: impl Into<String>, status: JobStatus) -> Self {
        Self {
            id: id.into(),
            kind: kind.into(),
            target: String::new(),
            status,
            started_micros: 0,
            elapsed_ms: None,
            detail: String::new(),
            meta: Vec::new(),
            parent_id: None,
        }
    }

    /// `true` when this entry is a sub-job (has a parent). Convenience over
    /// `parent_id.is_some()`.
    pub fn is_child(&self) -> bool {
        self.parent_id.is_some()
    }

    /// Stamp a **determinate** progress fraction (clamped to `[0,1]`) so the row
    /// draws a filled progress meter to that fill. Carried in [`meta`](Self::meta)
    /// under [`PROGRESS_META_KEY`] rather than a dedicated struct field: this keeps
    /// every `JobEntry { .. }` construction site in every host valid (adding a
    /// non-defaulted field would break full-struct-literal callers), and `meta` is
    /// exactly the "arbitrary extra key/values the host wants to surface" channel.
    /// Re-stamping replaces any prior progress marker (idempotent).
    pub fn with_progress(mut self, fraction: f32) -> Self {
        self.set_progress_meta(format!("{:.4}", fraction.clamp(0.0, 1.0)));
        self
    }

    /// Stamp an **indeterminate** progress marker (running, no measurable total) so
    /// the row draws an animated sweeping meter — the honest "in flight, unmeasured"
    /// state, never a fake full bar. See [`with_progress`](Self::with_progress).
    pub fn with_indeterminate_progress(mut self) -> Self {
        self.set_progress_meta(PROGRESS_INDETERMINATE.to_string());
        self
    }

    /// Stamp progress from a `current`/`total` count (a "3/10 tasks" / bytes form).
    /// `total == 0` ⇒ indeterminate. See [`with_progress`](Self::with_progress).
    pub fn with_progress_ratio(self, current: u64, total: u64) -> Self {
        if total == 0 {
            self.with_indeterminate_progress()
        } else {
            self.with_progress(current as f32 / total as f32)
        }
    }

    /// Stamp the **phase** a running job reports — `cloning`, `indexing`, `embedding`:
    /// *what* it is doing, next to how far along it is. Carried under
    /// [`PHASE_META_KEY`]; see that constant for why this writer is shared.
    ///
    /// `None`, empty and whitespace-only all stamp NOTHING and clear any prior phase.
    /// That is the load-bearing half: a key present with a blank value paints an empty
    /// cell where "no phase reported" is the fact, and a host mapping
    /// `Option<String> → String` with `unwrap_or_default()` would produce exactly that.
    /// Idempotent — a producer republishing on every phase change never accumulates
    /// entries.
    pub fn with_phase(mut self, phase: Option<&str>) -> Self {
        self.meta.retain(|(k, _)| k != PHASE_META_KEY);
        if let Some(p) = phase.map(str::trim).filter(|p| !p.is_empty()) {
            self.meta.push((PHASE_META_KEY.to_string(), p.to_string()));
        }
        self
    }

    /// The phase this row reports, if any — the read side of [`with_phase`](Self::with_phase).
    /// `None` when no phase was stamped (never `Some("")`).
    pub fn phase(&self) -> Option<&str> {
        self.meta
            .iter()
            .find(|(k, _)| k == PHASE_META_KEY)
            .map(|(_, v)| v.as_str())
            .filter(|v| !v.is_empty())
    }

    /// Replace the [`PROGRESS_META_KEY`] meta entry with `value` (private helper for
    /// the `with_progress*` builders — keeps the meta channel single-slotted).
    fn set_progress_meta(&mut self, value: String) {
        self.meta.retain(|(k, _)| k != PROGRESS_META_KEY);
        self.meta.push((PROGRESS_META_KEY.to_string(), value));
    }

    /// Stamp a **step meter** ("step 3 of 4", one segment per step) onto this row, under
    /// [`STEPS_META_KEY`]. Re-stamping replaces any prior meter (idempotent), so a
    /// producer republishing on every step change never accumulates entries.
    ///
    /// Independent of [`with_progress`](Self::with_progress): a row may carry a step
    /// meter, a fraction meter, both, or neither.
    pub fn with_steps(mut self, meter: &StepMeter) -> Self {
        let encoded = serde_json::to_string(meter).unwrap_or_default();
        self.meta.retain(|(k, _)| k != STEPS_META_KEY);
        self.meta.push((STEPS_META_KEY.to_string(), encoded));
        self
    }

    /// The **step meter** this row should draw, decoded from [`meta`](Self::meta)'s
    /// [`STEPS_META_KEY`] entry.
    ///
    /// `None` when there is no entry, when it does not parse, or when it parses to zero
    /// steps — an unparseable meter draws nothing rather than an empty bar that would
    /// read as "0 of 0 done".
    pub fn steps_meter(&self) -> Option<StepMeter> {
        let (_, v) = self.meta.iter().find(|(k, _)| k == STEPS_META_KEY)?;
        let m: StepMeter = serde_json::from_str(v).ok()?;
        (!m.steps.is_empty()).then_some(m)
    }

    /// The **honest** progress meter this row should draw, decoded from
    /// [`meta`](Self::meta)'s [`PROGRESS_META_KEY`] entry:
    ///
    /// - a parseable fraction (`"0.4"`) or ratio (`"3/10"`) ⇒
    ///   [`ProgressMeter::Determinate`] (filled to that fraction, clamped);
    /// - the literal `"indeterminate"` (or a `"n/0"` ratio) ⇒
    ///   [`ProgressMeter::Indeterminate`] (an animated sweep);
    /// - **no** progress marker ⇒ [`ProgressMeter::Indeterminate`] while the job is
    ///   [`JobStatus::Running`] (it IS in flight, we simply have no measure), else
    ///   [`ProgressMeter::None`] (no bar at all — never a fake full one).
    pub fn progress_meter(&self) -> ProgressMeter {
        if let Some((_, v)) = self.meta.iter().find(|(k, _)| k == PROGRESS_META_KEY) {
            let v = v.trim();
            if v.eq_ignore_ascii_case(PROGRESS_INDETERMINATE) {
                return ProgressMeter::Indeterminate;
            }
            if let Ok(f) = v.parse::<f32>() {
                return ProgressMeter::Determinate(f.clamp(0.0, 1.0));
            }
            if let Some((a, b)) = v.split_once('/')
                && let (Ok(a), Ok(b)) = (a.trim().parse::<f64>(), b.trim().parse::<f64>())
            {
                return if b > 0.0 {
                    ProgressMeter::Determinate((a / b).clamp(0.0, 1.0) as f32)
                } else {
                    ProgressMeter::Indeterminate
                };
            }
        }
        // No explicit progress marker: a running job honestly reads as indeterminate,
        // a queued/terminal job draws nothing.
        if self.status == JobStatus::Running {
            ProgressMeter::Indeterminate
        } else {
            ProgressMeter::None
        }
    }
}

/// The [`meta`](JobEntry::meta) key a job's progress fraction/marker rides under, so
/// a per-row progress meter can be plumbed WITHOUT a new [`JobEntry`] struct field
/// (which would break every full-struct-literal construction site in the hosts).
/// The value is either a fraction in `[0,1]` (`"0.4"`), a `"current/total"` ratio
/// (`"3/10"`), or the literal [`PROGRESS_INDETERMINATE`]. A jera host maps its
/// `JobReports::progress_meta()` straight into this slot.
pub const PROGRESS_META_KEY: &str = "progress";

/// The [`PROGRESS_META_KEY`] value that marks an **indeterminate** meter (running,
/// no measurable total) — an animated sweep, never a fake full bar.
pub const PROGRESS_INDETERMINATE: &str = "indeterminate";

/// The [`JobEntry::meta`] key a job's **phase** rides under — *what* a running job is
/// doing right now (`cloning`, `indexing`, `embedding`), as opposed to how far along it
/// is ([`PROGRESS_META_KEY`]) or which of its named steps it is on ([`STEPS_META_KEY`]).
/// Same no-new-struct-field channel, same reason: a non-defaulted field breaks every
/// full-struct-literal construction site in every host.
///
/// **Declared here because it had TWO independent droppers.** A jera `JobRecord` has
/// carried `phase` durably for a while — the daemon's report mirror writes it, and
/// jera's `upsert` grew a preserve limb so a `finish()` cannot erase it — and *both*
/// host mappers that turn a `JobRecord` into a [`JobEntry`]
/// (`dwarves_embed::jobsfleet::entry_from_record` and `nornir::viz::nornir_tab::job_entry`)
/// simply did not copy the field. Two hosts, one field, zero surfacing: the write side
/// was fixed twice while the read side could never show the result. The key and the
/// [`JobEntry::with_phase`] writer live here so there is one spelling and one
/// empty-value rule for every host, instead of two `meta.push(("phase", …))` twins that
/// agree until one of them does not.
pub const PHASE_META_KEY: &str = "phase";

/// How a job row's progress meter should render — the honest, decoded outcome of
/// [`JobEntry::progress_meter`]. `Determinate` fills a bar to its fraction;
/// `Indeterminate` sweeps a highlight (running, no measure); `None` draws nothing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ProgressMeter {
    /// A filled bar at this completion fraction (`∈[0,1]`, already clamped).
    Determinate(f32),
    /// An animated sweeping bar — in flight, no measurable fraction.
    Indeterminate,
    /// No meter at all (queued/terminal with no reported progress).
    None,
}

/// The filled width (px) of the **determinate** portion of a progress meter
/// `track_w` px wide — the exact value the paint path uses for the fill rect, and a
/// pure, value-assertable proxy for "the bar is drawn to N%". `Indeterminate`/`None`
/// have no determinate fill (`0.0`).
pub fn meter_fill_width(meter: ProgressMeter, track_w: f32) -> f32 {
    match meter {
        ProgressMeter::Determinate(f) => track_w * f.clamp(0.0, 1.0),
        ProgressMeter::Indeterminate | ProgressMeter::None => 0.0,
    }
}

// ───────────────────────────────────────────────────────────────────────────────
// The STEP meter — "step 3 of 4", one segment per step
// ───────────────────────────────────────────────────────────────────────────────

/// The [`JobEntry::meta`] key a job's **step meter** rides under — the same
/// no-new-struct-field channel [`PROGRESS_META_KEY`] uses, for the same reason (a new
/// non-defaulted field breaks every full-struct-literal construction site in every host).
///
/// The value is a [`StepMeter`] as compact JSON. A producer that runs a job in named
/// steps (a collector's convert → tables → relations → tiles chain) stamps it with
/// [`JobEntry::with_steps`]; a producer that only knows a fraction keeps using
/// [`PROGRESS_META_KEY`]. The two are independent: a row may carry either, both or
/// neither.
pub const STEPS_META_KEY: &str = "steps";

/// Where one step of a job's **current run** stands.
///
/// Deliberately not [`JobStatus`]: that is the status of the *job*, and a four-step job
/// is `Running` for the whole of steps 1–4. This says which of those four is happening,
/// which is the only thing a *"step 3 of 4"* bar can be drawn from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepPhase {
    /// Not reached yet in this run.
    #[default]
    Pending,
    /// Being worked on right now. **At most one per run.**
    Active,
    /// Finished in this run.
    Done,
    /// The run died here.
    Failed,
}

impl StepPhase {
    /// Stable lowercase key — the `state_json` encoding a robot asserts on.
    pub fn key(self) -> &'static str {
        match self {
            StepPhase::Pending => "pending",
            StepPhase::Active => "active",
            StepPhase::Done => "done",
            StepPhase::Failed => "failed",
        }
    }

    /// The upper-case tag a hover line shows — the shouting half of [`key`](Self::key),
    /// and the only place that spelling is produced.
    pub fn tag(self) -> &'static str {
        match self {
            StepPhase::Pending => "PENDING",
            StepPhase::Active => "ACTIVE",
            StepPhase::Done => "DONE",
            StepPhase::Failed => "FAILED",
        }
    }

    /// Parse a [`key`](Self::key) back; unknown text ⇒ [`StepPhase::Pending`], never a
    /// panic and never an optimistic `Done`.
    pub fn parse(s: &str) -> StepPhase {
        match s.trim().to_ascii_lowercase().as_str() {
            "active" => StepPhase::Active,
            "done" => StepPhase::Done,
            "failed" => StepPhase::Failed,
            _ => StepPhase::Pending,
        }
    }

    /// The segment colour. `Done` reads with the row's done-green, `Active` with the
    /// same amber [`JobStatus::Running`] uses, `Failed` with facett's warm red, and
    /// `Pending` is a dim track — so a half-finished chain is legible at a glance
    /// without a legend.
    pub fn color(self, th: &Theme) -> egui::Color32 {
        match self {
            StepPhase::Pending => th.edge.linear_multiply(0.55),
            StepPhase::Active => JobStatus::Running.color(th),
            StepPhase::Done => th.point,
            StepPhase::Failed => facett_core::look::RED,
        }
    }
}

/// One step of a job's run — everything a segment and its tooltip need, and nothing a
/// consumer has to re-derive.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StepCell {
    /// Stable short key (`"a"`, `"b"`, `"tile"`) — the hover prefix and the
    /// `state_json` id.
    pub key: String,
    /// The human label (`"relation clip + geometry assembly"`).
    pub label: String,
    /// Where this step stands.
    pub phase: StepPhase,
    /// **The tooltip line for this step, written by the producer** — carried rather than
    /// composed here so the words a human reads have exactly one author. See
    /// [`StepMeter`]'s docs.
    #[serde(default)]
    pub line: String,
    /// **A GENUINE `(current, total)` count for this step, or `None`.**
    ///
    /// `None` is the honest default and must stay cheap to choose: a producer that has
    /// no counter for a step says nothing, and [`meter`](Self::meter) then renders the
    /// step [`ProgressMeter::Indeterminate`] while it is active. **Never synthesise a
    /// fraction to fill this in** — an invented percentage is indistinguishable from a
    /// measured one on screen, which makes every measured one worthless.
    ///
    /// Where a real number exists it belongs here and the step reads
    /// [`ProgressMeter::Determinate`]. Two worked cases:
    ///
    /// - **dwarves populate, step "clone"** — the warehouse's `clone_events` writes one
    ///   row per member attempt with ok/failed, so `Some((n_ok, 19))` is measured, not
    ///   estimated.
    /// - **korp's region convert** — osm-katana logs phase transitions and no counter
    ///   korp reads, so every step there is `None` today. The step bar still says which
    ///   of the four is running, which was the whole ask; it does not pretend to know
    ///   how far into one it is.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub count: Option<(u64, u64)>,
}

impl StepCell {
    /// Build a cell, deriving [`line`](Self::line) from the parts when the producer has
    /// no better sentence of its own.
    pub fn new(key: impl Into<String>, label: impl Into<String>, phase: StepPhase) -> StepCell {
        let key = key.into();
        let label = label.into();
        let line = format!("{key}  {label}  {}", phase.key().to_ascii_uppercase());
        StepCell { key, label, phase, line, count: None }
    }

    /// Attach a **measured** `(current, total)` to this step. Only call this with a
    /// number the producer actually counted — see [`count`](Self::count).
    pub fn with_count(mut self, current: u64, total: u64) -> Self {
        self.count = Some((current, total));
        self
    }

    /// **How much of THIS step is known to be done** — expressed in the crate's existing
    /// [`ProgressMeter`] vocabulary rather than a second one (LAW 5).
    ///
    /// - a measured [`count`](Self::count) ⇒ [`ProgressMeter::Determinate`];
    /// - active with no count ⇒ [`ProgressMeter::Indeterminate`] — in flight, unmeasured,
    ///   and saying so;
    /// - pending / done / failed with no count ⇒ [`ProgressMeter::None`]: the segment's
    ///   own [`StepPhase`] colour already carries the whole answer, and a bar on top of it
    ///   would be a second, weaker rendering of the same fact.
    ///
    /// `total == 0` is treated as unmeasured, not as a division by zero.
    pub fn meter(&self) -> ProgressMeter {
        match self.count {
            Some((_, 0)) => ProgressMeter::Indeterminate,
            Some((c, t)) => ProgressMeter::Determinate((c as f32 / t as f32).clamp(0.0, 1.0)),
            None if self.phase == StepPhase::Active => ProgressMeter::Indeterminate,
            None => ProgressMeter::None,
        }
    }
}

/// **A job's step meter** — an ordered list of steps, the run it belongs to, and the two
/// sentences a human reads.
///
/// # Why `caption`/`hover` are carried, not computed
///
/// The producer owns the words. A collector says *"run #3 · step 3 of 4 · relation clip +
/// geometry assembly"*; this crate paints that string and republishes that string. If the
/// widget composed its own sentence from `steps`, there would be two authors of the same
/// text and `state_json` could report a sentence that was never drawn — `seam-1`, where a
/// published theme switch outlived the paint that never happened.
///
/// [`caption_fallback`](Self::caption_fallback) exists only for a producer that stamps
/// steps without a sentence; it is used when `caption` is empty and never overrides it.
///
/// # Run identity
///
/// [`generation`](Self::generation) is the producer's run number. It is what makes a
/// restart legible: a bar that read *"step 3 of 4"* and now reads *"step 1 of 4"* has
/// either lost ground or begun again, and only the run number tells a viewer which.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct StepMeter {
    /// The producer's monotone run number (`0` ⇒ the producer published none).
    #[serde(default)]
    pub generation: u64,
    /// The one-line sentence drawn next to the bar, **verbatim from the producer**.
    #[serde(default)]
    pub caption: String,
    /// The multi-line tooltip block, **verbatim from the producer** (typically the
    /// why-this-run-started line followed by one line per step).
    #[serde(default)]
    pub hover: String,
    /// The steps, in dependency order.
    #[serde(default)]
    pub steps: Vec<StepCell>,
}

impl StepMeter {
    /// Build a meter from steps alone, deriving both sentences. A producer with its own
    /// words should set [`caption`](Self::caption)/[`hover`](Self::hover) instead.
    pub fn from_steps(generation: u64, steps: Vec<StepCell>) -> StepMeter {
        let mut m = StepMeter { generation, caption: String::new(), hover: String::new(), steps };
        m.caption = m.caption_fallback();
        m.hover = m.steps.iter().map(|s| s.line.clone()).collect::<Vec<_>>().join("\n");
        m
    }

    /// How many steps — the *of N*.
    pub fn total(&self) -> usize {
        self.steps.len()
    }

    /// The index of the step being worked on, if any.
    pub fn active(&self) -> Option<usize> {
        self.steps.iter().position(|s| s.phase == StepPhase::Active)
    }

    /// The index of the step that failed, if any.
    pub fn failed(&self) -> Option<usize> {
        self.steps.iter().position(|s| s.phase == StepPhase::Failed)
    }

    /// How many steps are finished.
    pub fn done(&self) -> usize {
        self.steps.iter().filter(|s| s.phase == StepPhase::Done).count()
    }

    /// Every step finished. **An empty meter is not complete** — a zero-step bar reading
    /// "complete" is the hollow green a step bar exists to prevent.
    pub fn is_complete(&self) -> bool {
        !self.steps.is_empty() && self.steps.iter().all(|s| s.phase == StepPhase::Done)
    }

    /// The **1-based** step number a human reads: the active step, else the failed one,
    /// else how many are done. Never exceeds [`total`](Self::total).
    pub fn human_index(&self) -> usize {
        self.active().or_else(|| self.failed()).map_or_else(|| self.done(), |i| i + 1)
    }

    /// The sentence used when the producer supplied none. Finished steps only — this
    /// never guesses how far *into* a step the work has got.
    pub fn caption_fallback(&self) -> String {
        if self.steps.is_empty() {
            return String::new();
        }
        let run =
            if self.generation > 0 { format!("run #{} · ", self.generation) } else { String::new() };
        match (self.failed(), self.active()) {
            (Some(i), _) => format!("{run}step {} of {} FAILED", i + 1, self.total()),
            (None, Some(i)) => format!("{run}step {} of {}", i + 1, self.total()),
            (None, None) => format!("{run}{} of {} steps complete", self.done(), self.total()),
        }
    }

    /// The caption actually drawn: the producer's, or the fallback when it is blank.
    /// **The one place the drawn sentence is chosen** — the render path and
    /// `state_json` both call this, so they cannot disagree.
    pub fn caption_text(&self) -> String {
        if self.caption.trim().is_empty() { self.caption_fallback() } else { self.caption.clone() }
    }

    /// The tooltip actually shown: the producer's `hover`, or the per-step lines joined.
    /// Same one-writer argument as [`caption_text`](Self::caption_text).
    pub fn hover_text(&self) -> String {
        if self.hover.trim().is_empty() {
            self.steps.iter().map(|s| s.line.clone()).collect::<Vec<_>>().join("\n")
        } else {
            self.hover.clone()
        }
    }

    /// The meter as `state_json` data — every human-visible sentence carried **verbatim**
    /// (`caption`, `hover`, `steps[].line`), so a robot asserts the drawn text and not a
    /// second rendering of the same model.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "generation": self.generation,
            "caption": self.caption_text(),
            "hover": self.hover_text(),
            "index": self.human_index(),
            "total": self.total(),
            "done": self.done(),
            "active": self.active(),
            "failed": self.failed(),
            "complete": self.is_complete(),
            "steps": self.steps.iter().map(|s| serde_json::json!({
                "key": s.key,
                "label": s.label,
                "phase": s.phase.key(),
                "line": s.line,
                // The step's own fill, in the crate's existing ProgressMeter vocabulary.
                // `indeterminate` here is a POSITIVE statement - this step is running and
                // its producer has no counter - not a missing value.
                "meter": match s.meter() {
                    ProgressMeter::Determinate(f) => serde_json::json!({
                        "kind": "determinate",
                        "fraction": f,
                        "current": s.count.map(|(c, _)| c),
                        "total": s.count.map(|(_, t)| t),
                    }),
                    ProgressMeter::Indeterminate => serde_json::json!({ "kind": "indeterminate" }),
                    ProgressMeter::None => serde_json::Value::Null,
                },
            })).collect::<Vec<_>>(),
        })
    }

    /// **Read a published [`state_json`](Self::state_json) BACK into a meter** — the
    /// inverse of the projection directly above, deliberately kept beside it so the two
    /// cannot drift apart unnoticed (`state_json_round_trips_through_from_state_json`
    /// asserts they agree, and fails if either side gains a field the other ignores).
    ///
    /// # Why this exists
    ///
    /// `state_json` is a HAND-WRITTEN projection, not the serde derive. MEASURED, not
    /// assumed: the derive survives more of it than it looks — `StepPhase` carries
    /// `#[serde(rename_all = "lowercase")]`, so the `phase` strings parse — but it loses
    /// **`count`**, which the projection folds into the nested `meter` object and the
    /// derive has no field for. A consumer using the derive therefore shows a measured
    /// step as an indeterminate shimmer: a bar that renders, is plausible, and is wrong
    /// about the one number the producer actually knew. That single field is what this
    /// reader is for, and the test below asserts exactly that difference.
    ///
    /// The consumer this was written for is a THIN client. korp's step board lives in the
    /// PRODUCER's process: the emitters are in `korp/src/regions.rs` and the board is
    /// published into the SERVER's `$KORP_STATE`. A `korp-ui --server` reads its own
    /// state file, so it drew an empty local board while the server was three steps into
    /// a planet convert — and the map pane's own help text promised it "shows its
    /// progress while it runs". The progress existed, on the wire, in a field nothing
    /// read back. The owner, 2026-08-04: *"collectorns status ska jag kunna se i korp-ui."*
    ///
    /// Returns `None` for a value that is not an object. Missing keys take their
    /// defaults — a producer on an older build is read as far as it published, never
    /// rejected wholesale.
    pub fn from_state_json(v: &serde_json::Value) -> Option<StepMeter> {
        let o = v.as_object()?;
        let steps = o
            .get("steps")
            .and_then(|s| s.as_array())
            .map(|arr| arr.iter().filter_map(StepCell::from_state_json).collect())
            .unwrap_or_default();
        Some(StepMeter {
            generation: o.get("generation").and_then(|g| g.as_u64()).unwrap_or(0),
            caption: o.get("caption").and_then(|c| c.as_str()).unwrap_or_default().to_string(),
            hover: o.get("hover").and_then(|h| h.as_str()).unwrap_or_default().to_string(),
            steps,
        })
    }
}

impl StepCell {
    /// Read one published step object back into a cell — the inverse of the `steps[]`
    /// entry [`StepMeter::state_json`] writes. `count` comes out of the nested `meter`
    /// object's `current`/`total`, which is where the projection put it; a step whose
    /// meter is `indeterminate` or `null` correctly reads back as `count: None`, so an
    /// unmeasured step stays unmeasured rather than acquiring an invented fraction.
    pub fn from_state_json(v: &serde_json::Value) -> Option<StepCell> {
        let o = v.as_object()?;
        let phase = match o.get("phase").and_then(|p| p.as_str()).unwrap_or("pending") {
            "active" => StepPhase::Active,
            "done" => StepPhase::Done,
            "failed" => StepPhase::Failed,
            _ => StepPhase::Pending,
        };
        let count = o.get("meter").and_then(|m| m.as_object()).and_then(|m| {
            match (m.get("current").and_then(|c| c.as_u64()), m.get("total").and_then(|t| t.as_u64())) {
                (Some(c), Some(t)) => Some((c, t)),
                _ => None,
            }
        });
        Some(StepCell {
            key: o.get("key").and_then(|k| k.as_str()).unwrap_or_default().to_string(),
            label: o.get("label").and_then(|l| l.as_str()).unwrap_or_default().to_string(),
            phase,
            line: o.get("line").and_then(|l| l.as_str()).unwrap_or_default().to_string(),
            count,
        })
    }
}

/// **The segment rectangles of a step meter** — pure geometry, so a test asserts *"four
/// segments, the third one starts at x=…"* with no GPU and no window.
///
/// Segments split `rect` evenly with a `gap` px hairline between them. A meter too narrow
/// for its gaps degrades to touching segments rather than to negative widths.
pub fn step_segments(rect: egui::Rect, n: usize, gap: f32) -> Vec<egui::Rect> {
    if n == 0 {
        return Vec::new();
    }
    let n_f = n as f32;
    let total_gap = gap * (n_f - 1.0);
    let roomy = rect.width() > total_gap;
    let seg_w = if roomy { (rect.width() - total_gap) / n_f } else { rect.width() / n_f };
    let step = seg_w + if roomy { gap } else { 0.0 };
    (0..n)
        .map(|i| {
            egui::Rect::from_min_size(
                egui::pos2(rect.min.x + i as f32 * step, rect.min.y),
                egui::vec2(seg_w, rect.height()),
            )
        })
        .collect()
}

/// Width (px) of the segmented step meter — wider than [`METER_W`] because it must hold
/// N distinguishable segments rather than one fill.
const STEP_METER_W: f32 = 76.0;
/// Height (px) of the segmented step meter — matches the plain meter so a row holding
/// either keeps the same rhythm.
const STEP_METER_H: f32 = 6.0;
/// The hairline between segments. Without it a run of `Done` segments reads as one solid
/// bar and *"3 of 4"* becomes unreadable at a glance — the gap IS the step count.
const STEP_METER_GAP: f32 = 2.0;

/// Paint a job row's **segmented step meter**: one rounded segment per step, coloured by
/// its [`StepPhase`], with the producer's caption on hover of the meter and the
/// producer's per-step line on hover of a segment.
///
/// FC-4/FC-5: each segment gets an AccessKit node keyed on `(row_id, step.key)` — a
/// stable domain key, never the loop index — so a robot can find *"step c"* by label
/// instead of by pixel. The meter as a whole is one more node carrying the caption, so
/// `LabelVisible("run #3 · step 3 of 4 · …")` is assertable headlessly.
/// **Paint a step meter on its own, outside a [`JobList`] row.**
///
/// The same painter the job tree uses, with the current theme resolved for you — so a
/// host that has one step bar to show (dwarves' populate pane) draws the identical
/// widget, with the identical AccessKit nodes a robot asserts on, instead of
/// hand-rolling a second segmented bar that would drift from this one.
///
/// `row_id` namespaces the per-segment nodes; pass the run's stable id.
pub fn step_meter_ui(ui: &mut egui::Ui, row_id: &str, meter: &StepMeter) {
    let th = theme(ui);
    paint_step_meter(ui, row_id, meter, &th);
}

fn paint_step_meter(ui: &mut egui::Ui, row_id: &str, meter: &StepMeter, th: &Theme) {
    if meter.steps.is_empty() {
        return;
    }
    let (rect, _) =
        ui.allocate_exact_size(egui::vec2(STEP_METER_W, STEP_METER_H), egui::Sense::hover());
    let rounding = 1.5;
    // The track under the segments — visible through the gaps, so the bar reads as one
    // object with divisions rather than as N floating chips.
    ui.painter().rect_filled(rect, rounding, th.edge.linear_multiply(0.25));
    let segs = step_segments(rect, meter.total(), STEP_METER_GAP);
    let base = ui.id();
    for (i, (seg, cell)) in segs.iter().zip(meter.steps.iter()).enumerate() {
        // A step with a MEASURED count fills its own segment to that fraction over a
        // dimmed bed, so "clone: 12 of 19 members" is visible inside step 1 without a
        // second widget. A step with no count paints flat: the phase colour is the whole
        // answer, and a partial fill there would be an invented number.
        match cell.meter() {
            ProgressMeter::Determinate(f) => {
                ui.painter().rect_filled(*seg, rounding, cell.phase.color(th).linear_multiply(0.35));
                let w = meter_fill_width(ProgressMeter::Determinate(f), seg.width());
                if w > 0.5 {
                    let filled = egui::Rect::from_min_size(seg.min, egui::vec2(w, seg.height()));
                    ui.painter().rect_filled(filled, rounding, cell.phase.color(th));
                }
            }
            _ => {
                ui.painter().rect_filled(*seg, rounding, cell.phase.color(th));
            }
        }
        let sem = facett_core::a11y::Semantics::new(
            egui::WidgetType::ProgressIndicator,
            cell.line.clone(),
        )
        .value(i as f64 + 1.0);
        let resp = facett_core::a11y::node(
            ui,
            base,
            // FC-5: the stable domain key is (row, step key) — never the loop index.
            (row_id, cell.key.as_str()),
            egui::Sense::hover(),
            *seg,
            sem,
        );
        // The producer's own sentence for this step, verbatim.
        resp.on_hover_text(cell.line.clone());
    }
    // One node for the meter as a whole, carrying the caption a human reads.
    let caption = meter.caption_text();
    let whole = facett_core::a11y::node(
        ui,
        base,
        (row_id, "step-meter"),
        egui::Sense::hover(),
        rect,
        facett_core::a11y::Semantics::new(egui::WidgetType::ProgressIndicator, caption.clone())
            .value(meter.human_index() as f64),
    );
    let hover = meter.hover_text();
    whole.on_hover_ui(|ui| {
        ui.label(caption);
        if !hover.is_empty() {
            ui.separator();
            ui.label(hover);
        }
    });
}

/// Something the [`JobList`] can pull a fresh snapshot from each frame. A host
/// that prefers to push uses [`JobList::set_jobs`] instead and never implements
/// this. When a source is set, the [`Facet::ui`] bridge refreshes the rows from it
/// (through the FC-2 [`Msg::SetJobs`] mutation path) before the pure view paints.
pub trait JobSource {
    /// The current jobs, in any order (the list sorts them itself).
    fn jobs(&self) -> Vec<JobEntry>;
}

/// Which column the list is sorted by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortKey {
    /// By start time (the default; newest first when descending).
    Started,
    Kind,
    Target,
    Status,
    Elapsed,
}

/// One row of the display tree, enriched with everything an ASCII branch-guide
/// renderer needs. Internal: the public surface is [`JobList::tree`]
/// (`(entry, depth)`) plus the `guide`/`is_last` keys in `state_json`.
struct GuideRow {
    entry: JobEntry,
    /// Nesting depth (0 = top-level root, 1 = child, 2 = grandchild, …).
    depth: usize,
    /// `true` when this is the LAST of its siblings (`└─`), else `├─`.
    is_last: bool,
    /// For each ANCESTOR level (index 0 = root … index `depth-1` = parent),
    /// whether that ancestor still has a following sibling — i.e. whether a
    /// continuing vertical `│` is drawn at that level's column for this row.
    /// Length is always `depth`.
    ancestors_continue: Vec<bool>,
    /// Collapse progress `∈[0,1]` inherited from a folding ancestor: `0` = full
    /// height, `1` = fully collapsed (row about to vanish). Drives the fold-height
    /// tween in the render path; always `0` in the logical [`JobList::tree_rows`].
    collapse: f32,
}

/// The literal ASCII branch-guide prefix for a tree row: blank for a root, else
/// one 3-char cell per drawn level — `│  `/`   ` for each ANCESTOR column
/// (continuing vs. cleared) and `├─ `/`└─ ` for the node's own connector. The
/// root level has no column, so its mask entry is skipped.
fn guide_prefix(row: &GuideRow) -> String {
    if row.depth == 0 {
        return String::new();
    }
    let mut s = String::with_capacity(row.depth * 3);
    // Skip index 0 (the root level draws no column); each remaining ancestor
    // contributes a continuing `│` or a cleared blank.
    for &cont in &row.ancestors_continue[1..] {
        s.push_str(if cont { "│  " } else { "   " });
    }
    s.push_str(if row.is_last { "└─ " } else { "├─ " });
    s
}

/// **The complete observable state (FC-1 / FC-3)** of a [`JobList`], in one
/// serializable, round-trippable struct: the job rows plus the view controls. The
/// **only** thing that mutates it is [`JobList::update`]. Expand/collapse and
/// selection key on **stable job ids** (FC-5) — never tree-position indices, since
/// the tree streams in and reorders under sort.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JobState {
    /// The job rows the tree is built from (parents + children, any order).
    pub jobs: Vec<JobEntry>,
    /// The active sort column.
    pub sort: SortKey,
    /// `true` = descending (the default: newest / largest first).
    pub descending: bool,
    /// Active kind filter (`None` = all kinds).
    pub kind_filter: Option<String>,
    /// Selected row id (for the expanded meta line) — a **stable job id** (FC-5).
    pub selected: Option<String>,
    /// Parent ids the user has FOLDED (children hidden) — **stable job ids** (FC-5).
    /// A parent is unfolded by default, so the tree shows live progress the moment
    /// children stream in; folding a parent adds its id here.
    pub folded: Vec<String>,
}

impl Default for JobState {
    fn default() -> Self {
        Self {
            jobs: Vec::new(),
            sort: SortKey::Started,
            descending: true,
            kind_filter: None,
            selected: None,
            folded: Vec::new(),
        }
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the tree through, the same effect a canvas gesture
/// produces. Applied by [`JobList::update`]. Serde-tagged (`snake_case`) so the
/// robot `update_json` seam parses one message straight off the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Expand/collapse a parent node by its **stable job id** (FC-5) — the parent
    /// row's ▸/▾ toggle. Unknown ids are inserted (a not-yet-streamed parent can be
    /// pre-folded); toggling again removes it.
    ToggleFold(String),
    /// Select a row by its **stable job id** (FC-5). A no-op-safe re-select is fine.
    Select(String),
    /// Clear the row selection.
    ClearSelection,
    /// Click a sort-column header: the SAME key flips direction; a NEW key sorts
    /// descending.
    SetSort(SortKey),
    /// Set the active kind filter (`None` = all kinds).
    SetKindFilter(Option<String>),
    /// Replace the job rows — the host push ([`JobList::set_jobs`]) and the
    /// per-frame [`JobSource`] pull both route through this one mutation path.
    SetJobs(Vec<JobEntry>),
}

/// Side work as data (FC-8). The job view does no I/O — every [`Msg`] mutates only
/// the in-memory [`JobState`] — so this is uninhabited on purpose: the type-checked
/// statement that [`JobList::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// A task-manager-style hierarchical job tree: a sortable, kind-filterable tree of
/// [`JobEntry`] rows with foldable parents and a counts header. A [`Facet`].
pub struct JobList {
    title: String,
    /// All observable state (FC-3).
    state: JobState,
    /// Optional pull source; when set, the [`Facet::ui`] bridge refreshes `jobs`
    /// from it (via [`Msg::SetJobs`]) each frame. **Ephemeral input wiring, not
    /// observable state** — deliberately kept OUT of [`JobState`] (a `dyn` source is
    /// not serializable), like the motion caches below.
    source: Option<Box<dyn JobSource>>,
    /// Smooth vertical scroll for the row list (SCRL-3): eased, injected-clock
    /// (FC-7). **Ephemeral render cache, not observable state** — interior-mutable so
    /// the FC-9 pure [`view`](Self::view) can advance it through a `&self` borrow.
    scroll: Cell<SmoothScroll>,
    /// Per-row hover + selection fades (injected-clock, FC-7). Same story as the
    /// scroll: ephemeral animation caches, interior-mutable, outside the Model.
    hover_fades: RefCell<FadeTrack>,
    sel_fades: RefCell<FadeTrack>,
    /// Per-parent fold tween (`1` = folded/collapsed): drives the subtree
    /// fold-height animation. Injected-clock, interior-mutable, outside the Model.
    fold_fades: RefCell<FadeTrack>,
}

impl JobList {
    /// An empty list with a title.
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            state: JobState::default(),
            source: None,
            scroll: Cell::new(SmoothScroll::default()),
            hover_fades: RefCell::new(FadeTrack::default()),
            sel_fades: RefCell::new(FadeTrack::default()),
            fold_fades: RefCell::new(FadeTrack::default()),
        }
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &JobState {
        &self.state
    }

    /// Replace the rows (the push model — call each refresh). A thin wrapper over the
    /// FC-2 [`update`](Self::update) mutation path.
    pub fn set_jobs(&mut self, jobs: Vec<JobEntry>) {
        let _ = self.update(Msg::SetJobs(jobs));
    }

    /// Install a pull [`JobSource`] refreshed every frame (alternative to push).
    pub fn set_source(&mut self, source: Box<dyn JobSource>) {
        self.source = Some(source);
    }

    /// The current rows (post-set, pre-filter).
    pub fn jobs(&self) -> &[JobEntry] {
        &self.state.jobs
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas gesture routes
    /// its [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::ToggleFold(id) => self.toggle_fold(&id),
            Msg::Select(id) => self.state.selected = Some(id),
            Msg::ClearSelection => self.state.selected = None,
            Msg::SetSort(key) => self.set_sort(key),
            Msg::SetKindFilter(k) => self.state.kind_filter = k,
            Msg::SetJobs(jobs) => self.state.jobs = jobs,
        }
        Vec::new()
    }

    /// Count of rows in a given status.
    pub fn count(&self, status: JobStatus) -> usize {
        self.state.jobs.iter().filter(|j| j.status == status).count()
    }

    /// The distinct kinds present, sorted — drives the filter combo.
    pub fn kinds(&self) -> Vec<String> {
        let mut k: Vec<String> = self.state.jobs.iter().map(|j| j.kind.clone()).collect();
        k.sort();
        k.dedup();
        k
    }

    /// The rows after the active kind filter + sort — what the table draws (and
    /// what `state_json` reports), so headless tests see the visible order.
    pub fn visible(&self) -> Vec<JobEntry> {
        let mut rows: Vec<JobEntry> = self
            .state
            .jobs
            .iter()
            .filter(|j| self.state.kind_filter.as_deref().is_none_or(|k| j.kind == k))
            .cloned()
            .collect();
        self.sort_rows(&mut rows);
        rows
    }

    /// The direct children of `parent_id`, in the active sort order. A child is
    /// any entry whose `parent_id` equals `parent_id` (host-agnostic hierarchy).
    pub fn children_of(&self, parent_id: &str) -> Vec<JobEntry> {
        let mut kids: Vec<JobEntry> = self
            .state
            .jobs
            .iter()
            .filter(|j| j.parent_id.as_deref() == Some(parent_id))
            .cloned()
            .collect();
        self.sort_rows(&mut kids);
        kids
    }

    /// A one-line roll-up of `parent_id`'s children for the FOLDED parent row,
    /// e.g. `"3/5 ✓ · 1 ▶ · 1 ✗"` (done/total, running, failed). Empty string
    /// when the parent has no children. This is what a folded parent shows in
    /// place of its hidden child rows.
    pub fn child_summary(&self, parent_id: &str) -> String {
        let kids = self.children_of(parent_id);
        if kids.is_empty() {
            return String::new();
        }
        let total = kids.len();
        let done = kids.iter().filter(|k| k.status == JobStatus::Done).count();
        let running = kids.iter().filter(|k| k.status == JobStatus::Running).count();
        let failed = kids.iter().filter(|k| k.status == JobStatus::Failed).count();
        let mut parts = vec![format!("{done}/{total} ✓")];
        if running > 0 {
            parts.push(format!("{running} ▶"));
        }
        if failed > 0 {
            parts.push(format!("{failed} ✗"));
        }
        parts.join(" · ")
    }

    /// `true` when `parent_id` is currently folded (its children hidden).
    pub fn is_folded(&self, parent_id: &str) -> bool {
        self.state.folded.iter().any(|f| f == parent_id)
    }

    /// Fold/unfold a parent row (the FC-2 [`Msg::ToggleFold`] handler).
    fn toggle_fold(&mut self, parent_id: &str) {
        if let Some(i) = self.state.folded.iter().position(|f| f == parent_id) {
            self.state.folded.remove(i);
        } else {
            self.state.folded.push(parent_id.to_string());
        }
    }

    /// Select a row by id (the label-addressable counterpart of clicking it). A thin
    /// wrapper over the FC-2 [`update`](Self::update) mutation path; `None` clears it.
    pub fn select(&mut self, id: Option<String>) {
        let _ = match id {
            Some(i) => self.update(Msg::Select(i)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// The currently selected row id, if any.
    pub fn selected(&self) -> Option<&str> {
        self.state.selected.as_deref()
    }

    /// The display tree: top-level rows in sort order, each immediately followed
    /// by its (recursively nested) children in sort order UNLESS the parent is
    /// folded. An "orphan" child whose parent is not in the current (filtered)
    /// set is promoted to a top-level row so a kind filter can never hide it
    /// entirely. Each returned entry is tagged with its nesting `depth`
    /// (0 = top-level, 1 = child, 2 = grandchild, …) so a renderer (or
    /// `state_json`) can indent without re-deriving the hierarchy. Recurses to
    /// arbitrary depth, so a populate → phase → member hierarchy renders all
    /// three levels.
    pub fn tree(&self) -> Vec<(JobEntry, usize)> {
        self.tree_rows()
            .into_iter()
            .map(|r| (r.entry, r.depth))
            .collect()
    }

    /// The display tree, enriched with the per-row info an ASCII branch-guide
    /// renderer needs (see [`GuideRow`]): each row's `depth`, whether it is the
    /// LAST of its siblings (`└─` vs `├─`), and an ancestor "still-has-siblings"
    /// mask (which ancestor levels draw a continuing `│`). The plain
    /// [`tree`](JobList::tree) is this projected down to `(entry, depth)`.
    fn tree_rows(&self) -> Vec<GuideRow> {
        self.build_tree_rows(false)
    }

    /// Render-path tree: like [`tree_rows`](Self::tree_rows) but driven by the fold
    /// *animation* — a folding parent keeps emitting its children (each carrying a
    /// `collapse` progress) until the fold tween fully closes, so the subtree
    /// collapses smoothly instead of vanishing. Used only by `view`.
    fn tree_rows_animated(&self) -> Vec<GuideRow> {
        self.build_tree_rows(true)
    }

    fn build_tree_rows(&self, animated: bool) -> Vec<GuideRow> {
        let visible = self.visible(); // filtered + sorted
        let present: std::collections::HashSet<&str> =
            visible.iter().map(|j| j.id.as_str()).collect();
        // Roots = entries with no parent in the visible set (true top-level rows
        // plus promoted orphans), already in sort order.
        let roots: Vec<JobEntry> = visible
            .iter()
            .filter(|j| match &j.parent_id {
                Some(p) => !present.contains(p.as_str()),
                None => true,
            })
            .cloned()
            .collect();
        let mut out: Vec<GuideRow> = Vec::new();
        let n = roots.len();
        let mut ancestors: Vec<bool> = Vec::new();
        for (i, r) in roots.iter().enumerate() {
            self.push_subtree(r, 0, i + 1 == n, 0.0, animated, &mut ancestors, &mut out);
        }
        out
    }

    /// Depth-first emit of `node` then its children, recording branch-guide info.
    /// `is_last` = `node` is the last of its siblings. `ancestors` carries, for
    /// each ancestor level above `node`, whether that ancestor still has a
    /// following sibling (so its vertical `│` continues down through this row).
    #[allow(clippy::too_many_arguments)]
    fn push_subtree(
        &self,
        node: &JobEntry,
        depth: usize,
        is_last: bool,
        parent_collapse: f32,
        animated: bool,
        ancestors: &mut Vec<bool>,
        out: &mut Vec<GuideRow>,
    ) {
        out.push(GuideRow {
            entry: node.clone(),
            depth,
            is_last,
            ancestors_continue: ancestors.clone(),
            collapse: parent_collapse,
        });
        // This node's own fold progress: the animated render uses the eased fade,
        // the logical tree uses the binary fold set. Skip the children once fully
        // folded; while a fold is mid-tween, keep emitting them so they collapse.
        let fold = if animated {
            self.fold_fades.borrow().factor(FadeTrack::key(node.id.as_str()))
        } else if self.is_folded(node.id.as_str()) {
            1.0
        } else {
            0.0
        };
        if fold >= 0.999 {
            return;
        }
        // Children inherit the strongest collapsing ancestor's progress.
        let child_collapse = parent_collapse.max(fold);
        let kids: Vec<JobEntry> = self
            .children_of(&node.id)
            .into_iter()
            // Honour the kind filter for children too.
            .filter(|k| self.state.kind_filter.as_deref().is_none_or(|kf| k.kind == kf))
            .collect();
        let k = kids.len();
        // Descending past this node, its own vertical bar continues iff it has a
        // following sibling.
        ancestors.push(!is_last);
        for (i, kid) in kids.iter().enumerate() {
            self.push_subtree(kid, depth + 1, i + 1 == k, child_collapse, animated, ancestors, out);
        }
        ancestors.pop();
    }

    /// Sort `rows` in place by the active key/direction (shared by `visible` and
    /// `children_of`).
    fn sort_rows(&self, rows: &mut [JobEntry]) {
        let key = self.state.sort;
        rows.sort_by(|a, b| {
            let o = match key {
                SortKey::Started => a.started_micros.cmp(&b.started_micros),
                SortKey::Kind => a.kind.cmp(&b.kind),
                SortKey::Target => a.target.cmp(&b.target),
                SortKey::Status => a.status.as_str().cmp(b.status.as_str()),
                SortKey::Elapsed => a.elapsed_ms.unwrap_or(0).cmp(&b.elapsed_ms.unwrap_or(0)),
            };
            if self.state.descending { o.reverse() } else { o }
        });
    }

    /// Toggle sort: clicking the active column flips direction; a new column
    /// sorts descending. (The FC-2 [`Msg::SetSort`] handler.)
    fn set_sort(&mut self, key: SortKey) {
        if self.state.sort == key {
            self.state.descending = !self.state.descending;
        } else {
            self.state.sort = key;
            self.state.descending = true;
        }
    }

    /// One sortable column header button (shows a ▲/▼ on the active column). **Pure**
    /// (`&self`): returns `true` when clicked, so the caller emits [`Msg::SetSort`].
    fn header(&self, ui: &mut egui::Ui, label: &str, key: SortKey, th: &Theme) -> bool {
        let mark = if self.state.sort == key {
            if self.state.descending { " ▼" } else { " ▲" }
        } else {
            ""
        };
        let text = egui::RichText::new(format!("{label}{mark}"))
            .color(if self.state.sort == key { th.accent } else { th.text })
            .strong();
        ui.add(egui::Label::new(text).sense(egui::Sense::click())).clicked()
    }
}

/// Human elapsed: `—` (none), `…` (running), `340ms`, `1.2s`, `2m 03s`.
fn fmt_elapsed(status: JobStatus, elapsed_ms: Option<i64>) -> String {
    match elapsed_ms {
        None => {
            if status == JobStatus::Running {
                "…".to_string()
            } else {
                "—".to_string()
            }
        }
        Some(ms) if ms < 1000 => format!("{ms}ms"),
        Some(ms) if ms < 60_000 => format!("{:.1}s", ms as f64 / 1000.0),
        Some(ms) => {
            let s = ms / 1000;
            format!("{}m {:02}s", s / 60, s % 60)
        }
    }
}

/// Compact per-row **progress meter** width/height (px) — a slim rounded bar sized
/// to sit in the row without stealing height.
const METER_W: f32 = 54.0;
const METER_H: f32 = 6.0;

/// Paint a compact per-row progress meter matching the facett palette:
///
/// - a dim `edge` **track** underneath;
/// - **determinate** ⇒ a `fill` rect to [`meter_fill_width`], coloured by the row's
///   [`JobStatus`] (green when done, amber while running) so the meter reads with
///   the row's traffic-light;
/// - **indeterminate** ⇒ an accent highlight segment that ping-pongs across the
///   track (an honest "in flight, unmeasured" sweep), requesting repaint so it
///   animates.
///
/// [`ProgressMeter::None`] allocates nothing (early return). Injected-clock via
/// `time` (seconds) so the sweep is deterministic and testable, matching the FC-7
/// injected-clock convention the row fades already use.
fn paint_meter(ui: &mut egui::Ui, meter: ProgressMeter, status: JobStatus, th: &Theme, time: f64) {
    if meter == ProgressMeter::None {
        return;
    }
    let ctx = ui.ctx().clone();
    let (rect, _resp) =
        ui.allocate_exact_size(egui::vec2(METER_W, METER_H), egui::Sense::hover());
    let rounding = 2.0;
    let painter = ui.painter();
    // Track — a dim rounded band the fill/sweep rides over.
    painter.rect_filled(rect, rounding, th.edge.linear_multiply(0.35));
    match meter {
        ProgressMeter::Determinate(_) => {
            let fill_w = meter_fill_width(meter, rect.width());
            if fill_w > 0.5 {
                let fill = egui::Rect::from_min_size(rect.min, egui::vec2(fill_w, rect.height()));
                painter.rect_filled(fill, rounding, status.color(th));
            }
        }
        ProgressMeter::Indeterminate => {
            // A ~30%-wide highlight segment ping-ponging across the track.
            let seg = rect.width() * 0.3;
            let travel = (rect.width() - seg).max(0.0);
            let phase = (time * 0.8).rem_euclid(1.0) as f32; // 0..1 loop
            let x = if phase < 0.5 { phase * 2.0 } else { (1.0 - phase) * 2.0 }; // ping-pong 0..1
            let seg_rect = egui::Rect::from_min_size(
                egui::pos2(rect.min.x + x * travel, rect.min.y),
                egui::vec2(seg, rect.height()),
            );
            painter.rect_filled(seg_rect, rounding, th.accent.linear_multiply(0.85));
            ctx.request_repaint(); // keep the sweep moving
        }
        ProgressMeter::None => {}
    }
}

impl JobList {
    /// **FC-9 render** — a **pure** function of `&self`: it paints the counts header,
    /// the sort bar, and the hierarchical job tree, and *returns* the [`Msg`]s the
    /// interactions produced. It MUST NOT mutate the [`JobState`]; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update). The only `&self`-interior
    /// mutation is the ephemeral scroll/fade animation cache (FC-7), deliberately
    /// outside the observable Model. This is the same body the old `&mut self` `ui()`
    /// ran, with every inline `self.*` mutation replaced by a `msgs.push(..)`.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let th = theme(ui);

        // Pull from a source if one is installed (push hosts have no source and skip
        // this): refresh the input rows through the FC-2 [`Msg::SetJobs`] mutation
        // path — reading host input and returning it as a `Msg` keeps the view pure
        // (no model mutation here); the bridge applies it via `update`.
        if let Some(src) = &self.source {
            msgs.push(Msg::SetJobs(src.jobs()));
        }

        // ── header: counts + kind filter ───────────────────────────────────
        ui.horizontal_wrapped(|ui| {
            let (run, done, fail) = (
                self.count(JobStatus::Running),
                self.count(JobStatus::Done),
                self.count(JobStatus::Failed),
            );
            ui.label(egui::RichText::new(format!("{} job(s)", self.state.jobs.len())).color(th.text).strong());
            ui.separator();
            ui.label(egui::RichText::new(format!("▶ {run}")).color(JobStatus::Running.color(&th)));
            ui.label(egui::RichText::new(format!("✓ {done}")).color(JobStatus::Done.color(&th)));
            ui.label(egui::RichText::new(format!("✗ {fail}")).color(JobStatus::Failed.color(&th)));
            ui.separator();

            // Kind filter combo.
            let current = self.state.kind_filter.clone().unwrap_or_else(|| "all kinds".to_string());
            egui::ComboBox::from_id_salt("jobview-kind-filter")
                .selected_text(current)
                .show_ui(ui, |ui| {
                    if ui.selectable_label(self.state.kind_filter.is_none(), "all kinds").clicked() {
                        msgs.push(Msg::SetKindFilter(None));
                    }
                    for k in self.kinds() {
                        let sel = self.state.kind_filter.as_deref() == Some(k.as_str());
                        if ui.selectable_label(sel, &k).clicked() {
                            msgs.push(Msg::SetKindFilter(Some(k.clone())));
                        }
                    }
                });
        });
        ui.separator();

        if self.state.jobs.is_empty() {
            ui.label(egui::RichText::new("no jobs").color(th.text_dim));
            // ── render-lane emit: empty-data early return drew the hint ────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-jobview::JobList::view",
                "ui_render",
                true,
                &format!("jobs={} drew=empty_hint", self.state.jobs.len()),
            );
            return msgs;
        }

        // ── sort bar (the row columns are tree-shaped, so sorting lives in a
        //    compact button strip rather than a faux-aligned column header) ────
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new("sort").color(th.text_dim).small());
            for (label, key) in [
                ("status", SortKey::Status),
                ("kind", SortKey::Kind),
                ("target", SortKey::Target),
                ("elapsed", SortKey::Elapsed),
                ("started", SortKey::Started),
            ] {
                if self.header(ui, label, key, &th) {
                    msgs.push(Msg::SetSort(key));
                }
            }
        });
        ui.add_space(4.0);

        // ── rows (hierarchical: ASCII branch guides + foldable parents) ───────
        // Render path uses the fold-animated tree so a collapsing subtree eases
        // shut (each row carries a `collapse` progress) instead of vanishing.
        let rows = self.tree_rows_animated();
        // Collect which rows are hovered / selected this frame; the fade trackers
        // (interior-mutable on `self`) are advanced AFTER the closure so the row loop
        // keeps its `&self` borrow. Reading the *current* factor mid-loop is fine.
        let mut lit_hover: Vec<u64> = Vec::new();
        let mut lit_sel: Vec<u64> = Vec::new();
        // Dim, low-contrast guide colour so the labels pop; the theme's `edge`
        // is exactly the "connector between things" token.
        let guide_col = th.edge;
        let row_h = ui.text_style_height(&egui::TextStyle::Body) + 6.0;
        // Smooth-scroll the row list (SCRL-3): drive the eased offset from this
        // frame's `stable_dt` (FC-7). `SmoothScroll` is `Copy`, so we work on a copy
        // pulled from the interior-mutable cache and write it back afterward.
        let dt = ui.input(|i| i.stable_dt);
        let mut scroll = self.scroll.get();
        smooth_scroll_area(
            ui,
            egui::ScrollArea::vertical().id_salt("jobview-rows"),
            dt,
            &mut scroll,
            None,
            |ui| {
            ui.spacing_mut().item_spacing = egui::vec2(2.0, 0.0);
            // Injected clock for the indeterminate meter sweep (FC-7, like the fades).
            let time = ui.input(|i| i.time);
            for (i, row) in rows.iter().enumerate() {
                let j = &row.entry;
                let c = j.status.color(&th);
                let is_sel = self.state.selected.as_deref() == Some(j.id.as_str());
                let has_children = !self.children_of(&j.id).is_empty();
                let folded = self.is_folded(j.id.as_str());
                let guide = guide_prefix(row);

                // Fold-height tween: a collapsing subtree's rows ease their height
                // to zero. Reserve EXACTLY the (collapsing) height so the stack pulls
                // shut with no overlap, then render the row content into a clipped,
                // fading child whose layout box stays full-height (labels keep their
                // natural size) but is clipped to the collapsing band.
                let eff_h = (row_h * (1.0 - row.collapse)).max(0.0);
                if eff_h < 0.75 {
                    continue; // fully collapsed this frame — nothing to draw
                }
                let avail_w = ui.available_width();
                // Reserve a paint slot UNDER the row content for its background,
                // filled in once we know the row rect (hover / select / stripe).
                let bg_idx = ui.painter().add(egui::Shape::Noop);
                let (row_rect, resp) =
                    ui.allocate_exact_size(egui::vec2(avail_w, eff_h), egui::Sense::click());
                let content_rect =
                    egui::Rect::from_min_size(row_rect.min, egui::vec2(avail_w, row_h));
                let mut cui = ui.new_child(
                    egui::UiBuilder::new()
                        .max_rect(content_rect)
                        .layout(egui::Layout::left_to_right(egui::Align::Center)),
                );
                cui.set_clip_rect(ui.clip_rect().intersect(row_rect));
                if row.collapse > 0.0 {
                    cui.set_opacity(1.0 - row.collapse);
                }
                {
                            let ui = &mut cui;
                            ui.add_space(2.0);
                            // [guides] — monospaced so the vertical lines align.
                            if !guide.is_empty() {
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(&guide).monospace().color(guide_col),
                                    )
                                    .selectable(false),
                                );
                            }
                            // [toggle] — a parent's ▾/▸ sits where the node glyph
                            // is (guides to its left); leaves have no toggle.
                            if has_children {
                                let t = if folded { "▸" } else { "▾" };
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(t).monospace().color(th.text_dim),
                                    )
                                    .selectable(false),
                                );
                            }
                            // [status glyph] — colour-coded traffic light.
                            ui.add(
                                egui::Label::new(
                                    egui::RichText::new(j.status.glyph()).color(c).strong(),
                                )
                                .selectable(false),
                            );
                            ui.add_space(2.0);
                            // [label] — kind bright (accent when selected), target dim.
                            ui.add(
                                egui::Label::new(
                                    egui::RichText::new(&j.kind)
                                        .color(if is_sel { th.accent } else { th.text }),
                                )
                                .selectable(false),
                            );
                            if !j.target.is_empty() {
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(&j.target).color(th.text_dim),
                                    )
                                    .selectable(false),
                                );
                            }
                            // [elapsed] right-aligned, with the folded roll-up /
                            // detail muted just to its left.
                            ui.with_layout(
                                egui::Layout::right_to_left(egui::Align::Center),
                                |ui| {
                                    ui.add_space(2.0);
                                    ui.add(
                                        egui::Label::new(
                                            egui::RichText::new(fmt_elapsed(j.status, j.elapsed_ms))
                                                .monospace()
                                                .color(th.text_dim),
                                        )
                                        .selectable(false),
                                    );
                                    // [progress meter] — a per-row filled/animated bar
                                    // to the left of elapsed. Honest: determinate fills
                                    // to its fraction, a running-but-unmeasured job
                                    // sweeps indeterminate, and a job with nothing to
                                    // report draws no bar (never a fake full one).
                                    let meter = j.progress_meter();
                                    if meter != ProgressMeter::None {
                                        ui.add_space(8.0);
                                        paint_meter(ui, meter, j.status, &th, time);
                                    }
                                    // [step meter] — "step 3 of 4" as N segments, one
                                    // per step, each with its own phase colour and the
                                    // producer's own tooltip line. Sits left of the
                                    // fraction meter: the coarse "which step" reads
                                    // first, the fine fill (when any) second.
                                    if let Some(steps) = j.steps_meter() {
                                        ui.add_space(8.0);
                                        paint_step_meter(ui, &j.id, &steps, &th);
                                        ui.add_space(6.0);
                                        ui.add(
                                            egui::Label::new(
                                                egui::RichText::new(steps.caption_text())
                                                    .monospace()
                                                    .color(th.text_dim),
                                            )
                                            .selectable(false),
                                        );
                                    }
                                    let note = if has_children && folded {
                                        self.child_summary(&j.id)
                                    } else {
                                        j.detail.clone()
                                    };
                                    if !note.is_empty() {
                                        ui.add_space(10.0);
                                        ui.add(
                                            egui::Label::new(
                                                egui::RichText::new(note)
                                                    .color(th.text_dim)
                                                    .italics(),
                                            )
                                            .selectable(false),
                                        );
                                    }
                                },
                            );
                }

                // Row background: an injected-clock cross-fade of hover + selection
                // over the zebra stripe (generalises the old flat hover > selection
                // > stripe). Factors are last frame's eased state; we note this
                // frame's hovered/selected rows to advance after the loop.
                let key = FadeTrack::key(j.id.as_str());
                if resp.hovered() {
                    lit_hover.push(key);
                }
                if is_sel {
                    lit_sel.push(key);
                }
                let hover_f = self.hover_fades.borrow().factor(key);
                let sel_f = self.sel_fades.borrow().factor(key);
                // Faded accent fill (hover stronger than selection); falls back to
                // the static zebra stripe when neither is lit.
                let accent_a = 0.12 * hover_f + 0.06 * sel_f;
                let fill = if accent_a > 0.001 {
                    th.accent.linear_multiply(accent_a.min(0.20))
                } else if i % 2 == 1 {
                    th.text_dim.linear_multiply(0.04)
                } else {
                    egui::Color32::TRANSPARENT
                };
                if fill != egui::Color32::TRANSPARENT {
                    ui.painter().set(
                        bg_idx,
                        egui::Shape::rect_filled(resp.rect, 3.0, fill),
                    );
                }
                // Selection glow: a faded accent outline that eases in on select —
                // painted over the content so it never dims the row text.
                if sel_f > 0.01 {
                    ui.painter().rect_stroke(
                        resp.rect,
                        3.0,
                        egui::Stroke::new(1.0_f32, th.accent.linear_multiply(0.55 * sel_f)),
                        egui::StrokeKind::Inside,
                    );
                }
                if resp.clicked() {
                    // Clicking a PARENT toggles its fold; any row also (de)selects.
                    // FC-9: emit the Msgs instead of mutating `self`.
                    if has_children {
                        msgs.push(Msg::ToggleFold(j.id.clone()));
                    }
                    msgs.push(if is_sel {
                        Msg::ClearSelection
                    } else {
                        Msg::Select(j.id.clone())
                    });
                }

                // Expanded meta line for the selected row, indented past its guides.
                if is_sel && !j.meta.is_empty() {
                    let meta = j
                        .meta
                        .iter()
                        .map(|(k, v)| format!("{k}={v}"))
                        .collect::<Vec<_>>()
                        .join("   ");
                    ui.horizontal(|ui| {
                        if !guide.is_empty() {
                            ui.add(
                                egui::Label::new(
                                    egui::RichText::new(format!("{guide}  "))
                                        .monospace()
                                        .color(guide_col),
                                )
                                .selectable(false),
                            );
                        }
                        ui.add(
                            egui::Label::new(
                                egui::RichText::new(meta).color(th.text_dim).italics().small(),
                            )
                            .selectable(false),
                        );
                    });
                }
            }
            },
        );
        self.scroll.set(scroll);
        // Advance the row fades from this frame's hovered/selected set (FC-7), and
        // keep repainting while any highlight is still easing in/out. Interior-
        // mutable, ephemeral — the observable Model is untouched.
        {
            let mut hover_fades = self.hover_fades.borrow_mut();
            hover_fades.begin();
            for k in lit_hover {
                hover_fades.lit(k);
            }
        }
        {
            let mut sel_fades = self.sel_fades.borrow_mut();
            sel_fades.begin();
            for k in lit_sel {
                sel_fades.lit(k);
            }
        }
        let a = self.hover_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
        let b = self.sel_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
        // Advance the fold tween: every currently-folded parent eases toward
        // collapsed (factor 1); unfolded ones ease back open. Keep repainting while
        // any highlight or fold is still moving.
        {
            let mut fold_fades = self.fold_fades.borrow_mut();
            fold_fades.begin();
            for id in self.state.folded.iter() {
                fold_fades.lit(FadeTrack::key(id.as_str()));
            }
        }
        let f = self.fold_fades.borrow_mut().advance(dt, FOLD_SECS);
        if a || b || f {
            ui.ctx().request_repaint();
        }
        // ── render-lane emit: this view path RAN ─────────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-jobview::JobList::view",
            "ui_render",
            !self.state.jobs.is_empty(),
            &format!("jobs={}", self.state.jobs.len()),
        );

        msgs
    }

    /// The rich, fold-aware `state_json` (LAW 6) — the hierarchical display tree +
    /// hierarchy summary + motion state a headless driver reads. Kept as an inherent
    /// method so both the [`Facet::state_json`] bridge and tests share one source of
    /// truth. Publishes the exact keys facett-demo's mega_matrix reads for `"jobs"`.
    fn state_json_impl(&self) -> serde_json::Value {
        // The hierarchical display order (parents then their children, fold-aware)
        // — exactly the row sequence the table draws, so a headless test reads the
        // tree without re-deriving it. Each row carries its `depth` + `parent_id`.
        let tree = self.tree_rows();
        let parents: usize = tree.iter().filter(|r| !self.children_of(&r.entry.id).is_empty()).count();
        let children: usize = self.state.jobs.iter().filter(|j| j.parent_id.is_some()).count();
        serde_json::json!({
            "title": self.title,
            "count": self.state.jobs.len(),
            "running": self.count(JobStatus::Running),
            "done": self.count(JobStatus::Done),
            "failed": self.count(JobStatus::Failed),
            "queued": self.count(JobStatus::Queued),
            "sort": format!("{:?}", self.state.sort),
            "descending": self.state.descending,
            "kind_filter": self.state.kind_filter,
            "selected": self.state.selected,
            "kinds": self.kinds(),
            // Hierarchy summary: how many rows are parents (have children) and how
            // many entries are children (have a parent_id).
            "parents": parents,
            "children": children,
            "folded": self.state.folded.clone(),
            // MOTION STATE (T1.3 SCRL-3 + fade) as data: the live SmoothScroll
            // offset/target + whether it's easing, and how many hover/selection/fold
            // cross-fades are currently tracked (and animating). A headless robot
            // proof reads this to assert the row list's smooth-scroll + fade motion
            // machinery is actually exercised, not eyeballed.
            "motion": {
                "scroll_offset": self.scroll.get().offset,
                "scroll_target": self.scroll.get().target,
                "scroll_animating": self.scroll.get().animating(),
                "smooth": self.scroll.get().smooth,
                "hover_fades": self.hover_fades.borrow().active(),
                "sel_fades": self.sel_fades.borrow().active(),
                "fold_fades": self.fold_fades.borrow().active(),
                "fades_animating": self.hover_fades.borrow().is_animating()
                    || self.sel_fades.borrow().is_animating()
                    || self.fold_fades.borrow().is_animating(),
            },
            // The visible (filtered + sorted, fold-aware) display order, so a
            // headless test sees exactly what the table draws — including the
            // parent→child nesting via `depth` + `parent_id`, the folded
            // parent's `child_summary` roll-up, and the ASCII branch-guide info
            // (`is_last` + the rendered `guide` prefix) the table draws to its left.
            "jobs": tree.iter().map(|r| serde_json::json!({
                "id": r.entry.id,
                "kind": r.entry.kind,
                "target": r.entry.target,
                "status": r.entry.status.as_str(),
                "elapsed_ms": r.entry.elapsed_ms,
                "detail": r.entry.detail,
                "parent_id": r.entry.parent_id,
                "depth": r.depth,
                "is_last": r.is_last,
                "guide": guide_prefix(r),
                "child_summary": self.child_summary(&r.entry.id),
                // The honest per-row progress meter a headless driver reads (the
                // exact state the drawn bar renders): a determinate fraction, an
                // indeterminate sweep, or null (no bar).
                "progress": match r.entry.progress_meter() {
                    ProgressMeter::Determinate(f) => serde_json::json!({ "kind": "determinate", "fraction": f }),
                    ProgressMeter::Indeterminate => serde_json::json!({ "kind": "indeterminate" }),
                    ProgressMeter::None => serde_json::Value::Null,
                },
                // The segmented step meter, or null when the row has none.
                // `caption` and `steps[].line` here are the IDENTICAL Strings the row
                // paints and its tooltips show — `StepMeter::caption_text`/`hover_text`
                // are the one writer both paths call. A robot asserting on this text is
                // asserting on what a human sees, which is the whole point.
                "steps": r.entry.steps_meter().map(|m| m.state_json())
                    .unwrap_or(serde_json::Value::Null),
            })).collect::<Vec<_>>(),
        })
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl Elm for JobList {
    type Model = JobState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &JobState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        JobList::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        JobList::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for JobList` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the job view publishes a RICHER
// `state_json` than a plain `serde(state())`: the fold-aware display TREE (parents
// then children, `depth` + ASCII branch guides), the hierarchy summary
// (`parents`/`children`), and the motion state — the keys facett-demo's mega_matrix
// reads for the `"jobs"` cell (`count`/`parents`/`children`/`running`, and the
// `jobs[]` rows) come from the derived tree, not the raw Model — so the default is
// suppressed and this one supplied. The generated `ui` is the FC-9 loop verbatim
// (`for m in view(ui) { update(m) }`); a [`JobSource`] pull rides in as a
// [`Msg::SetJobs`] emitted from `view`, so no `ui` override is needed.
facett_core::impl_facet_via_elm!(JobList, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        self.state_json_impl()
    }

    /// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
    /// apply it through the single [`Elm::update`](facett_core::Elm::update)
    /// mutation path. The `impl_facet_via_elm!` bridge does NOT generate an
    /// `update_json`, so without this override the JSON drive seam is a dead no-op
    /// (a headless robot's messages mutate nothing). Malformed / unknown JSON is
    /// ignored (never a panic).
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = <Self as Elm>::update(self, msg);
        }
    }

    /// Reads the active [`Theme`] (status colours follow the palette) and refits
    /// to any host size.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }

    fn kind(&self) -> &'static str {
        "jobview"
    }

    /// The portable view config: sort key + direction, the active kind filter, the
    /// selected row, and the folded-parent set. (The job rows themselves stream from
    /// each list's own source; this clones how they're SORTED / FILTERED / FOLDED, so
    /// a sibling shows the same arrangement.)
    fn portable_state(&self) -> Option<serde_json::Value> {
        // Sorted so the portable state is stable regardless of insertion order.
        let mut folded = self.state.folded.clone();
        folded.sort();
        Some(serde_json::json!({
            "sort": sort_key_str(self.state.sort),
            "descending": self.state.descending,
            "kind_filter": self.state.kind_filter,
            "selected": self.state.selected,
            "folded": folded,
        }))
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        let obj = match state.as_object() {
            Some(o) => o,
            None => return false,
        };
        if let Some(s) = obj.get("sort").and_then(|v| v.as_str())
            && let Some(k) = sort_key_from_str(s)
        {
            self.state.sort = k;
        }
        if let Some(d) = obj.get("descending").and_then(|v| v.as_bool()) {
            self.state.descending = d;
        }
        // `kind_filter` / `selected`: a string sets it, null clears it.
        match obj.get("kind_filter") {
            Some(serde_json::Value::String(s)) => self.state.kind_filter = Some(s.clone()),
            Some(serde_json::Value::Null) => self.state.kind_filter = None,
            _ => {}
        }
        match obj.get("selected") {
            Some(serde_json::Value::String(s)) => self.state.selected = Some(s.clone()),
            Some(serde_json::Value::Null) => self.state.selected = None,
            _ => {}
        }
        if let Some(folded) = obj.get("folded").and_then(|v| v.as_array()) {
            self.state.folded = folded.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
        }
        true
    }
});

/// Stable lowercase tag for a [`SortKey`] (the portable-state encoding).
fn sort_key_str(k: SortKey) -> &'static str {
    match k {
        SortKey::Started => "started",
        SortKey::Kind => "kind",
        SortKey::Target => "target",
        SortKey::Status => "status",
        SortKey::Elapsed => "elapsed",
    }
}

/// Parse a [`sort_key_str`] tag back into a [`SortKey`] (`None` = unknown).
fn sort_key_from_str(s: &str) -> Option<SortKey> {
    Some(match s {
        "started" => SortKey::Started,
        "kind" => SortKey::Kind,
        "target" => SortKey::Target,
        "status" => SortKey::Status,
        "elapsed" => SortKey::Elapsed,
        _ => return None,
    })
}

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

    fn entry(id: &str, kind: &str, status: JobStatus, started: i64, elapsed: Option<i64>) -> JobEntry {
        JobEntry {
            id: id.into(),
            kind: kind.into(),
            target: "repo".into(),
            status,
            started_micros: started,
            elapsed_ms: elapsed,
            detail: String::new(),
            meta: Vec::new(),
            parent_id: None,
        }
    }

    fn three() -> JobList {
        let mut l = JobList::new("Jobs");
        l.set_jobs(vec![
            entry("a", "bench_run", JobStatus::Done, 100, Some(1200)),
            entry("b", "docs_book_svg", JobStatus::Running, 300, None),
            entry("c", "bench_run", JobStatus::Failed, 200, Some(50)),
        ]);
        l
    }

    #[test]
    fn status_parse_roundtrip_and_active() {
        for s in [JobStatus::Queued, JobStatus::Running, JobStatus::Done, JobStatus::Failed] {
            assert_eq!(JobStatus::parse(s.as_str()), s);
        }
        assert!(JobStatus::Running.is_active() && JobStatus::Queued.is_active());
        assert!(!JobStatus::Done.is_active() && !JobStatus::Failed.is_active());
        assert_eq!(JobStatus::parse("bogus"), JobStatus::Queued);
    }

    #[test]
    fn default_sort_is_newest_first_by_start() {
        let l = three();
        let v = l.visible();
        // started: b=300, c=200, a=100 → descending.
        assert_eq!(v.iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["b", "c", "a"]);
    }

    #[test]
    fn sort_toggle_and_by_elapsed() {
        let mut l = three();
        l.update(Msg::SetSort(SortKey::Elapsed)); // descending: 1200(a), 50(c), running→0(b)
        assert_eq!(l.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["a", "c", "b"]);
        l.update(Msg::SetSort(SortKey::Elapsed)); // same key → ascending
        assert_eq!(l.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(), ["b", "c", "a"]);
    }

    #[test]
    fn portable_state_round_trips_sort_filter_fold_between_instances() {
        use facett_core::clipboard;
        use facett_core::Facet;
        // A: sorted by elapsed ascending, filtered to bench_run, a row selected,
        // a parent folded.
        let mut a = three();
        a.update(Msg::SetSort(SortKey::Elapsed));
        a.update(Msg::SetSort(SortKey::Elapsed)); // toggle → ascending
        a.update(Msg::SetKindFilter(Some("bench_run".into())));
        a.update(Msg::Select("a".into()));
        a.update(Msg::ToggleFold("a".into()));

        let env = clipboard::encode_component(Facet::kind(&a), &Facet::portable_state(&a).unwrap());
        let (kind, state) = clipboard::decode_component(&env).unwrap();
        assert_eq!(kind, "jobview");

        // B is a fresh list with the SAME rows but default arrangement.
        let mut b = three();
        assert_ne!(Facet::portable_state(&b), Facet::portable_state(&a), "differ before paste");
        assert!(Facet::load_state(&mut b, &state));
        assert_eq!(Facet::portable_state(&b), Facet::portable_state(&a), "B mirrors A's sort/filter/fold/selection");
        assert_eq!(b.state().sort, SortKey::Elapsed);
        assert!(!b.state().descending, "ascending direction adopted");
        assert_eq!(b.state().kind_filter.as_deref(), Some("bench_run"));
        assert!(b.is_folded("a"));
        // And the arrangement actually drives the visible rows the same way.
        assert_eq!(
            b.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(),
            a.visible().iter().map(|j| j.id.as_str()).collect::<Vec<_>>(),
        );
    }

    #[test]
    fn kind_filter_scopes_rows() {
        let mut l = three();
        l.update(Msg::SetKindFilter(Some("bench_run".into())));
        let v = l.visible();
        assert_eq!(v.len(), 2, "only the two bench_run rows");
        assert!(v.iter().all(|j| j.kind == "bench_run"));
        assert_eq!(l.kinds(), vec!["bench_run".to_string(), "docs_book_svg".to_string()]);
    }

    #[test]
    fn counts_and_state_json() {
        use facett_core::Facet;
        let l = three();
        assert_eq!(l.count(JobStatus::Running), 1);
        assert_eq!(l.count(JobStatus::Done), 1);
        assert_eq!(l.count(JobStatus::Failed), 1);
        let j = Facet::state_json(&l);
        assert_eq!(j["count"], 3);
        assert_eq!(j["running"], 1);
        assert_eq!(j["failed"], 1);
        // state_json reports the VISIBLE (sorted) order.
        let ids: Vec<&str> = j["jobs"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect();
        assert_eq!(ids, ["b", "c", "a"]);
    }

    /// Build a parent populate job with two member children (one done, one
    /// running) — the streaming-populate shape nornir emits.
    fn parent_with_kids() -> JobList {
        let mut l = JobList::new("Jobs");
        let mut p = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
        p.target = "nordisk".into();
        let mut c1 = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(200));
        c1.parent_id = Some("pop".into());
        let mut c2 = entry("m2", "workspace_clone", JobStatus::Running, 120, None);
        c2.parent_id = Some("pop".into());
        l.set_jobs(vec![p, c1, c2]);
        l
    }

    #[test]
    fn hierarchy_unfolded_shows_children_under_parent_with_depth() {
        use facett_core::Facet;
        let l = parent_with_kids();
        // tree(): parent first (depth 0), then its children (depth 1), newest-first.
        let t = l.tree();
        let ids: Vec<(&str, usize)> = t.iter().map(|(j, d)| (j.id.as_str(), *d)).collect();
        // parent pop (start 100) is top-level; children m2(120) then m1(110) under it.
        assert_eq!(ids, [("pop", 0), ("m2", 1), ("m1", 1)], "parent then its children indented");
        // child_summary on the parent rolls up its kids: 1/2 ✓ · 1 ▶.
        assert_eq!(l.child_summary("pop"), "1/2 ✓ · 1 ▶");
        // state_json exposes the hierarchy.
        let s = Facet::state_json(&l);
        assert_eq!(s["parents"], 1, "one row has children");
        assert_eq!(s["children"], 2, "two entries are children");
        let rows = s["jobs"].as_array().unwrap();
        assert_eq!(rows[0]["id"], "pop");
        assert_eq!(rows[0]["depth"], 0);
        assert_eq!(rows[1]["depth"], 1);
        assert_eq!(rows[1]["parent_id"], "pop");
    }

    #[test]
    fn folding_a_parent_hides_children_but_keeps_rollup() {
        let mut l = parent_with_kids();
        l.update(Msg::ToggleFold("pop".into()));
        assert!(l.is_folded("pop"));
        let t = l.tree();
        let ids: Vec<&str> = t.iter().map(|(j, _)| j.id.as_str()).collect();
        assert_eq!(ids, ["pop"], "folded: only the parent row, children hidden");
        // The roll-up still reports both kids even though they're hidden.
        assert_eq!(l.child_summary("pop"), "1/2 ✓ · 1 ▶");
        // Unfold restores the children.
        l.update(Msg::ToggleFold("pop".into()));
        assert!(!l.is_folded("pop"));
        assert_eq!(l.tree().len(), 3, "unfolded again: parent + 2 children");
    }

    #[test]
    fn child_streams_in_progressively_via_set_jobs() {
        // Streaming: children land one-at-a-time across refreshes; the tree grows.
        let mut l = JobList::new("Jobs");
        let p = {
            let mut p = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
            p.target = "ws".into();
            p
        };
        l.set_jobs(vec![p.clone()]);
        assert_eq!(l.tree().len(), 1, "just the parent at first");
        assert_eq!(l.child_summary("pop"), "", "no children yet");
        // First member clone lands done.
        let mut c1 = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(50));
        c1.parent_id = Some("pop".into());
        l.set_jobs(vec![p.clone(), c1.clone()]);
        assert_eq!(l.tree().len(), 2, "parent + first child streamed in");
        assert_eq!(l.child_summary("pop"), "1/1 ✓");
        // Second member clone lands done; the parent flips done too.
        let mut c2 = entry("m2", "workspace_clone", JobStatus::Done, 120, Some(60));
        c2.parent_id = Some("pop".into());
        let mut p_done = p.clone();
        p_done.status = JobStatus::Done;
        l.set_jobs(vec![p_done, c1, c2]);
        assert_eq!(l.tree().len(), 3, "both children present");
        assert_eq!(l.child_summary("pop"), "2/2 ✓");
    }

    /// The 3-level populate → phase → members hierarchy nornir feeds: one
    /// populate parent, one phase under it, three member jobs under the phase.
    fn populate_phase_members() -> JobList {
        let mut l = JobList::new("Jobs");
        let mut pop = entry("pop", "WORKSPACE_POPULATE", JobStatus::Running, 100, None);
        pop.target = String::new();
        let mut phase = entry("scip_ingest", "scip_ingest", JobStatus::Running, 110, None);
        phase.parent_id = Some("pop".into());
        phase.target = String::new();
        // Three members, in newest-first start order m3,m2,m1 so sort is stable.
        let mut m1 = entry("m1", "scip", JobStatus::Done, 121, Some(12_300));
        m1.parent_id = Some("scip_ingest".into());
        m1.target = "nornir".into();
        let mut m2 = entry("m2", "scip", JobStatus::Running, 122, None);
        m2.parent_id = Some("scip_ingest".into());
        m2.target = "holger".into();
        let mut m3 = entry("m3", "scip", JobStatus::Queued, 123, None);
        m3.parent_id = Some("scip_ingest".into());
        m3.target = "skade".into();
        l.set_jobs(vec![pop, phase, m1, m2, m3]);
        l.update(Msg::SetSort(SortKey::Started)); // flip newest-first → ascending start order
        l
    }

    #[test]
    fn branch_guides_draw_box_drawing_connectors_for_three_levels() {
        let l = populate_phase_members();
        // The whole tree renders (recursion reaches the grandchild members).
        let t = l.tree();
        let ids: Vec<(&str, usize)> = t.iter().map(|(j, d)| (j.id.as_str(), *d)).collect();
        assert_eq!(
            ids,
            [
                ("pop", 0),
                ("scip_ingest", 1),
                ("m1", 2),
                ("m2", 2),
                ("m3", 2)
            ],
            "populate → phase → three members, recursed to depth 2",
        );
        // The rendered ASCII guide prefix per row (root has none; the phase is the
        // last/only child of populate, so its members carry a CLEARED `   ` at the
        // phase column, then their own ├─/├─/└─ connector).
        let guides: Vec<String> = l.tree_rows().iter().map(guide_prefix).collect();
        assert_eq!(
            guides,
            vec![
                "".to_string(),        // pop (root)
                "└─ ".to_string(),     // scip_ingest (last child of pop)
                "   ├─ ".to_string(),  // m1  (phase cleared → blank, own ├─)
                "   ├─ ".to_string(),  // m2
                "   └─ ".to_string(),  // m3  (last member)
            ],
        );
    }

    #[test]
    fn branch_guides_continue_vertical_bar_under_a_non_last_parent() {
        use facett_core::Facet;
        // Two phases under one populate: the FIRST phase is non-last, so its
        // members carry a CONTINUING `│  ` at the phase column. A last member of
        // the first phase thus shows `│  └─ ` (the task's worked example).
        let mut l = JobList::new("Jobs");
        let pop = entry("pop", "POP", JobStatus::Running, 100, None);
        let mut p1 = entry("p1", "phase", JobStatus::Running, 110, None);
        p1.parent_id = Some("pop".into());
        let mut p2 = entry("p2", "phase", JobStatus::Running, 120, None);
        p2.parent_id = Some("pop".into());
        let mut g1 = entry("g1", "leaf", JobStatus::Done, 111, Some(5));
        g1.parent_id = Some("p1".into());
        let mut g2 = entry("g2", "leaf", JobStatus::Done, 112, Some(5));
        g2.parent_id = Some("p1".into());
        l.set_jobs(vec![pop, p1, p2, g1, g2]);
        l.update(Msg::SetSort(SortKey::Started)); // ascending → p1 before p2, g1 before g2

        let rows = l.tree_rows();
        let by: std::collections::HashMap<&str, String> =
            rows.iter().map(|r| (r.entry.id.as_str(), guide_prefix(r))).collect();
        assert_eq!(by["pop"], "");
        assert_eq!(by["p1"], "├─ ", "p1 is a non-last child of pop");
        assert_eq!(by["p2"], "└─ ", "p2 is the last child of pop");
        // p1 continues, so its members keep a vertical bar at the phase column.
        assert_eq!(by["g1"], "│  ├─ ", "non-last grandchild under a continued parent");
        assert_eq!(by["g2"], "│  └─ ", "last grandchild keeps the leading │");

        // state_json surfaces the same guide + is_last per row (additive keys).
        let s = Facet::state_json(&l);
        let row = s["jobs"].as_array().unwrap().iter().find(|r| r["id"] == "g2").unwrap();
        assert_eq!(row["guide"], "│  └─ ");
        assert_eq!(row["is_last"], true);
        assert_eq!(row["depth"], 2);
    }

    #[test]
    fn three_siblings_yield_branch_then_branch_then_last() {
        // A parent with three children yields ├─, ├─, └─ on the children.
        let mut l = parent_with_kids(); // pop + m1(done) + m2(running)
        // Add a third child so we get the ├─,├─,└─ pattern.
        let mut jobs = l.jobs().to_vec();
        let mut c3 = entry("m3", "workspace_clone", JobStatus::Queued, 130, None);
        c3.parent_id = Some("pop".into());
        jobs.push(c3);
        l.set_jobs(jobs);
        l.update(Msg::SetSort(SortKey::Started)); // ascending start → m1, m2, m3
        let rows = l.tree_rows();
        let kid_guides: Vec<String> = rows
            .iter()
            .filter(|r| r.depth == 1)
            .map(guide_prefix)
            .collect();
        assert_eq!(kid_guides, vec!["├─ ", "├─ ", "└─ "], "three children: ├─, ├─, └─");
    }

    #[test]
    fn orphan_child_is_promoted_to_top_level() {
        // A child whose parent isn't in the set must NOT be hidden.
        let mut l = JobList::new("Jobs");
        let mut c = entry("m1", "workspace_clone", JobStatus::Done, 110, Some(50));
        c.parent_id = Some("missing-parent".into());
        l.set_jobs(vec![c]);
        let t = l.tree();
        assert_eq!(t.len(), 1, "orphan child still shows");
        assert_eq!(t[0].0.id, "m1");
        assert_eq!(t[0].1, 0, "promoted to top-level depth");
    }

    #[test]
    fn fmt_elapsed_buckets() {
        assert_eq!(fmt_elapsed(JobStatus::Running, None), "…");
        assert_eq!(fmt_elapsed(JobStatus::Done, None), "—");
        assert_eq!(fmt_elapsed(JobStatus::Done, Some(340)), "340ms");
        assert_eq!(fmt_elapsed(JobStatus::Done, Some(1200)), "1.2s");
        assert_eq!(fmt_elapsed(JobStatus::Done, Some(123_000)), "2m 03s");
    }

    #[test]
    fn pull_source_feeds_via_ui_bridge() {
        struct Src;
        impl JobSource for Src {
            fn jobs(&self) -> Vec<JobEntry> {
                vec![entry("x", "k", JobStatus::Done, 1, Some(1))]
            }
        }
        let mut l = JobList::new("Jobs");
        l.set_source(Box::new(Src));
        // The source feeds on the next ui pull (through Msg::SetJobs); pre-render the
        // push rows are still empty.
        assert!(l.jobs().is_empty());
    }

    #[test]
    fn headless_render_draws_rows_and_reports_state() {
        use facett_core::harness;
        let mut l = three();
        let r = harness::headless_render(&mut l);
        assert_eq!(r.title, "Jobs");
        assert!(r.drew(), "the table tessellated to vertices");
        assert_eq!(r.state["count"], 3);
        assert_eq!(r.state["running"], 1);
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness (no egui, no GPU) and assert the resulting `JobState`. ───────────

    #[test]
    fn harness_drives_fold_and_select_across_a_streamed_reorder() {
        use facett_core::harness;
        // Start with a parent + one child. Fold the parent and select the child by
        // its STABLE id (FC-5).
        let mut l = parent_with_kids(); // pop + m1(done,110) + m2(running,120)
        let snap = harness::snapshot(&mut l, [Msg::ToggleFold("pop".into()), Msg::Select("m1".into())]);
        assert_eq!(snap.folded, vec!["pop".to_string()], "the parent id is folded");
        assert_eq!(snap.selected.as_deref(), Some("m1"), "the child is selected by stable id");

        // Now STREAM a reorder: the same jobs arrive with different start times (m1
        // now newest) AND a brand-new child m3 — the tree reshuffles under sort. The
        // fold + selection MUST survive because they key on ids, not tree position.
        let mut pop = entry("pop", "workspace_populate", JobStatus::Running, 100, None);
        pop.target = "nordisk".into();
        let mut m1 = entry("m1", "workspace_clone", JobStatus::Done, 900, Some(200)); // now newest
        m1.parent_id = Some("pop".into());
        let mut m2 = entry("m2", "workspace_clone", JobStatus::Running, 500, None);
        m2.parent_id = Some("pop".into());
        let mut m3 = entry("m3", "workspace_clone", JobStatus::Queued, 700, None); // new arrival
        m3.parent_id = Some("pop".into());
        let snap = harness::snapshot(&mut l, [Msg::SetJobs(vec![pop, m1, m2, m3])]);
        assert_eq!(snap.selected.as_deref(), Some("m1"), "selection survives the reorder (id-keyed, FC-5)");
        assert_eq!(snap.folded, vec!["pop".to_string()], "fold survives the reorder (id-keyed, FC-5)");
        // Folded still hides the (now 3) children; unfolding shows all three.
        assert_eq!(l.tree().len(), 1, "folded parent hides its children after the reorder");
        l.update(Msg::ToggleFold("pop".into()));
        assert_eq!(l.tree().len(), 4, "unfolded: parent + three children (m3 streamed in)");
    }

    #[test]
    fn harness_select_and_clear_transitions() {
        use facett_core::harness;
        let mut l = three();
        let snap = harness::snapshot(&mut l, [Msg::Select("b".into())]);
        assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
        assert_eq!(&snap, l.state(), "the snapshot is a clone of the live state");
        let snap = harness::snapshot(&mut l, [Msg::Select("a".into()), Msg::ClearSelection]);
        assert!(snap.selected.is_none(), "ClearSelection empties the selection");
    }

    #[test]
    fn harness_kind_filter_and_sort_transitions() {
        use facett_core::harness;
        let mut l = three();
        // Filter to bench_run + sort ascending by elapsed in one Msg sequence.
        let snap = harness::snapshot(
            &mut l,
            [Msg::SetKindFilter(Some("bench_run".into())), Msg::SetSort(SortKey::Elapsed), Msg::SetSort(SortKey::Elapsed)],
        );
        assert_eq!(snap.kind_filter.as_deref(), Some("bench_run"));
        assert_eq!(snap.sort, SortKey::Elapsed);
        assert!(!snap.descending, "second SetSort on the same key flipped to ascending");
        // Clearing the filter restores all kinds.
        let snap = harness::snapshot(&mut l, [Msg::SetKindFilter(None)]);
        assert!(snap.kind_filter.is_none());
    }

    // ── progress meter (per-row progress bar) ─────────────────────────────────

    #[test]
    fn progress_fraction_renders_a_determinate_bar_at_that_fill() {
        // A job carrying a 0.4 progress fraction reads as a DETERMINATE meter at
        // 0.4, and the drawn fill width is 40% of the track — a value-assert on the
        // exact geometry the paint path uses, not an eyeballed pixel.
        let j = JobEntry::new("dl", "download", JobStatus::Running).with_progress(0.4);
        assert_eq!(j.progress_meter(), ProgressMeter::Determinate(0.4));
        assert_eq!(meter_fill_width(j.progress_meter(), 100.0), 40.0, "40%-wide fill");
        // Clamped: an over-range fraction never yields a >full bar.
        let over = JobEntry::new("x", "k", JobStatus::Running).with_progress(1.9);
        assert_eq!(over.progress_meter(), ProgressMeter::Determinate(1.0));
        assert_eq!(meter_fill_width(over.progress_meter(), 100.0), 100.0);
        // A tasks-done/total ratio maps to the same determinate fill (~30%).
        let ratio = JobEntry::new("y", "k", JobStatus::Running).with_progress_ratio(3, 10);
        assert!((meter_fill_width(ratio.progress_meter(), 100.0) - 30.0).abs() < 0.01, "3/10 → 30%");
    }

    #[test]
    fn no_progress_job_renders_the_honest_empty_or_indeterminate_state() {
        // A job with NO progress marker never fakes a full bar:
        //  • terminal/queued → NO meter at all (honest empty)…
        let done = JobEntry::new("a", "k", JobStatus::Done);
        assert_eq!(done.progress_meter(), ProgressMeter::None);
        assert_eq!(meter_fill_width(done.progress_meter(), 100.0), 0.0, "no fake fill");
        let queued = JobEntry::new("q", "k", JobStatus::Queued);
        assert_eq!(queued.progress_meter(), ProgressMeter::None);
        //  • …a RUNNING but unmeasured job → an honest INDETERMINATE sweep (not full).
        let running = JobEntry::new("r", "k", JobStatus::Running);
        assert_eq!(running.progress_meter(), ProgressMeter::Indeterminate);
        assert_eq!(meter_fill_width(running.progress_meter(), 100.0), 0.0, "indeterminate has no determinate fill");
        // An explicit indeterminate marker forces the sweep even off a running row.
        let indet = JobEntry::new("i", "k", JobStatus::Queued).with_indeterminate_progress();
        assert_eq!(indet.progress_meter(), ProgressMeter::Indeterminate);
    }

    #[test]
    fn state_json_surfaces_the_progress_meter_per_row() {
        use facett_core::Facet;
        let mut l = JobList::new("Jobs");
        l.set_jobs(vec![
            JobEntry::new("d", "download", JobStatus::Running).with_progress(0.4),
            JobEntry::new("t", "task", JobStatus::Done), // no progress → null
        ]);
        let s = Facet::state_json(&l);
        let rows = s["jobs"].as_array().unwrap();
        let d = rows.iter().find(|r| r["id"] == "d").unwrap();
        assert_eq!(d["progress"]["kind"], "determinate");
        assert!((d["progress"]["fraction"].as_f64().unwrap() - 0.4).abs() < 1e-4, "40% surfaced");
        let t = rows.iter().find(|r| r["id"] == "t").unwrap();
        assert_eq!(t["progress"], serde_json::Value::Null, "no-progress terminal job has no meter");
    }

    #[test]
    fn progress_stamp_is_single_slotted_and_survives_meta() {
        // Re-stamping progress replaces (never accumulates) the meta marker, and a
        // pre-existing meta key rides alongside untouched.
        let mut j = JobEntry::new("j", "k", JobStatus::Running);
        j.meta.push(("node".into(), "loki".into()));
        let j = j.with_progress(0.2).with_progress(0.7);
        let progs: Vec<&String> =
            j.meta.iter().filter(|(k, _)| k == PROGRESS_META_KEY).map(|(_, v)| v).collect();
        assert_eq!(progs.len(), 1, "one progress marker, the latest");
        assert_eq!(j.progress_meter(), ProgressMeter::Determinate(0.7));
        assert!(j.meta.iter().any(|(k, v)| k == "node" && v == "loki"), "other meta preserved");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut l = parent_with_kids();
        // FC-8: the job view does no I/O, so the effect stream is always empty.
        let effects = harness::drive(
            &mut l,
            [Msg::ToggleFold("pop".into()), Msg::Select("m1".into()), Msg::SetSort(SortKey::Kind)],
        );
        assert!(effects.is_empty(), "FC-8: jobview emits no Effects — `Effect` is uninhabited");
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(l.state()).unwrap();
        let back: JobState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, l.state(), "serde(state) -> state round-trips");
    }
}

#[cfg(test)]
mod state_json_inverse_tests {
    use super::*;
    /// **The published projection and its reader agree, field for field.**
    ///
    /// `state_json` is hand-written, so `serde_json::from_value` does NOT round-trip it:
    /// `phase` is a [`StepPhase::key`] string and `count` is folded into a nested `meter`
    /// object. A consumer that used the derive would get every step `Pending` with no
    /// counts — a bar that renders and lies.
    ///
    /// RED when reverted: change either side (drop `line` from the projection, or read
    /// `phase` with the derive) and the re-projection stops matching.
    #[test]
    fn state_json_round_trips_through_from_state_json() {
        let meter = StepMeter {
            generation: 7,
            caption: "sweden · step 2 of 4 — convert pass 2 (nodes/ways/relations)".into(),
            hover: "the previous cut failed - retrying\na DONE convert pass 1".into(),
            steps: vec![
                StepCell { key: "a".into(), label: "convert pass 1 (node coord store)".into(), phase: StepPhase::Done, line: "a DONE convert pass 1 (node coord store)".into(), count: None },
                StepCell { key: "b".into(), label: "convert pass 2 (nodes/ways/relations)".into(), phase: StepPhase::Active, line: "b ACTIVE convert pass 2".into(), count: Some((3, 19)) },
                StepCell { key: "c".into(), label: "relation clip + geometry assembly".into(), phase: StepPhase::Pending, line: "c PENDING relation clip".into(), count: None },
                StepCell { key: "d".into(), label: "tile pyramid".into(), phase: StepPhase::Failed, line: "d FAILED tile pyramid".into(), count: None },
            ],
        };

        let published = meter.state_json();
        let read_back = StepMeter::from_state_json(&published).expect("an object parses");

        // Every field a viewer sees, not a state round-trip: the sentence, the run
        // identity, the per-step phases and the ONE genuine count.
        assert_eq!(read_back.caption, meter.caption_text(), "the producer's sentence survives verbatim");
        assert_eq!(read_back.hover, meter.hover_text());
        assert_eq!(read_back.generation, 7, "run identity survives — a restart must stay legible");
        assert_eq!(read_back.steps.len(), 4);
        let phases: Vec<StepPhase> = read_back.steps.iter().map(|s| s.phase).collect();
        assert_eq!(phases, vec![StepPhase::Done, StepPhase::Active, StepPhase::Pending, StepPhase::Failed]);
        assert_eq!(read_back.steps[1].count, Some((3, 19)), "the measured count comes back out of `meter`");
        assert_eq!(read_back.steps[0].count, None, "an unmeasured step stays unmeasured");
        assert_eq!(read_back.steps[2].label, "relation clip + geometry assembly");
        assert_eq!(read_back.steps[3].line, "d FAILED tile pyramid");
        // And the whole projection is stable: re-publishing the read-back meter gives
        // the identical document, so nothing was dropped on the way through.
        assert_eq!(read_back.state_json(), published, "re-projecting must reproduce the document");

        // ── The negative leg: what the DERIVE loses, measured rather than assumed ──
        // This reader only earns its keep if the derive cannot do the job. It nearly
        // can: `StepPhase` is `rename_all = "lowercase"`, so the phases parse. What it
        // drops is `count`, which the projection folds into the nested `meter` object.
        // A thin consumer on the derive would draw dwarves' measured `(3, 19)` clone
        // step as an indeterminate shimmer — plausible, and wrong about the one number
        // the producer knew.
        let via_derive: StepMeter =
            serde_json::from_value(published.clone()).expect("the derive parses the document");
        assert_eq!(
            via_derive.steps.iter().map(|s| s.phase).collect::<Vec<_>>(),
            phases,
            "the derive DOES get the phases (rename_all = lowercase) — do not claim otherwise"
        );
        assert_eq!(
            via_derive.steps[1].count, None,
            "and it LOSES the measured count — this is the whole reason from_state_json exists"
        );
        assert_eq!(
            read_back.steps[1].count,
            Some((3, 19)),
            "which this reader keeps"
        );
    }

    /// A non-object is `None`, and a sparse object reads as far as it published — a
    /// producer on an older build is never rejected wholesale.
    #[test]
    fn a_partial_or_wrong_shaped_document_degrades_instead_of_lying() {
        assert!(StepMeter::from_state_json(&serde_json::json!("nope")).is_none());
        assert!(StepMeter::from_state_json(&serde_json::json!(null)).is_none());
        let sparse = StepMeter::from_state_json(&serde_json::json!({ "caption": "only a sentence" }))
            .expect("an object parses");
        assert_eq!(sparse.caption, "only a sentence");
        assert_eq!(sparse.generation, 0);
        assert!(sparse.steps.is_empty(), "no steps published ⇒ no steps invented");
    }
}