Skip to main content

frust_theme/
color.rs

1//! Color roles: [`ColorScheme`] (46 roles) and the [`Brightness`] a
2//! [`crate::theme::Theme`] selects one by.
3//!
4//! The role SET is Material 3's — 46 non-deprecated roles, the vocabulary
5//! every design system's table fills — but no Material or Cupertino *values*
6//! live here any more: those tables moved out with their design systems
7//! (`frust-material`, `frust-cupertino`), which fill these same fields from
8//! their own sources. What remains is the role struct, the
9//! [`ColorScheme::with_accent`] recolor helper, and
10//! [`ColorScheme::neutral_light`]/[`ColorScheme::neutral_dark`] — the
11//! language-free ramp [`crate::theme::Theme::neutral`] composes.
12//!
13//! `background`/`onBackground`/`surfaceVariant` are **not implemented** —
14//! Material 3 deprecated all three in favor of `surface`/`onSurface` and the
15//! `surfaceContainer*` ladder respectively; carrying them forward would just
16//! duplicate an existing role under a legacy name.
17//!
18//! A role whose real token is translucent may carry a non-opaque `Color`: the
19//! struct stores whatever a design system's source value is, and flattening a
20//! genuinely translucent token to opaque would misrepresent it.
21
22use peniko::Color;
23
24/// Which half of a [`crate::theme::Theme`]'s paired light/dark
25/// [`ColorScheme`]s is active.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum Brightness {
28    /// The light `ColorScheme`. Default.
29    #[default]
30    Light,
31    /// The dark `ColorScheme`.
32    Dark,
33}
34
35/// A full set of color roles for one brightness (light or dark), on Material
36/// 3's role vocabulary.
37///
38/// 46 non-deprecated roles (see module docs for the deprecated 3). This crate
39/// constructs one table, the language-free
40/// [`ColorScheme::neutral_light`]/[`ColorScheme::neutral_dark`] pair; a
41/// design system fills the same fields from its own source values in its own
42/// crate. Fields are normally opaque [`Color`]s — channel-appropriate
43/// translucency (e.g. for scrims) is applied by the widget/scene layer, not
44/// stored here — but a design system whose real token is itself translucent
45/// may store the non-opaque value rather than misrepresent it.
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct ColorScheme {
48    pub primary: Color,
49    pub on_primary: Color,
50    pub primary_container: Color,
51    pub on_primary_container: Color,
52    pub primary_fixed: Color,
53    pub primary_fixed_dim: Color,
54    pub on_primary_fixed: Color,
55    pub on_primary_fixed_variant: Color,
56
57    pub secondary: Color,
58    pub on_secondary: Color,
59    pub secondary_container: Color,
60    pub on_secondary_container: Color,
61    pub secondary_fixed: Color,
62    pub secondary_fixed_dim: Color,
63    pub on_secondary_fixed: Color,
64    pub on_secondary_fixed_variant: Color,
65
66    pub tertiary: Color,
67    pub on_tertiary: Color,
68    pub tertiary_container: Color,
69    pub on_tertiary_container: Color,
70    pub tertiary_fixed: Color,
71    pub tertiary_fixed_dim: Color,
72    pub on_tertiary_fixed: Color,
73    pub on_tertiary_fixed_variant: Color,
74
75    pub error: Color,
76    pub on_error: Color,
77    pub error_container: Color,
78    pub on_error_container: Color,
79
80    pub surface: Color,
81    pub on_surface: Color,
82    pub on_surface_variant: Color,
83    pub surface_dim: Color,
84    pub surface_bright: Color,
85    pub surface_container_lowest: Color,
86    pub surface_container_low: Color,
87    pub surface_container: Color,
88    pub surface_container_high: Color,
89    pub surface_container_highest: Color,
90
91    pub outline: Color,
92    pub outline_variant: Color,
93    pub shadow: Color,
94    pub scrim: Color,
95    pub inverse_surface: Color,
96    pub inverse_on_surface: Color,
97    pub inverse_primary: Color,
98    pub surface_tint: Color,
99}
100
101impl ColorScheme {
102    /// Replace the accent family from one seed color, Glyph-style.
103    ///
104    /// Swaps exactly four roles: `primary`, `on_primary`, `primary_container`,
105    /// and `on_primary_container`. All other 42 fields remain unchanged.
106    ///
107    /// - **primary**: set to the provided `accent` color.
108    /// - **on_primary**: selected by relative luminance (WCAG contrast ratio ≥
109    ///   4.5:1 preferred) — prefer white if the accent is dark enough, else
110    ///   dark ink. A simple luminance threshold is used (see docs below).
111    /// - **primary_container**: an alpha-wash of the accent at 10% opacity,
112    ///   flattened over the scheme's `surface` color to opaque.
113    /// - **on_primary_container**: set to the provided `accent` color (same as
114    ///   primary).
115    ///
116    /// ## Luminance and Contrast
117    ///
118    /// `on_primary` is determined by picking the ink that provides better
119    /// contrast against the accent. Luminance is calculated via the WCAG
120    /// relative luminance formula: `L = 0.2126*R + 0.7152*G + 0.0722*B`
121    /// (after linearizing each channel from sRGB). If the accent's luminance
122    /// is below 0.5 (midpoint), white is preferred; otherwise, dark ink is
123    /// preferred. This simple threshold provides acceptable contrast for most
124    /// colors and avoids the overhead of computing the full contrast ratio
125    /// (though a more precise contrast-ratio calculation could replace this later).
126    ///
127    /// ## Works with Any Scheme
128    ///
129    /// This method works on any `ColorScheme` — the neutral ramp below or any
130    /// design system's own table — without design-language branching.
131    ///
132    /// # Example
133    ///
134    /// ```
135    /// use peniko::Color;
136    /// use frust_theme::color::ColorScheme;
137    ///
138    /// let base = ColorScheme::neutral_dark();
139    /// let red = Color::from_rgb8(0xFF, 0x00, 0x00);
140    /// let accent_scheme = base.with_accent(red);
141    ///
142    /// assert_eq!(accent_scheme.primary, red);
143    /// assert_eq!(accent_scheme.on_primary_container, red);
144    /// // All other roles remain unchanged from the base scheme.
145    /// ```
146    pub fn with_accent(self, accent: Color) -> Self {
147        let on_primary = select_ink_by_luminance(accent);
148        let primary_container = blend_accent_over_surface(accent, self.surface);
149
150        Self {
151            primary: accent,
152            on_primary,
153            primary_container,
154            on_primary_container: accent,
155            ..self
156        }
157    }
158
159    /// The neutral, design-language-free light `ColorScheme`: a plain
160    /// achromatic surface/on-surface ramp, plus **one** restrained
161    /// slate-blue accent filling the `primary` family — every other role
162    /// (`secondary`/`tertiary`) stays gray too, since "neutral" means one
163    /// accent, not a second or third hue. See
164    /// [`crate::theme::Theme::neutral`]'s module docs for the design intent
165    /// ("deliberately plain, not half-finished").
166    ///
167    /// **Frust-authored, not sourced from any third-party design system** —
168    /// there is no external spec to
169    /// cite here; values are chosen for legible contrast against their
170    /// paired ink role (see [`ColorScheme::neutral_dark`]'s doc comment for
171    /// the contrast rationale, verified by
172    /// `neutral_surface_on_surface_contrast_is_legible` in this module's
173    /// tests). `error`/`on_error`/`error_container`/`on_error_container`
174    /// are the one exception kept in real red — a functional/semantic signal
175    /// (danger/destructive state), not a "look", the same reasoning
176    /// [`crate::theme::Theme::neutral`] uses to still attach
177    /// [`crate::status::StatusPalette::neutral`] rather than a grayscale
178    /// status set.
179    pub const fn neutral_light() -> Self {
180        // A single restrained slate-blue accent (`primary` family only).
181        const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
182        const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
183
184        Self {
185            primary: ACCENT_LIGHT,
186            on_primary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
187            primary_container: Color::from_rgb8(0xE3, 0xE9, 0xEE),
188            on_primary_container: Color::from_rgb8(0x1B, 0x2A, 0x35),
189            // "Fixed" roles are brightness-invariant by M3 definition —
190            // the same literal in both `neutral_light`/`neutral_dark`.
191            primary_fixed: ACCENT_LIGHT,
192            primary_fixed_dim: ACCENT_DARK,
193            on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
194            on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
195
196            // Plain gray — "one restrained accent" means the primary family
197            // above, not a second hue.
198            secondary: Color::from_rgb8(0x6B, 0x6B, 0x6B),
199            on_secondary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
200            secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
201            on_secondary_container: Color::from_rgb8(0x2B, 0x2B, 0x2B),
202            secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
203            secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
204            on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
205            on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
206
207            // Plain gray — a third hue would defeat the "one accent" floor.
208            tertiary: Color::from_rgb8(0x4A, 0x4A, 0x4A),
209            on_tertiary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
210            tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
211            on_tertiary_container: Color::from_rgb8(0x26, 0x26, 0x26),
212            tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
213            tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
214            on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
215            on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
216
217            // Kept real red — see this constructor's doc comment. The
218            // Material 3 baseline light `error` family, verbatim.
219            error: Color::from_rgb8(0xB3, 0x26, 0x1E),
220            on_error: Color::from_rgb8(0xFF, 0xFF, 0xFF),
221            error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
222            on_error_container: Color::from_rgb8(0x41, 0x0E, 0x0B),
223
224            surface: Color::from_rgb8(0xFA, 0xFA, 0xFA),
225            on_surface: Color::from_rgb8(0x1A, 0x1A, 0x1A),
226            on_surface_variant: Color::from_rgb8(0x5C, 0x5C, 0x5C),
227            surface_dim: Color::from_rgb8(0xE8, 0xE8, 0xE8),
228            surface_bright: Color::from_rgb8(0xFF, 0xFF, 0xFF),
229            surface_container_lowest: Color::from_rgb8(0xFF, 0xFF, 0xFF),
230            surface_container_low: Color::from_rgb8(0xF5, 0xF5, 0xF5),
231            surface_container: Color::from_rgb8(0xEF, 0xEF, 0xEF),
232            surface_container_high: Color::from_rgb8(0xE7, 0xE7, 0xE7),
233            surface_container_highest: Color::from_rgb8(0xDF, 0xDF, 0xDF),
234
235            outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
236            outline_variant: Color::from_rgb8(0xD0, 0xD0, 0xD0),
237            shadow: Color::from_rgb8(0x00, 0x00, 0x00),
238            scrim: Color::from_rgb8(0x00, 0x00, 0x00),
239            inverse_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
240            inverse_on_surface: Color::from_rgb8(0xF5, 0xF5, 0xF5),
241            inverse_primary: ACCENT_DARK,
242            // Mirrors M3's own convention: surface_tint == primary.
243            surface_tint: ACCENT_LIGHT,
244        }
245    }
246
247    /// The neutral, design-language-free dark `ColorScheme` — the dark-mode
248    /// mirror of [`ColorScheme::neutral_light`]; see that constructor's doc
249    /// comment for the design rationale.
250    ///
251    /// Contrast check (verified in this module's tests): `surface`
252    /// (`#121212`) against `on_surface` (`#F2F2F2`) is ~16.7:1, and
253    /// [`ColorScheme::neutral_light`]'s equivalent pair is ~16.7:1 too —
254    /// both far past the WCAG AA 4.5:1 normal-text floor.
255    pub const fn neutral_dark() -> Self {
256        const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
257        const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
258
259        Self {
260            primary: ACCENT_DARK,
261            on_primary: Color::from_rgb8(0x16, 0x23, 0x2C),
262            primary_container: Color::from_rgb8(0x22, 0x34, 0x41),
263            on_primary_container: Color::from_rgb8(0xC7, 0xDC, 0xEA),
264            primary_fixed: ACCENT_LIGHT,
265            primary_fixed_dim: ACCENT_DARK,
266            on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
267            on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
268
269            secondary: Color::from_rgb8(0xB0, 0xB0, 0xB0),
270            on_secondary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
271            secondary_container: Color::from_rgb8(0x3A, 0x3A, 0x3A),
272            on_secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
273            secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
274            secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
275            on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
276            on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
277
278            tertiary: Color::from_rgb8(0xB8, 0xB8, 0xB8),
279            on_tertiary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
280            tertiary_container: Color::from_rgb8(0x38, 0x38, 0x38),
281            on_tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
282            tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
283            tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
284            on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
285            on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
286
287            // Kept real red — see `neutral_light`'s doc comment. The
288            // Material 3 baseline dark `error` family, verbatim.
289            error: Color::from_rgb8(0xF2, 0xB8, 0xB5),
290            on_error: Color::from_rgb8(0x60, 0x14, 0x10),
291            error_container: Color::from_rgb8(0x8C, 0x1D, 0x18),
292            on_error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
293
294            surface: Color::from_rgb8(0x12, 0x12, 0x12),
295            on_surface: Color::from_rgb8(0xF2, 0xF2, 0xF2),
296            on_surface_variant: Color::from_rgb8(0xB0, 0xB0, 0xB0),
297            surface_dim: Color::from_rgb8(0x12, 0x12, 0x12),
298            surface_bright: Color::from_rgb8(0x38, 0x38, 0x38),
299            surface_container_lowest: Color::from_rgb8(0x0B, 0x0B, 0x0B),
300            surface_container_low: Color::from_rgb8(0x1C, 0x1C, 0x1C),
301            surface_container: Color::from_rgb8(0x20, 0x20, 0x20),
302            surface_container_high: Color::from_rgb8(0x2A, 0x2A, 0x2A),
303            surface_container_highest: Color::from_rgb8(0x35, 0x35, 0x35),
304
305            outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
306            outline_variant: Color::from_rgb8(0x47, 0x47, 0x47),
307            shadow: Color::from_rgb8(0x00, 0x00, 0x00),
308            scrim: Color::from_rgb8(0x00, 0x00, 0x00),
309            inverse_surface: Color::from_rgb8(0xE6, 0xE6, 0xE6),
310            inverse_on_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
311            inverse_primary: ACCENT_LIGHT,
312            surface_tint: ACCENT_DARK,
313        }
314    }
315}
316
317/// Calculate the relative luminance of a color per WCAG standards.
318///
319/// Luminance = 0.2126*R + 0.7152*G + 0.0722*B, where R, G, B are linearized
320/// from sRGB.
321fn relative_luminance(color: Color) -> f64 {
322    let [r, g, b, _] = color.to_rgba8().to_u8_array();
323    let r = linearize_srgb_channel(r as f64 / 255.0);
324    let g = linearize_srgb_channel(g as f64 / 255.0);
325    let b = linearize_srgb_channel(b as f64 / 255.0);
326    0.2126 * r + 0.7152 * g + 0.0722 * b
327}
328
329/// Linearize an sRGB channel value.
330///
331/// If the channel is ≤ 0.03928, divide by 12.92; otherwise, raise
332/// ((channel + 0.055) / 1.055) to the power of 2.4.
333fn linearize_srgb_channel(c: f64) -> f64 {
334    if c <= 0.03928 {
335        c / 12.92
336    } else {
337        ((c + 0.055) / 1.055).powf(2.4)
338    }
339}
340
341/// Select dark ink or white based on the luminance of a given color.
342///
343/// Returns white if the accent's relative luminance is below 0.5 (dark
344/// accent), else returns dark ink. This provides a simple heuristic for
345/// contrast without computing the full WCAG contrast ratio.
346fn select_ink_by_luminance(color: Color) -> Color {
347    if relative_luminance(color) < 0.5 {
348        // Dark accent: use white
349        Color::from_rgb8(0xFF, 0xFF, 0xFF)
350    } else {
351        // Light accent: use dark ink
352        Color::from_rgb8(0x1D, 0x1B, 0x20)
353    }
354}
355
356/// Blend an accent color at 10% opacity over a surface color, flattening to opaque.
357///
358/// Uses the standard alpha-blending formula: result = accent * alpha + surface * (1 - alpha).
359/// The alpha is fixed at 0.10 (10%) per the Glyph accent-variant recipe.
360fn blend_accent_over_surface(accent: Color, surface: Color) -> Color {
361    const ACCENT_ALPHA: f64 = 0.10;
362
363    let [a_r, a_g, a_b, _] = accent.to_rgba8().to_u8_array();
364    let [s_r, s_g, s_b, _] = surface.to_rgba8().to_u8_array();
365
366    let a_r = a_r as f64 / 255.0;
367    let a_g = a_g as f64 / 255.0;
368    let a_b = a_b as f64 / 255.0;
369    let s_r = s_r as f64 / 255.0;
370    let s_g = s_g as f64 / 255.0;
371    let s_b = s_b as f64 / 255.0;
372
373    let blend_channel = |a: f64, s: f64| -> u8 {
374        let result = a * ACCENT_ALPHA + s * (1.0 - ACCENT_ALPHA);
375        (result * 255.0).round() as u8
376    };
377
378    Color::from_rgb8(
379        blend_channel(a_r, s_r),
380        blend_channel(a_g, s_g),
381        blend_channel(a_b, s_b),
382    )
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    /// Round-trip a known ARGB hex value through `Color::from_rgb8` the same
390    /// way every scheme role above is constructed.
391    #[test]
392    fn hex_round_trip() {
393        let c = Color::from_rgb8(0x67, 0x50, 0xA4);
394        let [r, g, b, a] = c.to_rgba8().to_u8_array();
395        assert_eq!((r, g, b, a), (0x67, 0x50, 0xA4, 0xFF));
396    }
397
398    #[test]
399    fn deprecated_roles_absent() {
400        // background/onBackground/surfaceVariant are not fields on
401        // ColorScheme at all — this test exists as a documentation anchor,
402        // not a runtime check (a missing field is a compile error, not a
403        // test failure). See module docs for why they're omitted.
404        let _ = ColorScheme::neutral_light();
405    }
406
407    #[test]
408    fn with_accent_changes_exactly_four_roles() {
409        // Verify that with_accent changes exactly the 4 accent roles and
410        // leaves all other 42 fields byte-equal.
411        let base = ColorScheme::neutral_dark();
412        let red = Color::from_rgb8(0xFF, 0x00, 0x00);
413        let modified = base.with_accent(red);
414
415        // The four accent roles must change.
416        assert_eq!(modified.primary, red);
417        assert_eq!(modified.on_primary_container, red);
418        // on_primary and primary_container are derived; check they're not the
419        // same as the original.
420        assert_ne!(modified.on_primary, base.on_primary);
421        assert_ne!(modified.primary_container, base.primary_container);
422
423        // All other 42 roles must remain unchanged.
424        assert_eq!(modified.secondary, base.secondary);
425        assert_eq!(modified.on_secondary, base.on_secondary);
426        assert_eq!(modified.secondary_container, base.secondary_container);
427        assert_eq!(modified.on_secondary_container, base.on_secondary_container);
428        assert_eq!(modified.secondary_fixed, base.secondary_fixed);
429        assert_eq!(modified.secondary_fixed_dim, base.secondary_fixed_dim);
430        assert_eq!(modified.on_secondary_fixed, base.on_secondary_fixed);
431        assert_eq!(
432            modified.on_secondary_fixed_variant,
433            base.on_secondary_fixed_variant
434        );
435
436        assert_eq!(modified.tertiary, base.tertiary);
437        assert_eq!(modified.on_tertiary, base.on_tertiary);
438        assert_eq!(modified.tertiary_container, base.tertiary_container);
439        assert_eq!(modified.on_tertiary_container, base.on_tertiary_container);
440        assert_eq!(modified.tertiary_fixed, base.tertiary_fixed);
441        assert_eq!(modified.tertiary_fixed_dim, base.tertiary_fixed_dim);
442        assert_eq!(modified.on_tertiary_fixed, base.on_tertiary_fixed);
443        assert_eq!(
444            modified.on_tertiary_fixed_variant,
445            base.on_tertiary_fixed_variant
446        );
447
448        assert_eq!(modified.error, base.error);
449        assert_eq!(modified.on_error, base.on_error);
450        assert_eq!(modified.error_container, base.error_container);
451        assert_eq!(modified.on_error_container, base.on_error_container);
452
453        assert_eq!(modified.surface, base.surface);
454        assert_eq!(modified.on_surface, base.on_surface);
455        assert_eq!(modified.on_surface_variant, base.on_surface_variant);
456        assert_eq!(modified.surface_dim, base.surface_dim);
457        assert_eq!(modified.surface_bright, base.surface_bright);
458        assert_eq!(
459            modified.surface_container_lowest,
460            base.surface_container_lowest
461        );
462        assert_eq!(modified.surface_container_low, base.surface_container_low);
463        assert_eq!(modified.surface_container, base.surface_container);
464        assert_eq!(modified.surface_container_high, base.surface_container_high);
465        assert_eq!(
466            modified.surface_container_highest,
467            base.surface_container_highest
468        );
469
470        assert_eq!(modified.outline, base.outline);
471        assert_eq!(modified.outline_variant, base.outline_variant);
472        assert_eq!(modified.shadow, base.shadow);
473        assert_eq!(modified.scrim, base.scrim);
474        assert_eq!(modified.inverse_surface, base.inverse_surface);
475        assert_eq!(modified.inverse_on_surface, base.inverse_on_surface);
476        assert_eq!(modified.inverse_primary, base.inverse_primary);
477        assert_eq!(modified.surface_tint, base.surface_tint);
478
479        assert_eq!(modified.primary_fixed, base.primary_fixed);
480        assert_eq!(modified.primary_fixed_dim, base.primary_fixed_dim);
481        assert_eq!(modified.on_primary_fixed, base.on_primary_fixed);
482        assert_eq!(
483            modified.on_primary_fixed_variant,
484            base.on_primary_fixed_variant
485        );
486    }
487
488    #[test]
489    fn on_primary_contrast_for_dark_and_light_accents() {
490        // Verify on_primary is selected appropriately for both dark and light
491        // accents.
492        let base = ColorScheme::neutral_dark();
493
494        // Dark accent (red): should get white on_primary.
495        let dark_accent = Color::from_rgb8(0x80, 0x00, 0x00);
496        let dark_scheme = base.with_accent(dark_accent);
497        assert_eq!(
498            dark_scheme.on_primary,
499            Color::from_rgb8(0xFF, 0xFF, 0xFF),
500            "Dark accent should pair with white ink"
501        );
502
503        // Light accent (yellow): should get dark ink on_primary.
504        let light_accent = Color::from_rgb8(0xFF, 0xFF, 0x00);
505        let light_scheme = base.with_accent(light_accent);
506        assert_eq!(
507            light_scheme.on_primary,
508            Color::from_rgb8(0x1D, 0x1B, 0x20),
509            "Light accent should pair with dark ink"
510        );
511    }
512
513    // ---- Neutral baseline -------------------------------------------------
514
515    /// WCAG contrast ratio between two colors, per the standard `(L1 +
516    /// 0.05) / (L2 + 0.05)` formula (`L1` the lighter of the pair).
517    fn contrast_ratio(a: Color, b: Color) -> f64 {
518        let la = relative_luminance(a);
519        let lb = relative_luminance(b);
520        let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
521        (hi + 0.05) / (lo + 0.05)
522    }
523
524    #[test]
525    fn neutral_surface_on_surface_contrast_is_legible() {
526        // Both brightnesses clear the WCAG AA normal-text floor (4.5:1) for
527        // the surface/on-surface pair.
528        const AA_NORMAL_TEXT: f64 = 4.5;
529
530        let light = ColorScheme::neutral_light();
531        assert!(
532            contrast_ratio(light.surface, light.on_surface) >= AA_NORMAL_TEXT,
533            "neutral_light surface/on_surface contrast too low"
534        );
535
536        let dark = ColorScheme::neutral_dark();
537        assert!(
538            contrast_ratio(dark.surface, dark.on_surface) >= AA_NORMAL_TEXT,
539            "neutral_dark surface/on_surface contrast too low"
540        );
541    }
542
543    #[test]
544    fn neutral_light_and_dark_are_distinct() {
545        let light = ColorScheme::neutral_light();
546        let dark = ColorScheme::neutral_dark();
547        assert_ne!(light.surface, dark.surface);
548        assert_ne!(light.on_surface, dark.on_surface);
549        assert_ne!(light.primary, dark.primary);
550    }
551
552    #[test]
553    fn neutral_carries_exactly_one_accent_family() {
554        // "One restrained accent": secondary/tertiary stay achromatic
555        // (r == g == b) in both brightnesses, unlike primary.
556        fn is_achromatic(c: Color) -> bool {
557            let [r, g, b, _] = c.to_rgba8().to_u8_array();
558            r == g && g == b
559        }
560
561        for scheme in [ColorScheme::neutral_light(), ColorScheme::neutral_dark()] {
562            assert!(!is_achromatic(scheme.primary), "primary must be the accent");
563            assert!(is_achromatic(scheme.secondary));
564            assert!(is_achromatic(scheme.tertiary));
565            assert!(is_achromatic(scheme.surface));
566            assert!(is_achromatic(scheme.on_surface));
567        }
568    }
569
570    #[test]
571    fn neutral_fixed_roles_are_brightness_invariant() {
572        let light = ColorScheme::neutral_light();
573        let dark = ColorScheme::neutral_dark();
574        assert_eq!(light.primary_fixed, dark.primary_fixed);
575        assert_eq!(light.primary_fixed_dim, dark.primary_fixed_dim);
576        assert_eq!(
577            light.on_primary_fixed_variant,
578            dark.on_primary_fixed_variant
579        );
580        assert_eq!(light.secondary_fixed, dark.secondary_fixed);
581        assert_eq!(light.tertiary_fixed, dark.tertiary_fixed);
582    }
583
584    #[test]
585    fn neutral_is_const_constructible() {
586        const LIGHT: ColorScheme = ColorScheme::neutral_light();
587        const DARK: ColorScheme = ColorScheme::neutral_dark();
588        assert_ne!(LIGHT.surface, DARK.surface);
589    }
590}