Skip to main content

fenestra_core/
theme.rs

1//! Theme generation: OKLCH color ramps derived from one accent hue, plus the
2//! semantic roles, status colors, and shadow scale. The L/C tables here are
3//! the design spec; every generated value is locked by an insta snapshot.
4
5use color::{AlphaColor, Oklch, Srgb};
6use peniko::Color;
7
8use crate::style::{OpticalSizing, Shadow};
9use crate::tokens::{Elevation, RadiusScale, ShadowToken};
10
11/// Light or dark color mode. Both are always generated from the same hue.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Mode {
15    /// Light backgrounds, dark text.
16    Light,
17    /// Dark backgrounds, light text.
18    Dark,
19}
20
21/// The neutral-field character for [`Theme::derive`]: the hue the neutral ramp
22/// is tinted with and how far it departs from gray. `chroma` multiplies the
23/// neutral table's (very low) base chroma — `1.0` is the stock near-gray SaaS
24/// tint, `4`–`10` an atmospheric duotone field (deep green, warm paper), `0`
25/// pure gray.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct BaseField {
28    /// Neutral ramp hue in OKLCH degrees.
29    pub hue: f32,
30    /// Chroma multiplier on the neutral table's base chroma.
31    pub chroma: f32,
32}
33
34/// Contrast level for [`Theme::derive`]: scales every neutral step's lightness
35/// distance from the background, so text and UI separation widen or soften from
36/// one knob. `Standard` reproduces the stock ramps exactly.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Contrast {
40    /// Gentler separation (0.92× the stock spread).
41    Low,
42    /// The stock ramps (1.0×).
43    #[default]
44    Standard,
45    /// Crisper separation (1.10×).
46    High,
47}
48
49impl Contrast {
50    /// Lightness-distance multiplier from the background step.
51    fn factor(self) -> f32 {
52        match self {
53            Self::Low => 0.92,
54            Self::Standard => 1.0,
55            Self::High => 1.10,
56        }
57    }
58}
59
60/// A 12-step color ramp.
61#[derive(Debug, Clone)]
62pub struct Ramp(pub [Color; 12]);
63
64impl Ramp {
65    /// Returns step `n` (1-based, like the design spec tables). Out-of-range
66    /// values clamp to the nearest valid step.
67    pub fn step(&self, n: usize) -> Color {
68        self.0[n.clamp(1, 12) - 1]
69    }
70}
71
72/// The resolved colors of one status hue: tinted background, border, solid
73/// fill with its hover and pressed variants, and text.
74#[derive(Debug, Clone, Copy)]
75pub struct StatusColors {
76    /// Tinted background (step 3).
77    pub bg: Color,
78    /// Border (step 7).
79    pub border: Color,
80    /// Solid fill (step 9).
81    pub solid: Color,
82    /// Hover state of the solid fill (step 10).
83    pub solid_hover: Color,
84    /// Pressed state of the solid fill (one OKLCH-lightness notch below
85    /// `solid_hover`).
86    pub solid_active: Color,
87    /// Text on `bg` (step 11).
88    pub text: Color,
89}
90
91/// A text/background pair that failed its APCA Lc floor during
92/// [`Theme::validate_contrast`].
93#[derive(Debug, Clone, PartialEq)]
94pub struct ContrastViolation {
95    /// The pair that fell short, e.g. `"text_muted on surface_raised"`.
96    pub pair: String,
97    /// The measured APCA Lc magnitude.
98    pub measured_lc: f64,
99    /// The floor it failed to reach.
100    pub required_lc: f64,
101}
102
103impl std::fmt::Display for ContrastViolation {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(
106            f,
107            "{}: APCA Lc {:.1} < required {:.1}",
108            self.pair, self.measured_lc, self.required_lc
109        )
110    }
111}
112
113/// Writing direction for the UI — left-to-right (the default) or right-to-left.
114/// Set on the [`Theme`] ([`Theme::rtl`]); under `Rtl` the realized layout is
115/// mirrored horizontally and `start`-aligned text flips to the right edge.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum WritingDir {
118    /// Left-to-right (Latin, CJK, …). The default.
119    #[default]
120    Ltr,
121    /// Right-to-left (Arabic, Hebrew, …).
122    Rtl,
123}
124
125impl WritingDir {
126    /// Whether this is right-to-left.
127    #[must_use]
128    pub fn is_rtl(self) -> bool {
129        matches!(self, Self::Rtl)
130    }
131}
132
133/// The stock kit-wide [corner smoothing](Theme::corner_smoothing): `0.6` blends
134/// every rounded surface into an Apple-style squircle (continuous-curvature
135/// corners) instead of a plain circular arc. Calibrated by eye as the point
136/// where the squircle reads clearly without over-squaring small controls; set
137/// `0.0` for exact circular corners via [`Theme::with_corner_smoothing`].
138pub const DEFAULT_CORNER_SMOOTHING: f32 = 0.6;
139
140/// Design tokens resolved for one color mode.
141#[derive(Debug, Clone)]
142pub struct Theme {
143    /// The color mode this theme was generated for.
144    pub mode: Mode,
145    /// The accent hue (OKLCH degrees) this theme was generated from.
146    pub accent_hue: f32,
147    /// The hue (OKLCH degrees) the neutral ramp was generated with: equal to
148    /// `accent_hue` for `from_accent`, the duotone field hue otherwise.
149    pub neutral_hue: f32,
150    /// Chroma multiplier applied to the neutral ramp (1.0 for `from_accent`,
151    /// the duotone chroma boost otherwise) — so surfaces derived from the
152    /// ramp stay on the neutral field.
153    pub neutral_chroma_mult: f32,
154    /// The 12-step neutral ramp, tinted with the accent hue at low chroma.
155    pub neutrals: Ramp,
156    /// The 12-step accent ramp.
157    pub accents: Ramp,
158    /// Neutral alpha twins: each step as the smallest-alpha translucent color
159    /// that, composited over `bg`, reproduces the solid neutral step. Use for
160    /// overlays and state layers that must read correctly over any surface,
161    /// not only over `bg`.
162    pub neutral_alpha: Ramp,
163    /// Accent alpha twins (translucent over `bg`); see [`Theme::neutral_alpha`].
164    pub accent_alpha: Ramp,
165    /// Danger status colors (hue 25).
166    pub danger: StatusColors,
167    /// Warning status colors (hue 80).
168    pub warning: StatusColors,
169    /// Success status colors (hue 150).
170    pub success: StatusColors,
171
172    /// Window background (N1).
173    pub bg: Color,
174    /// Default surface (N2).
175    pub surface: Color,
176    /// Raised surface: pure white in light mode, N3 in dark mode.
177    pub surface_raised: Color,
178    /// Interactive element fill (N3): unselected ghost/soft control backgrounds.
179    pub element: Color,
180    /// Element hover fill (N4).
181    pub element_hover: Color,
182    /// Element active/pressed fill (N5).
183    pub element_active: Color,
184    /// Subtle border (N5); pairs with the Sm shadow on cards.
185    pub border_subtle: Color,
186    /// Default border (N6).
187    pub border: Color,
188    /// Strong border (N7).
189    pub border_strong: Color,
190    /// Primary text (N12).
191    pub text: Color,
192    /// Muted text (N11).
193    pub text_muted: Color,
194    /// Subtle text (N9).
195    pub text_subtle: Color,
196    /// Disabled text (N8).
197    pub text_disabled: Color,
198    /// Accent solid (A9): primary buttons, focus, selection.
199    pub accent: Color,
200    /// Accent hover (A10).
201    pub accent_hover: Color,
202    /// Accent pressed (one OKLCH-lightness notch below `accent_hover`; in light
203    /// mode this lands on A11's lightness at A10's chroma).
204    pub accent_active: Color,
205    /// Tinted accent background (A3).
206    pub accent_bg: Color,
207    /// Accent border (A7).
208    pub accent_border: Color,
209    /// Accent-colored text (A11).
210    pub accent_text: Color,
211    /// Text painted on top of `accent`.
212    pub on_accent: Color,
213
214    /// The corner-radius family the kit reads (controls, cards, menus, modals,
215    /// tooltips). Defaults to [`RadiusScale::default`] — which reproduces
216    /// `R_SM`..`R_XL` exactly, so the stock look is unchanged — and is the one
217    /// knob that re-rounds the whole kit: [`RadiusScale::sharp`] for un-rounded
218    /// tech chrome, [`RadiusScale::soft`] for a friendlier feel, set via
219    /// [`Theme::with_radius`]. Pills/avatars stay round regardless.
220    pub radius: RadiusScale,
221
222    /// Continuous-curvature corner smoothing applied kit-wide, `0.0..=1.0`
223    /// (Figma's "corner smoothing"). Defaults to [`DEFAULT_CORNER_SMOOTHING`]:
224    /// every rounded surface then reads as an Apple-style squircle rather than a
225    /// plain circular arc; `0.0` restores exact circular corners. The single
226    /// knob that re-curves the whole kit — set via
227    /// [`Theme::with_corner_smoothing`]. An element may still override it
228    /// per-element with [`Style::corner_smoothing`]. Applied only where a corner
229    /// radius is present, so square elements are unaffected.
230    ///
231    /// [`Style::corner_smoothing`]: crate::Style::corner_smoothing
232    pub corner_smoothing: f32,
233
234    /// Kit-wide optical sizing — how a variable font's `opsz` axis is driven for
235    /// text that doesn't set its own. Defaults to [`OpticalSizing::Auto`] (the
236    /// CSS default `font-optical-sizing: auto`): a variable text face tracks its
237    /// optical-size master to the rendered size, with no per-element setup. A
238    /// no-op on the static stock faces (Inter, JetBrains Mono), so the stock look
239    /// is unchanged; set via [`Theme::with_optical_sizing`], and an element may
240    /// still override or opt out with [`Style::optical`](crate::Style) /
241    /// `OpticalSizing::Default`.
242    pub optical_sizing: OpticalSizing,
243
244    /// How resting, same-plane cards convey separation — [`Elevation::Shadowed`]
245    /// (stock: a subtle shadow) or [`Elevation::Flat`] (border + surface
246    /// tone-step, no shadow). Floating surfaces always cast a shadow. Defaults
247    /// to `Shadowed` (no change to the stock look); set via
248    /// [`Theme::with_elevation`].
249    pub elevation: Elevation,
250
251    /// Dynamic Type: a multiplier applied to every resolved font size (1.0 =
252    /// stock). Clamped to a sane range at resolution. Set via
253    /// [`Theme::with_text_scale`]; lets a whole UI grow or shrink its type for
254    /// accessibility without touching individual sizes.
255    pub text_scale: f32,
256    /// Writing direction (LTR default; RTL mirrors the layout). Set via
257    /// [`Theme::rtl`] / [`Theme::with_direction`].
258    pub direction: WritingDir,
259}
260
261/// `(L, C)` per ramp step 1..=12.
262type RampTable = [(f32, f32); 12];
263
264const NEUTRAL_LIGHT: RampTable = [
265    (0.992, 0.002),
266    (0.978, 0.003),
267    (0.955, 0.004),
268    (0.930, 0.005),
269    (0.905, 0.006),
270    (0.875, 0.007),
271    (0.830, 0.008),
272    (0.730, 0.010),
273    (0.555, 0.012),
274    (0.510, 0.012),
275    (0.435, 0.010),
276    (0.235, 0.008),
277];
278
279const NEUTRAL_DARK: RampTable = [
280    (0.185, 0.004),
281    (0.215, 0.005),
282    (0.250, 0.006),
283    (0.280, 0.007),
284    (0.310, 0.008),
285    (0.345, 0.009),
286    (0.400, 0.010),
287    (0.490, 0.012),
288    (0.560, 0.012),
289    (0.610, 0.012),
290    (0.770, 0.008),
291    (0.945, 0.004),
292];
293
294const ACCENT_LIGHT: RampTable = [
295    (0.975, 0.020),
296    (0.950, 0.040),
297    (0.920, 0.060),
298    (0.880, 0.080),
299    (0.835, 0.100),
300    (0.785, 0.120),
301    (0.725, 0.140),
302    (0.660, 0.150),
303    (0.585, 0.160),
304    (0.545, 0.155),
305    (0.500, 0.135),
306    (0.380, 0.100),
307];
308
309// Steps 9 and 10 match light mode: the brand color is constant across modes.
310const ACCENT_DARK: RampTable = [
311    (0.250, 0.040),
312    (0.290, 0.055),
313    (0.330, 0.070),
314    (0.370, 0.085),
315    (0.415, 0.100),
316    (0.465, 0.120),
317    (0.530, 0.140),
318    (0.600, 0.150),
319    (0.585, 0.160),
320    (0.545, 0.155),
321    (0.720, 0.140),
322    (0.880, 0.110),
323];
324
325/// Status hues in OKLCH degrees.
326const DANGER_HUE: f32 = 25.0;
327const WARNING_HUE: f32 = 80.0;
328const SUCCESS_HUE: f32 = 150.0;
329
330/// Shadow tokens as `(dy, blur, alpha)` layers; dx and spread are 0. Multi-layer
331/// tokens stack a tight contact shadow under a softer ambient one (and a third,
332/// far layer for deep overlays) — the calibrated key+ambient ramp web systems use.
333const fn shadow_layers(token: ShadowToken) -> &'static [(f32, f32, f32)] {
334    match token {
335        ShadowToken::Xs => &[(1.0, 2.0, 0.05)],
336        ShadowToken::Sm => &[(1.0, 2.0, 0.05), (1.0, 3.0, 0.06)],
337        ShadowToken::Md => &[(2.0, 4.0, 0.05), (4.0, 12.0, 0.08)],
338        ShadowToken::Lg => &[(4.0, 10.0, 0.06), (16.0, 32.0, 0.12)],
339        ShadowToken::Xl => &[(2.0, 6.0, 0.04), (8.0, 16.0, 0.08), (24.0, 48.0, 0.16)],
340    }
341}
342
343/// Dark mode multiplies shadow alphas by this factor: soft black shadows
344/// read poorly on dark backgrounds.
345const DARK_SHADOW_ALPHA_FACTOR: f32 = 1.6;
346
347/// Per-level lightness boost for raised surfaces in dark mode.
348const DARK_ELEVATION_TINT: f32 = 0.025;
349
350/// Pressed states drop this much OKLCH lightness below the step-10 hover. In
351/// light mode the accent lands exactly on A11's lightness (0.545 → 0.500).
352const ACTIVE_DL: f32 = 0.045;
353
354/// APCA Lc floors enforced by [`Theme::validate_contrast`], as magnitudes.
355/// Primary body text targets Lc 90 and the stock themes reach it; the floor
356/// sits at the canonical body minimum (75) so it trips on a regression, not a
357/// design choice. Secondary, control-label, and colored-component text get
358/// progressively lower floors matching their role, size, and weight. Borders
359/// and other non-text delineation are intentionally not checked — APCA models
360/// text legibility, not non-text contrast.
361const PRIMARY_TEXT_MIN: f64 = 75.0;
362const SECONDARY_TEXT_MIN: f64 = 55.0;
363const CONTROL_LABEL_MIN: f64 = 60.0;
364const COMPONENT_TEXT_MIN: f64 = 40.0;
365
366impl Theme {
367    /// Generates every token from one accent hue (OKLCH degrees).
368    /// The default fenestra accent is hue 262 (violet-blue).
369    pub fn from_accent(hue_deg: f32, mode: Mode) -> Self {
370        let hue = hue_deg.rem_euclid(360.0);
371        let (neutral_table, accent_table) = match mode {
372            Mode::Light => (&NEUTRAL_LIGHT, &ACCENT_LIGHT),
373            Mode::Dark => (&NEUTRAL_DARK, &ACCENT_DARK),
374        };
375        let neutrals = make_ramp(neutral_table, hue);
376        let accents = make_ramp(accent_table, hue);
377        let status = |status_hue: f32| StatusColors {
378            bg: ramp_color(accent_table, 3, status_hue),
379            border: ramp_color(accent_table, 7, status_hue),
380            solid: ramp_color(accent_table, 9, status_hue),
381            solid_hover: ramp_color(accent_table, 10, status_hue),
382            solid_active: active_color(accent_table, status_hue),
383            text: ramp_color(accent_table, 11, status_hue),
384        };
385
386        let surface_raised = match mode {
387            Mode::Light => Color::new([1.0, 1.0, 1.0, 1.0]),
388            Mode::Dark => neutrals.step(3),
389        };
390        // The table L of A9 decides the on-accent text; gamut mapping never
391        // changes lightness, so read it straight from the spec table.
392        let on_accent = if accent_table[8].0 < 0.65 {
393            Color::new([1.0, 1.0, 1.0, 1.0])
394        } else {
395            neutrals.step(12)
396        };
397
398        Self {
399            mode,
400            accent_hue: hue,
401            neutral_hue: hue,
402            neutral_chroma_mult: 1.0,
403            danger: status(DANGER_HUE),
404            warning: status(WARNING_HUE),
405            success: status(SUCCESS_HUE),
406            bg: neutrals.step(1),
407            surface: neutrals.step(2),
408            surface_raised,
409            element: neutrals.step(3),
410            element_hover: neutrals.step(4),
411            element_active: neutrals.step(5),
412            border_subtle: neutrals.step(5),
413            border: neutrals.step(6),
414            border_strong: neutrals.step(7),
415            text: neutrals.step(12),
416            text_muted: neutrals.step(11),
417            text_subtle: neutrals.step(9),
418            text_disabled: neutrals.step(8),
419            accent: accents.step(9),
420            accent_hover: accents.step(10),
421            accent_active: active_color(accent_table, hue),
422            accent_bg: accents.step(3),
423            accent_border: accents.step(7),
424            accent_text: accents.step(11),
425            on_accent,
426            neutral_alpha: alpha_ramp(&neutrals, neutrals.step(1)),
427            accent_alpha: alpha_ramp(&accents, neutrals.step(1)),
428            neutrals,
429            accents,
430            radius: RadiusScale::default(),
431            corner_smoothing: DEFAULT_CORNER_SMOOTHING,
432            optical_sizing: OpticalSizing::Auto,
433            elevation: Elevation::default(),
434            text_scale: 1.0,
435            direction: WritingDir::default(),
436        }
437    }
438
439    /// Returns this theme with a different corner-radius family — the single
440    /// knob that un-rounds (or rounds) the whole kit, since widgets and
441    /// [`Surface`](crate::Surface) materials resolve their radii from it. Pairs
442    /// with [`RadiusScale::sharp`] / [`RadiusScale::soft`].
443    #[must_use]
444    pub fn with_radius(mut self, radius: RadiusScale) -> Self {
445        self.radius = radius;
446        self
447    }
448
449    /// Returns this theme with a different kit-wide corner smoothing — the
450    /// single knob that re-curves every rounded surface, from circular arcs
451    /// (`0.0`) to a fuller Apple-style squircle (toward `1.0`). Clamped to
452    /// `0.0..=1.0`. Per-element [`Style::corner_smoothing`] still overrides it.
453    ///
454    /// [`Style::corner_smoothing`]: crate::Style::corner_smoothing
455    #[must_use]
456    pub fn with_corner_smoothing(mut self, smoothing: f32) -> Self {
457        self.corner_smoothing = smoothing.clamp(0.0, 1.0);
458        self
459    }
460
461    /// Returns this theme with a different kit-wide [`OpticalSizing`] default.
462    /// `Auto` (the stock value) tracks a variable face's `opsz` axis to the
463    /// rendered size; `OpticalSizing::Default` turns optical sizing off kit-wide.
464    /// Per-element [`Style::optical`](crate::Style) still overrides it.
465    #[must_use]
466    pub fn with_optical_sizing(mut self, optical: OpticalSizing) -> Self {
467        self.optical_sizing = optical;
468        self
469    }
470
471    /// Returns this theme with a different [`Elevation`] for resting cards —
472    /// [`Elevation::Flat`] trades the card shadow for a border + tone-step
473    /// (sharper, dark-mode-honest), reserving shadows for surfaces that float.
474    #[must_use]
475    pub fn with_elevation(mut self, elevation: Elevation) -> Self {
476        self.elevation = elevation;
477        self
478    }
479
480    /// Returns this theme with a Dynamic Type scale — a multiplier on every
481    /// resolved font size (1.0 = stock, 1.3 ≈ "large text"). Clamped to
482    /// `0.5..=3.0` at resolution.
483    #[must_use]
484    pub fn with_text_scale(mut self, scale: f32) -> Self {
485        self.text_scale = scale;
486        self
487    }
488
489    /// Returns this theme with a [`WritingDir`].
490    #[must_use]
491    pub fn with_direction(mut self, direction: WritingDir) -> Self {
492        self.direction = direction;
493        self
494    }
495
496    /// Returns this theme set right-to-left (sugar for
497    /// [`with_direction`](Self::with_direction)`(WritingDir::Rtl)`).
498    #[must_use]
499    pub fn rtl(self) -> Self {
500        self.with_direction(WritingDir::Rtl)
501    }
502
503    /// Whether this theme lays out right-to-left.
504    #[must_use]
505    pub fn is_rtl(&self) -> bool {
506        self.direction.is_rtl()
507    }
508
509    /// A duotone theme: the neutral field takes its own hue with a chroma
510    /// multiplier (1.0 matches the standard near-gray neutrals; 4-10 gives
511    /// an atmospheric, editorial field like deep green or warm paper),
512    /// while the accent keeps `from_accent` semantics. Out-of-gamut chroma
513    /// is gamut-mapped per color, never clipped.
514    pub fn duotone(neutral_hue: f32, neutral_chroma: f32, accent_hue: f32, mode: Mode) -> Self {
515        let mut theme = Self::from_accent(accent_hue, mode);
516        let neutral_table = match mode {
517            Mode::Light => &NEUTRAL_LIGHT,
518            Mode::Dark => &NEUTRAL_DARK,
519        };
520        let hue = neutral_hue.rem_euclid(360.0);
521        let boost = neutral_chroma.clamp(0.0, 40.0);
522        let neutrals = Ramp(std::array::from_fn(|i| {
523            let (l, c) = neutral_table[i];
524            oklch(l, c * boost, hue)
525        }));
526        theme.apply_neutral_field(neutrals, hue, boost);
527        theme
528    }
529
530    /// Web-grade by default: the whole palette from three inputs (Linear's
531    /// model collapsed onto fenestra's OKLCH scales). `base` is the neutral
532    /// field (hue + how far from gray), `accent_hue` the brand hue, and
533    /// `contrast` the separation level. [`from_accent`](Self::from_accent) and
534    /// [`duotone`](Self::duotone) are special cases — `duotone(hue, c, accent,
535    /// mode)` equals `derive(BaseField{hue, chroma: c}, accent, Standard, mode)`.
536    /// A matching radius family comes from [`RadiusScale::from_base`].
537    ///
538    /// [`RadiusScale::from_base`]: crate::RadiusScale::from_base
539    pub fn derive(base: BaseField, accent_hue: f32, contrast: Contrast, mode: Mode) -> Self {
540        let mut theme = Self::from_accent(accent_hue, mode);
541        let neutral_table = match mode {
542            Mode::Light => &NEUTRAL_LIGHT,
543            Mode::Dark => &NEUTRAL_DARK,
544        };
545        let hue = base.hue.rem_euclid(360.0);
546        let chroma_mult = base.chroma.clamp(0.0, 40.0);
547        let k = contrast.factor();
548        let l_bg = neutral_table[0].0;
549        let neutrals = Ramp(std::array::from_fn(|i| {
550            let (l, c) = neutral_table[i];
551            // Scale each step's lightness distance from the page background, so
552            // contrast widens or softens against a fixed bg rather than drifting.
553            let l = (l_bg + (l - l_bg) * k).clamp(0.0, 1.0);
554            oklch(l, c * chroma_mult, hue)
555        }));
556        theme.apply_neutral_field(neutrals, hue, chroma_mult);
557        theme
558    }
559
560    /// Re-points every neutral-derived role at `neutrals` (already built for
561    /// `hue` at `chroma_mult` × the base table chroma). Shared by `duotone`
562    /// and `derive`; the accent ramp, status colors, and shadows are untouched.
563    fn apply_neutral_field(&mut self, neutrals: Ramp, hue: f32, chroma_mult: f32) {
564        let accent_table = match self.mode {
565            Mode::Light => &ACCENT_LIGHT,
566            Mode::Dark => &ACCENT_DARK,
567        };
568        let bg = neutrals.step(1);
569        self.bg = bg;
570        self.surface = neutrals.step(2);
571        if matches!(self.mode, Mode::Dark) {
572            self.surface_raised = neutrals.step(3);
573        }
574        self.element = neutrals.step(3);
575        self.element_hover = neutrals.step(4);
576        self.element_active = neutrals.step(5);
577        self.border_subtle = neutrals.step(5);
578        self.border = neutrals.step(6);
579        self.border_strong = neutrals.step(7);
580        self.text = neutrals.step(12);
581        self.text_muted = neutrals.step(11);
582        self.text_subtle = neutrals.step(9);
583        self.text_disabled = neutrals.step(8);
584        // A light accent (table L >= 0.65) takes dark on-accent text from the
585        // new field, matching `from_accent`'s rule against the field neutrals.
586        if accent_table[8].0 >= 0.65 {
587            self.on_accent = neutrals.step(12);
588        }
589        self.neutral_alpha = alpha_ramp(&neutrals, bg);
590        self.accent_alpha = alpha_ramp(&self.accents, bg);
591        self.neutral_hue = hue;
592        self.neutral_chroma_mult = chroma_mult;
593        self.neutrals = neutrals;
594    }
595
596    /// The brand accent ramp as a smooth OKLCH linear gradient (accent ramp
597    /// step 7 → step 10, the pairing the painting specimen uses) at a
598    /// CSS-style `angle_deg` (0 up, 90 right). A one-call themed gradient for
599    /// hero panels and brand fills.
600    pub fn accent_gradient(&self, angle_deg: f32) -> crate::style::Paint {
601        crate::style::linear_gradient(angle_deg, [self.accents.step(7), self.accents.step(10)])
602    }
603
604    /// The default light theme (accent hue 262).
605    pub fn light() -> Self {
606        Self::from_accent(262.0, Mode::Light)
607    }
608
609    /// The default dark theme (accent hue 262).
610    pub fn dark() -> Self {
611        Self::from_accent(262.0, Mode::Dark)
612    }
613
614    /// Resolves a shadow token to concrete layers for this mode. Dark mode
615    /// multiplies alphas by 1.6, and every layer is tinted with the surface
616    /// hue (see [`Theme::shadow_tint`]) rather than flat black.
617    pub fn shadow(&self, token: ShadowToken) -> Vec<Shadow> {
618        let factor = match self.mode {
619            Mode::Light => 1.0,
620            Mode::Dark => DARK_SHADOW_ALPHA_FACTOR,
621        };
622        let tint = self.shadow_tint();
623        shadow_layers(token)
624            .iter()
625            .map(|&(dy, blur, alpha)| Shadow {
626                dx: 0.0,
627                dy,
628                blur,
629                spread: 0.0,
630                color: tint.with_alpha((alpha * factor).min(1.0)),
631            })
632            .collect()
633    }
634
635    /// The base shadow color: a near-black carrying the surface hue at low
636    /// chroma. Shadows on a cool theme read cool, on a warm theme warm — the
637    /// web-craft alternative to flat `#000`. Alpha is applied per layer.
638    pub fn shadow_tint(&self) -> Color {
639        let [r, g, b, _] = self.bg.components;
640        // A pure-gray surface carries no hue, so its shadow is a neutral
641        // near-black — not an arbitrary one. (The color crate reports a fixed
642        // hue, never NaN, for achromatic colors, so checking the sRGB channels
643        // is the reliable test.)
644        if (r - g).abs() < 1e-4 && (g - b).abs() < 1e-4 {
645            return oklch(0.13, 0.0, 0.0);
646        }
647        let hue = self.bg.convert::<Oklch>().components[2];
648        let hue = if hue.is_nan() { 0.0 } else { hue };
649        oklch(0.13, 0.03, hue)
650    }
651
652    /// The surface color at an elevation level: 0 is `surface`, 1 is
653    /// `surface_raised`, and each level above adds +0.025 L in dark mode
654    /// (light mode raised surfaces are always pure white), because shadows
655    /// alone read poorly on dark backgrounds.
656    pub fn elevated_surface(&self, level: u8) -> Color {
657        match (self.mode, level) {
658            (_, 0) => self.surface,
659            (Mode::Light, _) => self.surface_raised,
660            (Mode::Dark, n) => {
661                // Lighten from N3 in the theme's own neutral field (duotone
662                // hue + boosted chroma included), not the stock accent hue.
663                let (l, c) = NEUTRAL_DARK[2];
664                oklch(
665                    l + DARK_ELEVATION_TINT * f32::from(n - 1),
666                    c * self.neutral_chroma_mult,
667                    self.neutral_hue,
668                )
669            }
670        }
671    }
672
673    /// A stable, human-readable dump of every generated color, used to lock
674    /// the theme with a text snapshot.
675    pub fn dump(&self) -> String {
676        use std::fmt::Write;
677        let mut out = String::new();
678        let mode = match self.mode {
679            Mode::Light => "light",
680            Mode::Dark => "dark",
681        };
682        writeln!(out, "theme: accent_hue {} mode {}", self.accent_hue, mode).unwrap();
683        writeln!(out, "\nneutrals:").unwrap();
684        for n in 1..=12 {
685            writeln!(out, "  N{n}: {}", hex(self.neutrals.step(n))).unwrap();
686        }
687        writeln!(out, "\naccents:").unwrap();
688        for n in 1..=12 {
689            writeln!(out, "  A{n}: {}", hex(self.accents.step(n))).unwrap();
690        }
691        writeln!(out, "\nneutral alpha twins (over bg):").unwrap();
692        for n in 1..=12 {
693            writeln!(out, "  NA{n}: {}", hex(self.neutral_alpha.step(n))).unwrap();
694        }
695        writeln!(out, "\naccent alpha twins (over bg):").unwrap();
696        for n in 1..=12 {
697            writeln!(out, "  AA{n}: {}", hex(self.accent_alpha.step(n))).unwrap();
698        }
699        for (name, s) in [
700            ("danger", &self.danger),
701            ("warning", &self.warning),
702            ("success", &self.success),
703        ] {
704            writeln!(out, "\n{name}:").unwrap();
705            writeln!(out, "  bg: {}", hex(s.bg)).unwrap();
706            writeln!(out, "  border: {}", hex(s.border)).unwrap();
707            writeln!(out, "  solid: {}", hex(s.solid)).unwrap();
708            writeln!(out, "  solid_hover: {}", hex(s.solid_hover)).unwrap();
709            writeln!(out, "  solid_active: {}", hex(s.solid_active)).unwrap();
710            writeln!(out, "  text: {}", hex(s.text)).unwrap();
711        }
712        writeln!(out, "\nroles:").unwrap();
713        for (name, c) in [
714            ("bg", self.bg),
715            ("surface", self.surface),
716            ("surface_raised", self.surface_raised),
717            ("element", self.element),
718            ("element_hover", self.element_hover),
719            ("element_active", self.element_active),
720            ("border_subtle", self.border_subtle),
721            ("border", self.border),
722            ("border_strong", self.border_strong),
723            ("text", self.text),
724            ("text_muted", self.text_muted),
725            ("text_subtle", self.text_subtle),
726            ("text_disabled", self.text_disabled),
727            ("accent", self.accent),
728            ("accent_hover", self.accent_hover),
729            ("accent_active", self.accent_active),
730            ("accent_bg", self.accent_bg),
731            ("accent_border", self.accent_border),
732            ("accent_text", self.accent_text),
733            ("on_accent", self.on_accent),
734        ] {
735            writeln!(out, "  {name}: {}", hex(c)).unwrap();
736        }
737        writeln!(out, "\nelevation:").unwrap();
738        for level in 0..=2 {
739            writeln!(
740                out,
741                "  level {level}: {}",
742                hex(self.elevated_surface(level))
743            )
744            .unwrap();
745        }
746        writeln!(out, "\nshadows (dx dy blur spread color):").unwrap();
747        for (name, token) in [
748            ("xs", ShadowToken::Xs),
749            ("sm", ShadowToken::Sm),
750            ("md", ShadowToken::Md),
751            ("lg", ShadowToken::Lg),
752            ("xl", ShadowToken::Xl),
753        ] {
754            let layers: Vec<String> = self
755                .shadow(token)
756                .iter()
757                .map(|s| {
758                    format!(
759                        "({} {} {} {} {})",
760                        s.dx,
761                        s.dy,
762                        s.blur,
763                        s.spread,
764                        hex(s.color)
765                    )
766                })
767                .collect();
768            writeln!(out, "  {name}: {}", layers.join(" + ")).unwrap();
769        }
770        out
771    }
772
773    /// Measures every text/background role pair against its APCA Lc floor and
774    /// returns the pairs that fall short (empty means the theme is legible
775    /// everywhere). Floors by role: primary text [`PRIMARY_TEXT_MIN`],
776    /// secondary/muted text [`SECONDARY_TEXT_MIN`], labels on filled controls
777    /// [`CONTROL_LABEL_MIN`], and colored accent/status text
778    /// [`COMPONENT_TEXT_MIN`]. Borders and other non-text contrast are not
779    /// checked — APCA scores text legibility, not delineation.
780    pub fn contrast_report(&self) -> Vec<ContrastViolation> {
781        let mut out = Vec::new();
782        // Primary body text on every surface it sits on.
783        check_pair(&mut out, "text on bg", self.text, self.bg, PRIMARY_TEXT_MIN);
784        check_pair(
785            &mut out,
786            "text on surface",
787            self.text,
788            self.surface,
789            PRIMARY_TEXT_MIN,
790        );
791        check_pair(
792            &mut out,
793            "text on surface_raised",
794            self.text,
795            self.surface_raised,
796            PRIMARY_TEXT_MIN,
797        );
798        // Secondary (muted) text.
799        check_pair(
800            &mut out,
801            "text_muted on bg",
802            self.text_muted,
803            self.bg,
804            SECONDARY_TEXT_MIN,
805        );
806        check_pair(
807            &mut out,
808            "text_muted on surface",
809            self.text_muted,
810            self.surface,
811            SECONDARY_TEXT_MIN,
812        );
813        check_pair(
814            &mut out,
815            "text_muted on surface_raised",
816            self.text_muted,
817            self.surface_raised,
818            SECONDARY_TEXT_MIN,
819        );
820        // Labels painted on filled controls (primary button, pressed states).
821        check_pair(
822            &mut out,
823            "on_accent on accent",
824            self.on_accent,
825            self.accent,
826            CONTROL_LABEL_MIN,
827        );
828        check_pair(
829            &mut out,
830            "on_accent on accent_hover",
831            self.on_accent,
832            self.accent_hover,
833            CONTROL_LABEL_MIN,
834        );
835        check_pair(
836            &mut out,
837            "on_accent on accent_active",
838            self.on_accent,
839            self.accent_active,
840            CONTROL_LABEL_MIN,
841        );
842        // Accent-colored text (links, selected option, avatar initials).
843        check_pair(
844            &mut out,
845            "accent_text on bg",
846            self.accent_text,
847            self.bg,
848            COMPONENT_TEXT_MIN,
849        );
850        check_pair(
851            &mut out,
852            "accent_text on accent_bg",
853            self.accent_text,
854            self.accent_bg,
855            COMPONENT_TEXT_MIN,
856        );
857        // Status colors: tinted text on its tint and on the page, plus the
858        // label painted on the solid status fill (e.g. a danger button).
859        for (name, s) in [
860            ("danger", &self.danger),
861            ("warning", &self.warning),
862            ("success", &self.success),
863        ] {
864            check_pair(
865                &mut out,
866                format!("{name}.text on {name}.bg"),
867                s.text,
868                s.bg,
869                COMPONENT_TEXT_MIN,
870            );
871            check_pair(
872                &mut out,
873                format!("{name}.text on bg"),
874                s.text,
875                self.bg,
876                COMPONENT_TEXT_MIN,
877            );
878            check_pair(
879                &mut out,
880                format!("on_accent on {name}.solid"),
881                self.on_accent,
882                s.solid,
883                CONTROL_LABEL_MIN,
884            );
885        }
886        out
887    }
888
889    /// `Ok(())` when every text/background pair clears its APCA floor, else the
890    /// list of violations. This is the contract behind fenestra's
891    /// "provably-legible themes": the stock themes and every shipped Look are
892    /// asserted to pass in headless tests, and any custom [`Theme`] can be
893    /// validated the same way.
894    ///
895    /// # Errors
896    /// Returns the pairs that fall below their floor; see [`ContrastViolation`].
897    pub fn validate_contrast(&self) -> Result<(), Vec<ContrastViolation>> {
898        let report = self.contrast_report();
899        if report.is_empty() {
900            Ok(())
901        } else {
902            Err(report)
903        }
904    }
905
906    /// The most legible neutral text color the theme's own ramp can offer on an
907    /// arbitrary background: whichever of the theme's near-black ink
908    /// (`neutrals.step(12)`) or near-white paper (`neutrals.step(1)`) wins APCA
909    /// `Lc` on `bg`. Generalizes the [`on_accent`](Self::on_accent) rule to any
910    /// custom or status surface, so a widget placing text on a color the theme
911    /// didn't generate still gets a theme-tinted (never raw) text color that
912    /// reads strongly on typical surfaces — though on a hard mid-tone, where no
913    /// color reads well, it only reaches secondary-text grade (use
914    /// [`contrast_ok`](Self::contrast_ok) to verify a specific case). Ties break
915    /// toward the ink.
916    #[must_use]
917    pub fn text_on(&self, bg: Color) -> Color {
918        let ink = self.neutrals.step(12);
919        let paper = self.neutrals.step(1);
920        if crate::apca::lc_abs(paper, bg) > crate::apca::lc_abs(ink, bg) {
921            paper
922        } else {
923            ink
924        }
925    }
926
927    /// Whether `text` on `bg` clears the APCA floor for text of `size_px` at
928    /// `weight` — `apca::lc_abs(text, bg) >= apca::required_lc(size_px, weight)`.
929    /// The size/weight-aware companion to [`validate_contrast`](Self::validate_contrast)'s
930    /// fixed role floors: lets an app prove a *specific* label legible at its
931    /// real rendered size, not just against a tier average. The verdict depends
932    /// only on the two colors and the size/weight (not on other theme state);
933    /// it lives on `Theme` for discoverability alongside `validate_contrast` and
934    /// `text_on`.
935    #[must_use]
936    pub fn contrast_ok(&self, text: Color, bg: Color, size_px: f32, weight: f32) -> bool {
937        crate::apca::lc_abs(text, bg) >= crate::apca::required_lc(size_px, weight)
938    }
939}
940
941fn make_ramp(table: &RampTable, hue: f32) -> Ramp {
942    Ramp(std::array::from_fn(|i| {
943        let (l, c) = table[i];
944        oklch(l, c, hue)
945    }))
946}
947
948fn ramp_color(table: &RampTable, step: usize, hue: f32) -> Color {
949    let (l, c) = table[step - 1];
950    oklch(l, c, hue)
951}
952
953/// Pressed state: one OKLCH-lightness notch (`ACTIVE_DL`) below the step-10
954/// hover, at `hue`. Read from the table so it is mode-invariant wherever the
955/// table's step 10 is — and both the brand accent and the status hues are.
956fn active_color(table: &RampTable, hue: f32) -> Color {
957    let (l, c) = table[9];
958    oklch((l - ACTIVE_DL).max(0.0), c, hue)
959}
960
961/// The alpha-twin ramp: each solid step rendered as the smallest-alpha
962/// translucent color that composites over `bg` back to that step.
963fn alpha_ramp(solid: &Ramp, bg: Color) -> Ramp {
964    Ramp(std::array::from_fn(|i| alpha_twin(solid.0[i], bg)))
965}
966
967/// The smallest-alpha translucent color that, painted over `bg`, reproduces
968/// `target`. For each channel the minimal alpha that keeps the back-solved
969/// foreground inside `[0, 1]` is required; the max across channels wins
970/// (mixed-direction channels — a tint both bluer and darker than a near-white
971/// bg — force alpha toward 1). Reconstruction is exact at f32 precision, so
972/// compositing the twin over `bg` round-trips to `target`.
973fn alpha_twin(target: Color, bg: Color) -> Color {
974    let t = target.components;
975    let b = bg.components;
976    let mut a = 0.0_f32;
977    for ch in 0..3 {
978        let (tc, bc) = (t[ch], b[ch]);
979        let bound = if tc < bc {
980            if bc > 0.0 { 1.0 - tc / bc } else { 0.0 }
981        } else if tc > bc {
982            if bc < 1.0 {
983                (tc - bc) / (1.0 - bc)
984            } else {
985                1.0
986            }
987        } else {
988            0.0
989        };
990        a = a.max(bound);
991    }
992    let a = a.clamp(0.0, 1.0);
993    if a <= f32::EPSILON {
994        // Fully transparent: the color is immaterial; keep bg for a stable dump.
995        return Color::new([b[0], b[1], b[2], 0.0]);
996    }
997    let solve = |tc: f32, bc: f32| ((tc - bc * (1.0 - a)) / a).clamp(0.0, 1.0);
998    Color::new([solve(t[0], b[0]), solve(t[1], b[1]), solve(t[2], b[2]), a])
999}
1000
1001/// Records a [`ContrastViolation`] when `text` on `bg` falls below `floor`
1002/// (by APCA Lc magnitude).
1003fn check_pair(
1004    out: &mut Vec<ContrastViolation>,
1005    pair: impl Into<String>,
1006    text: Color,
1007    bg: Color,
1008    floor: f64,
1009) {
1010    let measured_lc = crate::apca::lc_abs(text, bg);
1011    if measured_lc < floor {
1012        out.push(ContrastViolation {
1013            pair: pair.into(),
1014            measured_lc,
1015            required_lc: floor,
1016        });
1017    }
1018}
1019
1020/// Builds an sRGB [`Color`] from OKLCH (lightness 0..=1, chroma, hue degrees),
1021/// gamut-mapping by reducing chroma — never lightness — when the color is out
1022/// of gamut. This is the framework's color primitive: theme ramps, Looks, and
1023/// data-viz palettes are all built from it, so a generated color is always
1024/// in-gamut and keeps its intended lightness.
1025pub fn oklch(l: f32, c: f32, h: f32) -> Color {
1026    let convert = |chroma: f32| AlphaColor::<Oklch>::new([l, chroma, h, 1.0]).convert::<Srgb>();
1027    let mut srgb = convert(c);
1028    if !in_gamut(srgb) {
1029        let (mut lo, mut hi) = (0.0_f32, c);
1030        for _ in 0..24 {
1031            let mid = 0.5 * (lo + hi);
1032            if in_gamut(convert(mid)) {
1033                lo = mid;
1034            } else {
1035                hi = mid;
1036            }
1037        }
1038        srgb = convert(lo);
1039    }
1040    let [r, g, b, a] = srgb.components;
1041    Color::new([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a])
1042}
1043
1044/// The OKLCH components `[L, C, H]` of a color — the inverse of [`oklch`].
1045/// Hue is `0.0` for achromatic colors (where it is otherwise undefined), so the
1046/// result round-trips cleanly back through [`oklch`]. Use it to adapt an
1047/// existing color (e.g. lift a categorical swatch for a dark background).
1048pub fn oklch_of(color: Color) -> [f32; 3] {
1049    let [l, c, h, _] = color.convert::<Oklch>().components;
1050    [l, c, if h.is_nan() { 0.0 } else { h }]
1051}
1052
1053fn in_gamut(c: AlphaColor<Srgb>) -> bool {
1054    c.components[..3]
1055        .iter()
1056        .all(|&v| (-1e-4..=1.0 + 1e-4).contains(&v))
1057}
1058
1059/// `#rrggbb` (or `#rrggbbaa` when translucent) for snapshots and debugging.
1060fn hex(c: Color) -> String {
1061    let rgba = c.to_rgba8();
1062    if rgba.a == 255 {
1063        format!("#{:02x}{:02x}{:02x}", rgba.r, rgba.g, rgba.b)
1064    } else {
1065        format!("#{:02x}{:02x}{:02x}{:02x}", rgba.r, rgba.g, rgba.b, rgba.a)
1066    }
1067}
1068
1069/// A serializable theme *recipe*: the few numbers a theme generates
1070/// from, not hundreds of resolved colors — so files stay tiny, stable
1071/// across fenestra versions, and hand-editable.
1072///
1073/// ```json
1074/// {"mode": "dark", "duotone": {"neutral_hue": 152.0, "chroma": 6.0, "accent_hue": 72.0}}
1075/// ```
1076///
1077/// Precedence: `derive` wins over `duotone` wins over `accent_hue`; none
1078/// means the stock palette for `mode`.
1079#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1080#[serde(deny_unknown_fields)]
1081pub struct ThemeSpec {
1082    /// Light or dark.
1083    pub mode: Mode,
1084    /// Accent hue in OKLCH degrees (`Theme::from_accent`).
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    pub accent_hue: Option<f32>,
1087    /// Duotone field (`Theme::duotone`).
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub duotone: Option<DuotoneSpec>,
1090    /// Three-input derivation (`Theme::derive`).
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub derive: Option<DeriveSpec>,
1093}
1094
1095/// The duotone parameters of a [`ThemeSpec`].
1096#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1097#[serde(deny_unknown_fields)]
1098pub struct DuotoneSpec {
1099    /// Neutral ramp hue (OKLCH degrees).
1100    pub neutral_hue: f32,
1101    /// Chroma multiplier for the neutral ramp.
1102    pub chroma: f32,
1103    /// Accent hue (OKLCH degrees).
1104    pub accent_hue: f32,
1105}
1106
1107/// The three-input parameters of a [`ThemeSpec`] (`Theme::derive`).
1108#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1109#[serde(deny_unknown_fields)]
1110pub struct DeriveSpec {
1111    /// Neutral-field hue (OKLCH degrees).
1112    pub base_hue: f32,
1113    /// Neutral-field chroma multiplier.
1114    pub base_chroma: f32,
1115    /// Accent hue (OKLCH degrees).
1116    pub accent_hue: f32,
1117    /// Contrast level (defaults to `standard`).
1118    #[serde(default)]
1119    pub contrast: Contrast,
1120}
1121
1122impl ThemeSpec {
1123    /// Resolves the recipe into a full [`Theme`].
1124    pub fn theme(&self) -> Theme {
1125        if let Some(d) = &self.derive {
1126            return Theme::derive(
1127                BaseField {
1128                    hue: d.base_hue,
1129                    chroma: d.base_chroma,
1130                },
1131                d.accent_hue,
1132                d.contrast,
1133                self.mode,
1134            );
1135        }
1136        if let Some(d) = &self.duotone {
1137            return Theme::duotone(d.neutral_hue, d.chroma, d.accent_hue, self.mode);
1138        }
1139        if let Some(hue) = self.accent_hue {
1140            return Theme::from_accent(hue, self.mode);
1141        }
1142        match self.mode {
1143            Mode::Light => Theme::light(),
1144            Mode::Dark => Theme::dark(),
1145        }
1146    }
1147
1148    /// Parses a theme file's JSON. Unknown fields are errors — a typo'd
1149    /// recipe should fail loudly, not silently fall back.
1150    ///
1151    /// # Errors
1152    /// On malformed JSON or unknown fields.
1153    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
1154        serde_json::from_str(json)
1155    }
1156
1157    /// The recipe as pretty JSON, for writing theme files.
1158    ///
1159    /// # Panics
1160    /// Never (the type always serializes).
1161    #[must_use]
1162    pub fn to_json(&self) -> String {
1163        serde_json::to_string_pretty(self).expect("ThemeSpec serializes")
1164    }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    /// OKLCH lightness of a resolved color (for ordering assertions).
1172    fn lightness(c: Color) -> f32 {
1173        c.convert::<Oklch>().components[0]
1174    }
1175
1176    /// OKLCH hue (degrees) of a resolved color.
1177    fn lch_hue(c: Color) -> f32 {
1178        c.convert::<Oklch>().components[2]
1179    }
1180
1181    /// Circular distance between two hues in degrees.
1182    fn hue_delta(a: f32, b: f32) -> f32 {
1183        let d = (a - b).rem_euclid(360.0);
1184        d.min(360.0 - d)
1185    }
1186
1187    /// Composite a translucent color over an opaque background (straight
1188    /// source-over), returning RGB in 0..1.
1189    fn composite_over(fg: Color, bg: Color) -> [f32; 3] {
1190        let f = fg.components;
1191        let b = bg.components;
1192        let a = f[3];
1193        std::array::from_fn(|i| f[i] * a + b[i] * (1.0 - a))
1194    }
1195
1196    /// The stock themes plus the derive recipes behind the shipped Looks, in
1197    /// both modes — the same coverage `tests/contrast.rs` validates.
1198    fn representative_themes() -> Vec<(String, Theme)> {
1199        let mut v = Vec::new();
1200        for mode in [Mode::Light, Mode::Dark] {
1201            v.push((format!("base {mode:?}"), Theme::from_accent(262.0, mode)));
1202            v.push((
1203                format!("editorial {mode:?}"),
1204                Theme::duotone(152.0, 6.0, 72.0, mode),
1205            ));
1206            v.push((
1207                format!("terminal {mode:?}"),
1208                Theme::from_accent(145.0, mode),
1209            ));
1210            v.push((
1211                format!("warm-editorial {mode:?}"),
1212                Theme::derive(
1213                    BaseField {
1214                        hue: 80.0,
1215                        chroma: 2.5,
1216                    },
1217                    40.0,
1218                    Contrast::High,
1219                    mode,
1220                ),
1221            ));
1222            v.push((
1223                format!("playful {mode:?}"),
1224                Theme::derive(
1225                    BaseField {
1226                        hue: 280.0,
1227                        chroma: 2.0,
1228                    },
1229                    330.0,
1230                    Contrast::Standard,
1231                    mode,
1232                ),
1233            ));
1234        }
1235        v
1236    }
1237
1238    /// Every background a widget might place free-form text on, including
1239    /// surfaces the theme exposes only as solid fills.
1240    fn representative_surfaces(t: &Theme) -> [(&'static str, Color); 7] {
1241        [
1242            ("bg", t.bg),
1243            ("surface", t.surface),
1244            ("surface_raised", t.surface_raised),
1245            ("accent", t.accent),
1246            ("danger.solid", t.danger.solid),
1247            ("warning.solid", t.warning.solid),
1248            ("success.solid", t.success.solid),
1249        ]
1250    }
1251
1252    #[test]
1253    fn text_on_is_legible_on_every_surface() {
1254        // `text_on` returns the theme-tinted paper/ink (never raw white/black),
1255        // so on a few dark status/accent solids it lands ~0.7 Lc under the
1256        // strict control-label floor (60) that pure-white `on_accent` clears.
1257        // The robust, role-tied guarantee it always meets is secondary-text
1258        // grade (55) — proven here with comfortable margin (worst ≈ 59).
1259        for (name, t) in representative_themes() {
1260            for (bn, bg) in representative_surfaces(&t) {
1261                let lc = crate::apca::lc_abs(t.text_on(bg), bg);
1262                assert!(
1263                    lc >= SECONDARY_TEXT_MIN,
1264                    "{name}: text_on({bn}) only reached Lc {lc:.2}"
1265                );
1266            }
1267        }
1268    }
1269
1270    #[test]
1271    fn text_on_picks_the_winning_extreme() {
1272        // The selection rule, not a hardcoded value: `text_on` returns whichever
1273        // ramp extreme (step 1 paper / step 12 ink) wins APCA Lc on the bg.
1274        for (name, t) in representative_themes() {
1275            let ink = t.neutrals.step(12);
1276            let paper = t.neutrals.step(1);
1277            for (bn, bg) in representative_surfaces(&t) {
1278                let want = if crate::apca::lc_abs(paper, bg) > crate::apca::lc_abs(ink, bg) {
1279                    paper
1280                } else {
1281                    ink
1282                };
1283                assert_eq!(
1284                    t.text_on(bg).to_rgba8(),
1285                    want.to_rgba8(),
1286                    "{name}: text_on({bn}) did not pick the winning extreme"
1287                );
1288            }
1289        }
1290    }
1291
1292    #[test]
1293    fn contrast_ok_tracks_required_lc() {
1294        let t = Theme::light();
1295        // Stock body text at its real size clears the body floor.
1296        assert!(t.contrast_ok(t.text, t.bg, 16.0, 400.0));
1297        // A near-bg gray fails the body floor outright.
1298        assert!(!t.contrast_ok(t.neutrals.step(6), t.bg, 16.0, 400.0));
1299        // Size axis is wired: the same pair (text_subtle on surface, Lc ~68)
1300        // passes at a large size yet fails at caption size, where `required_lc`
1301        // demands far more contrast.
1302        assert!(t.contrast_ok(t.text_subtle, t.surface, 30.0, 400.0));
1303        assert!(!t.contrast_ok(t.text_subtle, t.surface, 12.0, 400.0));
1304    }
1305
1306    #[test]
1307    fn role_floors_agree_with_required_lc() {
1308        // Regression guard: the role floors are the documented values, so any
1309        // future edit to them trips this tie-in test.
1310        assert_eq!(PRIMARY_TEXT_MIN, 75.0);
1311        assert_eq!(CONTROL_LABEL_MIN, 60.0);
1312        assert_eq!(SECONDARY_TEXT_MIN, 55.0);
1313        assert_eq!(COMPONENT_TEXT_MIN, 40.0);
1314
1315        // Load-bearing identity: the primary floor *is* APCA's body requirement
1316        // at the canonical 16px/400 body size.
1317        assert!((crate::apca::required_lc(16.0, 400.0) - 75.0).abs() <= 2.0);
1318        assert!(PRIMARY_TEXT_MIN >= crate::apca::required_lc(16.0, 400.0));
1319
1320        // The lower roles' floors are intentionally relaxed below APCA-at-render
1321        // size (regression sentinels, not strict minima). Each still equals or
1322        // exceeds `required_lc` at the documented APCA size/weight *tier* the
1323        // floor encodes — a real point on the curve, not the role's smallest
1324        // render size — so both systems share one scale.
1325        assert!(CONTROL_LABEL_MIN >= crate::apca::required_lc(25.0, 400.0)); // ≈58.8
1326        assert!(SECONDARY_TEXT_MIN >= crate::apca::required_lc(31.0, 400.0)); // ≈51.3
1327        assert!(COMPONENT_TEXT_MIN >= crate::apca::required_lc(50.0, 400.0)); // ≈39.2
1328
1329        // Floors are ordered consistently with their tiers and bracketed by
1330        // `required_lc`'s range (the spot floor 15 .. the body requirement 75).
1331        // A const block: the ordering is a compile-time invariant of the floors.
1332        const {
1333            assert!(
1334                PRIMARY_TEXT_MIN > CONTROL_LABEL_MIN
1335                    && CONTROL_LABEL_MIN > SECONDARY_TEXT_MIN
1336                    && SECONDARY_TEXT_MIN > COMPONENT_TEXT_MIN
1337            );
1338        }
1339        for floor in [
1340            PRIMARY_TEXT_MIN,
1341            CONTROL_LABEL_MIN,
1342            SECONDARY_TEXT_MIN,
1343            COMPONENT_TEXT_MIN,
1344        ] {
1345            assert!(floor <= crate::apca::required_lc(16.0, 400.0));
1346            assert!(floor >= 15.0); // apca::LC_SPOT, the spot/decorative floor
1347        }
1348    }
1349
1350    #[test]
1351    fn alpha_twins_composite_back_to_solid_steps() {
1352        for theme in [Theme::light(), Theme::dark()] {
1353            let bg = theme.bg;
1354            for n in 1..=12 {
1355                for (solid, twin) in [
1356                    (theme.neutrals.step(n), theme.neutral_alpha.step(n)),
1357                    (theme.accents.step(n), theme.accent_alpha.step(n)),
1358                ] {
1359                    let got = composite_over(twin, bg);
1360                    let want = solid.components;
1361                    for ch in 0..3 {
1362                        assert!(
1363                            (got[ch] - want[ch]).abs() < 1e-4,
1364                            "step {n} channel {ch}: twin over bg = {got:?}, want {want:?}"
1365                        );
1366                    }
1367                }
1368            }
1369        }
1370    }
1371
1372    #[test]
1373    fn element_roles_are_neutral_steps_3_4_5() {
1374        for theme in [Theme::light(), Theme::dark()] {
1375            assert_eq!(theme.element.to_rgba8(), theme.neutrals.step(3).to_rgba8());
1376            assert_eq!(
1377                theme.element_hover.to_rgba8(),
1378                theme.neutrals.step(4).to_rgba8()
1379            );
1380            assert_eq!(
1381                theme.element_active.to_rgba8(),
1382                theme.neutrals.step(5).to_rgba8()
1383            );
1384        }
1385    }
1386
1387    #[test]
1388    fn pressed_states_are_darker_than_hover() {
1389        for theme in [Theme::light(), Theme::dark()] {
1390            assert!(
1391                lightness(theme.accent_active) < lightness(theme.accent_hover),
1392                "accent_active must be darker than accent_hover"
1393            );
1394            for status in [theme.danger, theme.warning, theme.success] {
1395                assert!(
1396                    lightness(status.solid_active) < lightness(status.solid_hover),
1397                    "solid_active must be darker than solid_hover"
1398                );
1399            }
1400        }
1401    }
1402
1403    #[test]
1404    fn light_accent_active_lands_on_a11_lightness() {
1405        // ACTIVE_DL = 0.045 drops A10 (L 0.545) onto A11's lightness (0.500).
1406        let active_l = lightness(Theme::light().accent_active);
1407        assert!(
1408            (active_l - 0.500).abs() < 0.01,
1409            "light accent_active L = {active_l}, expected ~0.500"
1410        );
1411    }
1412
1413    #[test]
1414    fn pressed_states_are_mode_invariant() {
1415        let (l, d) = (Theme::light(), Theme::dark());
1416        assert_eq!(l.accent_active.to_rgba8(), d.accent_active.to_rgba8());
1417        assert_eq!(
1418            l.danger.solid_active.to_rgba8(),
1419            d.danger.solid_active.to_rgba8()
1420        );
1421    }
1422
1423    #[test]
1424    fn elevated_surface_level_1_equals_surface_raised() {
1425        for theme in [Theme::dark(), Theme::duotone(152.0, 6.0, 72.0, Mode::Dark)] {
1426            assert_eq!(
1427                theme.elevated_surface(1).to_rgba8(),
1428                theme.surface_raised.to_rgba8()
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn duotone_dark_elevation_tracks_the_field_not_the_accent() {
1435        // Regression: dark elevated surfaces must follow the duotone field hue
1436        // (152), not the accent hue (72) — the editorial-Look overlay bug.
1437        let t = Theme::duotone(152.0, 6.0, 72.0, Mode::Dark);
1438        let field = lch_hue(t.surface_raised);
1439        let lifted = lch_hue(t.elevated_surface(2));
1440        assert!(
1441            hue_delta(lifted, field) < 25.0,
1442            "elev(2) hue {lifted} vs field {field}"
1443        );
1444        assert!(
1445            hue_delta(lifted, 72.0) > 40.0,
1446            "elev(2) must not be the accent hue 72"
1447        );
1448    }
1449
1450    #[test]
1451    fn shadow_tint_is_neutral_for_gray_themes_and_hued_otherwise() {
1452        // A grayscale (chroma 0) theme gets a neutral near-black shadow.
1453        let [r, g, b, _] = Theme::duotone(152.0, 0.0, 72.0, Mode::Dark)
1454            .shadow_tint()
1455            .components;
1456        assert!(
1457            (r - g).abs() < 1e-3 && (g - b).abs() < 1e-3,
1458            "gray theme shadow must be neutral, got {:?}",
1459            [r, g, b]
1460        );
1461        // The stock theme keeps a subtle hue.
1462        let [r2, _, b2, _] = Theme::dark().shadow_tint().components;
1463        assert!(
1464            (r2 - b2).abs() > 1e-3,
1465            "stock shadow tint should carry a hue"
1466        );
1467    }
1468
1469    #[test]
1470    fn derive_reproduces_from_accent_and_duotone() {
1471        // derive is the general path: at chroma 1.0 / Standard contrast it is
1472        // from_accent, and at a boosted chroma / Standard it is duotone.
1473        for mode in [Mode::Light, Mode::Dark] {
1474            let as_accent = Theme::derive(
1475                BaseField {
1476                    hue: 262.0,
1477                    chroma: 1.0,
1478                },
1479                262.0,
1480                Contrast::Standard,
1481                mode,
1482            );
1483            assert_eq!(as_accent.dump(), Theme::from_accent(262.0, mode).dump());
1484
1485            let as_duotone = Theme::derive(
1486                BaseField {
1487                    hue: 152.0,
1488                    chroma: 6.0,
1489                },
1490                72.0,
1491                Contrast::Standard,
1492                mode,
1493            );
1494            assert_eq!(
1495                as_duotone.dump(),
1496                Theme::duotone(152.0, 6.0, 72.0, mode).dump()
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn derive_contrast_orders_legibility_and_stays_legible() {
1503        let base = BaseField {
1504            hue: 262.0,
1505            chroma: 1.0,
1506        };
1507        let low = Theme::derive(base, 262.0, Contrast::Low, Mode::Light);
1508        let std = Theme::derive(base, 262.0, Contrast::Standard, Mode::Light);
1509        let high = Theme::derive(base, 262.0, Contrast::High, Mode::Light);
1510        let lc = |t: &Theme| crate::apca::lc_abs(t.text, t.bg);
1511        assert!(lc(&high) > lc(&std), "High must be crisper than Standard");
1512        assert!(lc(&std) > lc(&low), "Standard must be crisper than Low");
1513        // Every level still clears the APCA floors — derivation never ships an
1514        // illegible theme.
1515        for t in [&low, &std, &high] {
1516            assert!(
1517                t.validate_contrast().is_ok(),
1518                "derived theme failed contrast: {:?}",
1519                t.validate_contrast()
1520            );
1521        }
1522    }
1523
1524    #[test]
1525    fn derive_recipe_round_trips() {
1526        let spec = ThemeSpec {
1527            mode: Mode::Dark,
1528            accent_hue: None,
1529            duotone: None,
1530            derive: Some(DeriveSpec {
1531                base_hue: 90.0,
1532                base_chroma: 2.0,
1533                accent_hue: 40.0,
1534                contrast: Contrast::High,
1535            }),
1536        };
1537        let json = spec.to_json();
1538        let back = ThemeSpec::from_json(&json).expect("round-trip");
1539        assert_eq!(spec, back);
1540        // The recipe resolves to the same theme as the direct call.
1541        let direct = Theme::derive(
1542            BaseField {
1543                hue: 90.0,
1544                chroma: 2.0,
1545            },
1546            40.0,
1547            Contrast::High,
1548            Mode::Dark,
1549        );
1550        assert_eq!(spec.theme().dump(), direct.dump());
1551    }
1552}