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}