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

nordisk/facett

facett-card/src/lib.rs

//! **facett-card** — a YouTrack / Jira-style **record card**: a titled record
//! with a **typed field grid** ([`CardField`]) and **nestable sub-cards**
//! ([`Card::children`], each in a collapsible header, recursively — a parent
//! issue expands into its sub-tasks). A [`CardView`] is the [`Facet`] driver: it
//! renders the card read-only (theme-aware chips / bars / hyperlinks per field
//! kind), toggles into an **edit form** that *reuses* [`facett_form`] rather than
//! re-implementing inputs, and serializes the whole card (incl. children) as
//! pretty JSON for `copy()`.
//!
//! ## Why edit-mode reuses facett-form
//! A card in edit mode is just a *bound form* over the card's own fields. We make
//! the card itself the [`FormModel`]: [`Card::fields`] lays each [`CardField`]
//! into the [`FormUi`] field-builder with the right widget for its [`FieldKind`]
//! (text / number / select / date / …), and [`Card::from_clip`]/[`Card::to_clip`]
//! round-trip the card as JSON. The hard parts (labels, per-field outlines, the
//! Save/Reset bar, dirty-tracking, clipboard) are facett-form's — we don't
//! re-solve them. Sub-cards stay read-mode; expanding one and toggling *its* edit
//! is the same recursion.

use egui::Ui;
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::{Facet, FacetCaps, Semantics, theme};
use facett_form::{FormModel, FormUi, FormView, ValidationReport};
use serde::{Deserialize, Serialize};

/// A field's typed value. Kept deliberately small + serde-friendly so a whole
/// [`Card`] (incl. children) round-trips through JSON for copy/paste + edit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
    Text(String),
    Number(f64),
    /// A single state/enum token (e.g. `"open"`), plus the choosable set so edit
    /// mode can render a closed `ComboBox`.
    Enum { state: String, options: Vec<String> },
    /// A user/assignee — a display name (avatar is an initial).
    User(String),
    /// A `YYYY-MM-DD` date string (masked text in edit mode).
    Date(String),
    /// A list of tag tokens, rendered as chips.
    Tags(Vec<String>),
    /// A hyperlink: a label + a URL.
    Link { label: String, url: String },
    /// A 0.0–1.0 progress fraction, rendered as a bar.
    Progress(f32),
}

impl FieldValue {
    /// The kind this value renders as (drives the read view + edit widget).
    pub fn kind(&self) -> FieldKind {
        match self {
            FieldValue::Text(_) => FieldKind::Text,
            FieldValue::Number(_) => FieldKind::Number,
            FieldValue::Enum { .. } => FieldKind::Enum,
            FieldValue::User(_) => FieldKind::User,
            FieldValue::Date(_) => FieldKind::Date,
            FieldValue::Tags(_) => FieldKind::Tags,
            FieldValue::Link { .. } => FieldKind::Link,
            FieldValue::Progress(_) => FieldKind::Progress,
        }
    }

    /// A compact display string (for `state_json` and the avatar initial).
    pub fn display(&self) -> String {
        match self {
            FieldValue::Text(s) | FieldValue::User(s) | FieldValue::Date(s) => s.clone(),
            FieldValue::Number(n) => trim_float(*n),
            FieldValue::Enum { state, .. } => state.clone(),
            FieldValue::Tags(t) => t.join(", "),
            FieldValue::Link { label, .. } => label.clone(),
            FieldValue::Progress(p) => format!("{:.0}%", (p.clamp(0.0, 1.0)) * 100.0),
        }
    }
}

/// The render/edit kind of a field — what widget the read view paints and what
/// input the edit form swaps in. Mirrors the variants of [`FieldValue`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
    Text,
    Number,
    Enum,
    User,
    Date,
    Tags,
    Link,
    Progress,
}

impl FieldKind {
    pub fn as_str(self) -> &'static str {
        match self {
            FieldKind::Text => "text",
            FieldKind::Number => "number",
            FieldKind::Enum => "enum",
            FieldKind::User => "user",
            FieldKind::Date => "date",
            FieldKind::Tags => "tags",
            FieldKind::Link => "link",
            FieldKind::Progress => "progress",
        }
    }
}

/// One row of a card's field grid: a `label` + a typed `value`. `kind` is derived
/// from the value (kept as a field so it survives serde + can be asserted).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CardField {
    pub label: String,
    pub value: FieldValue,
    pub kind: FieldKind,
}

impl CardField {
    /// Build a field; the `kind` is inferred from the value.
    pub fn new(label: impl Into<String>, value: FieldValue) -> Self {
        let kind = value.kind();
        Self { label: label.into(), value, kind }
    }

    /// A stable, slug-ish id for the form/validation report (label, lowercased).
    fn id(&self) -> String {
        self.label.to_ascii_lowercase().replace([' ', '/', '(', ')'], "_")
    }

    /// Render this field's *value* read-only into `ui`, theme-aware per kind.
    fn show_read(&self, ui: &mut Ui) {
        let th = theme(ui);
        match &self.value {
            FieldValue::Text(s) => {
                ui.label(egui::RichText::new(s).color(th.text));
            }
            FieldValue::Number(n) => {
                ui.label(egui::RichText::new(trim_float(*n)).color(th.text).monospace());
            }
            FieldValue::Enum { state, options } => {
                // The enum chip carries how many states it can take as its value,
                // so the AccessKit node announces both the current state (label)
                // and the size of its option set (numeric_value).
                chip(ui, state, th.accent, th.glow, Some(options.len() as f64))
            }
            FieldValue::User(name) => {
                ui.horizontal(|ui| {
                    avatar(ui, name, th.accent);
                    ui.label(egui::RichText::new(name).color(th.text));
                });
            }
            FieldValue::Date(d) => {
                ui.label(egui::RichText::new(d).color(th.text_dim).monospace());
            }
            FieldValue::Tags(tags) => {
                ui.horizontal_wrapped(|ui| {
                    for t in tags {
                        chip(ui, t, th.point, th.point, None);
                    }
                });
            }
            FieldValue::Link { label, url } => {
                ui.hyperlink_to(egui::RichText::new(label).color(th.accent), url);
            }
            FieldValue::Progress(p) => {
                let frac = p.clamp(0.0, 1.0);
                ui.add(egui::ProgressBar::new(frac).desired_width(140.0).text(format!("{:.0}%", frac * 100.0)));
            }
        }
    }

    /// Lay this field into an edit form (reusing facett-form's field-builders).
    fn build_edit(&mut self, f: &mut FormUi<'_>) {
        let id = self.id();
        match &mut self.value {
            FieldValue::Text(s) => f.text(&id, &self.label, s),
            FieldValue::Number(n) => {
                // Edit as f64 via a scratch (DragValue<i64/f64>); facett-form has f64.
                f.number_f64(&id, &self.label, n, None);
            }
            FieldValue::Enum { state, options } => {
                let opts: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
                f.select(&id, &self.label, state, &opts);
            }
            FieldValue::User(name) => f.text(&id, &self.label, name),
            FieldValue::Date(d) => f.date(&id, &self.label, d),
            FieldValue::Tags(tags) => {
                // Edit the tag list as a comma-joined string, split back on commit.
                let mut joined = tags.join(", ");
                f.text(&id, &self.label, &mut joined);
                *tags = joined.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
            }
            FieldValue::Link { label, url } => {
                f.text(&format!("{id}_label"), &format!("{} (label)", self.label), label);
                f.text(&format!("{id}_url"), &format!("{} (url)", self.label), url);
            }
            FieldValue::Progress(p) => {
                // Edit progress as a 0–100 integer percent.
                let mut pct = (p.clamp(0.0, 1.0) * 100.0).round() as i64;
                f.number_i64(&id, &format!("{} (%)", self.label), &mut pct, Some(0..=100));
                *p = (pct as f32 / 100.0).clamp(0.0, 1.0);
            }
        }
    }

    /// This field as JSON for `state_json` (label/kind/display value).
    fn to_state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "label": self.label,
            "kind": self.kind.as_str(),
            "value": self.value.display(),
        })
    }
}

/// A titled record: an optional `id`/`title`, a `fields` grid, and nestable
/// `children` sub-cards (each rendered in a collapsible header, recursively).
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Card {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(default)]
    pub fields: Vec<CardField>,
    #[serde(default)]
    pub children: Vec<Card>,
}

impl Card {
    /// A card with a title (no id, no fields, no children yet).
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: Some(title.into()), ..Default::default() }
    }

    /// Builder: set the id.
    pub fn with_id(mut self, id: impl Into<String>) -> Self {
        self.id = Some(id.into());
        self
    }

    /// Builder: push a field.
    pub fn field(mut self, field: CardField) -> Self {
        self.fields.push(field);
        self
    }

    /// Builder: push a sub-card.
    pub fn child(mut self, child: Card) -> Self {
        self.children.push(child);
        self
    }

    /// Total descendant count (children + their children, recursively).
    pub fn descendant_count(&self) -> usize {
        self.children.iter().map(|c| 1 + c.descendant_count()).sum()
    }

    /// The card's title for display (falls back to id, then `"(untitled)"`).
    fn display_title(&self) -> String {
        self.title
            .clone()
            .or_else(|| self.id.clone())
            .unwrap_or_else(|| "(untitled)".to_string())
    }

    /// Render the field grid read-only (label column + themed value).
    fn show_fields_read(&self, ui: &mut Ui, salt: &str) {
        if self.fields.is_empty() {
            return;
        }
        let th = theme(ui);
        egui::Grid::new(format!("facett_card_grid_{salt}"))
            .num_columns(2)
            .spacing([16.0, 6.0])
            .striped(true)
            .show(ui, |ui| {
                for field in &self.fields {
                    ui.label(egui::RichText::new(&field.label).color(th.text_dim).strong());
                    field.show_read(ui);
                    ui.end_row();
                }
            });
    }

    /// Render this card read-only: title, fields, then each child in a
    /// `CollapsingHeader` (recursively). `salt` keeps egui ids unique per nesting.
    fn show_read(&self, ui: &mut Ui, salt: &str) {
        let th = theme(ui);
        if self.title.is_some() || self.id.is_some() {
            ui.horizontal(|ui| {
                ui.heading(egui::RichText::new(self.display_title()).color(th.text));
                if let Some(id) = &self.id {
                    ui.label(egui::RichText::new(format!("#{id}")).color(th.text_dim).monospace());
                }
            });
        }
        self.show_fields_read(ui, salt);
        for (i, child) in self.children.iter().enumerate() {
            let child_salt = format!("{salt}_{i}");
            egui::CollapsingHeader::new(egui::RichText::new(child.display_title()).color(th.accent))
                .id_salt(format!("facett_card_child_{child_salt}"))
                .default_open(false)
                .show(ui, |ui| child.show_read(ui, &child_salt));
        }
    }
}

// `Card` is the FormModel for its own edit mode — a card-edit is a bound form.
impl FormModel for Card {
    fn fields(&mut self, f: &mut FormUi<'_>) {
        f.section(&self.display_title(), |f| {
            for field in &mut self.fields {
                field.build_edit(f);
            }
        });
    }

    fn validate(&self) -> ValidationReport {
        // The card has no hard field rules of its own; consumers can subclass by
        // wrapping. Always-clean keeps the Save/Reset bar live for editing.
        ValidationReport::ok()
    }

    fn to_clip(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_default()
    }

    fn from_clip(&mut self, text: &str) -> bool {
        serde_json::from_str::<Card>(text).map(|c| *self = c).is_ok()
    }
}

/// **The Model (FC-1 / FC-3).** All of a [`CardView`]'s observable state in one
/// serializable, round-trippable struct: the live `card`, the edit-mode `baseline`
/// (the clean card that dirty-tracking / the form's *Reset* button revert to), the
/// read/`editing` flag, and the uniform `scale`. `card`/`baseline` are separate so
/// the form's unsaved-marker survives across frames even though the pure `view`
/// rebuilds the transient [`FormView`] buffer each frame (see [`CardView::view`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CardState {
    /// The live card (reflects in-progress edits).
    pub card: Card,
    /// The clean card the edit form's dirty-marker / Reset revert to.
    #[serde(default)]
    pub baseline: Card,
    /// Read (false) vs edit (true) mode.
    #[serde(default)]
    pub editing: bool,
    /// Uniform scale (1.0 = native); drives the read-view text size.
    #[serde(default = "default_scale")]
    pub scale: f32,
}

fn default_scale() -> f32 {
    1.0
}

/// **Every input the card accepts (FC-2).** The *only* legal way to mutate
/// [`CardState`] — funneled through [`CardView::update`].
#[derive(Clone, Debug)]
pub enum Msg {
    /// Flip read ⇄ edit mode (the "Edit" / "Done editing" toggle).
    ToggleEdit,
    /// Set edit mode explicitly (programmatic / headless entry point).
    SetEditing(bool),
    /// Set the uniform scale (clamped to `0.25..=4.0` in `update`).
    SetScale(f32),
    /// Apply the edit form's result: the whole edited card (baseline preserved, so
    /// dirty-tracking keeps working). Emitted by the pure `view` in edit mode.
    EditCard(Card),
    /// Commit the current edits: the *Save* button (`baseline = card`).
    Save,
    /// Wholesale replace the card (paste): `card` **and** `baseline` adopt it, so a
    /// pasted card starts clean.
    SetCard(Card),
}

/// **Side work as data (FC-8).** A card does no I/O — the uninhabited enum is the
/// honest statement of that.
#[derive(Clone, Debug)]
pub enum Effect {}

/// The [`Facet`] driver: a [`Card`] + ui state (scale, read/edit mode) as a single
/// [`Elm`](facett_core::Elm) component. Read mode paints the themed card; edit mode
/// renders a transient [`FormView<Card>`] over the card's own fields (children stay
/// read-mode). `copy()` emits the whole card as JSON.
pub struct CardView {
    /// Tab / heading label.
    pub title: String,
    /// All observable state (FC-3).
    state: CardState,
}

impl CardView {
    /// New view over `card`, titled `title`, starting in read mode (and clean:
    /// `baseline == card`).
    pub fn new(title: impl Into<String>, card: Card) -> Self {
        let title = title.into();
        let state = CardState { baseline: card.clone(), card, editing: false, scale: 1.0 };
        Self { title, state }
    }

    /// The live card (reflects in-progress edits).
    pub fn card(&self) -> &Card {
        &self.state.card
    }

    /// Whether the view is currently in edit mode.
    pub fn is_editing(&self) -> bool {
        self.state.editing
    }

    /// Switch read/edit mode programmatically (headless-test entry point).
    pub fn set_editing(&mut self, editing: bool) {
        let _ = self.update(Msg::SetEditing(editing));
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; a card does no
    /// I/O, so the returned [`Effect`] vec is always empty.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::ToggleEdit => self.state.editing = !self.state.editing,
            Msg::SetEditing(b) => self.state.editing = b,
            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
            Msg::EditCard(c) => self.state.card = c,
            Msg::Save => self.state.baseline = self.state.card.clone(),
            Msg::SetCard(c) => {
                self.state.baseline = c.clone();
                self.state.card = c;
            }
        }
        Vec::new()
    }

    /// **FC-9 / FC-4** — a **pure** function of `&self`: it paints and *returns* the
    /// [`Msg`]s it produced, mutating neither the model nor any I/O.
    ///
    /// Edit mode is the interesting case: `FormView::ui` mutates its model in place,
    /// so calling it on `self.form` would break purity. Instead we build a **local,
    /// throw-away** `FormView` seeded so `baseline == self.state.baseline` and
    /// `model == self.state.card` — then run it. All mutation lands on that local
    /// buffer; the only thing that escapes `&self` is the resulting card as an
    /// `EditCard` (and a `Save` when the form's Save button fires). egui's own
    /// per-widget state (text cursors, …) persists via egui memory keyed on the
    /// stable field ids, so rebuilding the buffer each frame is seamless.
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        let mut msgs = Vec::new();
        let editing = self.state.editing;

        // Mode toggle bar.
        ui.horizontal(|ui| {
            let label = if editing { "Done editing" } else { "Edit" };
            if ui.selectable_label(editing, label).clicked() {
                msgs.push(Msg::ToggleEdit);
            }
            let th = theme(ui);
            ui.label(
                egui::RichText::new(if editing { "edit" } else { "read" })
                    .color(th.text_dim)
                    .small(),
            );
        });
        ui.separator();

        if editing {
            // Edit mode: render the card's own fields as a bound facett-form over a
            // LOCAL buffer (baseline = clean, model = current) — pure w.r.t. `self`.
            let mut form = FormView::new(self.title.clone(), self.state.baseline.clone());
            *form.model_mut() = self.state.card.clone();
            form.ui(ui);
            let edited = form.model().clone();
            if edited != self.state.card {
                // Covers both live edits and a Reset click (edited == baseline).
                msgs.push(Msg::EditCard(edited));
            }
            if form.take_submitted().is_some() {
                msgs.push(Msg::Save);
            }
        } else {
            // Read mode: themed card + nested sub-cards, scaled.
            let prev = ui.style().text_styles.clone();
            if (self.state.scale - 1.0).abs() > f32::EPSILON {
                ui.style_mut().text_styles.iter_mut().for_each(|(_, font)| {
                    font.size *= self.state.scale;
                });
            }
            self.state.card.show_read(ui, "root");
            ui.style_mut().text_styles = prev;
        }

        // ── render-lane emit: this pure-view path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        {
            let card = &self.state.card;
            facett_core::testmatrix::emit(
                "facett-card::CardView::view",
                "ui_render",
                // OK = a read-mode card has content to paint (fields or children),
                // or it is in edit mode (the bound form always renders its section).
                editing || !card.fields.is_empty() || !card.children.is_empty(),
                &format!(
                    "editing={} fields={} children={} descendants={}",
                    editing,
                    card.fields.len(),
                    card.children.len(),
                    card.descendant_count(),
                ),
            );
        }

        msgs
    }
}

// ── typed copy/paste (§16) — the whole card as JSON Text ──────────────────────
impl CopySource for CardView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        let s = serde_json::to_string_pretty(&self.state.card).ok()?;
        (!s.is_empty()).then_some(ClipPayload::Text(s))
    }
}

impl PasteTarget for CardView {
    fn accepts(&self, k: ClipKind) -> bool {
        matches!(k, ClipKind::Text)
    }
    fn paste_payload(&mut self, p: &ClipPayload) {
        if let Ok(c) = serde_json::from_str::<Card>(&p.as_text()) {
            let _ = self.update(Msg::SetCard(c));
        }
    }
}

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

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

// The bridge macro writes `impl Facet for CardView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the splice below.
// **Form 3** (`custom_state_json`) because the card publishes a **richer**
// `state_json` than plain `serde(state())`: a structural projection of the card
// (id / title / per-field label·kind·display-value / recursive children with
// counts) — NOT the raw model (which carries the edit `baseline` + option sets).
// This preserves the exact observable state_json the demo mega-matrix reads.
facett_core::impl_facet_via_elm!(CardView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "editing": self.state.editing,
            "scale": self.state.scale,
            "card": card_state_json(&self.state.card),
        })
    }

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

    fn scale(&self) -> f32 {
        self.state.scale
    }

    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetScale(scale));
    }

    fn selection_json(&self) -> serde_json::Value {
        // The whole card is the "selection" for clipboard purposes.
        card_state_json(&self.state.card)
    }

    /// Facet-level copy: the whole card (incl. nested children) as pretty JSON.
    fn copy(&mut self) -> Option<String> {
        let s = serde_json::to_string_pretty(&self.state.card).ok()?;
        if s.is_empty() { None } else { Some(s) }
    }

    /// Facet-level paste: replace the card from pasted JSON (via `update`).
    fn paste(&mut self, text: &str) -> bool {
        match serde_json::from_str::<Card>(text) {
            Ok(c) => {
                let _ = self.update(Msg::SetCard(c));
                true
            }
            Err(_) => false,
        }
    }
});

/// Structural JSON for a card: id, title, fields (label/kind/value), and the
/// children recursively (with a `children_count` for quick assertions).
fn card_state_json(card: &Card) -> serde_json::Value {
    serde_json::json!({
        "id": card.id,
        "title": card.title,
        "fields": card.fields.iter().map(CardField::to_state_json).collect::<Vec<_>>(),
        "children_count": card.children.len(),
        "descendant_count": card.descendant_count(),
        "children": card.children.iter().map(card_state_json).collect::<Vec<_>>(),
    })
}

/// A tinted "chip" — a rounded, accent-tinted pill (status/enum + tags). The fill
/// is the glow at low alpha, stroked with the base colour; theme-aware.
///
/// FC-4: the chip is custom-painted, so on its own it emits no AccessKit node.
/// We allocate a labelled node (role `Label`) over the chip's rect, keyed on the
/// chip's *text* (a stable domain key, not a loop index — FC-5), so a screen
/// reader / `get_by_label` can find it. `count` (e.g. an enum's option-set size)
/// rides along as the AccessKit `numeric_value` when present.
fn chip(ui: &mut Ui, text: &str, base: egui::Color32, glow: egui::Color32, count: Option<f64>) {
    let th = theme(ui);
    let galley = ui.painter().layout_no_wrap(
        text.to_string(),
        egui::FontId::proportional(12.0),
        th.text,
    );
    let pad = egui::vec2(8.0, 3.0);
    let (rect, _) = ui.allocate_exact_size(galley.size() + pad * 2.0, egui::Sense::hover());
    let fill = egui::Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), 40);
    let cr = egui::CornerRadius::same(6);
    ui.painter().rect_filled(rect, cr, fill);
    ui.painter().rect_stroke(rect, cr, egui::Stroke::new(1.0_f32, base), egui::StrokeKind::Inside);
    ui.painter().galley(rect.min + pad, galley, th.text);

    // The value-bearing AccessKit node over the painted pill.
    let mut sem = Semantics::new(egui::WidgetType::Label, text);
    if let Some(c) = count {
        sem = sem.value(c);
    }
    let base_id = ui.id();
    let _ = facett_core::a11y::node(ui, base_id, ("card-chip", text), egui::Sense::hover(), rect, sem);
}

/// A small round avatar: a filled disc with the name's first letter, accent-tinted.
///
/// FC-4: the disc + initial are painter-only, so we allocate a labelled
/// AccessKit node (role `Label`, label = the full name) over the avatar's rect,
/// keyed on the name (stable domain key, not a loop index — FC-5). This makes
/// the avatar findable / announceable even though only an initial is drawn.
fn avatar(ui: &mut Ui, name: &str, accent: egui::Color32) {
    let r = 9.0;
    let (rect, _) = ui.allocate_exact_size(egui::vec2(r * 2.0, r * 2.0), egui::Sense::hover());
    let c = rect.center();
    let fill = egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 60);
    ui.painter().circle_filled(c, r, fill);
    ui.painter().circle_stroke(c, r, egui::Stroke::new(1.0_f32, accent));
    let initial = name.chars().next().unwrap_or('?').to_uppercase().to_string();
    ui.painter().text(c, egui::Align2::CENTER_CENTER, initial, egui::FontId::proportional(11.0), accent);

    // The avatar's AccessKit node carries the *full* name (not just the initial).
    let base_id = ui.id();
    let sem = Semantics::new(egui::WidgetType::Label, name);
    let _ = facett_core::a11y::node(ui, base_id, ("card-avatar", name), egui::Sense::hover(), rect, sem);
}

/// Render a float without a trailing `.0` for whole numbers.
fn trim_float(n: f64) -> String {
    if n.fract() == 0.0 && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        format!("{n}")
    }
}

#[cfg(test)]
mod tests;