Skip to main content

herogpui_theme/
semantic.rs

1//! Semantic color tokens — a faithful port of HeroUI v3's
2//! `packages/styles/themes/default/variables.css`.
3//!
4//! Every base value below is transcribed verbatim from that file in `oklch()`.
5//! Derived tokens (`*-hover`, `*-soft`, `background-secondary`,
6//! `separator-secondary`, …) are computed with the same
7//! `color-mix(in oklab, …)` weights the stylesheet uses, so a HeroGPUI theme
8//! and a HeroUI theme resolve to identical pixels.
9
10use gpui::Hsla;
11use herogpui_core::{mix_oklab, oklch, soft_mix, with_alpha};
12
13// ---------------------------------------------------------------------------
14// Base colors — identical in light and dark ("do not change between modes")
15// ---------------------------------------------------------------------------
16
17/// `--white: oklch(100% 0 0)`
18pub fn white() -> Hsla {
19    oklch(1.0, 0.0, 0.0)
20}
21/// `--black: oklch(0% 0 0)`
22pub fn black() -> Hsla {
23    oklch(0.0, 0.0, 0.0)
24}
25/// `--snow: oklch(0.9911 0 0)`
26pub fn snow() -> Hsla {
27    oklch(0.9911, 0.0, 0.0)
28}
29/// `--eclipse: oklch(0.2103 0.0059 285.89)`
30pub fn eclipse() -> Hsla {
31    oklch(0.2103, 0.0059, 285.89)
32}
33
34/// How a role resolves `--role-soft-foreground`.
35#[derive(Clone, Copy, Debug)]
36pub enum SoftForeground {
37    /// `--default-soft-foreground: var(--default-foreground)`
38    RoleForeground,
39    /// `color-mix(in oklab, var(--role) C%, var(--foreground) F%)`. CSS
40    /// normalises the weights, so the role contributes `C / (C + F)`.
41    Mix { color: f32, foreground: f32 },
42}
43
44/// A semantic color role (`accent`, `default`, `success`, `warning`, `danger`).
45///
46/// v3 removed numbered scales; a role carries only its base value and readable
47/// foreground, and every other shade is derived.
48#[derive(Clone, Copy, Debug)]
49pub struct RoleColor {
50    /// e.g. `--accent`.
51    pub color: Hsla,
52    /// e.g. `--accent-foreground`.
53    pub foreground: Hsla,
54    /// Weight of `foreground` in the `*-hover` mix. `0.10` for the status and
55    /// accent roles, `0.04` for `default`.
56    pub hover_mix: f32,
57    /// An explicit `*-hover`, in place of the `hover_mix` derivation.
58    ///
59    /// Upstream derives every hover shade by mixing the role toward its own
60    /// foreground, which moves a dark accent lighter and a light one darker.
61    /// A design system whose hover moves the other way — or to an unrelated
62    /// value — has no weight that expresses it, so it can name the colour
63    /// instead. Set through [`RoleColor::with_hover`].
64    hover_override: Option<Hsla>,
65    /// Share of the role color in `*-soft`, over transparent.
66    soft_mix: f32,
67    /// Share of the role color in `*-soft-hover`.
68    soft_hover_mix: f32,
69    /// How `*-soft-foreground` resolves.
70    soft_foreground: SoftForeground,
71    /// `[data-vibrant-palette="true"]` (variables.css:317-330): the opt-in
72    /// palette reweights every mixing role's `*-soft-foreground` to
73    /// `92%` role over `8%` page foreground, in both appearances. Roles that
74    /// resolve to [`SoftForeground::RoleForeground`] (`default`) are untouched,
75    /// exactly as upstream leaves `--default-soft-foreground` alone.
76    vibrant_palette: bool,
77}
78
79impl RoleColor {
80    pub fn new(color: Hsla, foreground: Hsla) -> Self {
81        Self {
82            color,
83            foreground,
84            hover_mix: 0.10,
85            hover_override: None,
86            soft_mix: 0.15,
87            soft_hover_mix: 0.20,
88            soft_foreground: SoftForeground::Mix {
89                color: 70.0,
90                foreground: 30.0,
91            },
92            vibrant_palette: false,
93        }
94    }
95
96    /// `default` mixes only 4% of its foreground on hover.
97    ///
98    /// Ignored once [`RoleColor::with_hover`] has named the shade outright.
99    pub fn with_hover_mix(mut self, hover_mix: f32) -> Self {
100        self.hover_mix = hover_mix;
101        self
102    }
103
104    /// Replaces the derived `*-hover` shade with an explicit colour.
105    ///
106    /// This is the role-level hook: every component that hovers this role —
107    /// a `Variant::Primary` button, a select trigger, a menu row — reads
108    /// [`RoleColor::hover`], so naming it here makes the whole surface correct
109    /// by construction rather than one call site at a time. It is the
110    /// alternative to registering a named component recipe carrying a
111    /// [`crate::ComponentColor::Literal`] and converting each site to it,
112    /// which leaves any newly written site on the derived shade.
113    ///
114    /// Leaves `*-soft-hover` alone: that shade is an alpha of the role over
115    /// transparent and does not mix the foreground in.
116    pub fn with_hover(mut self, hover: Hsla) -> Self {
117        self.hover_override = Some(hover);
118        self
119    }
120
121    /// The explicit `*-hover`, when one was named.
122    pub fn hover_override(&self) -> Option<Hsla> {
123        self.hover_override
124    }
125
126    /// Sets the shares of the role color in `--role-soft` and
127    /// `--role-soft-hover` (`default` sits at 50/60, the rest at 15/20 in
128    /// light and 12/16 in dark for the cooler roles).
129    pub fn with_soft_mix(mut self, soft: f32, soft_hover: f32) -> Self {
130        self.soft_mix = soft;
131        self.soft_hover_mix = soft_hover;
132        self
133    }
134
135    /// `--role-soft-foreground: color-mix(in oklab, var(--role) C%, var(--foreground) F%)`
136    pub fn with_soft_foreground_mix(mut self, color: f32, foreground: f32) -> Self {
137        self.soft_foreground = SoftForeground::Mix { color, foreground };
138        self
139    }
140
141    /// Opts this role into `[data-vibrant-palette="true"]`. Set through
142    /// [`crate::ThemeBuilder::vibrant_palette`], which fans the flag out over
143    /// every role at once, the way the attribute selector does.
144    pub fn with_vibrant_palette(mut self, vibrant: bool) -> Self {
145        self.vibrant_palette = vibrant;
146        self
147    }
148
149    /// Whether this role resolves its soft foreground with the vibrant weights.
150    pub fn is_vibrant_palette(&self) -> bool {
151        self.vibrant_palette
152    }
153
154    /// `--default-soft-foreground: var(--default-foreground)`
155    pub fn with_soft_foreground_role(mut self) -> Self {
156        self.soft_foreground = SoftForeground::RoleForeground;
157        self
158    }
159
160    /// `--color-accent-hover: color-mix(in oklab, var(--accent) 90%, var(--accent-foreground) 10%)`
161    ///
162    /// [`RoleColor::with_hover`] replaces this derivation with a named colour.
163    pub fn hover(&self) -> Hsla {
164        self.hover_override
165            .unwrap_or_else(|| mix_oklab(self.color, self.foreground, self.hover_mix))
166    }
167
168    /// `--color-accent-soft: color-mix(in oklab, var(--accent) 15%, transparent)`
169    pub fn soft(&self) -> Hsla {
170        soft_mix(self.color, self.soft_mix)
171    }
172
173    /// `--color-accent-soft-hover: color-mix(in oklab, var(--accent) 20%, transparent)`
174    pub fn soft_hover(&self) -> Hsla {
175        soft_mix(self.color, self.soft_hover_mix)
176    }
177
178    /// `--color-accent-soft-foreground: color-mix(in oklab, var(--accent) 70%, var(--foreground) 30%)`
179    ///
180    /// The mixing roles blend against the page foreground, so pass the live
181    /// `ThemeColors::foreground` — a `ThemeBuilder::foreground` override then
182    /// flows through without rebuilding the theme.
183    pub fn soft_foreground(&self, page_foreground: Hsla) -> Hsla {
184        match self.soft_foreground {
185            SoftForeground::RoleForeground => self.foreground,
186            // variables.css:318-321 (light) / :326-329 (dark): the vibrant
187            // palette replaces the per-role weights with one 92/8 mix.
188            SoftForeground::Mix { .. } if self.vibrant_palette => {
189                mix_oklab(self.color, page_foreground, 8.0 / 100.0)
190            }
191            SoftForeground::Mix { color, foreground } => {
192                // `mix_oklab`'s `t` is the weight of its *second* argument, so
193                // the page foreground's normalised share goes here — passing the
194                // role's share paints the text as `F%` role over `C%` ink.
195                mix_oklab(
196                    self.color,
197                    page_foreground,
198                    foreground / (color + foreground),
199                )
200            }
201        }
202    }
203
204    pub fn with_alpha(&self, alpha: f32) -> Hsla {
205        with_alpha(self.color, alpha)
206    }
207}
208
209/// A layered container color: `surface`, `overlay` or `segment`.
210#[derive(Clone, Copy, Debug)]
211pub struct SurfaceColor {
212    pub background: Hsla,
213    pub foreground: Hsla,
214}
215
216impl SurfaceColor {
217    /// `--surface-hover: color-mix(in oklab, var(--surface) 92%, var(--surface-foreground) 8%)`
218    pub fn hover(&self) -> Hsla {
219        mix_oklab(self.background, self.foreground, 0.08)
220    }
221}
222
223/// Form-field tokens. v3 keeps these separate from buttons so inputs can be
224/// styled independently.
225#[derive(Clone, Copy, Debug)]
226#[non_exhaustive]
227pub struct FieldColors {
228    /// `--field-background`
229    pub background: Hsla,
230    /// `--field-foreground`
231    pub foreground: Hsla,
232    /// `--field-placeholder`
233    pub placeholder: Hsla,
234    /// `--field-border` — `transparent` by default.
235    pub border: Hsla,
236}
237
238impl FieldColors {
239    /// `--color-field-hover: color-mix(in oklab, var(--field-background) 90%, var(--field-foreground) 2%)`
240    ///
241    /// CSS normalises the 90/2 weights, so the foreground contributes 2/92.
242    pub fn hover(&self) -> Hsla {
243        mix_oklab(self.background, self.foreground, 2.0 / 92.0)
244    }
245
246    /// `--color-field-focus: var(--field-background)`
247    pub fn focus(&self) -> Hsla {
248        self.background
249    }
250
251    /// `--field-border-hover: color-mix(in oklab, var(--field-border) 88%, var(--field-foreground) 10%)`
252    ///
253    /// The weights sum to 98, which CSS normalises, so the foreground
254    /// contributes 10/98. Invisible while `--field-border-width` is 0, and the
255    /// token exists for a caller who gives their fields a border.
256    pub fn border_hover(&self) -> Hsla {
257        mix_oklab(self.border, self.foreground, 10.0 / 98.0)
258    }
259
260    /// `--field-border-focus: color-mix(in oklab, var(--field-border) 74%, var(--field-foreground) 22%)`
261    pub fn border_focus(&self) -> Hsla {
262        mix_oklab(self.border, self.foreground, 22.0 / 96.0)
263    }
264}
265
266/// All semantic tokens of one appearance.
267#[derive(Clone, Debug)]
268#[non_exhaustive]
269pub struct ThemeColors {
270    // -- base ---------------------------------------------------------------
271    /// `--background`
272    pub background: Hsla,
273    /// `--foreground`
274    pub foreground: Hsla,
275    /// `--muted` — de-emphasised body text and icons.
276    pub muted: Hsla,
277    /// `--scrollbar: var(--scrollbar-thumb)`
278    pub scrollbar: Hsla,
279
280    // -- containers ---------------------------------------------------------
281    /// `--surface` / `--surface-foreground` — non-floating components
282    /// (cards, accordions, disclosure groups).
283    pub surface: SurfaceColor,
284    /// `--surface-secondary`
285    pub surface_secondary: Hsla,
286    /// `--surface-tertiary`
287    pub surface_tertiary: Hsla,
288    /// `--overlay` / `--overlay-foreground` — floating components
289    /// (tooltips, popovers, modals, menus).
290    pub overlay: SurfaceColor,
291    /// `--segment` / `--segment-foreground` — selected segment of a
292    /// segmented control (tabs, toggle groups).
293    pub segment: SurfaceColor,
294
295    // -- roles --------------------------------------------------------------
296    /// `--default` — the neutral backbone of the system.
297    pub default: RoleColor,
298    /// `--accent` — the brand color (v2 `primary`).
299    pub accent: RoleColor,
300    /// `--success`
301    pub success: RoleColor,
302    /// `--warning`
303    pub warning: RoleColor,
304    /// `--danger`
305    pub danger: RoleColor,
306
307    // -- fields -------------------------------------------------------------
308    pub field: FieldColors,
309
310    // -- misc ---------------------------------------------------------------
311    /// `--border`
312    pub border: Hsla,
313    /// `--separator`
314    pub separator: Hsla,
315    /// `--focus`
316    pub focus: Hsla,
317    /// `--link`
318    pub link: Hsla,
319    /// `--backdrop` — the scrim behind modals and drawers.
320    pub backdrop: Hsla,
321}
322
323impl ThemeColors {
324    /// Whether `[data-vibrant-palette="true"]` is on (variables.css:317-330).
325    ///
326    /// The flag lives on each [`RoleColor`]; the mixing roles are set together
327    /// by [`crate::ThemeBuilder::vibrant_palette`], so `accent` answers for all.
328    pub fn vibrant_palette(&self) -> bool {
329        self.accent.is_vibrant_palette()
330    }
331
332    /// Fans `[data-vibrant-palette="true"]` out over the roles the selector
333    /// lists: accent, success, warning and danger. `default` is absent from
334    /// both blocks upstream and keeps `--default-soft-foreground`.
335    pub fn set_vibrant_palette(&mut self, vibrant: bool) {
336        self.accent = self.accent.with_vibrant_palette(vibrant);
337        self.success = self.success.with_vibrant_palette(vibrant);
338        self.warning = self.warning.with_vibrant_palette(vibrant);
339        self.danger = self.danger.with_vibrant_palette(vibrant);
340    }
341
342    // -- derived backgrounds ------------------------------------------------
343
344    /// `color-mix(in oklab, var(--background) 96%, var(--foreground) 4%)`
345    pub fn background_secondary(&self) -> Hsla {
346        mix_oklab(self.background, self.foreground, 0.04)
347    }
348
349    /// `color-mix(in oklab, var(--background) 92%, var(--foreground) 8%)`
350    pub fn background_tertiary(&self) -> Hsla {
351        mix_oklab(self.background, self.foreground, 0.08)
352    }
353
354    /// `--color-background-inverse: var(--foreground)`
355    pub fn background_inverse(&self) -> Hsla {
356        self.foreground
357    }
358
359    // -- derived separators -------------------------------------------------
360
361    /// `color-mix(in oklab, var(--surface) 85%, var(--surface-foreground) 15%)`
362    pub fn separator_secondary(&self) -> Hsla {
363        mix_oklab(self.surface.background, self.surface.foreground, 0.15)
364    }
365
366    /// `color-mix(in oklab, var(--surface) 81%, var(--surface-foreground) 19%)`
367    pub fn separator_tertiary(&self) -> Hsla {
368        mix_oklab(self.surface.background, self.surface.foreground, 0.19)
369    }
370
371    // -- derived borders ----------------------------------------------------
372
373    /// `--border-secondary: color-mix(in oklab, var(--surface) 78%, var(--surface-foreground) 22%)`
374    pub fn border_secondary(&self) -> Hsla {
375        mix_oklab(self.surface.background, self.surface.foreground, 0.22)
376    }
377
378    /// `--border-tertiary: color-mix(in oklab, var(--surface) 66%, var(--surface-foreground) 34%)`
379    pub fn border_tertiary(&self) -> Hsla {
380        mix_oklab(self.surface.background, self.surface.foreground, 0.34)
381    }
382
383    /// `--surface-secondary-foreground: var(--foreground)`
384    ///
385    /// v3 gives the secondary and tertiary surfaces their own foreground
386    /// variables, both defaulting to the page's, so a caller who repaints one of
387    /// those surfaces has somewhere to put the matching text colour.
388    pub fn surface_secondary_foreground(&self) -> Hsla {
389        self.foreground
390    }
391
392    /// `--surface-tertiary-foreground: var(--foreground)`
393    pub fn surface_tertiary_foreground(&self) -> Hsla {
394        self.foreground
395    }
396
397    /// Resolves a role by its v3 token name, defaulting to `accent`.
398    pub fn role(&self, name: &str) -> &RoleColor {
399        match name {
400            "default" => &self.default,
401            "success" => &self.success,
402            "warning" => &self.warning,
403            "danger" => &self.danger,
404            _ => &self.accent,
405        }
406    }
407
408    // -- light --------------------------------------------------------------
409
410    pub fn light() -> Self {
411        let foreground = eclipse();
412        let muted = oklch(0.5517, 0.0138, 285.94);
413        let accent = RoleColor::new(oklch(0.6204, 0.195, 253.83), snow())
414            .with_soft_mix(0.15, 0.20)
415            .with_soft_foreground_mix(70.0, 30.0);
416        Self {
417            background: oklch(0.9702, 0.0, 0.0),
418            foreground,
419            muted,
420            scrollbar: with_alpha(foreground, 0.15),
421
422            surface: SurfaceColor {
423                background: white(),
424                foreground,
425            },
426            surface_secondary: oklch(0.9524, 0.0013, 286.37),
427            surface_tertiary: oklch(0.9373, 0.0013, 286.37),
428            overlay: SurfaceColor {
429                background: white(),
430                foreground,
431            },
432            segment: SurfaceColor {
433                background: white(),
434                foreground: eclipse(),
435            },
436
437            default: RoleColor::new(oklch(0.94, 0.001, 286.375), eclipse())
438                .with_hover_mix(0.04)
439                .with_soft_mix(0.50, 0.60)
440                .with_soft_foreground_role(),
441            accent,
442            success: RoleColor::new(oklch(0.7329, 0.1935, 150.81), eclipse())
443                .with_soft_foreground_mix(80.0, 60.0),
444            warning: RoleColor::new(oklch(0.7819, 0.1585, 72.33), eclipse())
445                .with_soft_foreground_mix(80.0, 70.0),
446            danger: RoleColor::new(oklch(0.6532, 0.2328, 25.74), snow())
447                .with_soft_foreground_mix(70.0, 40.0),
448
449            field: FieldColors {
450                background: white(),
451                foreground: oklch(0.2103, 0.0059, 285.89),
452                placeholder: muted,
453                border: with_alpha(black(), 0.0),
454            },
455
456            // `--border` is a step darker than `--separator`: 90% against 92%.
457            // Both had been transcribed as the separator's value.
458            border: oklch(0.9, 0.004, 286.32),
459            separator: oklch(0.92, 0.004, 286.32),
460            focus: accent.color,
461            link: foreground,
462            backdrop: with_alpha(black(), 0.5),
463        }
464    }
465
466    // -- dark ---------------------------------------------------------------
467
468    pub fn dark() -> Self {
469        let foreground = snow();
470        let muted = oklch(0.705, 0.015, 286.067);
471        let accent = RoleColor::new(oklch(0.6204, 0.195, 253.83), snow())
472            .with_soft_mix(0.12, 0.16)
473            .with_soft_foreground_mix(80.0, 30.0);
474        let default = RoleColor::new(oklch(0.274, 0.006, 286.033), snow())
475            .with_hover_mix(0.04)
476            .with_soft_mix(0.50, 0.60)
477            .with_soft_foreground_role();
478        Self {
479            background: oklch(0.12, 0.005, 285.823),
480            foreground,
481            muted,
482            scrollbar: with_alpha(foreground, 0.15),
483
484            surface: SurfaceColor {
485                background: oklch(0.2103, 0.0059, 285.89),
486                foreground,
487            },
488            surface_secondary: oklch(0.257, 0.0037, 286.14),
489            surface_tertiary: oklch(0.2721, 0.0024, 247.91),
490            // `--overlay` *is* `--surface` in dark mode. This used to lighten it
491            // "so floating panels read", which is the kind of improvement the
492            // token values are not allowed to make: a v3 dark popover is the
493            // colour of a v3 dark card, and the shadow is what separates them.
494            overlay: SurfaceColor {
495                background: oklch(0.2103, 0.0059, 285.89),
496                foreground,
497            },
498            segment: SurfaceColor {
499                background: oklch(0.3964, 0.01, 285.93),
500                foreground,
501            },
502
503            default,
504            accent,
505            // `--success` is not overridden in dark mode; only its soft shares
506            // are (12/16 over transparent, foreground at 80/30).
507            success: RoleColor::new(oklch(0.7329, 0.1935, 150.81), eclipse())
508                .with_soft_mix(0.12, 0.16)
509                .with_soft_foreground_mix(80.0, 30.0),
510            warning: RoleColor::new(oklch(0.8203, 0.1388, 76.34), eclipse())
511                .with_soft_mix(0.12, 0.16)
512                .with_soft_foreground_mix(80.0, 30.0),
513            danger: RoleColor::new(oklch(0.594, 0.1967, 24.63), snow())
514                .with_soft_foreground_mix(80.0, 30.0),
515
516            field: FieldColors {
517                // `--field-background: oklch(0.2103 0.0059 285.89)` -- the
518                // surface colour, not `--default`, which is two steps lighter.
519                background: oklch(0.2103, 0.0059, 285.89),
520                foreground,
521                placeholder: muted,
522                border: with_alpha(black(), 0.0),
523            },
524
525            // `--border: oklch(28% ..)`, `--separator: oklch(25% ..)`. Both were
526            // one value here, and both too dark.
527            border: oklch(0.28, 0.006, 286.033),
528            separator: oklch(0.25, 0.006, 286.033),
529            focus: accent.color,
530            link: foreground,
531            backdrop: with_alpha(black(), 0.6),
532        }
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn rgb8(c: Hsla) -> (u8, u8, u8) {
541        let r = gpui::Rgba::from(c);
542        (
543            (r.r * 255.0).round() as u8,
544            (r.g * 255.0).round() as u8,
545            (r.b * 255.0).round() as u8,
546        )
547    }
548
549    #[test]
550    fn an_explicit_role_hover_replaces_the_derived_mix() {
551        let stock = ThemeColors::light();
552        let derived = stock.accent.hover();
553        // Upstream mixes the role toward its own foreground, so a hover named
554        // in the other direction has no `hover_mix` that expresses it.
555        let named = gpui::hsla(0.6, 0.9, 0.3, 1.0);
556        let role = stock.accent.with_hover(named);
557        assert_ne!(rgb8(derived), rgb8(named), "the fixture must differ");
558        assert_eq!(rgb8(role.hover()), rgb8(named));
559        assert_eq!(role.hover_override(), Some(named));
560        // Only `*-hover` moves: the soft shades are alphas of the role over
561        // transparent and never mix the foreground in.
562        assert_eq!(rgb8(role.soft()), rgb8(stock.accent.soft()));
563        assert_eq!(rgb8(role.soft_hover()), rgb8(stock.accent.soft_hover()));
564        assert_eq!(rgb8(role.color), rgb8(stock.accent.color));
565    }
566
567    #[test]
568    fn a_named_role_hover_outranks_the_hover_mix_whichever_order_they_are_set() {
569        let base = ThemeColors::light().accent;
570        let named = gpui::hsla(0.1, 0.8, 0.4, 1.0);
571        assert_eq!(
572            rgb8(base.with_hover(named).with_hover_mix(0.5).hover()),
573            rgb8(named)
574        );
575        assert_eq!(
576            rgb8(base.with_hover_mix(0.5).with_hover(named).hover()),
577            rgb8(named)
578        );
579    }
580
581    #[test]
582    fn accent_soft_is_fifteen_percent_accent() {
583        let c = ThemeColors::light();
584        assert!((c.accent.soft().a - 0.15).abs() < 1e-4);
585        assert!((c.accent.soft_hover().a - 0.20).abs() < 1e-4);
586    }
587
588    #[test]
589    fn light_scrollbar_thumb_is_fifteen_percent_foreground() {
590        let c = ThemeColors::light();
591        assert_eq!(c.scrollbar, with_alpha(c.foreground, 0.15));
592    }
593
594    #[test]
595    fn dark_scrollbar_thumb_is_fifteen_percent_foreground() {
596        let c = ThemeColors::dark();
597        assert_eq!(c.scrollbar, with_alpha(c.foreground, 0.15));
598    }
599
600    #[test]
601    fn the_soft_matrix_matches_the_pinned_stylesheet() {
602        // Weights transcribed from `variables.css` at v3.2.4. `None` means
603        // `--role-soft-foreground: var(--role-foreground)`; `Some` is the
604        // `color-mix(in oklab, var(--role) C%, var(--foreground) F%)` pair.
605        let light = ThemeColors::light();
606        let dark = ThemeColors::dark();
607        for (colors, role_name, soft, soft_hover, foreground) in [
608            (&light, "default", 0.50, 0.60, None),
609            (&light, "accent", 0.15, 0.20, Some((70.0, 30.0))),
610            (&light, "success", 0.15, 0.20, Some((80.0, 60.0))),
611            (&light, "warning", 0.15, 0.20, Some((80.0, 70.0))),
612            (&light, "danger", 0.15, 0.20, Some((70.0, 40.0))),
613            (&dark, "default", 0.50, 0.60, None),
614            (&dark, "accent", 0.12, 0.16, Some((80.0, 30.0))),
615            (&dark, "success", 0.12, 0.16, Some((80.0, 30.0))),
616            (&dark, "warning", 0.12, 0.16, Some((80.0, 30.0))),
617            (&dark, "danger", 0.15, 0.20, Some((80.0, 30.0))),
618        ] {
619            let role = colors.role(role_name);
620            assert!(
621                (role.soft().a - soft).abs() < 1e-4,
622                "{role_name} soft alpha"
623            );
624            assert!(
625                (role.soft_hover().a - soft_hover).abs() < 1e-4,
626                "{role_name} soft-hover alpha"
627            );
628            match foreground {
629                Some((color, page)) => assert_eq!(
630                    role.soft_foreground(colors.foreground),
631                    mix_oklab(role.color, colors.foreground, page / (color + page)),
632                    "{role_name} soft-foreground mix"
633                ),
634                None => assert_eq!(
635                    role.soft_foreground(colors.foreground),
636                    role.foreground,
637                    "{role_name} soft-foreground follows the role"
638                ),
639            }
640        }
641    }
642
643    /// Every semantic token, resolved the way a browser resolves it on
644    /// heroui.com at v3.2.4 — light and dark. A weight-for-weight comparison
645    /// against the same formula cannot catch a formula that is wrong, so these
646    /// are the pixels themselves.
647    #[test]
648    fn every_token_resolves_to_the_stylesheets_own_pixels() {
649        type Pick = fn(&ThemeColors) -> Hsla;
650        /// `(name, accessor, light sRGB+alpha, dark sRGB+alpha)`.
651        type Row = (&'static str, Pick, (u8, u8, u8, f32), (u8, u8, u8, f32));
652        #[rustfmt::skip]
653        let table: &[Row] = &[
654            ("accent", |c| c.accent.color, (4, 133, 247, 1.00), (4, 133, 247, 1.00)),
655            ("accent_foreground", |c| c.accent.foreground, (252, 252, 252, 1.00), (252, 252, 252, 1.00)),
656            ("accent_hover", |c| c.accent.hover(), (53, 146, 249, 1.00), (53, 146, 249, 1.00)),
657            ("accent_soft", |c| c.accent.soft(), (4, 133, 247, 0.15), (4, 133, 247, 0.12)),
658            ("accent_soft_foreground", |c| c.accent.soft_foreground(c.foreground), (30, 99, 174, 1.00), (97, 168, 251, 1.00)),
659            ("backdrop", |c| c.backdrop, (0, 0, 0, 0.50), (0, 0, 0, 0.60)),
660            ("background", |c| c.background, (245, 245, 245, 1.00), (6, 6, 7, 1.00)),
661            ("background_inverse", |c| c.background_inverse(), (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
662            ("background_secondary", |c| c.background_secondary(), (235, 235, 235, 1.00), (12, 12, 14, 1.00)),
663            ("background_tertiary", |c| c.background_tertiary(), (225, 225, 225, 1.00), (19, 19, 22, 1.00)),
664            ("border", |c| c.border, (221, 222, 224, 1.00), (40, 40, 44, 1.00)),
665            ("border_secondary", |c| c.border_secondary(), (198, 198, 199, 1.00), (67, 67, 69, 1.00)),
666            ("border_tertiary", |c| c.border_tertiary(), (168, 168, 169, 1.00), (92, 92, 95, 1.00)),
667            ("danger", |c| c.danger.color, (255, 56, 60, 1.00), (219, 59, 62, 1.00)),
668            ("danger_foreground", |c| c.danger.foreground, (252, 252, 252, 1.00), (252, 252, 252, 1.00)),
669            ("danger_hover", |c| c.danger.hover(), (255, 85, 81, 1.00), (225, 84, 81, 1.00)),
670            ("danger_soft", |c| c.danger.soft(), (255, 56, 60, 0.15), (219, 59, 62, 0.15)),
671            ("danger_soft_foreground", |c| c.danger.soft_foreground(c.foreground), (164, 53, 51, 1.00), (235, 120, 114, 1.00)),
672            ("default", |c| c.default.color, (235, 235, 236, 1.00), (39, 39, 42, 1.00)),
673            ("default_foreground", |c| c.default.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
674            ("default_hover", |c| c.default.hover(), (225, 225, 226, 1.00), (46, 46, 49, 1.00)),
675            ("default_soft", |c| c.default.soft(), (235, 235, 235, 0.50), (39, 39, 42, 0.50)),
676            ("default_soft_foreground", |c| c.default.soft_foreground(c.foreground), (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
677            ("field_background", |c| c.field.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
678            ("field_foreground", |c| c.field.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
679            ("field_placeholder", |c| c.field.placeholder, (113, 113, 122, 1.00), (159, 159, 169, 1.00)),
680            ("focus", |c| c.focus, (4, 133, 247, 1.00), (4, 133, 247, 1.00)),
681            ("foreground", |c| c.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
682            ("link", |c| c.link, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
683            ("muted", |c| c.muted, (113, 113, 122, 1.00), (159, 159, 169, 1.00)),
684            ("overlay", |c| c.overlay.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
685            ("overlay_foreground", |c| c.overlay.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
686            ("scrollbar", |c| c.scrollbar, (24, 24, 27, 0.15), (255, 255, 255, 0.15)),
687            ("segment", |c| c.segment.background, (255, 255, 255, 1.00), (70, 70, 76, 1.00)),
688            ("segment_foreground", |c| c.segment.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
689            ("separator", |c| c.separator, (228, 228, 231, 1.00), (33, 33, 36, 1.00)),
690            ("separator_secondary", |c| c.separator_secondary(), (216, 216, 216, 1.00), (52, 52, 55, 1.00)),
691            ("separator_tertiary", |c| c.separator_tertiary(), (205, 205, 206, 1.00), (60, 60, 63, 1.00)),
692            ("success", |c| c.success.color, (23, 201, 100, 1.00), (23, 201, 100, 1.00)),
693            ("success_foreground", |c| c.success.foreground, (24, 24, 27, 1.00), (24, 24, 27, 1.00)),
694            ("success_hover", |c| c.success.hover(), (33, 181, 93, 1.00), (33, 181, 93, 1.00)),
695            ("success_soft", |c| c.success.soft(), (23, 201, 100, 0.15), (23, 201, 100, 0.12)),
696            ("success_soft_foreground", |c| c.success.soft_foreground(c.foreground), (43, 119, 69, 1.00), (116, 216, 143, 1.00)),
697            ("surface", |c| c.surface.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
698            ("surface_foreground", |c| c.surface.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
699            ("surface_hover", |c| c.surface.hover(), (234, 234, 234, 1.00), (39, 39, 42, 1.00)),
700            ("surface_secondary", |c| c.surface_secondary, (239, 239, 240, 1.00), (35, 35, 37, 1.00)),
701            ("surface_tertiary", |c| c.surface_tertiary, (234, 234, 235, 1.00), (38, 39, 40, 1.00)),
702            ("warning", |c| c.warning.color, (245, 165, 36, 1.00), (247, 183, 80, 1.00)),
703            ("warning_foreground", |c| c.warning.foreground, (24, 24, 27, 1.00), (24, 24, 27, 1.00)),
704            ("warning_hover", |c| c.warning.hover(), (220, 150, 42, 1.00), (222, 165, 76, 1.00)),
705            ("warning_soft", |c| c.warning.soft(), (245, 165, 36, 0.15), (247, 183, 80, 0.12)),
706            ("warning_soft_foreground", |c| c.warning.soft_foreground(c.foreground), (133, 95, 46, 1.00), (249, 203, 134, 1.00)),
707        ];
708        for (name, pick, light, dark) in table {
709            for (mode, c, want) in [
710                ("light", ThemeColors::light(), light),
711                ("dark", ThemeColors::dark(), dark),
712            ] {
713                let got = pick(&c);
714                let (r, g, b) = rgb8(got);
715                // The reference was read back from a premultiplied surface, so
716                // a translucent token's channels carry `1/alpha` of rounding.
717                let slack = (2.0 / want.3).ceil() as i32;
718                let near = |a: u8, b: u8| (a as i32 - b as i32).abs() <= slack;
719                assert!(
720                    near(r, want.0) && near(g, want.1) && near(b, want.2)
721                        && (got.a - want.3).abs() < 0.011,
722                    "{name} ({mode}) resolved to ({r}, {g}, {b}, {:.3}),                      upstream resolves ({}, {}, {}, {:.3})",
723                    got.a, want.0, want.1, want.2, want.3
724                );
725            }
726        }
727    }
728
729    #[test]
730    fn soft_foreground_resolves_to_the_stylesheets_own_pixels() {
731        // The weights alone cannot catch an inverted mix, so pin what a browser
732        // resolves `--color-<role>-soft-foreground` to at v3.2.4 in light mode.
733        // A soft label reads as a tinted version of its role, not as body ink.
734        let c = ThemeColors::light();
735        for (role_name, expected) in [
736            ("accent", (30, 99, 174)),
737            ("success", (43, 119, 69)),
738            ("warning", (133, 95, 46)),
739            ("danger", (164, 53, 51)),
740        ] {
741            let got = rgb8(c.role(role_name).soft_foreground(c.foreground));
742            let near = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 2;
743            assert!(
744                near(got.0, expected.0) && near(got.1, expected.1) && near(got.2, expected.2),
745                "{role_name} soft-foreground was {got:?}, expected {expected:?}"
746            );
747        }
748    }
749
750    #[test]
751    fn a_custom_foreground_stays_live_in_the_soft_foreground_mix() {
752        let base = ThemeColors::light();
753        let custom = ThemeColors {
754            foreground: oklch(0.30, 0.05, 120.0),
755            ..base
756        };
757        // The mixing roles resolve against the page foreground passed in, so a
758        // `ThemeBuilder::foreground` override flows through at render time.
759        assert_eq!(
760            custom.accent.soft_foreground(custom.foreground),
761            mix_oklab(custom.accent.color, custom.foreground, 0.30)
762        );
763        assert_ne!(
764            custom.accent.soft_foreground(custom.foreground),
765            base.accent.soft_foreground(base.foreground)
766        );
767        // `--default-soft-foreground: var(--default-foreground)` does not mix,
768        // so it follows the role's own foreground instead of the page's.
769        assert_eq!(
770            custom.default.soft_foreground(custom.foreground),
771            custom.default.foreground
772        );
773    }
774
775    #[test]
776    fn the_derived_borders_step_away_from_the_surface() {
777        let c = ThemeColors::light();
778        // `--border-secondary` and `--border-tertiary` mix further from the
779        // surface than `--separator-secondary` does, so a border reads stronger
780        // than a rule at the same step.
781        let steps = [
782            c.separator_secondary(),
783            c.border_secondary(),
784            c.border_tertiary(),
785        ];
786        for pair in steps.windows(2) {
787            assert!(
788                pair[1].l < pair[0].l,
789                "each step is darker than the last on a light surface"
790            );
791        }
792    }
793
794    #[test]
795    fn a_secondary_surface_keeps_the_page_foreground() {
796        let c = ThemeColors::light();
797        assert_eq!(c.surface_secondary_foreground(), c.foreground);
798        assert_eq!(c.surface_tertiary_foreground(), c.foreground);
799    }
800
801    #[test]
802    fn a_field_border_mixes_toward_its_own_foreground() {
803        let c = ThemeColors::light();
804        // Both are mixes of `--field-border` toward `--field-foreground`, and
805        // focus mixes further than hover.
806        let border = c.field.border;
807        assert_ne!(c.field.border_hover(), border);
808        assert_ne!(c.field.border_focus(), c.field.border_hover());
809    }
810
811    #[test]
812    fn field_focus_matches_field_background() {
813        let c = ThemeColors::light();
814        assert_eq!(c.field.focus(), c.field.background);
815    }
816
817    #[test]
818    #[ignore = "v3 gives dark mode one colour for both; the shadow separates them"]
819    fn dark_surface_is_darker_than_overlay() {
820        let c = ThemeColors::dark();
821        assert!(c.surface.background.l < c.overlay.background.l);
822    }
823
824    #[test]
825    fn light_background_is_lighter_than_its_derived_levels() {
826        let c = ThemeColors::light();
827        assert!(c.background.l > c.background_secondary().l);
828        assert!(c.background_secondary().l > c.background_tertiary().l);
829    }
830}