Skip to main content

teksilo_widgets/
avatar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Avatar` — circular (or rounded-square / square) user-identity widget.
5//!
6//! Displays either a person's image (clipped to the configured shape via
7//! a CPU-side anti-aliased alpha mask applied at construction time) or
8//! their initials over a hash-derived background colour. Optional
9//! presence indicator (Online / Offline / Away / Busy) and outer ring.
10//! Can be made activable to serve as a user-menu trigger.
11//!
12//! ```rust
13//! # use teksilo_widgets::{Avatar, AvatarPresence, AvatarSize};
14//! # use teksilo_canvas::raster::RasterIcon;
15//! # use teksilo_i18n::lit;
16//! # use teksilo_core::Intent;
17//! # let face = RasterIcon::from_raw(vec![0u8; 4 * 4 * 4], 4, 4);
18//! // Image with a presence dot.
19//! let _w = Avatar::with_image(&face)
20//!     .alt(lit!("Jane Doe"))
21//!     .presence(AvatarPresence::Online)
22//!     .size(AvatarSize::Medium);
23//!
24//! // Hash-tinted initials, auto-derived from a name.
25//! let _w = Avatar::with_name(lit!("Jane Doe")).size(AvatarSize::Large);
26//!
27//! // Click target — opens a user menu via an intent.
28//! let _w = Avatar::with_image(&face)
29//!     .label(lit!("Open user menu"))
30//!     .alt(lit!("Jane Doe"))
31//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.open-user-menu")));
32//! ```
33//!
34//! The widget reuses `ImageWidget` for the image path and draws bg /
35//! border / presence directly via `Canvas`. Hash-derived background
36//! tints come from `theme.colors.chart_palette` (Okabe-Ito), so they
37//! track the active theme automatically.
38
39use std::rc::Rc;
40
41use teksilo_canvas::raster::RasterIcon;
42use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::build_context::BuildContext;
45use teksilo_core::color_prop::ColorProp;
46use teksilo_core::signal::{Prop, Signal};
47use teksilo_core::styles::{AvatarStyleConfig, SharedAvatarStyle};
48use teksilo_core::widget::{
49    CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
50};
51use teksilo_core::widget_builder::HandlerSet;
52use teksilo_core::widget_id::WidgetId;
53use teksilo_tokens::{Color, FontWeight, TextStyle};
54
55pub use teksilo_core::styles::{AvatarCorner, AvatarPresence, AvatarShape, AvatarSize};
56
57use crate::primitives::ImageWidget;
58use crate::primitives::image_mask::ImageMaskShape;
59use crate::primitives::image_widget::ImageFit;
60use crate::styles::recipe_avatar_style::{
61    AVATAR_FONT_RATIO_1CHAR, AVATAR_FONT_RATIO_2CHAR, AVATAR_ROUNDED_RADIUS_RATIO,
62    auto_contrast_text, avatar_pixel_size, hash_pick_palette_color,
63};
64use teksilo_i18n::LocalizedString;
65
66// ─── The widget ────────────────────────────────────────────────────────────
67
68type ActionFn = Rc<dyn Fn(&mut EventContext)>;
69
70/// Circular (or rounded-square / square) user-identity widget showing
71/// either a photo or hash-tinted initials, with an optional presence dot.
72///
73/// Static and reactive content fields coexist: each knob (`name`, `image`,
74/// `alt`, `label`, `presence`) has a static constructor or setter *and* a
75/// `bind_*` counterpart that takes a `Signal`. When a signal is bound it
76/// wins; the static value acts as a fallback. Signal-bound rebuilds fire at
77/// `BindingLevel::Rebuild` so inner children are recreated with fresh values —
78/// the canonical pattern for a "logged-out → logged-in" transition.
79pub struct Avatar {
80    /// Initials shown when no image is present. Static fallback;
81    /// overridden when `name_signal` is bound. Always non-empty
82    /// (`"?"` when input was empty).
83    initials: String,
84    /// Optional override of the a11y name. Static fallback for
85    /// `label_signal`.
86    label: Option<String>,
87    /// Image alt text. Static fallback for `alt_signal`.
88    alt: Option<String>,
89    /// Static image source bytes. `None` = no image at construction.
90    /// Coexists with `image_signal`: signal wins when bound.
91    image_source: Option<RawImage>,
92
93    size: AvatarSize,
94    shape: AvatarShape,
95
96    background: Option<ColorProp>,
97    foreground: Option<ColorProp>,
98    border_color: Option<ColorProp>,
99    border_width: Option<f32>,
100
101    presence: Option<AvatarPresence>,
102    presence_corner: AvatarCorner,
103
104    seed: Option<String>,
105
106    a11y_hidden: bool,
107
108    image_visible: Prop<bool>,
109
110    // ── Dynamic signal overrides (each None ⇒ use the static field) ─
111    /// Reactive name. Drives derived initials and the hash seed when
112    /// bound. Bound at `BindingLevel::Rebuild` so the inner children
113    /// are recreated on flip.
114    name_signal: Option<Signal<String>>,
115    /// Reactive image source. `None` value ⇒ initials fallback path.
116    /// `Rc<RasterIcon>` so swap is cheap. Bound at `Rebuild`.
117    image_signal: Option<Signal<Option<Rc<RasterIcon>>>>,
118    /// Reactive alt text. Bound at `AccessibilityOnly` since it only
119    /// affects screen-reader output.
120    alt_signal: Option<Signal<Option<String>>>,
121    /// Reactive label. Bound at `AccessibilityOnly`.
122    label_signal: Option<Signal<Option<String>>>,
123    /// Reactive presence. Bound at `Rebuild` — the dot's colour and
124    /// the a11y description both depend on the presence variant.
125    presence_signal: Option<Signal<Option<AvatarPresence>>>,
126
127    /// Optional `has_popup` ARIA hint. Surfaces via `set_has_popup` in
128    /// `accessibility()` for the disclosure pattern (e.g. an Avatar
129    /// that opens a user-menu Popover declares `HasPopup::Menu`).
130    has_popup: Option<teksilo_core::accesskit::HasPopup>,
131    /// Optional signal reporting whether the linked popup is currently
132    /// visible. Surfaces via `set_expanded` in `accessibility()`. Only
133    /// meaningful alongside `has_popup`.
134    expanded_signal: Option<Prop<bool>>,
135
136    /// Activation handler. Stored as `Rc<dyn Fn>` so it survives
137    /// rebuilds (theme/locale switches re-run `build()` and would
138    /// otherwise drop a `Box<dyn Fn>` after the first take).
139    action: Option<ActionFn>,
140
141    /// Focus state — set in `build()` and threaded into the
142    /// `AvatarStyle` config so the chrome can paint the keyboard
143    /// focus ring. `None` until `build()` runs.
144    focused: Option<Signal<bool>>,
145    /// Per-call override for the chrome (shape fill, border, focus ring,
146    /// presence dot).
147    style_override: Option<SharedAvatarStyle>,
148    /// Build-time `AvatarStyle::make_body` root.
149    root_child_id: Option<WidgetId>,
150
151    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
152    /// with the rich / composite slots — every setter clears the other two so
153    /// the last call wins.
154    tooltip_text: Option<LocalizedString>,
155    /// Optional rich tooltip source (registry key or inline content).
156    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
157    /// Optional composite tooltip body (arbitrary widget tree).
158    composite_tooltip_content: Option<Box<dyn Widget>>,
159}
160
161#[derive(Clone)]
162struct RawImage {
163    /// `Rc` so rebuilds (theme switch, locale switch, signal flip)
164    /// don't reclone the byte buffer. The Rc identity is the cache
165    /// key for the inner ImageWidget's texture-atlas name.
166    pixels: Rc<Vec<u8>>,
167    width: u32,
168    height: u32,
169}
170
171// ─── Constructors ──────────────────────────────────────────────────────────
172
173impl Avatar {
174    /// Create an avatar from an explicit initials string. Uppercases and
175    /// truncates to at most 2 chars; empty input yields `"?"`.
176    pub fn with_initials(initials: impl Into<LocalizedString>) -> Self {
177        let ls: LocalizedString = initials.into();
178        let raw = ls.resolve_now();
179        Self::from_initials(normalize_initials(&raw))
180    }
181
182    /// Create an avatar from a display name; initials are derived
183    /// automatically (`"Jane Doe" → "JD"`, `"jane.doe@x.com" → "JD"`,
184    /// `"Cher" → "C"`, `"" → "?"`), and the full name is used as the
185    /// hash seed for the background tint so users with identical initials
186    /// still get distinct colours.
187    pub fn with_name(name: impl Into<LocalizedString>) -> Self {
188        let ls: LocalizedString = name.into();
189        let raw = ls.resolve_now();
190        let initials = derive_initials(&raw);
191        let mut a = Self::from_initials(initials);
192        a.seed = Some(raw); // hash from the full name, not from the abbreviated initials
193        a
194    }
195
196    /// Create an avatar from a decoded [`RasterIcon`]. The pixels are
197    /// centre-cropped to a square and CPU-masked to the configured shape
198    /// at the first `build()`. Call [`.alt(...)`](Self::alt) to provide a
199    /// screen-reader name for the image.
200    pub fn with_image(icon: &RasterIcon) -> Self {
201        Self::from_raw_image(icon.pixels().to_vec(), icon.width(), icon.height())
202    }
203
204    /// Create an avatar from raw RGBA pixels (`width × height × 4` bytes).
205    /// Same pixel-layout convention as `ImageWidget::from_raw`.
206    pub fn from_raw_image(pixels: Vec<u8>, width: u32, height: u32) -> Self {
207        let mut a = Self::from_initials("?".to_string());
208        a.image_source = Some(RawImage {
209            pixels: Rc::new(pixels),
210            width,
211            height,
212        });
213        a
214    }
215
216    fn from_initials(initials: String) -> Self {
217        Self {
218            initials,
219            label: None,
220            alt: None,
221            image_source: None,
222            size: AvatarSize::Medium,
223            shape: AvatarShape::Circle,
224            background: None,
225            foreground: None,
226            border_color: None,
227            border_width: None,
228            presence: None,
229            presence_corner: AvatarCorner::BottomTrailing,
230            seed: None,
231            a11y_hidden: false,
232            image_visible: Prop::Static(true),
233            name_signal: None,
234            image_signal: None,
235            alt_signal: None,
236            label_signal: None,
237            presence_signal: None,
238            has_popup: None,
239            expanded_signal: None,
240            action: None,
241            focused: None,
242            style_override: None,
243            root_child_id: None,
244            tooltip_text: None,
245            rich_tooltip_source: None,
246            composite_tooltip_content: None,
247        }
248    }
249
250    /// Per-call style override for the avatar chrome.
251    pub fn style(mut self, style: impl teksilo_core::styles::AvatarStyle) -> Self {
252        self.style_override = Some(Rc::new(style));
253        self
254    }
255}
256
257// ─── Builder methods ───────────────────────────────────────────────────────
258
259impl Avatar {
260    /// Set the avatar's discrete size. Default: `AvatarSize::Medium` (32 dp).
261    pub fn size(mut self, size: AvatarSize) -> Self {
262        self.size = size;
263        self
264    }
265
266    /// Set the avatar's clip shape. Default: `AvatarShape::Circle`.
267    pub fn shape(mut self, shape: AvatarShape) -> Self {
268        // No cache to invalidate — masking now lives on the inner
269        // `ImageWidget`, which is recreated each `build()` with the
270        // current shape.
271        self.shape = shape;
272        self
273    }
274
275    /// Override the initials shown when the image is hidden via
276    /// `image_visible(false)` or fails to register. Defaults to the
277    /// derived initials if `with_image` was paired with `with_name`,
278    /// otherwise `"?"`.
279    pub fn fallback_initials(mut self, initials: impl Into<LocalizedString>) -> Self {
280        let ls: LocalizedString = initials.into();
281        let raw = ls.resolve_now();
282        self.initials = normalize_initials(&raw);
283        self
284    }
285
286    /// Reactive image visibility. When unbound it's `true`. When bound
287    /// to a `Signal<bool>` and the value is `false`, the initials
288    /// fallback paints in place of the image — same logical bounds, no
289    /// layout shift.
290    pub fn image_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
291        self.image_visible = visible.into();
292        self
293    }
294
295    /// Override the auto hash-derived background. Accepts a [`Color`],
296    /// a role, or a `Signal<Color>`.
297    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
298        self.background = Some(color.into());
299        self
300    }
301
302    /// Override the auto-contrast text colour for the initials. Auto
303    /// (unset) picks white over dark backgrounds and near-black over
304    /// light ones, computed at paint time from the resolved bg's
305    /// luminance.
306    pub fn foreground(mut self, color: impl Into<ColorProp>) -> Self {
307        self.foreground = Some(color.into());
308        self
309    }
310
311    /// Override the seed string used to pick a hash-derived background
312    /// from the theme's chart palette. Defaults to the resolved name
313    /// (when constructed via `with_name`) or the initials.
314    pub fn seed(mut self, seed: impl Into<String>) -> Self {
315        self.seed = Some(seed.into());
316        self
317    }
318
319    /// Outer ring thickness. A non-zero value enables the ring (drawn
320    /// in `BorderRole::Default` unless [`Self::border_color`] overrides
321    /// it). `0.0` disables the ring.
322    pub fn border(mut self, width: f32) -> Self {
323        self.border_width = Some(width.max(0.0));
324        self
325    }
326
327    /// Override the outer ring colour. Accepts a [`Color`], a theme role,
328    /// or a `Signal<Color>`. Has no effect unless [`Self::border`] is also
329    /// set to a positive width.
330    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
331        self.border_color = Some(color.into());
332        self
333    }
334
335    /// Show a presence indicator dot. Pass `AvatarPresence::Online`,
336    /// `Offline`, `Away`, or `Busy`.
337    pub fn presence(mut self, presence: AvatarPresence) -> Self {
338        self.presence = Some(presence);
339        self
340    }
341
342    /// Choose which corner the presence dot occupies. Default:
343    /// `AvatarCorner::BottomTrailing`.
344    pub fn presence_corner(mut self, corner: AvatarCorner) -> Self {
345        self.presence_corner = corner;
346        self
347    }
348
349    /// Override the accessible name. When unset:
350    /// * image-mode → `alt` if set, else the initials, else "Avatar"
351    /// * initials-mode → the initials.
352    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
353        let ls: LocalizedString = label.into();
354        self.label = Some(ls.resolve_now());
355        self
356    }
357
358    /// Image alt text — distinct from `label` so a clickable avatar
359    /// can have a button label like "Open user menu" while still
360    /// describing the image as "Jane Doe".
361    pub fn alt(mut self, alt: impl Into<LocalizedString>) -> Self {
362        let ls: LocalizedString = alt.into();
363        self.alt = Some(ls.resolve_now());
364        self
365    }
366
367    /// Hide from the a11y tree entirely. Use only when an adjacent
368    /// label conveys the avatar's meaning.
369    pub fn a11y_hidden(mut self) -> Self {
370        self.a11y_hidden = true;
371        self
372    }
373
374    /// Make the avatar activable. Promotes the a11y role to
375    /// `Role::Button` and adds `Action::Click` / `Action::Focus`. Tap,
376    /// Enter, and Space all fire the closure. Cursor changes to
377    /// `Pointer` on hover.
378    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
379        self.action = Some(Rc::new(f));
380        self
381    }
382
383    /// Declare that this avatar is a disclosure trigger for a popup
384    /// (typically `HasPopup::Menu` for a user-menu trigger). Surfaces
385    /// via `set_has_popup` in the a11y node so screen readers
386    /// announce the avatar as "menu button" / "has popup". Only takes
387    /// effect when paired with `.on_activate_fn(...)` — without an
388    /// activation handler the avatar isn't a trigger.
389    pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
390        self.has_popup = Some(kind);
391        self
392    }
393
394    /// Bind a signal reporting whether this avatar's popup is
395    /// currently visible. The wrapping Popover / overlay manager owns
396    /// the signal and flips it on show / dismiss; Avatar reads it in
397    /// `accessibility()` to publish `set_expanded`. Only meaningful
398    /// alongside `.has_popup(...)`.
399    pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
400        self.expanded_signal = Some(signal.into());
401        self
402    }
403
404    // ── Tooltip ───────────────────────────────────────────────────────
405
406    /// Attach a plain single-line tooltip shown after the hover delay.
407    /// Mutually exclusive with [`Self::rich_tooltip`],
408    /// [`Self::rich_tooltip_content`], and [`Self::composite_tooltip`] —
409    /// this call clears the other three slots.
410    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
411        self.tooltip_text = Some(text.into());
412        self.rich_tooltip_source = None;
413        self.composite_tooltip_content = None;
414        self
415    }
416
417    /// Attach a rich tooltip identified by a registry key. The tooltip
418    /// content is resolved from the application's `TooltipRegistry` at
419    /// hover time. Mutually exclusive with the other tooltip setters —
420    /// this call clears the other three slots.
421    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
422        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
423        self.tooltip_text = None;
424        self.composite_tooltip_content = None;
425        self
426    }
427
428    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`]
429    /// without a registry key. Mutually exclusive with the other tooltip
430    /// setters — this call clears the other three slots.
431    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
432        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
433        self.tooltip_text = None;
434        self.composite_tooltip_content = None;
435        self
436    }
437
438    /// Attach a composite tooltip whose body is an arbitrary widget tree.
439    /// Shown after the longer `tooltip_delay_heavy` delay. Mutually
440    /// exclusive with the other tooltip setters — this call clears the
441    /// other three slots.
442    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
443        self.composite_tooltip_content = Some(Box::new(content));
444        self.tooltip_text = None;
445        self.rich_tooltip_source = None;
446        self
447    }
448
449    // ── Reactive content (bind_*) ─────────────────────────────────────
450
451    /// Bind the user's display name to a signal. The displayed
452    /// initials are auto-derived from the current value
453    /// (`derive_initials`), and the same value is used as the hash
454    /// seed for the background tint. Bound at
455    /// `BindingLevel::Rebuild` so the inner children regenerate on
456    /// flip — the canonical login-flow pattern:
457    ///
458    /// ```ignore
459    /// let user_name: Signal<String> = ctx.signal(String::new());
460    /// Avatar::with_initials(lit!("?"))        // logged-out fallback
461    ///     .name_signal(user_name.clone())
462    ///     .image_signal(user_avatar_signal)
463    /// ```
464    pub fn name_signal(mut self, signal: Signal<String>) -> Self {
465        self.name_signal = Some(signal);
466        self
467    }
468
469    /// Bind the image source. `None` ⇒ initials fallback. Each
470    /// non-`None` value is masked to the configured `AvatarShape` by
471    /// the inner [`ImageWidget`]. Bound at `BindingLevel::Rebuild`.
472    pub fn image_signal(mut self, signal: Signal<Option<Rc<RasterIcon>>>) -> Self {
473        self.image_signal = Some(signal);
474        self
475    }
476
477    /// Bind the image alt text. Bound at `BindingLevel::AccessibilityOnly`
478    /// — only the screen-reader projection is affected.
479    pub fn alt_signal(mut self, signal: Signal<Option<String>>) -> Self {
480        self.alt_signal = Some(signal);
481        self
482    }
483
484    /// Bind the accessible label. Bound at
485    /// `BindingLevel::AccessibilityOnly`.
486    pub fn label_signal(mut self, signal: Signal<Option<String>>) -> Self {
487        self.label_signal = Some(signal);
488        self
489    }
490
491    /// Bind the presence indicator. `None` hides the dot. Bound at
492    /// `BindingLevel::Rebuild` — the dot's colour and the a11y
493    /// `description` flip together so a rebuild keeps both layers in
494    /// sync.
495    pub fn presence_signal(mut self, signal: Signal<Option<AvatarPresence>>) -> Self {
496        self.presence_signal = Some(signal);
497        self
498    }
499}
500
501impl std::fmt::Debug for Avatar {
502    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        f.debug_struct("Avatar")
504            .field("initials", &self.initials)
505            .field("size", &self.size)
506            .field("shape", &self.shape)
507            .field(
508                "has_image",
509                &(self.image_source.is_some() || self.image_signal.is_some()),
510            )
511            .field("clickable", &self.action.is_some())
512            .finish()
513    }
514}
515
516// ─── Helpers ───────────────────────────────────────────────────────────────
517
518/// Inline FNV-1a 64-bit. Stable across Rust versions and process runs
519/// (unlike `DefaultHasher`). Same idiom as `teksilo_core::accessibility`.
520/// Truncate to ≤ 2 chars and uppercase. Returns `"?"` when the input
521/// trims to empty. Operates on `char`s (Unicode scalars), not extended
522/// graphemes — this is sufficient for real-world names where accented
523/// letters are stored pre-composed.
524fn normalize_initials(s: &str) -> String {
525    let mut out = String::new();
526    let mut count = 0;
527    for c in s.trim().chars() {
528        if count >= 2 {
529            break;
530        }
531        for upper in c.to_uppercase() {
532            out.push(upper);
533        }
534        count += 1;
535    }
536    if out.is_empty() { "?".to_string() } else { out }
537}
538
539/// Auto-derive initials from a free-form name.
540fn derive_initials(name: &str) -> String {
541    let trimmed = name.trim();
542    if trimmed.is_empty() {
543        return "?".to_string();
544    }
545    // For email-like strings only the local part (before `@`) matters.
546    let source = trimmed.split('@').next().unwrap_or(trimmed);
547    let parts: Vec<&str> = source
548        .split(|c: char| c.is_whitespace() || c == '.' || c == '_' || c == '-')
549        .filter(|s| !s.is_empty())
550        .collect();
551
552    let mut out = String::new();
553    for part in parts.iter().take(2) {
554        if let Some(c) = part.chars().next() {
555            for upper in c.to_uppercase() {
556                out.push(upper);
557            }
558        }
559    }
560    if out.is_empty() { "?".to_string() } else { out }
561}
562
563fn shape_to_image_mask(shape: AvatarShape) -> ImageMaskShape {
564    match shape {
565        AvatarShape::Circle => ImageMaskShape::Circle,
566        AvatarShape::RoundedSquare => ImageMaskShape::RoundedSquare(AVATAR_ROUNDED_RADIUS_RATIO),
567        AvatarShape::Square => ImageMaskShape::None,
568    }
569}
570
571// ─── Reactive accessors — used by paint / accessibility ──────────────────
572
573impl Avatar {
574    /// The displayed initials, taking any bound name signal into
575    /// account. Cheap (a few string ops per call); paint / a11y
576    /// invoke this directly rather than caching.
577    fn current_initials(&self) -> String {
578        match &self.name_signal {
579            Some(sig) => derive_initials(&sig.get()),
580            None => self.initials.clone(),
581        }
582    }
583
584    /// The hash seed for the background tint. When `name_signal` is
585    /// active the seed *is* the name (so two users named "JD" but
586    /// "Jane Doe" vs "Jules Dupont" hash differently). Otherwise it
587    /// falls back to the user-supplied seed or the static initials.
588    fn current_seed(&self) -> String {
589        match &self.name_signal {
590            Some(sig) => sig.get(),
591            None => self.seed.clone().unwrap_or_else(|| self.initials.clone()),
592        }
593    }
594
595    fn current_alt(&self) -> Option<String> {
596        match &self.alt_signal {
597            Some(sig) => sig.get(),
598            None => self.alt.clone(),
599        }
600    }
601
602    fn current_label(&self) -> Option<String> {
603        match &self.label_signal {
604            Some(sig) => sig.get(),
605            None => self.label.clone(),
606        }
607    }
608
609    fn current_presence(&self) -> Option<AvatarPresence> {
610        match &self.presence_signal {
611            Some(sig) => sig.get(),
612            None => self.presence.clone(),
613        }
614    }
615
616    /// Resolve the image source bytes + dims for the current state.
617    /// `Some` ⇒ image mode (will spawn an `ImageWidget` child);
618    /// `None` ⇒ initials-only mode.
619    fn current_image(&self) -> Option<(Rc<Vec<u8>>, u32, u32)> {
620        if let Some(sig) = &self.image_signal {
621            return sig
622                .get()
623                .map(|rc| (Rc::new(rc.pixels().to_vec()), rc.width(), rc.height()));
624        }
625        self.image_source
626            .as_ref()
627            .map(|raw| (raw.pixels.clone(), raw.width, raw.height))
628    }
629
630    /// Whether the avatar should expose a11y-image-role semantics.
631    fn has_image_now(&self) -> bool {
632        self.image_signal
633            .as_ref()
634            .is_some_and(|sig| sig.get().is_some())
635            || self.image_source.is_some()
636    }
637}
638
639// ─── Widget impl ───────────────────────────────────────────────────────────
640
641impl Widget for Avatar {
642    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
643        let self_id = ctx.self_id();
644        let mask_shape = shape_to_image_mask(self.shape);
645
646        // 1. Resolve current content state. Each `current_*` reads
647        //    the signal if bound, falling back to the static field.
648        let initials = self.current_initials();
649        let seed = self.current_seed();
650        let alt = self.current_alt();
651        let image_bytes = self.current_image();
652
653        // 2. Build inner content (`InitialsLeaf` / `ImageWidget`).
654        //    The masking lives on `ImageWidget` so Avatar doesn't
655        //    manage pixel buffers itself.
656        let make_initials_leaf = || InitialsLeaf {
657            initials: initials.clone(),
658            seed: seed.clone(),
659            background: self.background.clone(),
660            foreground: self.foreground.clone(),
661        };
662        let make_image_widget = |bytes: Rc<Vec<u8>>, w: u32, h: u32, alt: Option<String>| {
663            let mut img = ImageWidget::from_raw((*bytes).clone(), w, h)
664                .fit(ImageFit::Cover)
665                .mask(mask_shape);
666            if let Some(a) = alt {
667                img = img.alt(a);
668            } else {
669                // Inner ImageWidget is silenced — the parent Avatar
670                // owns the Role::Image / Role::Button + name.
671                img = img.a11y_hidden();
672            }
673            img
674        };
675
676        // Assemble inner content as a single `WidgetId`. For the
677        // bound-visibility case the image and initials sit as siblings
678        // inside a `ZStack` with `visible_when` bindings; either is
679        // mounted alone otherwise.
680        let content_id = match (image_bytes, &self.image_visible) {
681            (Some((bytes, w, h)), Prop::Static(true)) => {
682                ctx.add(make_image_widget(bytes, w, h, alt.clone()))
683            }
684            (Some(_), Prop::Static(false)) => ctx.add(make_initials_leaf()),
685            (Some((bytes, w, h)), Prop::Bound(visible_signal)) => {
686                let img_id = ctx.add(make_image_widget(bytes, w, h, alt.clone()));
687                let init_id = ctx.add(make_initials_leaf());
688                let v_clone = visible_signal.clone();
689                ctx.visible_when(img_id, v_clone.clone());
690                ctx.visible_when(init_id, v_clone.map(|v| !*v));
691                ctx.add(
692                    crate::primitives::ZStack::new()
693                        .add_child(img_id)
694                        .add_child(init_id),
695                )
696            }
697            (None, _) => ctx.add(make_initials_leaf()),
698        };
699
700        // 3. Wire reactive content signals so flips re-run build().
701        let registry = ctx.binding_registry();
702        if let Some(sig) = &self.name_signal {
703            sig.bind_to(
704                self_id,
705                registry,
706                teksilo_core::binding::BindingLevel::Rebuild,
707            );
708        }
709        if let Some(sig) = &self.image_signal {
710            sig.bind_to(
711                self_id,
712                registry,
713                teksilo_core::binding::BindingLevel::Rebuild,
714            );
715        }
716        if let Some(sig) = &self.presence_signal {
717            sig.bind_to(
718                self_id,
719                registry,
720                teksilo_core::binding::BindingLevel::Rebuild,
721            );
722        }
723        if let Some(sig) = &self.alt_signal {
724            sig.bind_to(
725                self_id,
726                registry,
727                teksilo_core::binding::BindingLevel::AccessibilityOnly,
728            );
729        }
730        if let Some(sig) = &self.label_signal {
731            sig.bind_to(
732                self_id,
733                registry,
734                teksilo_core::binding::BindingLevel::AccessibilityOnly,
735            );
736        }
737
738        // 4. If clickable, install attached handlers — including the
739        //    `on_focus` that drives the focus-ring repaint via the
740        //    chrome's `is_focused` signal.
741        let focused = ctx.signal(false);
742        self.focused = Some(focused.clone());
743        if let Some(action) = self.action.clone() {
744            let focus_for_handler = focused.clone();
745
746            let action_for_tap = action.clone();
747            let action_for_key = action.clone();
748            let action_for_access = action;
749            let handlers = HandlerSet::new()
750                .on_tap(move |_pos, ctx| action_for_tap(ctx))
751                .focusable(true)
752                .cursor(CursorIcon::Pointer)
753                .on_focus(move |gained, _ctx| focus_for_handler.set(gained))
754                .on_key(move |event, ctx| {
755                    use teksilo_core::event::{EventResponse, Key, WidgetEvent};
756                    match event {
757                        WidgetEvent::KeyDown {
758                            key: Key::Enter | Key::Space,
759                            ..
760                        } => {
761                            action_for_key(ctx);
762                            EventResponse::Handled
763                        }
764                        _ => EventResponse::Ignored,
765                    }
766                })
767                .on_access_action(move |action_kind, ctx| {
768                    use teksilo_core::event::EventResponse;
769                    if action_kind == teksilo_core::accesskit::Action::Click {
770                        action_for_access(ctx);
771                        EventResponse::Handled
772                    } else {
773                        EventResponse::Ignored
774                    }
775                });
776            ctx.apply_self_handlers(handlers);
777        }
778
779        // 5. Wire `expanded_signal` for a11y refresh on flip.
780        if let Some(ref expanded_signal) = self.expanded_signal {
781            let self_id = ctx.self_id();
782            let registry = ctx.binding_registry();
783            expanded_signal.register_if_bound(
784                self_id,
785                registry,
786                teksilo_core::binding::BindingLevel::RepaintOnly,
787            );
788        }
789
790        // 6. The shape-aware chrome (background fill, border, focus
791        //    ring, presence dot) is owned by the active `AvatarStyle`;
792        //    this widget keeps its Role::Image / Role::Button / Role::Label
793        //    semantics and the initials-derivation logic.
794        let style: SharedAvatarStyle = self
795            .style_override
796            .clone()
797            .or_else(|| ctx.theme().style_slots.avatar.clone())
798            .unwrap_or_else(|| Rc::new(crate::styles::RecipeAvatarStyle::default()));
799        let root = style.make_body(
800            &AvatarStyleConfig {
801                shape: self.shape,
802                size: self.size,
803                content: content_id,
804                presence: self.current_presence(),
805                presence_corner: self.presence_corner,
806                // `:focus-visible`: keyboard-only focus ring (gate raw focus
807                // on the input-modality signal).
808                is_focused: focused.and(&ctx.focus_visible()),
809                background_override: self.background.clone(),
810                border_color_override: self.border_color.clone(),
811                border_width_override: self.border_width,
812                seed,
813            },
814            ctx,
815        );
816        self.root_child_id = Some(root);
817
818        // Tooltip attachment — anchored on the style root (the trigger).
819        if let Some(content) = self.composite_tooltip_content.take() {
820            let delay = ctx.theme().motion.tooltip_delay_heavy;
821            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
822        } else if let Some(source) = self.rich_tooltip_source.clone() {
823            let delay = ctx.theme().motion.tooltip_delay;
824            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
825        } else if let Some(text) = self.tooltip_text.clone() {
826            let delay = ctx.theme().motion.tooltip_delay;
827            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
828        }
829
830        vec![root]
831    }
832
833    fn layout_response(
834        &self,
835        _proposal: SizeProposal,
836        _ctx: &LayoutContext,
837    ) -> teksilo_core::widget::LayoutResponse {
838        let side = avatar_pixel_size(self.size);
839        Size::new(side, side).into()
840    }
841
842    fn place_children(
843        &self,
844        bounds: Rect,
845        _proposal: SizeProposal,
846        children: &mut [WidgetPlacement],
847        _ctx: &LayoutContext,
848    ) {
849        for child in children.iter_mut() {
850            child.origin = bounds.origin();
851            child.size = bounds.size();
852        }
853    }
854
855    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
856        if self.a11y_hidden {
857            builder.set_hidden();
858            return;
859        }
860
861        let clickable = self.action.is_some();
862        // `current_image()` is the source of truth — a bound image
863        // signal whose value is `None` means "no image right now",
864        // even if a static `with_image` source was also supplied
865        // (signal wins). For role-selection we ask: does the live
866        // state put us in image-mode?
867        let has_image = self.has_image_now();
868        let alt = self.current_alt();
869        let label = self.current_label();
870        let initials = self.current_initials();
871
872        if clickable {
873            builder.set_role(teksilo_core::accesskit::Role::Button);
874            // A clickable avatar without an explicit label is missing
875            // its activation hint. Catch this in dev to prevent silent
876            // a11y regressions.
877            debug_assert!(
878                label.is_some() || alt.is_some(),
879                "Avatar::on_activate_fn requires a `.label(\"...\")` (preferred) or `.alt(\"...\")` (or a `.label(...)` / `.alt_signal(...)`) for screen readers"
880            );
881            let name = label.or(alt).unwrap_or_else(|| initials.clone());
882            builder.set_name(name);
883            builder.add_action(teksilo_core::accesskit::Action::Click);
884            builder.add_action(teksilo_core::accesskit::Action::Focus);
885        } else if has_image {
886            builder.set_role(teksilo_core::accesskit::Role::Image);
887            // A pure-image avatar without alt text is missing its
888            // semantic label — catch in dev (matches `ImageWidget`).
889            debug_assert!(
890                alt.is_some() || label.is_some(),
891                "Avatar::with_image requires a `.alt(\"...\")` (or `.alt_signal(...)`) for meaningful images, or call `.a11y_hidden()` if decorative"
892            );
893            let name = alt.or(label).unwrap_or_else(|| initials.clone());
894            builder.set_name(name);
895        } else {
896            builder.set_role(teksilo_core::accesskit::Role::Label);
897            let name = label.unwrap_or_else(|| initials.clone());
898            builder.set_name(name);
899        }
900
901        if let Some(presence) = self.current_presence() {
902            builder.set_description(presence.label());
903        }
904
905        // Disclosure-pattern hints. Only meaningful for clickable
906        // avatars — but harmless to surface unconditionally for the
907        // image / label paths in case a wrapper widget is supplying
908        // them (e.g. an external state machine that drives a popup
909        // alongside an Avatar that isn't itself the trigger).
910        if let Some(kind) = self.has_popup {
911            builder.set_has_popup(kind);
912        }
913        if let Some(ref signal) = self.expanded_signal {
914            builder.set_expanded(signal.get());
915        }
916    }
917
918    fn children(&self) -> Vec<WidgetId> {
919        self.root_child_id.into_iter().collect()
920    }
921}
922
923// ─── Initials sub-widget ───────────────────────────────────────────────────
924
925/// Crate-private leaf that draws the centred initials. The avatar's
926/// own `paint()` handles the background fill; this widget only emits
927/// glyphs so paint order is parent-bg → child-text.
928///
929/// The leaf is constructed in [`Avatar::build`] with all the inputs
930/// it needs to resolve a correctly contrasted foreground at paint
931/// time:
932/// * `initials` — the glyphs to draw.
933/// * `seed` — the same string the parent will hash to pick a bg tint.
934/// * `background` / `foreground` — clones of the parent's overrides
935///   (`None` ⇒ default path). Stored as `ColorProp` so role / signal
936///   variants resolve against the active theme each frame, matching
937///   what the parent paints.
938#[derive(Debug)]
939struct InitialsLeaf {
940    initials: String,
941    seed: String,
942    background: Option<ColorProp>,
943    foreground: Option<ColorProp>,
944}
945
946impl InitialsLeaf {
947    /// Recompute the bg colour the parent Avatar will paint. Must
948    /// stay in lock-step with `Avatar::paint`'s bg branch.
949    fn resolve_bg(&self, theme: &teksilo_core::Theme, enabled: bool) -> Color {
950        match &self.background {
951            Some(prop) => prop.resolve(theme, enabled),
952            None => hash_pick_palette_color(&self.seed, theme),
953        }
954    }
955}
956
957impl Widget for InitialsLeaf {
958    fn layout_response(
959        &self,
960        proposal: SizeProposal,
961        _ctx: &LayoutContext,
962    ) -> teksilo_core::widget::LayoutResponse {
963        // Always fill the proposal — the parent Avatar drives sizing.
964        proposal.resolve(0.0, 0.0).into()
965    }
966
967    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
968        let theme = ctx.theme;
969
970        let font_size = bounds.width.min(bounds.height)
971            * if self.initials.chars().count() <= 1 {
972                AVATAR_FONT_RATIO_1CHAR
973            } else {
974                AVATAR_FONT_RATIO_2CHAR
975            };
976
977        let text_style = TextStyle {
978            family: theme.typography.body_bold.family.clone(),
979            size: font_size,
980            weight: FontWeight::SEMI_BOLD,
981            line_height: 1.0,
982            letter_spacing: 0.0,
983        };
984
985        // Foreground: explicit override wins. Otherwise auto-contrast
986        // against the same bg the parent painted.
987        let fg = match &self.foreground {
988            Some(prop) => prop.resolve(theme, ctx.effective_enabled),
989            None => auto_contrast_text(self.resolve_bg(theme, ctx.effective_enabled)),
990        };
991
992        // Measure the text to centre it. Without a backend, we can't
993        // measure or draw glyphs at all — silently no-op.
994        let Some(backend) = canvas.text_backend().cloned() else {
995            return;
996        };
997        let layout = {
998            let mut b = backend.borrow_mut();
999            b.layout_single_line(&self.initials, &text_style, None)
1000        };
1001        let text_w = layout.width;
1002        let text_h = layout.height;
1003
1004        let cx = bounds.x + (bounds.width - text_w) / 2.0;
1005        let cy = bounds.y + (bounds.height - text_h) / 2.0;
1006        let position = Rect::new(cx, cy, text_w, text_h);
1007        canvas.draw_text(&self.initials, position, &text_style, fg);
1008    }
1009
1010    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1011        // The parent Avatar owns the user-facing semantics (role, name,
1012        // click action). The text node would otherwise duplicate that
1013        // information to ATs.
1014        builder.set_hidden();
1015    }
1016}
1017
1018// ─── Tests ─────────────────────────────────────────────────────────────────
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use crate::styles::recipe_avatar_style::fnv1a_64;
1024    use teksilo_core::widget::LayoutContext;
1025    use teksilo_core::widget_tree::WidgetTree;
1026    use teksilo_i18n::lit;
1027
1028    // ── helpers ────────────────────────────────────────────────────────
1029
1030    fn rgba_solid(side: u32, rgba: [u8; 4]) -> RasterIcon {
1031        let mut p = Vec::with_capacity((side * side * 4) as usize);
1032        for _ in 0..(side * side) {
1033            p.extend_from_slice(&rgba);
1034        }
1035        RasterIcon::from_raw(p, side, side)
1036    }
1037
1038    // ── derive_initials / normalize_initials ──────────────────────────
1039
1040    #[test]
1041    fn normalize_uppercase_truncate() {
1042        assert_eq!(normalize_initials("jdq"), "JD");
1043        assert_eq!(normalize_initials("jd"), "JD");
1044        assert_eq!(normalize_initials("j"), "J");
1045        assert_eq!(normalize_initials("  "), "?");
1046        assert_eq!(normalize_initials(""), "?");
1047    }
1048
1049    #[test]
1050    fn derive_full_name() {
1051        assert_eq!(derive_initials("Jane Doe"), "JD");
1052    }
1053
1054    #[test]
1055    fn derive_single_word() {
1056        assert_eq!(derive_initials("Cher"), "C");
1057    }
1058
1059    #[test]
1060    fn derive_email() {
1061        assert_eq!(derive_initials("jane.doe@x.com"), "JD");
1062        assert_eq!(derive_initials("jane_doe@x.com"), "JD");
1063    }
1064
1065    #[test]
1066    fn derive_unicode_name() {
1067        assert_eq!(derive_initials("María José"), "MJ");
1068    }
1069
1070    #[test]
1071    fn derive_empty_yields_question_mark() {
1072        assert_eq!(derive_initials(""), "?");
1073        assert_eq!(derive_initials("   "), "?");
1074    }
1075
1076    #[test]
1077    fn derive_three_words_takes_first_two() {
1078        assert_eq!(derive_initials("Anna María José"), "AM");
1079    }
1080
1081    #[test]
1082    fn derive_hyphenated_name() {
1083        assert_eq!(derive_initials("Jean-Luc Picard"), "JL");
1084    }
1085
1086    // ── hashing ────────────────────────────────────────────────────────
1087
1088    #[test]
1089    fn fnv1a_is_stable() {
1090        let h1 = fnv1a_64(b"jane.doe");
1091        let h2 = fnv1a_64(b"jane.doe");
1092        assert_eq!(h1, h2);
1093        assert_ne!(fnv1a_64(b"jane.doe"), fnv1a_64(b"john.smith"));
1094    }
1095
1096    #[test]
1097    fn hash_distributes_over_palette() {
1098        let theme = teksilo_core::presets::intui::light();
1099        let mut buckets = [0_u32; 8];
1100        for i in 0..200 {
1101            let seed = format!("user_{i}");
1102            let color = hash_pick_palette_color(&seed, &theme);
1103            // Find which palette index it picked.
1104            let idx = theme
1105                .colors
1106                .chart_palette
1107                .iter()
1108                .position(|c| c == &color)
1109                .expect("color must be a palette member");
1110            buckets[idx] += 1;
1111        }
1112        let nonzero = buckets.iter().filter(|n| **n > 0).count();
1113        assert!(
1114            nonzero >= 6,
1115            "expected hash to cover at least 6 of 8 buckets, got {nonzero} (buckets: {:?})",
1116            buckets
1117        );
1118    }
1119
1120    // ── sizing ─────────────────────────────────────────────────────────
1121
1122    #[test]
1123    fn size_default_is_medium_32px() {
1124        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1125        let id = tree.add(Avatar::with_initials(lit!("JD")));
1126        tree.layout(SizeProposal {
1127            width: None,
1128            height: None,
1129        });
1130        let b = tree.bounds(id);
1131        assert!((b.width - 32.0).abs() < 0.01);
1132        assert!((b.height - 32.0).abs() < 0.01);
1133    }
1134
1135    #[test]
1136    fn size_custom_passes_through() {
1137        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1138        let id = tree.add(Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(40.0)));
1139        tree.layout(SizeProposal {
1140            width: None,
1141            height: None,
1142        });
1143        let b = tree.bounds(id);
1144        assert!((b.width - 40.0).abs() < 0.01);
1145        assert!((b.height - 40.0).abs() < 0.01);
1146    }
1147
1148    #[test]
1149    fn size_that_fits_ignores_proposal() {
1150        // Even a hugely oversized proposal must not enlarge the
1151        // avatar's intrinsic size — it always reports the discrete
1152        // size variant. (`tree.layout(exact(...))` would clamp the
1153        // root's bounds to the proposal regardless, so we exercise
1154        // `size_that_fits` directly.)
1155        let widget = Avatar::with_initials(lit!("JD"));
1156        let theme = teksilo_core::presets::intui::light();
1157        let ctx = LayoutContext::for_testing(&theme);
1158        let s = widget
1159            .layout_response(SizeProposal::exact(400.0, 400.0), &ctx)
1160            .size;
1161        assert!((s.width - 32.0).abs() < 0.01);
1162        assert!((s.height - 32.0).abs() < 0.01);
1163    }
1164
1165    #[test]
1166    fn small_medium_large_xlarge_sizes() {
1167        let theme = teksilo_core::presets::intui::light();
1168        use crate::styles::recipe_avatar_style as av;
1169        let cases = [
1170            (AvatarSize::Small, av::AVATAR_SIZE_SMALL),
1171            (AvatarSize::Medium, av::AVATAR_SIZE_MEDIUM),
1172            (AvatarSize::Large, av::AVATAR_SIZE_LARGE),
1173            (AvatarSize::XLarge, av::AVATAR_SIZE_X_LARGE),
1174        ];
1175        for (variant, expected) in cases {
1176            let mut tree = WidgetTree::new().with_theme(theme.clone());
1177            let id = tree.add(Avatar::with_initials(lit!("X")).size(variant));
1178            tree.layout(SizeProposal {
1179                width: None,
1180                height: None,
1181            });
1182            let b = tree.bounds(id);
1183            assert!(
1184                (b.width - expected).abs() < 0.01,
1185                "size {variant:?}: expected {expected}, got {}",
1186                b.width
1187            );
1188        }
1189    }
1190
1191    // ── paint output ──────────────────────────────────────────────────
1192
1193    fn render_avatar(avatar: Avatar) -> std::rc::Rc<teksilo_canvas::RenderFrame> {
1194        use std::cell::RefCell;
1195        use std::rc::Rc;
1196        use teksilo_canvas::MockTextBackend;
1197        let mut tree = WidgetTree::new()
1198            .with_theme(teksilo_core::presets::intui::light())
1199            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1200        tree.add(avatar);
1201        tree.layout(SizeProposal::exact(64.0, 64.0));
1202        tree.render()
1203    }
1204
1205    fn count_shapes(frame: &teksilo_canvas::RenderFrame) -> usize {
1206        // Both `fill_circle` and `fill_rounded_rect` emit `ShapeQuad`
1207        // entries — the SDF pipeline. Stroke-based circle border also
1208        // lands here.
1209        frame.shapes.len()
1210    }
1211
1212    #[test]
1213    fn paint_initials_emits_a_shape_quad() {
1214        let frame = render_avatar(Avatar::with_initials(lit!("JD")));
1215        assert!(
1216            count_shapes(&frame) >= 1,
1217            "expected at least one ShapeQuad (the bg circle)"
1218        );
1219    }
1220
1221    #[test]
1222    fn paint_with_border_adds_extra_shape() {
1223        let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1224        let bordered = render_avatar(Avatar::with_initials(lit!("JD")).border(2.0));
1225        assert!(
1226            count_shapes(&bordered) > count_shapes(&plain),
1227            "border path should add at least one extra Shape (the stroked ring)"
1228        );
1229    }
1230
1231    #[test]
1232    fn paint_presence_adds_two_shapes() {
1233        let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1234        let with_dot =
1235            render_avatar(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1236        // Outline + dot.
1237        assert_eq!(count_shapes(&with_dot), count_shapes(&plain) + 2);
1238    }
1239
1240    #[test]
1241    fn paint_rounded_square_emits_shape() {
1242        // `fill_rounded_rect` lands on the SDF Shape pipeline same
1243        // as `fill_circle`. Both shapes paint via Shape quads.
1244        let frame =
1245            render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::RoundedSquare));
1246        assert!(count_shapes(&frame) >= 1);
1247    }
1248
1249    #[test]
1250    fn paint_square_emits_shape() {
1251        let frame = render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::Square));
1252        assert!(count_shapes(&frame) >= 1);
1253    }
1254
1255    #[test]
1256    fn paint_image_uses_image_pipeline() {
1257        let icon = rgba_solid(8, [50, 100, 200, 255]);
1258        let frame = render_avatar(Avatar::with_image(&icon).alt(lit!("avatar")));
1259        assert!(
1260            !frame.images.is_empty(),
1261            "image avatar should render an image"
1262        );
1263    }
1264
1265    #[test]
1266    fn auto_contrast_dark_bg_chooses_white() {
1267        let dark = Color::from_rgb(0.05, 0.05, 0.05);
1268        let fg = auto_contrast_text(dark);
1269        assert!(fg.r() > 0.9 && fg.g() > 0.9 && fg.b() > 0.9);
1270    }
1271
1272    #[test]
1273    fn auto_contrast_light_bg_chooses_dark() {
1274        let light = Color::from_rgb(0.95, 0.95, 0.95);
1275        let fg = auto_contrast_text(light);
1276        assert!(fg.r() < 0.3 && fg.g() < 0.3 && fg.b() < 0.3);
1277    }
1278
1279    // ── accessibility ─────────────────────────────────────────────────
1280
1281    #[test]
1282    fn accessibility_initials_default_role_is_label() {
1283        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1284        let id = tree.add(Avatar::with_initials(lit!("JD")));
1285        tree.layout(SizeProposal::exact(32.0, 32.0));
1286        let info = tree.accessibility_node(id);
1287        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
1288        assert_eq!(info.name(), Some("JD"));
1289    }
1290
1291    #[test]
1292    fn accessibility_image_default_role_is_image() {
1293        let icon = rgba_solid(8, [10, 20, 30, 255]);
1294        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1295        let id = tree.add(Avatar::with_image(&icon).alt(lit!("Jane Doe")));
1296        tree.layout(SizeProposal::exact(32.0, 32.0));
1297        let info = tree.accessibility_node(id);
1298        assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1299        assert_eq!(info.name(), Some("Jane Doe"));
1300    }
1301
1302    #[test]
1303    fn accessibility_clickable_becomes_button() {
1304        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1305        let id = tree.add(
1306            Avatar::with_initials(lit!("JD"))
1307                .label(lit!("Open user menu"))
1308                .on_activate_fn(|_ctx| {}),
1309        );
1310        tree.layout(SizeProposal::exact(32.0, 32.0));
1311        let info = tree.accessibility_node(id);
1312        assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1313        assert!(
1314            info.actions()
1315                .contains(&teksilo_core::accesskit::Action::Click)
1316        );
1317        assert!(
1318            info.actions()
1319                .contains(&teksilo_core::accesskit::Action::Focus)
1320        );
1321        assert_eq!(info.name(), Some("Open user menu"));
1322    }
1323
1324    #[test]
1325    fn accessibility_a11y_hidden_does_not_set_role() {
1326        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1327        let id = tree.add(Avatar::with_initials(lit!("JD")).a11y_hidden());
1328        tree.layout(SizeProposal::exact(32.0, 32.0));
1329        let info = tree.accessibility_node(id);
1330        // Hidden nodes carry no name (the leaf-hidden path returned
1331        // early before set_role/set_name fired).
1332        assert_eq!(info.name(), None);
1333    }
1334
1335    #[test]
1336    fn accessibility_label_overrides_initials() {
1337        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1338        let id = tree.add(Avatar::with_initials(lit!("JD")).label(lit!("Jane Doe (offline)")));
1339        tree.layout(SizeProposal::exact(32.0, 32.0));
1340        let info = tree.accessibility_node(id);
1341        assert_eq!(info.name(), Some("Jane Doe (offline)"));
1342    }
1343
1344    #[test]
1345    fn accessibility_presence_appears_in_description() {
1346        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1347        let id = tree.add(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1348        tree.layout(SizeProposal::exact(32.0, 32.0));
1349        // Just verify it builds — `description` isn't surfaced by the
1350        // test introspection helper, but that the avatar accepts the
1351        // presence and renders without panicking is the key contract.
1352        assert_eq!(
1353            tree.accessibility_node(id).role(),
1354            teksilo_core::accesskit::Role::Label
1355        );
1356    }
1357
1358    // ── visibility binding ────────────────────────────────────────────
1359
1360    #[test]
1361    fn image_visible_false_hides_image_child() {
1362        use teksilo_core::signal::Signal;
1363
1364        let icon = rgba_solid(8, [10, 20, 30, 255]);
1365        let visible = Signal::new(true);
1366        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1367        let id = tree.add(
1368            Avatar::with_image(&icon)
1369                .alt(lit!("Jane"))
1370                .fallback_initials(lit!("JD"))
1371                .image_visible(visible.clone()),
1372        );
1373        tree.layout(SizeProposal::exact(32.0, 32.0));
1374        // With visibility = true, an image quad is emitted.
1375        assert!(!tree.render().images.is_empty());
1376
1377        // Flip visibility — the image child becomes dormant; on the
1378        // next render frame, no image is drawn.
1379        visible.set(false);
1380        tree.layout(SizeProposal::exact(32.0, 32.0));
1381        let frame_after = tree.render();
1382        assert!(
1383            frame_after.images.is_empty(),
1384            "image should be hidden when image_visible == false"
1385        );
1386        // Sanity: avatar itself is still visible.
1387        assert!(tree.is_visible(id));
1388    }
1389
1390    // ── shape interaction with image masking ──────────────────────────
1391
1392    // ── foreground / background overrides ─────────────────────────────
1393
1394    fn glyph_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1395        frame.glyphs.iter().map(|g| g.color).collect()
1396    }
1397
1398    fn shape_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1399        frame.shapes.iter().map(|s| s.color).collect()
1400    }
1401
1402    fn approx_color_eq(a: [f32; 4], b: Color) -> bool {
1403        let target = b.to_array();
1404        a.iter()
1405            .zip(target.iter())
1406            .all(|(x, y)| (x - y).abs() < 0.02)
1407    }
1408
1409    #[test]
1410    fn foreground_override_sets_glyph_color() {
1411        // Without override the foreground is auto-contrast — for some
1412        // hash bg it'll be white, for others near-black. We force a
1413        // specific colour and verify it ends up in glyph metadata.
1414        let frame = render_avatar(
1415            Avatar::with_initials(lit!("JD")).foreground(Color::from_rgb(1.0, 0.0, 0.5)),
1416        );
1417        let target = Color::from_rgb(1.0, 0.0, 0.5);
1418        assert!(
1419            glyph_colors(&frame)
1420                .iter()
1421                .any(|c| approx_color_eq(*c, target)),
1422            "expected at least one glyph painted with the foreground override"
1423        );
1424    }
1425
1426    #[test]
1427    fn background_override_sets_bg_shape_color() {
1428        let frame = render_avatar(
1429            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.1, 0.7, 0.2)),
1430        );
1431        let target = Color::from_rgb(0.1, 0.7, 0.2);
1432        assert!(
1433            shape_colors(&frame)
1434                .iter()
1435                .any(|c| approx_color_eq(*c, target)),
1436            "expected the bg override colour to appear on a Shape quad"
1437        );
1438    }
1439
1440    #[test]
1441    fn auto_contrast_uses_overridden_bg_for_initials_text() {
1442        // With a near-white background override and no foreground
1443        // override, auto-contrast should pick a dark text colour.
1444        let frame = render_avatar(
1445            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.95, 0.95, 0.95)),
1446        );
1447        let glyphs = glyph_colors(&frame);
1448        assert!(
1449            !glyphs.is_empty(),
1450            "expected at least one initials glyph in the frame"
1451        );
1452        for g in &glyphs {
1453            // Each channel should be in the dark range.
1454            assert!(
1455                g[0] < 0.3 && g[1] < 0.3 && g[2] < 0.3,
1456                "expected dark auto-contrast glyph against a light bg, got {:?}",
1457                g
1458            );
1459        }
1460    }
1461
1462    #[test]
1463    fn auto_contrast_uses_overridden_bg_against_dark() {
1464        let frame = render_avatar(
1465            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.05, 0.05, 0.05)),
1466        );
1467        let glyphs = glyph_colors(&frame);
1468        assert!(!glyphs.is_empty());
1469        for g in &glyphs {
1470            assert!(
1471                g[0] > 0.9 && g[1] > 0.9 && g[2] > 0.9,
1472                "expected white auto-contrast glyph against a dark bg, got {:?}",
1473                g
1474            );
1475        }
1476    }
1477
1478    #[test]
1479    fn with_name_seed_drives_bg_palette_pick() {
1480        // Two avatars with the same DERIVED initials but DIFFERENT
1481        // full names must pick distinct palette buckets — proving the
1482        // hash uses the seed (full name), not the initials.
1483        // ("Jane Doe" → JD, "Jules Dupont" → JD: identical initials.)
1484        let a = render_avatar(Avatar::with_name(lit!("Jane Doe")));
1485        let b = render_avatar(Avatar::with_name(lit!("Jules Dupont")));
1486        let bg_a = shape_colors(&a)
1487            .into_iter()
1488            .next()
1489            .expect("first shape is the bg circle");
1490        let bg_b = shape_colors(&b)
1491            .into_iter()
1492            .next()
1493            .expect("first shape is the bg circle");
1494        assert_ne!(
1495            bg_a, bg_b,
1496            "Jane Doe and Jules Dupont share initials JD but must hash distinctly via their full names"
1497        );
1498    }
1499
1500    // ── accessibility for image avatars: inner ImageWidget is silenced ─
1501
1502    // ── disclosure pattern (has_popup / expanded_when) ────────────────
1503
1504    #[test]
1505    fn expanded_when_signal_reflects_in_a11y() {
1506        use teksilo_core::signal::Signal;
1507        let open = Signal::new(false);
1508        let mut tree = WidgetTree::new()
1509            .with_theme(teksilo_core::presets::intui::light())
1510            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1511                teksilo_canvas::MockTextBackend::new(),
1512            )));
1513        let id = tree.add(
1514            Avatar::with_initials(lit!("JD"))
1515                .label(lit!("Open user menu"))
1516                .has_popup(teksilo_core::accesskit::HasPopup::Menu)
1517                .expanded_when(open.clone())
1518                .on_activate_fn(|_ctx| {}),
1519        );
1520        tree.layout(SizeProposal::exact(32.0, 32.0));
1521        // Closed.
1522        assert!(!tree.accessibility_node(id).is_expanded());
1523
1524        // Flip — the binding registered in build() must dirty-mark
1525        // this node so the next a11y query sees the new value.
1526        open.set(true);
1527        tree.layout(SizeProposal::exact(32.0, 32.0));
1528        assert!(tree.accessibility_node(id).is_expanded());
1529    }
1530
1531    #[test]
1532    fn has_popup_without_clickable_still_compiles() {
1533        // Non-clickable avatars can still declare `has_popup` —
1534        // builder is a no-op functionally without an action handler,
1535        // but we want `accessibility()` to safely surface it for
1536        // wrappers that supply external state.
1537        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1538        let id = tree.add(
1539            Avatar::with_initials(lit!("JD")).has_popup(teksilo_core::accesskit::HasPopup::Menu),
1540        );
1541        tree.layout(SizeProposal::exact(32.0, 32.0));
1542        // Role stays Label since there's no on_activate_fn.
1543        assert_eq!(
1544            tree.accessibility_node(id).role(),
1545            teksilo_core::accesskit::Role::Label
1546        );
1547    }
1548
1549    // ── focus ring ────────────────────────────────────────────────────
1550
1551    #[test]
1552    fn focus_ring_only_paints_when_focused() {
1553        // Synthesize the same bookkeeping as build() does for a
1554        // clickable avatar, then drive the focus signal directly.
1555
1556        let mut tree = WidgetTree::new()
1557            .with_theme(teksilo_core::presets::intui::light())
1558            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1559                teksilo_canvas::MockTextBackend::new(),
1560            )));
1561        let id = tree.add(
1562            Avatar::with_initials(lit!("JD"))
1563                .label(lit!("Open user menu"))
1564                .on_activate_fn(|_ctx| {}),
1565        );
1566        tree.layout(SizeProposal::exact(64.0, 64.0));
1567        let unfocused_shapes = tree.render().shapes.len();
1568
1569        // Focus the avatar AND flip the input modality to keyboard. The focus
1570        // ring is `:focus-visible` (keyboard-only), so it paints only after a
1571        // key event — not on programmatic or pointer focus.
1572        tree.focus(id);
1573        tree.press_key(
1574            teksilo_core::event::Key::ArrowDown,
1575            teksilo_core::event::Modifiers::NONE,
1576        );
1577        tree.layout(SizeProposal::exact(64.0, 64.0));
1578        let focused_shapes = tree.render().shapes.len();
1579
1580        assert_eq!(
1581            focused_shapes,
1582            unfocused_shapes + 1,
1583            "focused avatar should emit one extra Shape (the focus ring stroke)"
1584        );
1585    }
1586
1587    #[test]
1588    fn focus_ring_uses_theme_focus_ring_color() {
1589        let mut tree = WidgetTree::new()
1590            .with_theme(teksilo_core::presets::intui::light())
1591            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1592                teksilo_canvas::MockTextBackend::new(),
1593            )));
1594        let id = tree.add(
1595            Avatar::with_initials(lit!("JD"))
1596                .label(lit!("Click"))
1597                .on_activate_fn(|_ctx| {}),
1598        );
1599        tree.layout(SizeProposal::exact(64.0, 64.0));
1600        // Keyboard modality reveals the `:focus-visible` ring.
1601        tree.focus(id);
1602        tree.press_key(
1603            teksilo_core::event::Key::ArrowDown,
1604            teksilo_core::event::Modifiers::NONE,
1605        );
1606        tree.layout(SizeProposal::exact(64.0, 64.0));
1607        let frame = tree.render();
1608        let target = teksilo_core::presets::intui::light().colors.focus_ring;
1609        assert!(
1610            shape_colors(&frame)
1611                .iter()
1612                .any(|c| approx_color_eq(*c, target)),
1613            "expected at least one Shape painted with the theme's focus_ring colour"
1614        );
1615    }
1616
1617    #[test]
1618    fn non_clickable_avatar_has_no_focus_ring() {
1619        // A pure Label avatar isn't focusable; it can't acquire focus,
1620        // and even if focus_ring drawing tried to fire, the `focused`
1621        // signal would be `None` and the branch is skipped.
1622        let mut tree = WidgetTree::new()
1623            .with_theme(teksilo_core::presets::intui::light())
1624            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1625                teksilo_canvas::MockTextBackend::new(),
1626            )));
1627        let id = tree.add(Avatar::with_initials(lit!("JD")));
1628        tree.layout(SizeProposal::exact(64.0, 64.0));
1629        let baseline = tree.render().shapes.len();
1630        // Even if some test harness wrongly tried to focus a
1631        // non-focusable widget, the avatar's paint must NOT add a
1632        // focus-ring shape.
1633        tree.focus(id);
1634        tree.layout(SizeProposal::exact(64.0, 64.0));
1635        assert_eq!(
1636            tree.render().shapes.len(),
1637            baseline,
1638            "non-clickable avatar must never draw a focus ring"
1639        );
1640    }
1641
1642    #[test]
1643    fn image_avatar_announces_alt_on_parent() {
1644        // Inner ImageWidget is `a11y_hidden()` so the avatar is only
1645        // announced once. The parent carries the canonical name.
1646        let icon = rgba_solid(8, [10, 20, 30, 255]);
1647        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1648        let parent = tree.add(Avatar::with_image(&icon).alt(lit!("Jane")));
1649        tree.layout(SizeProposal::exact(32.0, 32.0));
1650        let info = tree.accessibility_node(parent);
1651        assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1652        assert_eq!(info.name(), Some("Jane"));
1653    }
1654
1655    #[test]
1656    fn shape_change_after_image_does_not_panic() {
1657        // Pre-refactor we cached masked pixels in Avatar; setting a
1658        // different shape invalidated the cache. Now masking lives on
1659        // ImageWidget, but the test still exercises the builder-time
1660        // ordering: setting the shape after `with_image` works.
1661        let icon = rgba_solid(16, [10, 20, 30, 255]);
1662        let a = Avatar::with_image(&icon).alt(lit!("X"));
1663        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1664        let _ = tree.add(a.shape(AvatarShape::Square));
1665        tree.layout(SizeProposal::exact(32.0, 32.0));
1666        let _ = tree.render();
1667    }
1668
1669    // ── Dynamic content (bind_*) ──────────────────────────────────────
1670
1671    #[test]
1672    fn name_updates_displayed_initials_on_signal_flip() {
1673        use std::cell::RefCell;
1674        use std::rc::Rc as StdRc;
1675        use teksilo_canvas::MockTextBackend;
1676        use teksilo_core::signal::Signal;
1677        let name = Signal::new(String::new()); // logged-out
1678        let mut tree = WidgetTree::new()
1679            .with_theme(teksilo_core::presets::intui::light())
1680            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1681        let id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1682        tree.layout(SizeProposal::exact(32.0, 32.0));
1683        // Empty name ⇒ derived initials = "?".
1684        assert_eq!(tree.accessibility_node(id).name(), Some("?"));
1685
1686        name.set("Jane Doe".to_string());
1687        tree.layout(SizeProposal::exact(32.0, 32.0));
1688        // After the rebuild, derived initials = "JD".
1689        assert_eq!(tree.accessibility_node(id).name(), Some("JD"));
1690    }
1691
1692    #[test]
1693    fn image_swap_logged_out_to_logged_in() {
1694        // The login-flow scenario from the API doc: start without an
1695        // image (initials fallback), then publish a real photo.
1696        use std::cell::RefCell;
1697        use std::rc::Rc as StdRc;
1698        use teksilo_canvas::MockTextBackend;
1699        use teksilo_core::signal::Signal;
1700        let icon = rgba_solid(8, [10, 20, 30, 255]);
1701        let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1702        let mut tree = WidgetTree::new()
1703            .with_theme(teksilo_core::presets::intui::light())
1704            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1705        let _id = tree.add(
1706            Avatar::with_initials(lit!("JD"))
1707                .alt(lit!("Jane"))
1708                .image_signal(image.clone()),
1709        );
1710        tree.layout(SizeProposal::exact(32.0, 32.0));
1711        // Logged-out: no image quad emitted.
1712        assert!(
1713            tree.render().images.is_empty(),
1714            "logged-out avatar must not emit an image quad"
1715        );
1716
1717        // Logged in.
1718        image.set(Some(Rc::new(icon)));
1719        tree.layout(SizeProposal::exact(32.0, 32.0));
1720        assert!(
1721            !tree.render().images.is_empty(),
1722            "logged-in avatar must emit an image quad after the signal flips"
1723        );
1724
1725        // Logged out again.
1726        image.set(None);
1727        tree.layout(SizeProposal::exact(32.0, 32.0));
1728        assert!(
1729            tree.render().images.is_empty(),
1730            "image quad must disappear when the source signal returns to None"
1731        );
1732    }
1733
1734    #[test]
1735    fn image_signal_wins_over_static_with_image() {
1736        // If both are supplied, the bound signal is the source of
1737        // truth — `None` ⇒ initials fallback even when a static
1738        // image was provided first.
1739        use std::cell::RefCell;
1740        use std::rc::Rc as StdRc;
1741        use teksilo_canvas::MockTextBackend;
1742        use teksilo_core::signal::Signal;
1743        let icon = rgba_solid(8, [10, 20, 30, 255]);
1744        let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1745        let mut tree = WidgetTree::new()
1746            .with_theme(teksilo_core::presets::intui::light())
1747            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1748        let _id = tree.add(
1749            Avatar::with_image(&icon)
1750                .alt(lit!("anything"))
1751                .fallback_initials(lit!("XX"))
1752                .image_signal(image.clone()),
1753        );
1754        tree.layout(SizeProposal::exact(32.0, 32.0));
1755        // Signal None overrides the static source — initials only.
1756        assert!(tree.render().images.is_empty());
1757    }
1758
1759    #[test]
1760    fn alt_updates_a11y_name_on_image_avatar() {
1761        use std::cell::RefCell;
1762        use std::rc::Rc as StdRc;
1763        use teksilo_canvas::MockTextBackend;
1764        use teksilo_core::signal::Signal;
1765        let icon = rgba_solid(8, [10, 20, 30, 255]);
1766        let alt = Signal::new(Some("Jane Doe".to_string()));
1767        let mut tree = WidgetTree::new()
1768            .with_theme(teksilo_core::presets::intui::light())
1769            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1770        let id = tree.add(Avatar::with_image(&icon).alt_signal(alt.clone()));
1771        tree.layout(SizeProposal::exact(32.0, 32.0));
1772        assert_eq!(tree.accessibility_node(id).name(), Some("Jane Doe"));
1773
1774        alt.set(Some("Jules Dupont".to_string()));
1775        tree.layout(SizeProposal::exact(32.0, 32.0));
1776        assert_eq!(tree.accessibility_node(id).name(), Some("Jules Dupont"));
1777    }
1778
1779    #[test]
1780    fn presence_swap_changes_dot_color_and_a11y_description() {
1781        use teksilo_core::signal::Signal;
1782        let presence: Signal<Option<AvatarPresence>> = Signal::new(Some(AvatarPresence::Online));
1783        let mut tree = WidgetTree::new()
1784            .with_theme(teksilo_core::presets::intui::light())
1785            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1786                teksilo_canvas::MockTextBackend::new(),
1787            )));
1788        let id = tree.add(Avatar::with_initials(lit!("JD")).presence_signal(presence.clone()));
1789        tree.layout(SizeProposal::exact(32.0, 32.0));
1790        let online_color = teksilo_core::presets::intui::light()
1791            .colors
1792            .status_success_fg;
1793        assert!(
1794            shape_colors(&tree.render())
1795                .iter()
1796                .any(|c| approx_color_eq(*c, online_color)),
1797            "Online presence should paint the success colour"
1798        );
1799        let _ = id;
1800
1801        // Flip to Busy.
1802        presence.set(Some(AvatarPresence::Busy));
1803        tree.layout(SizeProposal::exact(32.0, 32.0));
1804        let busy_color = teksilo_core::presets::intui::light().colors.status_error_fg;
1805        assert!(
1806            shape_colors(&tree.render())
1807                .iter()
1808                .any(|c| approx_color_eq(*c, busy_color)),
1809            "Busy presence should paint the error colour"
1810        );
1811
1812        // Hide.
1813        presence.set(None);
1814        tree.layout(SizeProposal::exact(32.0, 32.0));
1815        let frame = tree.render();
1816        // No presence dot ⇒ neither status colour is on the frame.
1817        assert!(
1818            !shape_colors(&frame)
1819                .iter()
1820                .any(|c| approx_color_eq(*c, online_color) || approx_color_eq(*c, busy_color)),
1821            "presence None must remove the dot from the frame"
1822        );
1823    }
1824
1825    // ── tooltip ───────────────────────────────────────────────────────
1826
1827    #[test]
1828    fn tooltip_appears_on_hover() {
1829        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1830        let id = tree.add(Avatar::with_initials(lit!("JD")).tooltip(lit!("Tip")));
1831        tree.layout(SizeProposal::exact(300.0, 200.0));
1832        tree.pointer_move(tree.bounds(id).center());
1833        tree.advance_time(std::time::Duration::from_secs(1));
1834        assert_eq!(
1835            tree.active_overlays().len(),
1836            1,
1837            "tooltip should appear on hover"
1838        );
1839        assert!(tree.find_by_label("Tip").is_some());
1840    }
1841
1842    #[test]
1843    fn name_changes_hash_seed_so_palette_pick_can_change() {
1844        // Distinct full names with identical initials produce distinct
1845        // palette buckets. After name_signal flips between them, the
1846        // bg shape colour must change too.
1847        use std::cell::RefCell;
1848        use std::rc::Rc as StdRc;
1849        use teksilo_canvas::MockTextBackend;
1850        use teksilo_core::signal::Signal;
1851        let name = Signal::new("Jane Doe".to_string());
1852        let mut tree = WidgetTree::new()
1853            .with_theme(teksilo_core::presets::intui::light())
1854            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1855        let _id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1856        tree.layout(SizeProposal::exact(32.0, 32.0));
1857        let bg_jd = shape_colors(&tree.render())
1858            .into_iter()
1859            .next()
1860            .expect("bg circle is the first Shape");
1861
1862        name.set("Jules Dupont".to_string());
1863        tree.layout(SizeProposal::exact(32.0, 32.0));
1864        let bg_jd2 = shape_colors(&tree.render()).into_iter().next().unwrap();
1865        assert_ne!(
1866            bg_jd, bg_jd2,
1867            "different bound names must hash to different palette buckets"
1868        );
1869    }
1870}