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