Skip to main content

frust_material/tokens/
hct.rs

1//! The HCT seed-color engine: build a full Material 3 [`ColorScheme`] (plus
2//! its [`MaterialTokens`] semantic extension) from *any* seed color at
3//! runtime, instead of only the hand-baked `#6750A4` tables in
4//! [`super::color`].
5//!
6//! Ported from material-color-utilities 0.11.1 (Apache-2.0, © 2021 Google
7//! LLC; <https://github.com/material-foundation/material-color-utilities>,
8//! the Dart package Flutter itself ships), retrieved 2026-08-19 — the
9//! `utils/color_utils`, `hct/viewing_conditions`, `hct/cam16`,
10//! `hct/src/hct_solver`, `palettes/tonal_palette` and `palettes/core_palette`
11//! files. See this crate's `NOTICE`.
12//!
13//! # Porting decisions
14//!
15//! - **Only the scheme-generation path is ported.** CAM16 keeps its forward
16//!   transform, and of its nine dimensions only `hue`/`chroma` (the two HCT
17//!   needs) are returned; the inverse transform is the HCT solver's job, so
18//!   `Cam16.viewed`/`fromUcs`/`distance` and the blend, quantize, contrast,
19//!   dislike, score and `DynamicScheme` modules are all absent.
20//! - **[`TonalPalette`] drops MCU's key-color search and its per-tone
21//!   cache.** The key color feeds `DynamicScheme`, which this module does not
22//!   implement, and computing it eagerly costs up to 99 extra solves per
23//!   palette; the cache would cost interior mutability for a table each
24//!   scheme builds exactly once.
25//! - **The 255-entry critical-plane table is computed, not transcribed.** Its
26//!   entry `i` is by construction the linear-RGB value whose delinearization
27//!   is `i + 0.5`, i.e. MCU's own `linearized()` evaluated at a fractional
28//!   8-bit input ([`critical_plane`]); `critical_planes_match_upstream` pins
29//!   four sampled entries against MCU's literals.
30//! - **Palette definition** (see [`CorePalette::of`]): primary chroma follows
31//!   MCU's `CorePalette` (`max(48, seed chroma)`) so the seed itself surfaces
32//!   as `primary`, while the neutral palette follows the 2023 `tonalSpot`
33//!   value (6, not `CorePalette`'s 4) because the `surface_container_*` tone
34//!   table below *is* the 2023 spec's and was drawn against that palette.
35//! - **The nine semantic roles reuse [`super::color`]'s derivation rules
36//!   exactly** — six aliases of scheme fields, `success`/`warning` read off
37//!   the baked constants (they are seed-independent in the reference too),
38//!   and `surface_strong` computed with the same 6% blend.
39//!
40//! # Relationship to the baked baseline
41//!
42//! [`from_seed`] at seed `#6750A4` does **not** reproduce
43//! [`super::color::color_scheme_light`]/`_dark` byte for byte, and cannot:
44//! those tables are Google's *published* Material 3 baseline ref palette
45//! (material-web tokens v0.192), generated years earlier by a different
46//! material-color-utilities release. This module matches *current* MCU
47//! exactly instead (the golden tests), which leaves it very close to those
48//! tables but not identical: outside the error family, 18 light / 21 dark
49//! roles agree byte for byte, 22 light / 20 dark differ by a single 8-bit
50//! step in a single channel, and three roles in all (`surface_container`
51//! both ways, `surface_container_high` light) differ by two or three. The
52//! error family is a wholesale difference — the baseline's error ramp is not
53//! MCU's `TonalPalette.of(25, 84)` at all; its tone-40 entry measures hue
54//! 26.0/chroma 76.3, not 25/84.
55//! `from_seed_diverges_from_the_baked_baseline_only_as_recorded` pins every
56//! divergent role's pair of values, so the set can only change deliberately.
57//!
58//! # Cost
59//!
60//! Each role is an independent solve; a [`from_seed`] call builds both
61//! brightnesses (92 solves, sub-millisecond but not free) and allocates
62//! nothing. Call it when the seed changes — never per frame, and never from
63//! a widget's `paint`/`layout`.
64
65use std::sync::LazyLock;
66
67use frust::{Brightness, ColorScheme, Theme};
68use peniko::Color;
69
70use super::color::{MaterialSemanticColors, semantic_dark, semantic_light};
71use super::extension::MaterialTokens;
72
73// ---- sRGB / XYZ / L* conversions (MCU `ColorUtils`) ---------------------
74
75/// sRGB primaries to XYZ under the D65 white point.
76const SRGB_TO_XYZ: [[f64; 3]; 3] = [
77    [0.41233895, 0.35762064, 0.18051042],
78    [0.2126, 0.7152, 0.0722],
79    [0.01932141, 0.11916382, 0.95034478],
80];
81
82/// D65, "white on a sunny day" — the white point every conversion here
83/// assumes.
84const WHITE_POINT_D65: [f64; 3] = [95.047, 100.0, 108.883];
85
86fn matrix_multiply(row: [f64; 3], matrix: &[[f64; 3]; 3]) -> [f64; 3] {
87    [
88        row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2],
89        row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2],
90        row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2],
91    ]
92}
93
94/// MCU's `MathUtils.signum`: **zero for zero**, unlike `f64::signum` (which
95/// answers `1.0` for `+0.0`). The CAM16 chromatic-adaptation terms depend on
96/// the zero case, so this cannot be swapped for the std method.
97fn signum(value: f64) -> f64 {
98    if value < 0.0 {
99        -1.0
100    } else if value == 0.0 {
101        0.0
102    } else {
103        1.0
104    }
105}
106
107fn sanitize_degrees(degrees: f64) -> f64 {
108    degrees.rem_euclid(360.0)
109}
110
111/// One 8-bit channel to its linear-RGB value, `0.0..=100.0`.
112fn linearized(component: u8) -> f64 {
113    linearized_f64(component as f64)
114}
115
116/// [`linearized`] over a *fractional* 8-bit input — the form
117/// [`critical_plane`] needs.
118fn linearized_f64(component: f64) -> f64 {
119    let normalized = component / 255.0;
120    if normalized <= 0.040_449_936 {
121        normalized / 12.92 * 100.0
122    } else {
123        ((normalized + 0.055) / 1.055).powf(2.4) * 100.0
124    }
125}
126
127/// One linear-RGB value back to an 8-bit channel, clamped.
128fn delinearized(rgb_component: f64) -> u8 {
129    let value = (true_delinearized(rgb_component)).round() as i32;
130    value.clamp(0, 255) as u8
131}
132
133/// [`delinearized`] without the rounding/clamping — the solver bisects
134/// against 8-bit *plane* coordinates, which are fractional.
135fn true_delinearized(rgb_component: f64) -> f64 {
136    let normalized = rgb_component / 100.0;
137    let delinearized = if normalized <= 0.003_130_8 {
138        normalized * 12.92
139    } else {
140        1.055 * normalized.powf(1.0 / 2.4) - 0.055
141    };
142    delinearized * 255.0
143}
144
145fn lab_f(t: f64) -> f64 {
146    const E: f64 = 216.0 / 24389.0;
147    const KAPPA: f64 = 24389.0 / 27.0;
148    if t > E {
149        t.cbrt()
150    } else {
151        (KAPPA * t + 16.0) / 116.0
152    }
153}
154
155fn lab_inv_f(ft: f64) -> f64 {
156    const E: f64 = 216.0 / 24389.0;
157    const KAPPA: f64 = 24389.0 / 27.0;
158    let ft3 = ft * ft * ft;
159    if ft3 > E {
160        ft3
161    } else {
162        (116.0 * ft - 16.0) / KAPPA
163    }
164}
165
166/// L* (perceptual luminance) to Y (relative luminance).
167fn y_from_lstar(lstar: f64) -> f64 {
168    100.0 * lab_inv_f((lstar + 16.0) / 116.0)
169}
170
171fn lstar_from_y(y: f64) -> f64 {
172    lab_f(y / 100.0) * 116.0 - 16.0
173}
174
175fn argb_from_rgb(red: u8, green: u8, blue: u8) -> u32 {
176    0xFF00_0000 | (red as u32) << 16 | (green as u32) << 8 | blue as u32
177}
178
179fn argb_from_linrgb(linrgb: [f64; 3]) -> u32 {
180    argb_from_rgb(
181        delinearized(linrgb[0]),
182        delinearized(linrgb[1]),
183        delinearized(linrgb[2]),
184    )
185}
186
187/// The neutral gray with the requested L* — what the solver answers with
188/// when chroma is unreachable (a fully desaturated request, or a tone at
189/// either end of the range).
190fn argb_from_lstar(lstar: f64) -> u32 {
191    let component = delinearized(y_from_lstar(lstar));
192    argb_from_rgb(component, component, component)
193}
194
195fn xyz_from_argb(argb: u32) -> [f64; 3] {
196    let r = linearized((argb >> 16 & 255) as u8);
197    let g = linearized((argb >> 8 & 255) as u8);
198    let b = linearized((argb & 255) as u8);
199    matrix_multiply([r, g, b], &SRGB_TO_XYZ)
200}
201
202fn lstar_from_argb(argb: u32) -> f64 {
203    lstar_from_y(xyz_from_argb(argb)[1])
204}
205
206fn argb_from_color(color: Color) -> u32 {
207    let [r, g, b, _] = color.to_rgba8().to_u8_array();
208    argb_from_rgb(r, g, b)
209}
210
211fn color_from_argb(argb: u32) -> Color {
212    Color::from_rgb8(
213        (argb >> 16 & 255) as u8,
214        (argb >> 8 & 255) as u8,
215        (argb & 255) as u8,
216    )
217}
218
219// ---- CAM16 viewing conditions -------------------------------------------
220
221/// The precomputed CAM16 environment every conversion in this module is
222/// measured in — MCU's `ViewingConditions.sRgb`, i.e. its `make()` defaults
223/// (D65 white point, an adapting luminance derived from L*50, a 50 L*
224/// background, "average" surround 2.0, eyes not discounting the
225/// illuminant). MCU exposes the general constructor; only this one
226/// instantiation is ever used to build a scheme, so only it is ported —
227/// likewise its `fl_root` field, which only CAM16's unported
228/// brightness/colorfulness/saturation dimensions consume.
229struct ViewingConditions {
230    background_y_to_white_point_y: f64,
231    aw: f64,
232    nbb: f64,
233    ncb: f64,
234    c: f64,
235    n_c: f64,
236    rgb_d: [f64; 3],
237    fl: f64,
238    z: f64,
239}
240
241static SRGB_VIEWING_CONDITIONS: LazyLock<ViewingConditions> = LazyLock::new(|| {
242    let white_point = WHITE_POINT_D65;
243    let adapting_luminance = 200.0 / std::f64::consts::PI * y_from_lstar(50.0) / 100.0;
244    let background_lstar = 50.0_f64;
245
246    // Test illuminant white, in 'cone'/RGB responses.
247    let r_w = white_point[0] * 0.401288 + white_point[1] * 0.650173 + white_point[2] * -0.051461;
248    let g_w = white_point[0] * -0.250268 + white_point[1] * 1.204414 + white_point[2] * 0.045854;
249    let b_w = white_point[0] * -0.002079 + white_point[1] * 0.048952 + white_point[2] * 0.953127;
250
251    // Surround 2.0 ("average"), scaled into CAM16's (0.8, 1.0) domain.
252    let f = 0.8 + 2.0 / 10.0;
253    let c = lerp(0.59, 0.69, (f - 0.9) * 10.0);
254    let d = (f * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp())).clamp(0.0, 1.0);
255    let n_c = f;
256
257    // 100.0 rather than the white point's own luminance is deliberate:
258    // later stages already scale appearance relative to it (Fairchild,
259    // *Color Appearance Models* 3rd ed., on the CIE 2004a CIECAM02 report).
260    let rgb_d = [
261        d * (100.0 / r_w) + 1.0 - d,
262        d * (100.0 / g_w) + 1.0 - d,
263        d * (100.0 / b_w) + 1.0 - d,
264    ];
265
266    let k = 1.0 / (5.0 * adapting_luminance + 1.0);
267    let k4 = k * k * k * k;
268    let k4_f = 1.0 - k4;
269    let fl = (k4 * adapting_luminance) + (0.1 * k4_f * k4_f * (5.0 * adapting_luminance).cbrt());
270    let n = y_from_lstar(background_lstar) / white_point[1];
271    let z = 1.48 + n.sqrt();
272    let nbb = 0.725 / n.powf(0.2);
273
274    let rgb_a_factors = [
275        (fl * rgb_d[0] * r_w / 100.0).powf(0.42),
276        (fl * rgb_d[1] * g_w / 100.0).powf(0.42),
277        (fl * rgb_d[2] * b_w / 100.0).powf(0.42),
278    ];
279    let rgb_a = [
280        400.0 * rgb_a_factors[0] / (rgb_a_factors[0] + 27.13),
281        400.0 * rgb_a_factors[1] / (rgb_a_factors[1] + 27.13),
282        400.0 * rgb_a_factors[2] / (rgb_a_factors[2] + 27.13),
283    ];
284
285    ViewingConditions {
286        background_y_to_white_point_y: n,
287        aw: (40.0 * rgb_a[0] + 20.0 * rgb_a[1] + rgb_a[2]) / 20.0 * nbb,
288        nbb,
289        ncb: nbb,
290        c,
291        n_c,
292        rgb_d,
293        fl,
294        z,
295    }
296});
297
298fn lerp(start: f64, stop: f64, amount: f64) -> f64 {
299    (1.0 - amount) * start + amount * stop
300}
301
302// ---- CAM16 (forward only) -----------------------------------------------
303
304/// The two CAM16 dimensions HCT is built on. MCU's `Cam16` carries nine:
305/// lightness `J` is computed here as a local (chroma depends on it), and the
306/// remaining six — brightness, colorfulness, saturation and the three
307/// CAM16-UCS coordinates — serve perceptual-distance work this module has no
308/// caller for.
309struct Cam16 {
310    hue: f64,
311    chroma: f64,
312}
313
314impl Cam16 {
315    fn from_argb(argb: u32) -> Self {
316        let vc = &*SRGB_VIEWING_CONDITIONS;
317        let [x, y, z] = xyz_from_argb(argb);
318
319        // XYZ to 'cone'/RGB responses, then discount the illuminant.
320        let r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z;
321        let g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z;
322        let b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z;
323        let r_d = vc.rgb_d[0] * r_c;
324        let g_d = vc.rgb_d[1] * g_c;
325        let b_d = vc.rgb_d[2] * b_c;
326
327        // Chromatic adaptation.
328        let r_af = (vc.fl * r_d.abs() / 100.0).powf(0.42);
329        let g_af = (vc.fl * g_d.abs() / 100.0).powf(0.42);
330        let b_af = (vc.fl * b_d.abs() / 100.0).powf(0.42);
331        let r_a = signum(r_d) * 400.0 * r_af / (r_af + 27.13);
332        let g_a = signum(g_d) * 400.0 * g_af / (g_af + 27.13);
333        let b_a = signum(b_d) * 400.0 * b_af / (b_af + 27.13);
334
335        // Redness-greenness, yellowness-blueness, and the two auxiliaries.
336        let a = (11.0 * r_a + -12.0 * g_a + b_a) / 11.0;
337        let b = (r_a + g_a - 2.0 * b_a) / 9.0;
338        let u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0;
339        let p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0;
340
341        let atan_degrees = b.atan2(a) * 180.0 / std::f64::consts::PI;
342        let hue = if atan_degrees < 0.0 {
343            atan_degrees + 360.0
344        } else if atan_degrees >= 360.0 {
345            atan_degrees - 360.0
346        } else {
347            atan_degrees
348        };
349
350        let ac = p2 * vc.nbb;
351        let j = 100.0 * (ac / vc.aw).powf(vc.c * vc.z);
352
353        // `hue_prime` closes CAM16's hue quadrature at the red end.
354        let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
355        let e_hue = 0.25 * ((hue_prime * std::f64::consts::PI / 180.0 + 2.0).cos() + 3.8);
356        let p1 = 50000.0 / 13.0 * e_hue * vc.n_c * vc.ncb;
357        let t = p1 * (a * a + b * b).sqrt() / (u + 0.305);
358        let alpha =
359            t.powf(0.9) * (1.64 - 0.29_f64.powf(vc.background_y_to_white_point_y)).powf(0.73);
360
361        Self {
362            hue,
363            chroma: alpha * (j / 100.0).sqrt(),
364        }
365    }
366}
367
368// ---- The HCT solver (MCU `HctSolver`) -----------------------------------
369
370const SCALED_DISCOUNT_FROM_LINRGB: [[f64; 3]; 3] = [
371    [
372        0.001200833568784504,
373        0.002389694492170889,
374        0.0002795742885861124,
375    ],
376    [
377        0.0005891086651375999,
378        0.0029785502573438758,
379        0.0003270666104008398,
380    ],
381    [
382        0.00010146692491640572,
383        0.0005364214359186694,
384        0.0032979401770712076,
385    ],
386];
387
388const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
389    [1373.2198709594231, -1100.4251190754821, -7.278681089101213],
390    [-271.815969077903, 559.6580465940733, -32.46047482791194],
391    [1.9622899599665666, -57.173814538844006, 308.7233197812385],
392];
393
394/// Y (relative luminance) coefficients of the linear-RGB primaries.
395const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
396
397/// The linear-RGB value that delinearizes to exactly `index + 0.5`, i.e. the
398/// boundary plane between 8-bit outputs `index` and `index + 1`. MCU ships
399/// these 255 numbers as a literal table; the generating identity is exact,
400/// so this computes them instead (pinned by
401/// `critical_planes_match_upstream`).
402fn critical_plane(index: usize) -> f64 {
403    linearized_f64(index as f64 + 0.5)
404}
405
406/// A coterminal angle in `0..2pi`, for angles that stay near zero.
407fn sanitize_radians(angle: f64) -> f64 {
408    (angle + std::f64::consts::PI * 8.0).rem_euclid(std::f64::consts::PI * 2.0)
409}
410
411fn chromatic_adaptation(component: f64) -> f64 {
412    let af = component.abs().powf(0.42);
413    signum(component) * 400.0 * af / (af + 27.13)
414}
415
416fn inverse_chromatic_adaptation(adapted: f64) -> f64 {
417    let adapted_abs = adapted.abs();
418    let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
419    signum(adapted) * base.powf(1.0 / 0.42)
420}
421
422/// The CAM16 hue of a linear-RGB color, in radians.
423fn hue_of(linrgb: [f64; 3]) -> f64 {
424    let scaled_discount = matrix_multiply(linrgb, &SCALED_DISCOUNT_FROM_LINRGB);
425    let r_a = chromatic_adaptation(scaled_discount[0]);
426    let g_a = chromatic_adaptation(scaled_discount[1]);
427    let b_a = chromatic_adaptation(scaled_discount[2]);
428    let a = (11.0 * r_a + -12.0 * g_a + b_a) / 11.0;
429    let b = (r_a + g_a - 2.0 * b_a) / 9.0;
430    b.atan2(a)
431}
432
433fn are_in_cyclic_order(a: f64, b: f64, c: f64) -> bool {
434    sanitize_radians(b - a) < sanitize_radians(c - a)
435}
436
437fn lerp_point(source: [f64; 3], t: f64, target: [f64; 3]) -> [f64; 3] {
438    [
439        source[0] + (target[0] - source[0]) * t,
440        source[1] + (target[1] - source[1]) * t,
441        source[2] + (target[2] - source[2]) * t,
442    ]
443}
444
445/// Where the segment `source`..`target` crosses the plane `axis =
446/// coordinate`.
447fn set_coordinate(source: [f64; 3], coordinate: f64, target: [f64; 3], axis: usize) -> [f64; 3] {
448    let t = (coordinate - source[axis]) / (target[axis] - source[axis]);
449    lerp_point(source, t, target)
450}
451
452/// The `n`th (`0..12`) candidate vertex of the polygon where the plane
453/// `Y = y` cuts the RGB cube, or `None` when that candidate falls outside
454/// the cube.
455fn nth_vertex(y: f64, n: usize) -> Option<[f64; 3]> {
456    let [k_r, k_g, k_b] = Y_FROM_LINRGB;
457    let coord_a = if n % 4 <= 1 { 0.0 } else { 100.0 };
458    let coord_b = if n.is_multiple_of(2) { 0.0 } else { 100.0 };
459    let bounded = |x: f64| (0.0..=100.0).contains(&x);
460    if n < 4 {
461        let (g, b) = (coord_a, coord_b);
462        let r = (y - g * k_g - b * k_b) / k_r;
463        bounded(r).then_some([r, g, b])
464    } else if n < 8 {
465        let (b, r) = (coord_a, coord_b);
466        let g = (y - r * k_r - b * k_b) / k_g;
467        bounded(g).then_some([r, g, b])
468    } else {
469        let (r, g) = (coord_a, coord_b);
470        let b = (y - r * k_r - g * k_g) / k_b;
471        bounded(b).then_some([r, g, b])
472    }
473}
474
475/// The segment of that polygon containing `target_hue`, as its endpoints.
476fn bisect_to_segment(y: f64, target_hue: f64) -> ([f64; 3], [f64; 3]) {
477    let mut left = [-1.0; 3];
478    let mut right = left;
479    let mut left_hue = 0.0;
480    let mut right_hue = 0.0;
481    let mut initialized = false;
482    let mut uncut = true;
483    for n in 0..12 {
484        let Some(mid) = nth_vertex(y, n) else {
485            continue;
486        };
487        let mid_hue = hue_of(mid);
488        if !initialized {
489            left = mid;
490            right = mid;
491            left_hue = mid_hue;
492            right_hue = mid_hue;
493            initialized = true;
494            continue;
495        }
496        if uncut || are_in_cyclic_order(left_hue, mid_hue, right_hue) {
497            uncut = false;
498            if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
499                right = mid;
500                right_hue = mid_hue;
501            } else {
502                left = mid;
503                left_hue = mid_hue;
504            }
505        }
506    }
507    (left, right)
508}
509
510/// The color of luminance `y` and hue `target_hue` on the sRGB cube's
511/// boundary — the maximum-chroma answer, used when the requested chroma is
512/// out of gamut.
513fn bisect_to_limit(y: f64, target_hue: f64) -> [f64; 3] {
514    let (mut left, mut right) = bisect_to_segment(y, target_hue);
515    let mut left_hue = hue_of(left);
516    for axis in 0..3 {
517        if left[axis] == right[axis] {
518            continue;
519        }
520        let (mut l_plane, mut r_plane) = if left[axis] < right[axis] {
521            (
522                (true_delinearized(left[axis]) - 0.5).floor() as i32,
523                (true_delinearized(right[axis]) - 0.5).ceil() as i32,
524            )
525        } else {
526            (
527                (true_delinearized(left[axis]) - 0.5).ceil() as i32,
528                (true_delinearized(right[axis]) - 0.5).floor() as i32,
529            )
530        };
531        for _ in 0..8 {
532            if (r_plane - l_plane).abs() <= 1 {
533                break;
534            }
535            let m_plane = ((l_plane + r_plane) as f64 / 2.0).floor() as i32;
536            let mid = set_coordinate(
537                left,
538                critical_plane(m_plane.clamp(0, 254) as usize),
539                right,
540                axis,
541            );
542            let mid_hue = hue_of(mid);
543            if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
544                right = mid;
545                r_plane = m_plane;
546            } else {
547                left = mid;
548                left_hue = mid_hue;
549                l_plane = m_plane;
550            }
551        }
552    }
553    [
554        (left[0] + right[0]) / 2.0,
555        (left[1] + right[1]) / 2.0,
556        (left[2] + right[2]) / 2.0,
557    ]
558}
559
560/// Newton-iterates CAM16's lightness `J` for the color with this hue,
561/// chroma and luminance. `None` means the request left the sRGB gamut, and
562/// the caller falls back to [`bisect_to_limit`].
563fn find_result_by_j(hue_radians: f64, chroma: f64, y: f64) -> Option<u32> {
564    let vc = &*SRGB_VIEWING_CONDITIONS;
565    let mut j = y.sqrt() * 11.0;
566    let t_inner_coeff = 1.0 / (1.64 - 0.29_f64.powf(vc.background_y_to_white_point_y)).powf(0.73);
567    let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
568    let p1 = e_hue * (50000.0 / 13.0) * vc.n_c * vc.ncb;
569    let h_sin = hue_radians.sin();
570    let h_cos = hue_radians.cos();
571
572    for iteration in 0..5 {
573        let j_normalized = j / 100.0;
574        let alpha = if chroma == 0.0 || j == 0.0 {
575            0.0
576        } else {
577            chroma / j_normalized.sqrt()
578        };
579        let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
580        let ac = vc.aw * j_normalized.powf(1.0 / vc.c / vc.z);
581        let p2 = ac / vc.nbb;
582        let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin);
583        let a = gamma * h_cos;
584        let b = gamma * h_sin;
585        let r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
586        let g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
587        let b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
588        let linrgb = matrix_multiply(
589            [
590                inverse_chromatic_adaptation(r_a),
591                inverse_chromatic_adaptation(g_a),
592                inverse_chromatic_adaptation(b_a),
593            ],
594            &LINRGB_FROM_SCALED_DISCOUNT,
595        );
596        if linrgb[0] < 0.0 || linrgb[1] < 0.0 || linrgb[2] < 0.0 {
597            return None;
598        }
599        let fnj = Y_FROM_LINRGB[0] * linrgb[0]
600            + Y_FROM_LINRGB[1] * linrgb[1]
601            + Y_FROM_LINRGB[2] * linrgb[2];
602        if fnj <= 0.0 {
603            return None;
604        }
605        if iteration == 4 || (fnj - y).abs() < 0.002 {
606            if linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01 {
607                return None;
608            }
609            return Some(argb_from_linrgb(linrgb));
610        }
611        // Newton's method, approximating fn'(j) as 2 * fn(j) / j.
612        j -= (fnj - y) * j / (2.0 * fnj);
613    }
614    None
615}
616
617/// The sRGB color closest to the requested HCT triple: hue and tone are
618/// honored, chroma is maximized when the request is out of gamut.
619fn solve_to_argb(hue_degrees: f64, chroma: f64, lstar: f64) -> u32 {
620    if chroma < 0.0001 || !(0.0001..=99.9999).contains(&lstar) {
621        return argb_from_lstar(lstar);
622    }
623    let hue_radians = sanitize_degrees(hue_degrees) / 180.0 * std::f64::consts::PI;
624    let y = y_from_lstar(lstar);
625    if let Some(exact) = find_result_by_j(hue_radians, chroma, y) {
626        return exact;
627    }
628    argb_from_linrgb(bisect_to_limit(y, hue_radians))
629}
630
631// ---- Public HCT types ----------------------------------------------------
632
633/// A color in HCT: **h**ue (0..360), **c**hroma (0..~130, hue- and
634/// tone-dependent), **t**one (0..100, L\* from L\*a\*b\*).
635///
636/// Constructing one from a hue/chroma/tone triple maps it into sRGB
637/// immediately, so [`Hct::to_color`] round-trips exactly; a request outside
638/// the gamut comes back with its chroma reduced until it fits, never with a
639/// shifted hue or tone.
640#[derive(Clone, Copy, Debug, PartialEq)]
641pub struct Hct {
642    hue: f64,
643    chroma: f64,
644    tone: f64,
645    argb: u32,
646}
647
648impl Hct {
649    /// The HCT coordinates of an existing color (its alpha is ignored).
650    pub fn from_color(color: Color) -> Self {
651        Self::from_argb(argb_from_color(color))
652    }
653
654    /// The in-gamut sRGB color nearest the requested triple — see the type's
655    /// docs for what "nearest" gives up.
656    pub fn new(hue: f64, chroma: f64, tone: f64) -> Self {
657        Self::from_argb(solve_to_argb(hue, chroma, tone))
658    }
659
660    fn from_argb(argb: u32) -> Self {
661        let cam = Cam16::from_argb(argb);
662        Self {
663            hue: cam.hue,
664            chroma: cam.chroma,
665            tone: lstar_from_argb(argb),
666            argb,
667        }
668    }
669
670    /// Hue in degrees, `0.0..360.0`.
671    pub fn hue(&self) -> f64 {
672        self.hue
673    }
674
675    /// Chroma — informally colorfulness; its maximum depends on hue and
676    /// tone.
677    pub fn chroma(&self) -> f64 {
678        self.chroma
679    }
680
681    /// Tone (L\*), `0.0..=100.0`.
682    pub fn tone(&self) -> f64 {
683        self.tone
684    }
685
686    /// The opaque sRGB color this HCT resolves to.
687    pub fn to_color(&self) -> Color {
688        color_from_argb(self.argb)
689    }
690}
691
692/// One Material tonal ramp: a fixed hue and chroma, sampled by tone.
693///
694/// This is the unit a Material scheme is built from — every role in a
695/// [`from_seed`] scheme is one `(palette, tone)` lookup. Sampling is *not*
696/// cached: each [`TonalPalette::tone`] call runs a full HCT solve, which is
697/// why scheme generation belongs to a seed change and not to a frame.
698#[derive(Clone, Copy, Debug, PartialEq)]
699pub struct TonalPalette {
700    hue: f64,
701    chroma: f64,
702}
703
704impl TonalPalette {
705    /// The ramp at `hue` (degrees) and `chroma`.
706    pub fn of(hue: f64, chroma: f64) -> Self {
707        Self { hue, chroma }
708    }
709
710    /// The ramp through an existing color's own hue and chroma.
711    pub fn from_color(color: Color) -> Self {
712        let hct = Hct::from_color(color);
713        Self::of(hct.hue(), hct.chroma())
714    }
715
716    /// This ramp's hue, in degrees.
717    pub fn hue(&self) -> f64 {
718        self.hue
719    }
720
721    /// This ramp's requested chroma — a given tone may resolve to less if
722    /// the gamut cannot hold it.
723    pub fn chroma(&self) -> f64 {
724        self.chroma
725    }
726
727    /// The color at `tone` (0 = black, 100 = white).
728    pub fn tone(&self, tone: f64) -> Color {
729        color_from_argb(self.tone_argb(tone))
730    }
731
732    fn tone_argb(&self, tone: f64) -> u32 {
733        solve_to_argb(self.hue, self.chroma, tone)
734    }
735}
736
737/// Material's minimum primary chroma: a seed duller than this is boosted so
738/// the resulting theme still reads as colored.
739const PRIMARY_MIN_CHROMA: f64 = 48.0;
740const SECONDARY_CHROMA: f64 = 16.0;
741const TERTIARY_HUE_ROTATION: f64 = 60.0;
742const TERTIARY_CHROMA: f64 = 24.0;
743/// The 2023 spec's neutral chroma (MCU `SchemeTonalSpot`); `CorePalette`'s
744/// own value is 4, which predates the `surface_container_*` roles this
745/// module's tone table carries — see the module docs' porting decisions.
746const NEUTRAL_CHROMA: f64 = 6.0;
747const NEUTRAL_VARIANT_CHROMA: f64 = 8.0;
748/// The error ramp is fixed, not seed-derived — in MCU too.
749const ERROR_HUE: f64 = 25.0;
750const ERROR_CHROMA: f64 = 84.0;
751
752/// The six tonal ramps a Material scheme is drawn from.
753#[derive(Clone, Copy, Debug, PartialEq)]
754pub struct CorePalette {
755    /// The seed's own hue at chroma `max(48, seed chroma)`.
756    pub primary: TonalPalette,
757    /// The seed's hue, muted (chroma 16).
758    pub secondary: TonalPalette,
759    /// The seed's hue rotated +60°, chroma 24.
760    pub tertiary: TonalPalette,
761    /// The seed's hue, near-gray (chroma 6) — surfaces and their content.
762    pub neutral: TonalPalette,
763    /// The seed's hue, slightly more colored than [`Self::neutral`] (chroma
764    /// 8) — outlines and surface-variant content.
765    pub neutral_variant: TonalPalette,
766    /// A fixed red ramp (hue 25, chroma 84), independent of the seed.
767    pub error: TonalPalette,
768}
769
770impl CorePalette {
771    /// Derive all six ramps from one seed color (its alpha is ignored).
772    ///
773    /// `primary` keeps the seed's own chroma whenever that clears Material's
774    /// floor of 48, so a sufficiently colorful seed reappears verbatim as the
775    /// light scheme's `primary` (tone 40). A gray or black
776    /// seed has no meaningful hue of its own: CAM16 reports ~209.5° for
777    /// grays and whites and exactly 0° for pure black, and this module keeps
778    /// that behavior rather than substituting a hue of its own — the chroma
779    /// floor alone is what turns a colorless seed into a usable theme.
780    pub fn of(seed: Color) -> Self {
781        let hct = Hct::from_color(seed);
782        let hue = hct.hue();
783        Self {
784            primary: TonalPalette::of(hue, hct.chroma().max(PRIMARY_MIN_CHROMA)),
785            secondary: TonalPalette::of(hue, SECONDARY_CHROMA),
786            tertiary: TonalPalette::of(
787                sanitize_degrees(hue + TERTIARY_HUE_ROTATION),
788                TERTIARY_CHROMA,
789            ),
790            neutral: TonalPalette::of(hue, NEUTRAL_CHROMA),
791            neutral_variant: TonalPalette::of(hue, NEUTRAL_VARIANT_CHROMA),
792            error: TonalPalette::of(ERROR_HUE, ERROR_CHROMA),
793        }
794    }
795}
796
797// ---- Scheme assembly -----------------------------------------------------
798
799/// `surface_strong`'s mix, matching [`super::color`]'s hand-baked value: a
800/// 6% wash of `primary` over `surface`.
801const SURFACE_STRONG_ALPHA: f64 = 0.06;
802
803/// `accent` at `alpha` flattened over an opaque `surface`, rounded per
804/// channel — the same formula (and rounding) [`super::color`] applied by
805/// hand for the baked `surface_strong` constants.
806fn alpha_blend(accent: Color, surface: Color, alpha: f64) -> Color {
807    let [ar, ag, ab, _] = accent.to_rgba8().to_u8_array();
808    let [sr, sg, sb, _] = surface.to_rgba8().to_u8_array();
809    let mix = |a: u8, s: u8| -> u8 {
810        ((a as f64 / 255.0 * alpha + s as f64 / 255.0 * (1.0 - alpha)) * 255.0).round() as u8
811    };
812    Color::from_rgb8(mix(ar, sr), mix(ag, sg), mix(ab, sb))
813}
814
815/// The Material 3 tone-role table: each role is one `(palette, tone)` pick,
816/// with the light tone first and the dark tone second. Source: Material 3's
817/// 2023 color roles (the `surface_container_*` generation), as encoded by
818/// material-color-utilities' `MaterialDynamicColors`.
819fn scheme_from_palettes(core: &CorePalette, brightness: Brightness) -> ColorScheme {
820    let dark = matches!(brightness, Brightness::Dark);
821    let pick = |palette: &TonalPalette, light_tone: f64, dark_tone: f64| {
822        palette.tone(if dark { dark_tone } else { light_tone })
823    };
824    let (p, s, t, n, v, e) = (
825        &core.primary,
826        &core.secondary,
827        &core.tertiary,
828        &core.neutral,
829        &core.neutral_variant,
830        &core.error,
831    );
832    ColorScheme {
833        primary: pick(p, 40.0, 80.0),
834        on_primary: pick(p, 100.0, 20.0),
835        primary_container: pick(p, 90.0, 30.0),
836        on_primary_container: pick(p, 10.0, 90.0),
837        // The four `*_fixed*` roles are brightness-independent by design:
838        // they hold still so a shared element can cross a light/dark
839        // boundary without recoloring.
840        primary_fixed: p.tone(90.0),
841        primary_fixed_dim: p.tone(80.0),
842        on_primary_fixed: p.tone(10.0),
843        on_primary_fixed_variant: p.tone(30.0),
844
845        secondary: pick(s, 40.0, 80.0),
846        on_secondary: pick(s, 100.0, 20.0),
847        secondary_container: pick(s, 90.0, 30.0),
848        on_secondary_container: pick(s, 10.0, 90.0),
849        secondary_fixed: s.tone(90.0),
850        secondary_fixed_dim: s.tone(80.0),
851        on_secondary_fixed: s.tone(10.0),
852        on_secondary_fixed_variant: s.tone(30.0),
853
854        tertiary: pick(t, 40.0, 80.0),
855        on_tertiary: pick(t, 100.0, 20.0),
856        tertiary_container: pick(t, 90.0, 30.0),
857        on_tertiary_container: pick(t, 10.0, 90.0),
858        tertiary_fixed: t.tone(90.0),
859        tertiary_fixed_dim: t.tone(80.0),
860        on_tertiary_fixed: t.tone(10.0),
861        on_tertiary_fixed_variant: t.tone(30.0),
862
863        error: pick(e, 40.0, 80.0),
864        on_error: pick(e, 100.0, 20.0),
865        error_container: pick(e, 90.0, 30.0),
866        on_error_container: pick(e, 10.0, 90.0),
867
868        surface: pick(n, 98.0, 6.0),
869        on_surface: pick(n, 10.0, 90.0),
870        on_surface_variant: pick(v, 30.0, 80.0),
871        surface_dim: pick(n, 87.0, 6.0),
872        surface_bright: pick(n, 98.0, 24.0),
873        surface_container_lowest: pick(n, 100.0, 4.0),
874        surface_container_low: pick(n, 96.0, 10.0),
875        surface_container: pick(n, 94.0, 12.0),
876        surface_container_high: pick(n, 92.0, 17.0),
877        surface_container_highest: pick(n, 90.0, 22.0),
878
879        outline: pick(v, 50.0, 60.0),
880        outline_variant: pick(v, 80.0, 30.0),
881        shadow: n.tone(0.0),
882        scrim: n.tone(0.0),
883        inverse_surface: pick(n, 20.0, 90.0),
884        inverse_on_surface: pick(n, 95.0, 20.0),
885        inverse_primary: pick(p, 80.0, 40.0),
886        surface_tint: pick(p, 40.0, 80.0),
887    }
888}
889
890/// The nine M3E semantic roles for an already-built `scheme`, following
891/// [`super::color`]'s derivation rules exactly: six are aliases of scheme
892/// fields, `success`/`warning` are the reference's two seed-independent
893/// constants (read off the baked tables rather than re-transcribed), and
894/// `surface_strong` is the 6% `primary`-over-`surface` wash.
895fn semantic_from_scheme(scheme: &ColorScheme, brightness: Brightness) -> MaterialSemanticColors {
896    let baked = match brightness {
897        Brightness::Light => semantic_light(),
898        Brightness::Dark => semantic_dark(),
899    };
900    MaterialSemanticColors {
901        emphasis: scheme.primary,
902        on_emphasis: scheme.on_primary,
903        info: scheme.tertiary,
904        success: baked.success,
905        warning: baked.warning,
906        danger: scheme.error,
907        surface_strong: alpha_blend(scheme.primary, scheme.surface, SURFACE_STRONG_ALPHA),
908        on_surface_strong: scheme.on_surface,
909        outline_strong: scheme.outline,
910    }
911}
912
913/// Generate a Material 3 color scheme from any seed color.
914///
915/// Returns the [`ColorScheme`] for `brightness` paired with the
916/// [`MaterialTokens`] semantic extension — which, like
917/// [`MaterialTokens::material`], always carries *both* brightnesses, since a
918/// themed app may flip live. Both halves are computed either way, so ask for
919/// the pair once and reuse it rather than calling twice.
920///
921/// ```
922/// use frust::Brightness;
923/// use frust_material::from_seed;
924/// use peniko::Color;
925///
926/// let (scheme, tokens) = from_seed(Color::from_rgb8(0x00, 0xA8, 0x6B), Brightness::Light);
927/// assert_eq!(tokens.light.emphasis, scheme.primary);
928/// ```
929///
930/// See [`theme_from_seed`] for the whole-[`Theme`] form, and this module's
931/// docs for how the output relates to the hand-baked `#6750A4` baseline
932/// ([`super::baseline`]) — near, deliberately not identical.
933pub fn from_seed(seed: Color, brightness: Brightness) -> (ColorScheme, MaterialTokens) {
934    let (light, dark, tokens) = seeded_schemes(seed);
935    let scheme = match brightness {
936        Brightness::Light => light,
937        Brightness::Dark => dark,
938    };
939    (scheme, tokens)
940}
941
942/// Both schemes and the extension in one pass over the palettes — the shared
943/// body of [`from_seed`] and [`theme_from_seed`], so neither solves the same
944/// tone twice.
945fn seeded_schemes(seed: Color) -> (ColorScheme, ColorScheme, MaterialTokens) {
946    let core = CorePalette::of(seed);
947    let light = scheme_from_palettes(&core, Brightness::Light);
948    let dark = scheme_from_palettes(&core, Brightness::Dark);
949    let tokens = MaterialTokens {
950        light: semantic_from_scheme(&light, Brightness::Light),
951        dark: semantic_from_scheme(&dark, Brightness::Dark),
952    };
953    (light, dark, tokens)
954}
955
956/// [`super::baseline`] with its colors regenerated from `seed`: the same M3
957/// type scale, shape scale, elevation table, motion scheme and
958/// [`super::status_palette`], with both color schemes and the
959/// [`MaterialTokens`] extension seeded instead of hand-baked, starting at
960/// `brightness`.
961///
962/// This is the one-call form an app's theme picker wants; the `StatusPalette`
963/// extension is deliberately left at its baseline value, since its
964/// success/warning/info roles are semantic constants rather than seed-derived
965/// (the same rule [`from_seed`]'s own `success`/`warning` follow).
966pub fn theme_from_seed(seed: Color, brightness: Brightness) -> Theme {
967    let (light, dark, tokens) = seeded_schemes(seed);
968    let mut theme = super::baseline();
969    theme.light = light;
970    theme.dark = dark;
971    theme.brightness = brightness;
972    theme.extensions.insert(tokens);
973    theme
974}
975
976#[cfg(test)]
977mod tests {
978    use frust::StatusPalette;
979
980    use super::*;
981    use crate::tokens::color::{color_scheme_dark, color_scheme_light};
982
983    /// The Material seed every baked table in [`super::super::color`] came
984    /// from.
985    const BASELINE_SEED: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
986
987    fn rgb(color: Color) -> u32 {
988        argb_from_color(color) & 0xFF_FFFF
989    }
990
991    fn max_channel_delta(a: Color, b: Color) -> i32 {
992        let [ar, ag, ab, _] = a.to_rgba8().to_u8_array();
993        let [br, bg, bb, _] = b.to_rgba8().to_u8_array();
994        [
995            (ar as i32 - br as i32).abs(),
996            (ag as i32 - bg as i32).abs(),
997            (ab as i32 - bb as i32).abs(),
998        ]
999        .into_iter()
1000        .max()
1001        .expect("three channels")
1002    }
1003
1004    /// Every one of `ColorScheme`'s 46 roles, by name — the shape the golden
1005    /// and divergence tables below are keyed on.
1006    fn roles(scheme: &ColorScheme) -> Vec<(&'static str, Color)> {
1007        vec![
1008            ("primary", scheme.primary),
1009            ("on_primary", scheme.on_primary),
1010            ("primary_container", scheme.primary_container),
1011            ("on_primary_container", scheme.on_primary_container),
1012            ("primary_fixed", scheme.primary_fixed),
1013            ("primary_fixed_dim", scheme.primary_fixed_dim),
1014            ("on_primary_fixed", scheme.on_primary_fixed),
1015            ("on_primary_fixed_variant", scheme.on_primary_fixed_variant),
1016            ("secondary", scheme.secondary),
1017            ("on_secondary", scheme.on_secondary),
1018            ("secondary_container", scheme.secondary_container),
1019            ("on_secondary_container", scheme.on_secondary_container),
1020            ("secondary_fixed", scheme.secondary_fixed),
1021            ("secondary_fixed_dim", scheme.secondary_fixed_dim),
1022            ("on_secondary_fixed", scheme.on_secondary_fixed),
1023            (
1024                "on_secondary_fixed_variant",
1025                scheme.on_secondary_fixed_variant,
1026            ),
1027            ("tertiary", scheme.tertiary),
1028            ("on_tertiary", scheme.on_tertiary),
1029            ("tertiary_container", scheme.tertiary_container),
1030            ("on_tertiary_container", scheme.on_tertiary_container),
1031            ("tertiary_fixed", scheme.tertiary_fixed),
1032            ("tertiary_fixed_dim", scheme.tertiary_fixed_dim),
1033            ("on_tertiary_fixed", scheme.on_tertiary_fixed),
1034            (
1035                "on_tertiary_fixed_variant",
1036                scheme.on_tertiary_fixed_variant,
1037            ),
1038            ("error", scheme.error),
1039            ("on_error", scheme.on_error),
1040            ("error_container", scheme.error_container),
1041            ("on_error_container", scheme.on_error_container),
1042            ("surface", scheme.surface),
1043            ("on_surface", scheme.on_surface),
1044            ("on_surface_variant", scheme.on_surface_variant),
1045            ("surface_dim", scheme.surface_dim),
1046            ("surface_bright", scheme.surface_bright),
1047            ("surface_container_lowest", scheme.surface_container_lowest),
1048            ("surface_container_low", scheme.surface_container_low),
1049            ("surface_container", scheme.surface_container),
1050            ("surface_container_high", scheme.surface_container_high),
1051            (
1052                "surface_container_highest",
1053                scheme.surface_container_highest,
1054            ),
1055            ("outline", scheme.outline),
1056            ("outline_variant", scheme.outline_variant),
1057            ("shadow", scheme.shadow),
1058            ("scrim", scheme.scrim),
1059            ("inverse_surface", scheme.inverse_surface),
1060            ("inverse_on_surface", scheme.inverse_on_surface),
1061            ("inverse_primary", scheme.inverse_primary),
1062            ("surface_tint", scheme.surface_tint),
1063        ]
1064    }
1065
1066    fn assert_golden(seed: Color, brightness: Brightness, golden: &[(&str, u32)]) {
1067        let (scheme, _) = from_seed(seed, brightness);
1068        let produced = roles(&scheme);
1069        for (role, want) in golden {
1070            let (_, got) = produced
1071                .iter()
1072                .find(|(name, _)| name == role)
1073                .unwrap_or_else(|| panic!("unknown role `{role}`"));
1074            assert_eq!(
1075                rgb(*got),
1076                *want,
1077                "role `{role}` at seed {:06X} ({brightness:?}): got {:06X}, want {:06X}",
1078                rgb(seed),
1079                rgb(*got),
1080                want
1081            );
1082        }
1083    }
1084
1085    /// Sampled entries of MCU's hard-coded `HctSolver._criticalPlanes`
1086    /// (indices 0, 1, 127, 254), proving [`critical_plane`]'s generating
1087    /// identity reproduces the published table rather than approximating it.
1088    #[test]
1089    fn critical_planes_match_upstream() {
1090        let upstream = [
1091            (0usize, 0.015176349177441876),
1092            (1, 0.045529047532325624),
1093            (127, 21.404114048223256),
1094            (254, 99.55452497210776),
1095        ];
1096        for (index, want) in upstream {
1097            let got = critical_plane(index);
1098            assert!(
1099                (got - want).abs() <= want.abs() * 1e-12,
1100                "critical plane {index}: got {got}, upstream {want}"
1101            );
1102        }
1103    }
1104
1105    /// The CAM16 forward transform, against material-color-utilities 0.11.1's
1106    /// own `Cam16.fromInt` output.
1107    #[test]
1108    fn cam16_matches_upstream_for_the_baseline_seed() {
1109        let hct = Hct::from_color(BASELINE_SEED);
1110        assert!((hct.hue() - 298.980997210704).abs() < 1e-9, "{}", hct.hue());
1111        assert!(
1112            (hct.chroma() - 47.8565263749703).abs() < 1e-9,
1113            "{}",
1114            hct.chroma()
1115        );
1116        assert!(
1117            (hct.tone() - 40.08324408746242).abs() < 1e-9,
1118            "{}",
1119            hct.tone()
1120        );
1121    }
1122
1123    /// A color's HCT coordinates, resolved back to sRGB, must land on the
1124    /// color it came from. The loose assertion is the ±1 an 8-bit round trip
1125    /// through a perceptual space is entitled to; the strict one pins that
1126    /// upstream is in fact exact over this grid, so a solver regression
1127    /// cannot hide inside the tolerance.
1128    #[test]
1129    fn hct_round_trips_across_the_rgb_cube() {
1130        let mut exact = 0;
1131        let mut samples = 0;
1132        for r in (0..=255).step_by(17) {
1133            for g in (0..=255).step_by(17) {
1134                for b in (0..=255).step_by(17) {
1135                    let color = Color::from_rgb8(r as u8, g as u8, b as u8);
1136                    let hct = Hct::from_color(color);
1137                    let round_tripped = Hct::new(hct.hue(), hct.chroma(), hct.tone()).to_color();
1138                    let delta = max_channel_delta(color, round_tripped);
1139                    assert!(
1140                        delta <= 1,
1141                        "round trip of {:06X} landed on {:06X}",
1142                        rgb(color),
1143                        rgb(round_tripped)
1144                    );
1145                    samples += 1;
1146                    if delta == 0 {
1147                        exact += 1;
1148                    }
1149                }
1150            }
1151        }
1152        assert_eq!(samples, 16 * 16 * 16);
1153        assert_eq!(exact, samples, "upstream round-trips this grid exactly");
1154    }
1155
1156    /// Tones outside the solvable range fall back to the neutral gray of
1157    /// that L*, exactly as upstream does.
1158    #[test]
1159    fn extreme_tones_resolve_to_black_and_white() {
1160        let palette = TonalPalette::of(298.98, 48.0);
1161        assert_eq!(rgb(palette.tone(0.0)), 0x000000);
1162        assert_eq!(rgb(palette.tone(100.0)), 0xFFFFFF);
1163    }
1164
1165    /// Full 46-role light scheme for the baseline seed, generated by running
1166    /// material-color-utilities 0.11.1 directly.
1167    #[test]
1168    fn from_seed_matches_mcu_golden_light() {
1169        assert_golden(
1170            BASELINE_SEED,
1171            Brightness::Light,
1172            &[
1173                ("primary", 0x6750A4),
1174                ("on_primary", 0xFFFFFF),
1175                ("primary_container", 0xE9DDFF),
1176                ("on_primary_container", 0x22005D),
1177                ("primary_fixed", 0xE9DDFF),
1178                ("primary_fixed_dim", 0xCFBCFF),
1179                ("on_primary_fixed", 0x22005D),
1180                ("on_primary_fixed_variant", 0x4F378A),
1181                ("secondary", 0x625B71),
1182                ("on_secondary", 0xFFFFFF),
1183                ("secondary_container", 0xE8DEF8),
1184                ("on_secondary_container", 0x1E192B),
1185                ("secondary_fixed", 0xE8DEF8),
1186                ("secondary_fixed_dim", 0xCBC2DB),
1187                ("on_secondary_fixed", 0x1E192B),
1188                ("on_secondary_fixed_variant", 0x4A4458),
1189                ("tertiary", 0x7E5260),
1190                ("on_tertiary", 0xFFFFFF),
1191                ("tertiary_container", 0xFFD9E3),
1192                ("on_tertiary_container", 0x31101D),
1193                ("tertiary_fixed", 0xFFD9E3),
1194                ("tertiary_fixed_dim", 0xEFB8C8),
1195                ("on_tertiary_fixed", 0x31101D),
1196                ("on_tertiary_fixed_variant", 0x633B48),
1197                ("error", 0xBA1A1A),
1198                ("on_error", 0xFFFFFF),
1199                ("error_container", 0xFFDAD6),
1200                ("on_error_container", 0x410002),
1201                ("surface", 0xFDF7FF),
1202                ("on_surface", 0x1D1B20),
1203                ("on_surface_variant", 0x49454E),
1204                ("surface_dim", 0xDED8E0),
1205                ("surface_bright", 0xFDF7FF),
1206                ("surface_container_lowest", 0xFFFFFF),
1207                ("surface_container_low", 0xF8F2FA),
1208                ("surface_container", 0xF2ECF4),
1209                ("surface_container_high", 0xECE6EE),
1210                ("surface_container_highest", 0xE6E0E9),
1211                ("outline", 0x7A757F),
1212                ("outline_variant", 0xCAC4CF),
1213                ("shadow", 0x000000),
1214                ("scrim", 0x000000),
1215                ("inverse_surface", 0x322F35),
1216                ("inverse_on_surface", 0xF5EFF7),
1217                ("inverse_primary", 0xCFBCFF),
1218                ("surface_tint", 0x6750A4),
1219            ],
1220        );
1221    }
1222
1223    /// The dark half of the same golden run.
1224    #[test]
1225    fn from_seed_matches_mcu_golden_dark() {
1226        assert_golden(
1227            BASELINE_SEED,
1228            Brightness::Dark,
1229            &[
1230                ("primary", 0xCFBCFF),
1231                ("on_primary", 0x381E72),
1232                ("primary_container", 0x4F378A),
1233                ("on_primary_container", 0xE9DDFF),
1234                ("primary_fixed", 0xE9DDFF),
1235                ("primary_fixed_dim", 0xCFBCFF),
1236                ("on_primary_fixed", 0x22005D),
1237                ("on_primary_fixed_variant", 0x4F378A),
1238                ("secondary", 0xCBC2DB),
1239                ("on_secondary", 0x332D41),
1240                ("secondary_container", 0x4A4458),
1241                ("on_secondary_container", 0xE8DEF8),
1242                ("secondary_fixed", 0xE8DEF8),
1243                ("secondary_fixed_dim", 0xCBC2DB),
1244                ("on_secondary_fixed", 0x1E192B),
1245                ("on_secondary_fixed_variant", 0x4A4458),
1246                ("tertiary", 0xEFB8C8),
1247                ("on_tertiary", 0x4A2532),
1248                ("tertiary_container", 0x633B48),
1249                ("on_tertiary_container", 0xFFD9E3),
1250                ("tertiary_fixed", 0xFFD9E3),
1251                ("tertiary_fixed_dim", 0xEFB8C8),
1252                ("on_tertiary_fixed", 0x31101D),
1253                ("on_tertiary_fixed_variant", 0x633B48),
1254                ("error", 0xFFB4AB),
1255                ("on_error", 0x690005),
1256                ("error_container", 0x93000A),
1257                ("on_error_container", 0xFFDAD6),
1258                ("surface", 0x141218),
1259                ("on_surface", 0xE6E0E9),
1260                ("on_surface_variant", 0xCAC4CF),
1261                ("surface_dim", 0x141218),
1262                ("surface_bright", 0x3B383E),
1263                ("surface_container_lowest", 0x0F0D13),
1264                ("surface_container_low", 0x1D1B20),
1265                ("surface_container", 0x211F24),
1266                ("surface_container_high", 0x2B292F),
1267                ("surface_container_highest", 0x36343A),
1268                ("outline", 0x948F99),
1269                ("outline_variant", 0x49454E),
1270                ("shadow", 0x000000),
1271                ("scrim", 0x000000),
1272                ("inverse_surface", 0xE6E0E9),
1273                ("inverse_on_surface", 0x322F35),
1274                ("inverse_primary", 0x6750A4),
1275                ("surface_tint", 0xCFBCFF),
1276            ],
1277        );
1278    }
1279
1280    /// Three further seeds — a blue, a green and an orange — spot-checked
1281    /// against the same upstream run, both brightnesses.
1282    #[test]
1283    fn from_seed_matches_mcu_goldens_for_further_seeds() {
1284        let blue = Color::from_rgb8(0x0B, 0x57, 0xD0);
1285        assert_golden(
1286            blue,
1287            Brightness::Light,
1288            &[
1289                ("primary", 0x0856CF),
1290                ("on_primary", 0xFFFFFF),
1291                ("primary_container", 0xDAE2FF),
1292                ("on_primary_container", 0x001847),
1293                ("secondary", 0x585E71),
1294                ("tertiary", 0x735471),
1295                ("surface", 0xFAF8FF),
1296                ("on_surface", 0x1A1B21),
1297                ("surface_container", 0xEEEDF4),
1298                ("outline", 0x757780),
1299                ("inverse_primary", 0xB2C5FF),
1300            ],
1301        );
1302        assert_golden(
1303            blue,
1304            Brightness::Dark,
1305            &[
1306                ("primary", 0xB2C5FF),
1307                ("on_primary", 0x002B72),
1308                ("primary_container", 0x0040A1),
1309                ("on_primary_container", 0xDAE2FF),
1310                ("secondary", 0xC0C6DD),
1311                ("tertiary", 0xE1BBDD),
1312                ("surface", 0x121318),
1313                ("on_surface", 0xE2E2E9),
1314                ("surface_container", 0x1E1F25),
1315                ("outline", 0x8F909A),
1316                ("inverse_primary", 0x0856CF),
1317            ],
1318        );
1319
1320        let green = Color::from_rgb8(0x00, 0xA8, 0x6B);
1321        assert_golden(
1322            green,
1323            Brightness::Light,
1324            &[
1325                ("primary", 0x006D43),
1326                ("primary_container", 0x78FBB6),
1327                ("on_primary_container", 0x002111),
1328                ("secondary", 0x4E6355),
1329                ("tertiary", 0x3C6471),
1330                ("surface", 0xF6FBF4),
1331                ("on_surface", 0x171D19),
1332                ("outline", 0x717972),
1333            ],
1334        );
1335        assert_golden(
1336            green,
1337            Brightness::Dark,
1338            &[
1339                ("primary", 0x59DE9B),
1340                ("primary_container", 0x005232),
1341                ("on_primary_container", 0x78FBB6),
1342                ("secondary", 0xB5CCBB),
1343                ("tertiary", 0xA3CDDC),
1344                ("surface", 0x0F1511),
1345                ("on_surface", 0xDFE4DD),
1346                ("outline", 0x8A938B),
1347            ],
1348        );
1349
1350        let orange = Color::from_rgb8(0xFF, 0x57, 0x22);
1351        assert_golden(
1352            orange,
1353            Brightness::Light,
1354            &[
1355                ("primary", 0xB02F00),
1356                ("primary_container", 0xFFDBD1),
1357                ("on_primary_container", 0x3B0900),
1358                ("secondary", 0x77574E),
1359                ("tertiary", 0x6C5D2F),
1360                ("surface", 0xFFF8F6),
1361                ("on_surface", 0x231917),
1362                ("outline", 0x85736E),
1363            ],
1364        );
1365        assert_golden(
1366            orange,
1367            Brightness::Dark,
1368            &[
1369                ("primary", 0xFFB5A0),
1370                ("primary_container", 0x862200),
1371                ("on_primary_container", 0xFFDBD1),
1372                ("secondary", 0xE7BDB2),
1373                ("tertiary", 0xD8C58D),
1374                ("surface", 0x1A110F),
1375                ("on_surface", 0xF1DFDA),
1376                ("outline", 0xA08C87),
1377            ],
1378        );
1379    }
1380
1381    /// The error ramp is fixed upstream, so it must not move with the seed.
1382    #[test]
1383    fn the_error_ramp_is_seed_independent() {
1384        let (blue, _) = from_seed(Color::from_rgb8(0x0B, 0x57, 0xD0), Brightness::Light);
1385        let (green, _) = from_seed(Color::from_rgb8(0x00, 0xA8, 0x6B), Brightness::Light);
1386        assert_eq!(blue.error, green.error);
1387        assert_eq!(blue.error_container, green.error_container);
1388        assert_ne!(blue.primary, green.primary);
1389    }
1390
1391    /// The roles where [`from_seed`] at the baseline seed differs from the
1392    /// hand-baked tables, as `(role, generated, baked)`. Every entry is a
1393    /// generator-vintage difference, not a solver bug: the baked tables are
1394    /// Google's *published* baseline ref palette (material-web tokens
1395    /// v0.192), and the generated column is what current
1396    /// material-color-utilities answers (pinned by the golden tests above).
1397    /// See the module docs.
1398    const LIGHT_DIVERGENCE: &[(&str, u32, u32)] = &[
1399        ("primary_container", 0xE9DDFF, 0xEADDFF),
1400        ("on_primary_container", 0x22005D, 0x21005D),
1401        ("primary_fixed", 0xE9DDFF, 0xEADDFF),
1402        ("primary_fixed_dim", 0xCFBCFF, 0xD0BCFF),
1403        ("on_primary_fixed", 0x22005D, 0x21005D),
1404        ("on_primary_fixed_variant", 0x4F378A, 0x4F378B),
1405        ("on_secondary_container", 0x1E192B, 0x1D192B),
1406        ("secondary_fixed_dim", 0xCBC2DB, 0xCCC2DC),
1407        ("on_secondary_fixed", 0x1E192B, 0x1D192B),
1408        ("tertiary", 0x7E5260, 0x7D5260),
1409        ("tertiary_container", 0xFFD9E3, 0xFFD8E4),
1410        ("on_tertiary_container", 0x31101D, 0x31111D),
1411        ("tertiary_fixed", 0xFFD9E3, 0xFFD8E4),
1412        ("on_tertiary_fixed", 0x31101D, 0x31111D),
1413        ("error", 0xBA1A1A, 0xB3261E),
1414        ("error_container", 0xFFDAD6, 0xF9DEDC),
1415        ("on_error_container", 0x410002, 0x410E0B),
1416        ("surface", 0xFDF7FF, 0xFEF7FF),
1417        ("on_surface_variant", 0x49454E, 0x49454F),
1418        ("surface_dim", 0xDED8E0, 0xDED8E1),
1419        ("surface_bright", 0xFDF7FF, 0xFEF7FF),
1420        ("surface_container_low", 0xF8F2FA, 0xF7F2FA),
1421        ("surface_container", 0xF2ECF4, 0xF3EDF7),
1422        ("surface_container_high", 0xECE6EE, 0xECE6F0),
1423        ("outline", 0x7A757F, 0x79747E),
1424        ("outline_variant", 0xCAC4CF, 0xCAC4D0),
1425        ("inverse_primary", 0xCFBCFF, 0xD0BCFF),
1426    ];
1427
1428    /// The dark half of [`LIGHT_DIVERGENCE`].
1429    const DARK_DIVERGENCE: &[(&str, u32, u32)] = &[
1430        ("primary", 0xCFBCFF, 0xD0BCFF),
1431        ("primary_container", 0x4F378A, 0x4F378B),
1432        ("on_primary_container", 0xE9DDFF, 0xEADDFF),
1433        ("primary_fixed", 0xE9DDFF, 0xEADDFF),
1434        ("primary_fixed_dim", 0xCFBCFF, 0xD0BCFF),
1435        ("on_primary_fixed", 0x22005D, 0x21005D),
1436        ("on_primary_fixed_variant", 0x4F378A, 0x4F378B),
1437        ("secondary", 0xCBC2DB, 0xCCC2DC),
1438        ("secondary_fixed_dim", 0xCBC2DB, 0xCCC2DC),
1439        ("on_secondary_fixed", 0x1E192B, 0x1D192B),
1440        ("on_tertiary", 0x4A2532, 0x492532),
1441        ("on_tertiary_container", 0xFFD9E3, 0xFFD8E4),
1442        ("tertiary_fixed", 0xFFD9E3, 0xFFD8E4),
1443        ("on_tertiary_fixed", 0x31101D, 0x31111D),
1444        ("error", 0xFFB4AB, 0xF2B8B5),
1445        ("on_error", 0x690005, 0x601410),
1446        ("error_container", 0x93000A, 0x8C1D18),
1447        ("on_error_container", 0xFFDAD6, 0xF9DEDC),
1448        ("on_surface_variant", 0xCAC4CF, 0xCAC4D0),
1449        ("surface_container", 0x211F24, 0x211F26),
1450        ("surface_container_high", 0x2B292F, 0x2B2930),
1451        ("surface_container_highest", 0x36343A, 0x36343B),
1452        ("outline", 0x948F99, 0x938F99),
1453        ("outline_variant", 0x49454E, 0x49454F),
1454        ("surface_tint", 0xCFBCFF, 0xD0BCFF),
1455    ];
1456
1457    fn assert_divergence(
1458        brightness: Brightness,
1459        baked: &ColorScheme,
1460        divergence: &[(&str, u32, u32)],
1461    ) {
1462        let (generated, _) = from_seed(BASELINE_SEED, brightness);
1463        let baked_roles = roles(baked);
1464        for (role, got) in roles(&generated) {
1465            let (_, want) = baked_roles
1466                .iter()
1467                .find(|(name, _)| *name == role)
1468                .expect("both schemes carry the same role set");
1469            match divergence.iter().find(|(name, _, _)| *name == role) {
1470                None => assert_eq!(
1471                    rgb(got),
1472                    rgb(*want),
1473                    "role `{role}` ({brightness:?}) was expected to match the baked baseline"
1474                ),
1475                Some((_, expected_generated, expected_baked)) => {
1476                    assert_eq!(
1477                        rgb(got),
1478                        *expected_generated,
1479                        "recorded divergence for `{role}` ({brightness:?}) is stale"
1480                    );
1481                    assert_eq!(rgb(*want), *expected_baked, "baked `{role}` moved");
1482                }
1483            }
1484        }
1485    }
1486
1487    /// Pins the whole relationship to the hand-baked baseline: every role
1488    /// not listed as divergent matches it byte for byte, and every listed
1489    /// role still holds exactly the pair of values recorded.
1490    #[test]
1491    fn from_seed_diverges_from_the_baked_baseline_only_as_recorded() {
1492        assert_divergence(Brightness::Light, &color_scheme_light(), LIGHT_DIVERGENCE);
1493        assert_divergence(Brightness::Dark, &color_scheme_dark(), DARK_DIVERGENCE);
1494    }
1495
1496    /// Outside the error family — a wholly different upstream ramp — the
1497    /// divergence is a rounding-scale one, never a visible color shift. The
1498    /// counts asserted here are the ones this module's docs quote.
1499    #[test]
1500    fn baseline_divergence_outside_the_error_family_is_at_most_three_steps() {
1501        for (brightness, baked, divergence, expected_exact, expected_one_step) in [
1502            (
1503                Brightness::Light,
1504                color_scheme_light(),
1505                LIGHT_DIVERGENCE,
1506                18,
1507                22,
1508            ),
1509            (
1510                Brightness::Dark,
1511                color_scheme_dark(),
1512                DARK_DIVERGENCE,
1513                21,
1514                20,
1515            ),
1516        ] {
1517            let (generated, _) = from_seed(BASELINE_SEED, brightness);
1518            let baked_roles = roles(&baked);
1519            let (mut exact, mut one_step) = (0, 0);
1520            for (role, got) in roles(&generated) {
1521                let (_, want) = baked_roles
1522                    .iter()
1523                    .find(|(name, _)| *name == role)
1524                    .expect("same role set");
1525                let delta = max_channel_delta(got, *want);
1526                if role.contains("error") {
1527                    continue;
1528                }
1529                assert!(
1530                    delta <= 3,
1531                    "role `{role}` ({brightness:?}) drifted more than three steps"
1532                );
1533                match delta {
1534                    0 => exact += 1,
1535                    1 => one_step += 1,
1536                    _ => {}
1537                }
1538            }
1539            assert_eq!(exact, expected_exact, "exact-match count ({brightness:?})");
1540            assert_eq!(
1541                one_step, expected_one_step,
1542                "single-step divergence count ({brightness:?})"
1543            );
1544            assert!(divergence.iter().any(|(name, _, _)| name.contains("error")));
1545        }
1546    }
1547
1548    /// A colorless seed has no hue to work from. Upstream substitutes none —
1549    /// CAM16 simply reports its own answer (0° for pure black, ~209.5° for
1550    /// grays and white) and the chroma floor does the rest. Pinned here
1551    /// because it is surprising, not because it is a fallback.
1552    #[test]
1553    fn degenerate_seeds_follow_upstream_rather_than_a_substituted_hue() {
1554        let black = Hct::from_color(Color::BLACK);
1555        assert_eq!(black.hue(), 0.0);
1556        assert_eq!(black.chroma(), 0.0);
1557        let gray = Hct::from_color(Color::from_rgb8(0x80, 0x80, 0x80));
1558        assert!(
1559            (gray.hue() - 209.49382556955806).abs() < 1e-9,
1560            "{}",
1561            gray.hue()
1562        );
1563        let white = Hct::from_color(Color::WHITE);
1564        assert!(
1565            (white.hue() - 209.49195947383808).abs() < 1e-9,
1566            "{}",
1567            white.hue()
1568        );
1569
1570        // Upstream's own output for the three (light brightness).
1571        assert_golden(
1572            Color::BLACK,
1573            Brightness::Light,
1574            &[
1575                ("primary", 0x984061),
1576                ("surface", 0xFFF8F8),
1577                ("on_surface", 0x22191C),
1578                ("outline", 0x837377),
1579            ],
1580        );
1581        for colorless in [Color::from_rgb8(0x80, 0x80, 0x80), Color::WHITE] {
1582            assert_golden(
1583                colorless,
1584                Brightness::Light,
1585                &[
1586                    ("primary", 0x006874),
1587                    ("surface", 0xF5FAFB),
1588                    ("on_surface", 0x171D1E),
1589                    ("outline", 0x6F797A),
1590                ],
1591            );
1592        }
1593    }
1594
1595    /// Whatever the seed, a scheme has to stay usable: opaque throughout,
1596    /// with light surfaces light and dark surfaces dark.
1597    #[test]
1598    fn every_seed_yields_a_well_formed_scheme() {
1599        for seed in [
1600            BASELINE_SEED,
1601            Color::BLACK,
1602            Color::WHITE,
1603            Color::from_rgb8(0x80, 0x80, 0x80),
1604            Color::from_rgb8(0xFF, 0x00, 0x00),
1605            Color::from_rgb8(0x00, 0x00, 0x01),
1606        ] {
1607            let (light, _) = from_seed(seed, Brightness::Light);
1608            let (dark, _) = from_seed(seed, Brightness::Dark);
1609            for (role, color) in roles(&light).into_iter().chain(roles(&dark)) {
1610                assert_eq!(
1611                    color.to_rgba8().to_u8_array()[3],
1612                    255,
1613                    "role `{role}` came back translucent"
1614                );
1615            }
1616            assert!(
1617                Hct::from_color(light.surface).tone() > Hct::from_color(light.on_surface).tone(),
1618                "light surface must be lighter than its content"
1619            );
1620            assert!(
1621                Hct::from_color(dark.surface).tone() < Hct::from_color(dark.on_surface).tone(),
1622                "dark surface must be darker than its content"
1623            );
1624            assert_ne!(light.surface, dark.surface);
1625            assert_ne!(light.primary, dark.primary);
1626        }
1627    }
1628
1629    /// The nine semantic roles follow the baked tables' derivation rules,
1630    /// seeded.
1631    #[test]
1632    fn semantic_roles_reuse_the_baked_derivation_rules() {
1633        let seed = Color::from_rgb8(0x00, 0xA8, 0x6B);
1634        let (light, tokens) = from_seed(seed, Brightness::Light);
1635        let (dark, _) = from_seed(seed, Brightness::Dark);
1636
1637        assert_eq!(tokens.light.emphasis, light.primary);
1638        assert_eq!(tokens.light.on_emphasis, light.on_primary);
1639        assert_eq!(tokens.light.info, light.tertiary);
1640        assert_eq!(tokens.light.danger, light.error);
1641        assert_eq!(tokens.light.on_surface_strong, light.on_surface);
1642        assert_eq!(tokens.light.outline_strong, light.outline);
1643        assert_eq!(
1644            tokens.light.surface_strong,
1645            alpha_blend(light.primary, light.surface, SURFACE_STRONG_ALPHA)
1646        );
1647        assert_eq!(tokens.dark.emphasis, dark.primary);
1648        assert_eq!(tokens.dark.danger, dark.error);
1649
1650        // The two seed-independent constants stay put.
1651        assert_eq!(tokens.light.success, semantic_light().success);
1652        assert_eq!(tokens.light.warning, semantic_light().warning);
1653        assert_eq!(tokens.dark.success, semantic_dark().success);
1654        assert_eq!(tokens.dark.warning, semantic_dark().warning);
1655    }
1656
1657    /// Both halves come back whichever brightness was asked for, and the
1658    /// requested one is the one returned.
1659    #[test]
1660    fn from_seed_returns_the_requested_brightness_and_both_token_halves() {
1661        let seed = Color::from_rgb8(0x0B, 0x57, 0xD0);
1662        let (light, light_tokens) = from_seed(seed, Brightness::Light);
1663        let (dark, dark_tokens) = from_seed(seed, Brightness::Dark);
1664        assert_eq!(light_tokens, dark_tokens);
1665        assert_eq!(
1666            light_tokens.colors(Brightness::Light).emphasis,
1667            light.primary
1668        );
1669        assert_eq!(dark_tokens.colors(Brightness::Dark).emphasis, dark.primary);
1670        assert_ne!(light_tokens.light, light_tokens.dark);
1671    }
1672
1673    #[test]
1674    fn theme_from_seed_keeps_the_baseline_scales_and_swaps_the_colors() {
1675        let seed = Color::from_rgb8(0xFF, 0x57, 0x22);
1676        let theme = theme_from_seed(seed, Brightness::Dark);
1677        let baseline = super::super::baseline();
1678
1679        assert_eq!(theme.brightness, Brightness::Dark);
1680        assert_eq!(theme.type_scale, baseline.type_scale);
1681        assert_eq!(theme.shape, baseline.shape);
1682        assert_eq!(theme.elevation, baseline.elevation);
1683        assert_eq!(theme.motion, baseline.motion);
1684        assert_eq!(
1685            theme.extension::<StatusPalette>(),
1686            baseline.extension::<StatusPalette>()
1687        );
1688
1689        let (light, tokens) = from_seed(seed, Brightness::Light);
1690        assert_eq!(theme.light, light);
1691        assert_eq!(theme.extension::<MaterialTokens>(), Some(&tokens));
1692        assert_ne!(theme.light, baseline.light);
1693        assert_ne!(theme.dark, baseline.dark);
1694    }
1695
1696    /// Re-seeding a theme with the baseline seed leaves the baked baseline's
1697    /// own extension attached only where the two agree — the guard against
1698    /// silently shipping two different `MaterialTokens` for one seed.
1699    #[test]
1700    fn theme_from_seed_attaches_the_seeded_tokens_not_the_baked_ones() {
1701        let theme = theme_from_seed(BASELINE_SEED, Brightness::Light);
1702        let attached = theme
1703            .extension::<MaterialTokens>()
1704            .expect("theme_from_seed attaches MaterialTokens");
1705        let (_, seeded) = from_seed(BASELINE_SEED, Brightness::Light);
1706        assert_eq!(attached, &seeded);
1707        assert_eq!(attached.light.emphasis, theme.light.primary);
1708    }
1709
1710    /// A tonal palette is monotonic in tone and honors its own hue.
1711    #[test]
1712    fn tonal_palette_tones_ascend_and_hold_their_hue() {
1713        let palette = TonalPalette::of(120.0, 40.0);
1714        let mut previous = -1.0;
1715        for tone in [0.0, 10.0, 20.0, 40.0, 60.0, 80.0, 90.0, 100.0] {
1716            let resolved = Hct::from_color(palette.tone(tone));
1717            assert!(
1718                resolved.tone() > previous,
1719                "tone {tone} did not ascend ({} after {previous})",
1720                resolved.tone()
1721            );
1722            previous = resolved.tone();
1723            if tone > 0.0 && tone < 100.0 {
1724                assert!(
1725                    (resolved.hue() - 120.0).abs() < 2.0,
1726                    "tone {tone} drifted to hue {}",
1727                    resolved.hue()
1728                );
1729            }
1730        }
1731    }
1732
1733    /// `TonalPalette::from_color` reads a color's own ramp — the entry point
1734    /// a seed-picker UI uses to preview a ramp without building a scheme.
1735    #[test]
1736    fn tonal_palette_from_color_matches_the_core_primary_ramp() {
1737        let seed = Color::from_rgb8(0x0B, 0x57, 0xD0);
1738        let core = CorePalette::of(seed);
1739        let direct = TonalPalette::from_color(seed);
1740        // The seed's chroma clears the floor, so both ramps coincide.
1741        assert!(Hct::from_color(seed).chroma() > PRIMARY_MIN_CHROMA);
1742        assert_eq!(direct.tone(40.0), core.primary.tone(40.0));
1743    }
1744
1745    #[test]
1746    fn core_palette_applies_the_chroma_floor_and_hue_rotation() {
1747        let gray = CorePalette::of(Color::from_rgb8(0x80, 0x80, 0x80));
1748        assert_eq!(gray.primary.chroma(), PRIMARY_MIN_CHROMA);
1749        let vivid = CorePalette::of(Color::from_rgb8(0xFF, 0x57, 0x22));
1750        assert!(vivid.primary.chroma() > PRIMARY_MIN_CHROMA);
1751        assert_eq!(
1752            vivid.tertiary.hue(),
1753            sanitize_degrees(vivid.primary.hue() + TERTIARY_HUE_ROTATION)
1754        );
1755        assert_eq!(vivid.error.hue(), ERROR_HUE);
1756    }
1757}