Skip to main content

frust_material/tokens/
mod.rs

1//! The Material 3 baseline design tokens this crate installs: a [`Theme`]
2//! aggregate plus its constituent light/dark [`ColorScheme`](frust::ColorScheme)s
3//! and [`MaterialTokens`] semantic extension, the M3 type scale/shape
4//! scale/elevation table/Expressive motion scheme, and the
5//! success/warning/info [`StatusPalette`] extension.
6//!
7//! Values sourced from Google's Material 3 design-system tokens v0.192
8//! (`material-components/material-web` `tokens/versions/v0_192/
9//! _md-sys-color.scss` + `_md-ref-palette.scss`), resolved 2026-07-17, seed
10//! color `#6750A4`. These tables lived in `frust-theme` until the Material
11//! catalog moved out of tree; nothing in the framework constructs them any
12//! more, so this crate owns them outright.
13//!
14//! Module layout:
15//!
16//! - This module — `baseline()`, the public re-export surface, the bundled
17//!   font family constants + `NativeTypefaces` attach, and (still here) the
18//!   shape scale and `StatusPalette` bodies.
19//! - [`color`] — [`color_scheme_light`]/[`color_scheme_dark`] plus the M3E
20//!   semantic-role expansion ([`MaterialSemanticColors`]).
21//! - [`extension`] — [`MaterialTokens`], the `ThemeExtensions` payload
22//!   wrapping [`color::MaterialSemanticColors`] for both brightnesses.
23//! - [`hct`] — the material-color-utilities port behind [`from_seed`]/
24//!   [`theme_from_seed`].
25//! - [`motion`] — the 16-duration/7-easing/11-spring M3E motion tables
26//!   ([`MaterialMotion`]/[`MaterialSpring`]) `motion_scheme()` is built from.
27//! - [`type_scale`] — the 30-role M3 type scale, resolved to Roboto Flex.
28//! - [`metrics`] — the elevation table plus the [`MaterialSpacing`]/
29//!   [`MaterialDimensions`] const catalogs.
30//! - [`fonts`] — the bundled Roboto Flex/Mono font bytes ([`font_data`]).
31//!
32//! # M3E role partition
33//!
34//! The Material 3 Expressive reference (`paadevelopments/material_3_expressive`
35//! v1.0.8, `lib/foundations/m3e_color_scheme.dart`, retrieved 2026-08-19)'s
36//! `M3EColorScheme` carries 43 `Color` roles. 34 have a direct analogue in
37//! frust-theme's baseline `ColorScheme` and flow through
38//! [`color_scheme_light`]/[`color_scheme_dark`]; the remaining 9 — the
39//! semantic set this `ColorScheme` has no fixed slot for — are
40//! [`MaterialTokens`] fields instead:
41//!
42//! | M3E role (`m3e_color_scheme.dart`) | Carried by |
43//! |---|---|
44//! | `primary` | `ColorScheme::primary` |
45//! | `onPrimary` | `ColorScheme::on_primary` |
46//! | `primaryContainer` | `ColorScheme::primary_container` |
47//! | `onPrimaryContainer` | `ColorScheme::on_primary_container` |
48//! | `secondary` | `ColorScheme::secondary` |
49//! | `onSecondary` | `ColorScheme::on_secondary` |
50//! | `secondaryContainer` | `ColorScheme::secondary_container` |
51//! | `onSecondaryContainer` | `ColorScheme::on_secondary_container` |
52//! | `tertiary` | `ColorScheme::tertiary` |
53//! | `onTertiary` | `ColorScheme::on_tertiary` |
54//! | `tertiaryContainer` | `ColorScheme::tertiary_container` |
55//! | `onTertiaryContainer` | `ColorScheme::on_tertiary_container` |
56//! | `error` | `ColorScheme::error` |
57//! | `onError` | `ColorScheme::on_error` |
58//! | `errorContainer` | `ColorScheme::error_container` |
59//! | `onErrorContainer` | `ColorScheme::on_error_container` |
60//! | `surface` | `ColorScheme::surface` |
61//! | `onSurface` | `ColorScheme::on_surface` |
62//! | `onSurfaceVariant` | `ColorScheme::on_surface_variant` |
63//! | `surfaceContainerLowest` | `ColorScheme::surface_container_lowest` |
64//! | `surfaceContainerLow` | `ColorScheme::surface_container_low` |
65//! | `surfaceContainer` | `ColorScheme::surface_container` |
66//! | `surfaceContainerHigh` | `ColorScheme::surface_container_high` |
67//! | `surfaceContainerHighest` | `ColorScheme::surface_container_highest` |
68//! | `surfaceDim` | `ColorScheme::surface_dim` |
69//! | `surfaceBright` | `ColorScheme::surface_bright` |
70//! | `inverseSurface` | `ColorScheme::inverse_surface` |
71//! | `onInverseSurface` | `ColorScheme::inverse_on_surface` |
72//! | `inversePrimary` | `ColorScheme::inverse_primary` |
73//! | `outline` | `ColorScheme::outline` |
74//! | `outlineVariant` | `ColorScheme::outline_variant` |
75//! | `shadow` | `ColorScheme::shadow` |
76//! | `scrim` | `ColorScheme::scrim` |
77//! | `surfaceTint` | `ColorScheme::surface_tint` |
78//! | `emphasis` | `MaterialTokens.{light,dark}.emphasis` |
79//! | `onEmphasis` | `MaterialTokens.{light,dark}.on_emphasis` |
80//! | `info` | `MaterialTokens.{light,dark}.info` (see *Two semantic vocabularies*) |
81//! | `success` | `MaterialTokens.{light,dark}.success` (see *Two semantic vocabularies*) |
82//! | `warning` | `MaterialTokens.{light,dark}.warning` (see *Two semantic vocabularies*) |
83//! | `danger` | `MaterialTokens.{light,dark}.danger` |
84//! | `surfaceStrong` | `MaterialTokens.{light,dark}.surface_strong` |
85//! | `onSurfaceStrong` | `MaterialTokens.{light,dark}.on_surface_strong` |
86//! | `outlineStrong` | `MaterialTokens.{light,dark}.outline_strong` |
87//!
88//! (`brightness` is the reference's 44th constructor parameter, but it's a
89//! `Brightness` enum, not a `Color` — not part of the 43-role count.)
90//!
91//! # Two semantic vocabularies
92//!
93//! [`baseline`] attaches *both* [`MaterialTokens`] and a [`StatusPalette`]
94//! (via [`status_palette`]), and both carry `info`/`success`/`warning`
95//! fields — two **different vocabularies that happen to share role names**,
96//! not one role table with two readers:
97//!
98//! - **[`MaterialTokens`]** carries the M3E reference's own semantic roles,
99//!   `info`/`success`/`warning` included — `info` is the reference's
100//!   `scheme.tertiary` alias exactly as `m3e_color_scheme.dart` defines it
101//!   (see [`color`]'s module docs), and `success`/`warning` are its two
102//!   independent constants, transcribed verbatim. **Binding rule for this
103//!   crate (prescriptive — no widget consumes either extension yet):
104//!   frust-material catalog code MUST resolve `MaterialTokens`, never
105//!   `StatusPalette`** — it is the crate's one source of truth for M3E
106//!   parity.
107//! - **[`StatusPalette`]** (`frust::StatusPalette`, defined in
108//!   `frust-theme`) is the framework-wide, community-approximate status
109//!   trio. Its readers are NOT in baseline `frust-widgets` (which has no
110//!   `StatusPalette` consumer) — they are sibling design-system catalogs
111//!   (`frust-glyph`'s badge/alert/toast/stat-card family,
112//!   `frust-cupertino`'s tokens) and app code, all of which resolve
113//!   `theme.extension::<StatusPalette>()` from whatever `Theme` is active.
114//!
115//! Their same-named fields hold **deliberately different values** (e.g.
116//! light `warning`: `MaterialTokens` `#EF6C00` vs. `StatusPalette`
117//! `#8A5300`; light `info`: `MaterialTokens` `#7D5260` (tertiary) vs.
118//! `StatusPalette` `#0061A4`) — see
119//! [`two_semantic_vocabularies_diverge_by_design`](tests::two_semantic_vocabularies_diverge_by_design)
120//! below, which pins both tables so a future reconciliation is a deliberate,
121//! test-editing act rather than an accidental drift. **Neither table takes
122//! precedence over the other** — there is no resolution ladder between
123//! them; each reader class names its extension explicitly. The mixed case
124//! is real and defined: a Glyph/Cupertino status widget rendered under a
125//! Material theme reads this crate's `status_palette()` values (`#0061A4`
126//! info etc.), NOT the M3E `MaterialTokens` values — two same-named roles
127//! can therefore legitimately paint differently on one screen when catalogs
128//! are mixed. Note also that [`theme_from_seed`] regenerates
129//! `MaterialTokens` from the seed but leaves `status_palette()`'s baked
130//! values untouched — the two vocabularies diverge further under seeding,
131//! by the same rule.
132
133mod color;
134mod extension;
135mod fonts;
136mod hct;
137mod metrics;
138mod motion;
139mod type_scale;
140
141pub use color::{MaterialSemanticColors, color_scheme_dark, color_scheme_light};
142pub use extension::MaterialTokens;
143pub use fonts::font_data;
144pub use hct::{CorePalette, Hct, TonalPalette, from_seed, theme_from_seed};
145pub use metrics::{MaterialDimensions, MaterialSpacing, elevation};
146pub use motion::{MaterialMotion, MaterialSpring};
147pub use type_scale::type_scale;
148
149use frust::authoring::text::TextStyle;
150use frust::{
151    Brightness, CosmeticLoopRate, DesignLanguage, EasingSet, FontFace, GlassScale, MotionDurations,
152    MotionScheme, MotionSpring, NativeTypefaces, ShapeScale, StatusColors, StatusPalette, Theme,
153    ThemeExtensions,
154};
155use peniko::Color;
156
157/// The family name Roboto Flex's own `name` table reports (`name` ID 1) — what
158/// a font stack must name for the bundled face to resolve. Note it is
159/// `"Roboto Flex"`, matching the variable release.
160pub const ROBOTO_FLEX_FAMILY: &str = "Roboto Flex";
161
162/// The family name Roboto Mono's own `name` table reports (`name` ID 1).
163#[allow(dead_code)]
164pub const ROBOTO_MONO_FAMILY: &str = "Roboto Mono";
165
166/// The Material 3 baseline theme: baseline light/dark color schemes, the M3
167/// type scale (built from `TextStyle::default()`), the M3 shape scale, the M3
168/// elevation table, and the M3 Expressive motion scheme. Starts in
169/// [`Brightness::Light`]. Attaches [`status_palette`],
170/// [`MaterialTokens::material`], and [`native_typefaces`] as pre-populated
171/// extensions (see `Theme::extension`), so `extension::<StatusPalette>()`,
172/// `extension::<MaterialTokens>()`, and `extension::<NativeTypefaces>()` are
173/// always `Some` on this baseline. See [module docs](self)'s "Two semantic
174/// vocabularies" section for why the first two both carry `info`/`success`/
175/// `warning` fields at different values — that is by design, not a
176/// conflict.
177pub fn baseline() -> Theme {
178    let mut extensions = ThemeExtensions::new();
179    extensions.insert(status_palette());
180    extensions.insert(MaterialTokens::material());
181    extensions.insert(native_typefaces());
182    Theme {
183        light: color_scheme_light(),
184        dark: color_scheme_dark(),
185        type_scale: type_scale(&TextStyle::default()),
186        shape: shape_scale(),
187        elevation: elevation(),
188        motion: motion_scheme(),
189        glass: GlassScale::opaque_material(),
190        brightness: Brightness::Light,
191        design_language: DesignLanguage::Material3,
192        extensions,
193    }
194}
195
196/// The Material 3 baseline [`StatusPalette`] default: success/warning/info
197/// color roles Material 3's baseline 46-role `ColorScheme` has no fixed slot
198/// for.
199///
200/// **Community-approximate**: Material 3 has no single official token source
201/// for a `success`/`warning`/`info` role table (Google's own Material Theme
202/// Builder covers the gap via a "custom color" extension, not a fixed role
203/// table) — the values below apply the same tone-relationship
204/// [`color_scheme_light`]/[`color_scheme_dark`]'s `error` roles use (light:
205/// base/on/container/on-container ≈ tone 40/100/90/10; dark: ≈ tone
206/// 80/20/30/90) to green (success), amber (warning), and blue (info) seed
207/// hues, chosen for conventional semantic association and AA contrast
208/// against `surface`/`on_surface`.
209pub fn status_palette() -> StatusPalette {
210    StatusPalette {
211        light: StatusColors {
212            // Green seed, ~tone 40/100/90/10 (mirrors `error`'s light
213            // tone relationship: base/on/container/on-container).
214            success: Color::from_rgb8(0x2E, 0x7D, 0x32),
215            on_success: Color::from_rgb8(0xFF, 0xFF, 0xFF),
216            success_container: Color::from_rgb8(0xC8, 0xE6, 0xC9),
217            on_success_container: Color::from_rgb8(0x1B, 0x5E, 0x20),
218
219            // Amber seed, tuned dark enough for AA-on-white at the base
220            // tone (a literal amber-400 like `#FFC107` fails AA on white).
221            warning: Color::from_rgb8(0x8A, 0x53, 0x00),
222            on_warning: Color::from_rgb8(0xFF, 0xFF, 0xFF),
223            warning_container: Color::from_rgb8(0xFF, 0xDD, 0xB0),
224            on_warning_container: Color::from_rgb8(0x2B, 0x17, 0x00),
225
226            // Blue seed, echoing M3's own `primary`-adjacent "info" blue
227            // used elsewhere in Google's Material guidance.
228            info: Color::from_rgb8(0x00, 0x61, 0xA4),
229            on_info: Color::from_rgb8(0xFF, 0xFF, 0xFF),
230            info_container: Color::from_rgb8(0xD1, 0xE4, 0xFF),
231            on_info_container: Color::from_rgb8(0x00, 0x1D, 0x36),
232        },
233        dark: StatusColors {
234            // Dark tone relationship (~80/20/30/90), mirroring `error`'s
235            // dark tones (`F2B8B5`/`601410`/`8C1D18`/`F9DEDC`).
236            success: Color::from_rgb8(0xA6, 0xF1, 0xA1),
237            on_success: Color::from_rgb8(0x00, 0x39, 0x0F),
238            success_container: Color::from_rgb8(0x20, 0x57, 0x23),
239            on_success_container: Color::from_rgb8(0xC8, 0xE6, 0xC9),
240
241            warning: Color::from_rgb8(0xFF, 0xC4, 0x6B),
242            on_warning: Color::from_rgb8(0x45, 0x2B, 0x00),
243            warning_container: Color::from_rgb8(0x6F, 0x49, 0x00),
244            on_warning_container: Color::from_rgb8(0xFF, 0xDD, 0xB0),
245
246            info: Color::from_rgb8(0x9F, 0xCA, 0xFF),
247            on_info: Color::from_rgb8(0x00, 0x32, 0x50),
248            info_container: Color::from_rgb8(0x00, 0x4A, 0x76),
249            on_info_container: Color::from_rgb8(0xD1, 0xE4, 0xFF),
250        },
251    }
252}
253
254// ---- Shape scale --------------------------------------------------------
255
256/// The Material 3 shape scale: 10 corner-radius tokens (post-Expressive
257/// scale). Source: <https://m3.material.io/styles/shape/corner-radius-scale>
258/// (verified 2026-07-17; the pre-Expressive scale had 7 tokens, not 10).
259/// Radii are dp, treated 1:1 as logical px.
260pub const fn shape_scale() -> ShapeScale {
261    ShapeScale {
262        none: 0.0,
263        extra_small: 4.0,
264        small: 8.0,
265        medium: 12.0,
266        large: 16.0,
267        large_increased: 20.0,
268        extra_large: 28.0,
269        extra_large_increased: 32.0,
270        extra_extra_large: 48.0,
271        full: f64::INFINITY,
272    }
273}
274
275// ---- Motion ---------------------------------------------------------------
276
277/// The Material 3 Expressive baseline motion scheme: six spring presets
278/// (of [`MaterialMotion`]/[`MaterialSpring`]'s full 34-token M3E table — see
279/// their own module doc for the source citation and the Dart → Rust naming
280/// map).
281///
282/// Spring slots take their values from six of [`MaterialSpring`]'s 11
283/// presets: `fast_spatial`/`default_spatial`/`slow_spatial` from
284/// [`MaterialSpring::SPATIAL_FAST`]/[`SPATIAL_DEFAULT`](MaterialSpring::SPATIAL_DEFAULT)/[`SPATIAL_SLOW`](MaterialSpring::SPATIAL_SLOW),
285/// `fast_effects`/`default_effects`/`slow_effects` from
286/// [`MaterialSpring::EFFECTS_FAST`]/[`EFFECTS_DEFAULT`](MaterialSpring::EFFECTS_DEFAULT)/[`EFFECTS_SLOW`](MaterialSpring::EFFECTS_SLOW).
287/// "Effects" springs (opacity/color) are critically damped
288/// (`damping_ratio: 1.0`) by design — no bounce; "spatial" springs
289/// (position/size) are `0.9`, allowing a small overshoot. The remaining five
290/// [`MaterialSpring`] presets have no slot here and stay reachable directly.
291///
292/// Glyph's five-slot `MotionDurations` vocabulary maps onto
293/// [`MaterialMotion`]'s 16-value duration scale as: `instant` →
294/// [`MaterialMotion::SHORT_1`] (50ms), `fast` → [`MaterialMotion::SHORT_3`]
295/// (150ms), `base` → [`MaterialMotion::MEDIUM_2`] (300ms, the most
296/// commonly-cited M3 "default" transition duration), `slow` →
297/// [`MaterialMotion::LONG_2`] (500ms), `deliberate` →
298/// [`MaterialMotion::EXTRA_LONG_1`] (700ms, the Extra Long tier's floor).
299///
300/// `EasingSet`'s three slots map onto three of [`MaterialMotion`]'s 7
301/// easings: `spatial` → [`MaterialMotion::EMPHASIZED_DECELERATE`] (M3's
302/// curve for entering/spatial transitions — its steep initial deceleration
303/// reads as a slight overshoot-adjacent settle), `effects` →
304/// [`MaterialMotion::STANDARD`] (M3's curve for opacity/color fades — never
305/// overshoots), `exit` → [`MaterialMotion::STANDARD_ACCELERATE`] (M3's curve
306/// for elements leaving the screen).
307///
308/// `cosmetic_loop_rate` is this design system's own authored value — 30Hz,
309/// the same cap every built-in baseline declares.
310pub const fn motion_scheme() -> MotionScheme {
311    MotionScheme {
312        fast_spatial: MotionSpring {
313            damping_ratio: MaterialSpring::SPATIAL_FAST.damping_ratio,
314            stiffness: MaterialSpring::SPATIAL_FAST.stiffness,
315        },
316        fast_effects: MotionSpring {
317            damping_ratio: MaterialSpring::EFFECTS_FAST.damping_ratio,
318            stiffness: MaterialSpring::EFFECTS_FAST.stiffness,
319        },
320        default_spatial: MotionSpring {
321            damping_ratio: MaterialSpring::SPATIAL_DEFAULT.damping_ratio,
322            stiffness: MaterialSpring::SPATIAL_DEFAULT.stiffness,
323        },
324        default_effects: MotionSpring {
325            damping_ratio: MaterialSpring::EFFECTS_DEFAULT.damping_ratio,
326            stiffness: MaterialSpring::EFFECTS_DEFAULT.stiffness,
327        },
328        slow_spatial: MotionSpring {
329            damping_ratio: MaterialSpring::SPATIAL_SLOW.damping_ratio,
330            stiffness: MaterialSpring::SPATIAL_SLOW.stiffness,
331        },
332        slow_effects: MotionSpring {
333            damping_ratio: MaterialSpring::EFFECTS_SLOW.damping_ratio,
334            stiffness: MaterialSpring::EFFECTS_SLOW.stiffness,
335        },
336        durations: MotionDurations {
337            instant: MaterialMotion::SHORT_1.as_millis() as f64,
338            fast: MaterialMotion::SHORT_3.as_millis() as f64,
339            base: MaterialMotion::MEDIUM_2.as_millis() as f64,
340            slow: MaterialMotion::LONG_2.as_millis() as f64,
341            deliberate: MaterialMotion::EXTRA_LONG_1.as_millis() as f64,
342        },
343        easing: EasingSet {
344            spatial: MaterialMotion::EMPHASIZED_DECELERATE,
345            effects: MaterialMotion::STANDARD,
346            exit: MaterialMotion::STANDARD_ACCELERATE,
347        },
348        reduce_motion: false,
349        cosmetic_loop_rate: CosmeticLoopRate::new(30.0),
350    }
351}
352
353/// The native-control typeface binding [`baseline`] attaches: the bundled
354/// Roboto Flex face in both button and body slots.
355///
356/// Material 3 names one sans family for all text (display/heading/body alike),
357/// so both native control slots carry the same face. Both are taken
358/// **out of [`fonts::font_data`]'s own array** rather than re-referenced from
359/// the underlying constants: a native host de-duplicates published payloads by
360/// byte identity (address + length), so a slot's face and the bytes a shell
361/// registers through [`frust::register_app_fonts`] must be the *same*
362/// `&'static [u8]`, not merely equal ones.
363pub fn native_typefaces() -> NativeTypefaces {
364    match fonts::font_data()
365        .get(fonts::ROBOTO_FLEX_VARIABLE_INDEX)
366        .copied()
367    {
368        Some(bytes) => NativeTypefaces::uniform(FontFace::new(ROBOTO_FLEX_FAMILY, bytes)),
369        None => NativeTypefaces::default(),
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn shape_scale_matches_table() {
379        let s = shape_scale();
380        assert_eq!(s.none, 0.0);
381        assert_eq!(s.extra_small, 4.0);
382        assert_eq!(s.small, 8.0);
383        assert_eq!(s.medium, 12.0);
384        assert_eq!(s.large, 16.0);
385        assert_eq!(s.large_increased, 20.0);
386        assert_eq!(s.extra_large, 28.0);
387        assert_eq!(s.extra_large_increased, 32.0);
388        assert_eq!(s.extra_extra_large, 48.0);
389        assert!(s.full.is_infinite());
390    }
391
392    #[test]
393    fn elevation_dp_matches_table() {
394        let e = elevation();
395        assert_eq!(e.level0.dp, 0.0);
396        assert_eq!(e.level1.dp, 1.0);
397        assert_eq!(e.level2.dp, 3.0);
398        assert_eq!(e.level3.dp, 6.0);
399        assert_eq!(e.level4.dp, 8.0);
400        assert_eq!(e.level5.dp, 12.0);
401    }
402
403    #[test]
404    fn elevation_shadow_is_identical_on_both_brightnesses() {
405        let e = elevation();
406        assert_eq!(e.level3.shadow_light, e.level3.shadow_dark);
407    }
408
409    #[test]
410    fn motion_scheme_spring_presets_are_exact() {
411        let m = motion_scheme();
412        assert_eq!(
413            m.fast_spatial,
414            MotionSpring {
415                damping_ratio: 0.9,
416                stiffness: 1400.0
417            }
418        );
419        assert_eq!(
420            m.fast_effects,
421            MotionSpring {
422                damping_ratio: 1.0,
423                stiffness: 3800.0
424            }
425        );
426    }
427
428    #[test]
429    fn motion_scheme_effects_springs_are_critically_damped() {
430        let m = motion_scheme();
431        assert_eq!(m.fast_effects.damping_ratio, 1.0);
432        assert_eq!(m.default_effects.damping_ratio, 1.0);
433        assert_eq!(m.slow_effects.damping_ratio, 1.0);
434    }
435
436    #[test]
437    fn motion_scheme_cosmetic_loop_rate_is_30hz() {
438        assert_eq!(motion_scheme().cosmetic_loop_rate.hz(), 30.0);
439    }
440
441    #[test]
442    fn baseline_composes_the_m3_scales() {
443        let theme = baseline();
444        assert_eq!(theme.shape, shape_scale());
445        assert_eq!(theme.elevation, elevation());
446        assert_eq!(theme.motion, motion_scheme());
447    }
448
449    #[test]
450    fn baseline_attaches_all_three_extensions() {
451        let theme = baseline();
452        assert_eq!(theme.extension::<StatusPalette>(), Some(&status_palette()));
453        assert_eq!(
454            theme.extension::<MaterialTokens>(),
455            Some(&MaterialTokens::material())
456        );
457        assert_eq!(
458            theme.extension::<NativeTypefaces>(),
459            Some(&native_typefaces())
460        );
461    }
462
463    /// Pins both semantic-trio tables `baseline()` attaches — [`MaterialTokens`]
464    /// (M3E-parity `info`/`success`/`warning`) and [`StatusPalette`] (the
465    /// framework-wide status trio, via [`status_palette`]) — at their
466    /// deliberately different values. See [module docs](self)'s "Two semantic
467    /// vocabularies" section: these are two different vocabularies that
468    /// happen to share role names, neither overriding the other, each read
469    /// by disjoint consumers (frust-material catalog code reads only
470    /// `MaterialTokens`; baseline `frust-widgets` components read only
471    /// `StatusPalette`). If a future change makes these converge, that must
472    /// be a deliberate edit to *this* test, not an accidental drift.
473    #[test]
474    fn two_semantic_vocabularies_diverge_by_design() {
475        let material = MaterialTokens::material();
476        let status = status_palette();
477
478        // Light: warning.
479        assert_eq!(material.light.warning, Color::from_rgb8(0xEF, 0x6C, 0x00));
480        assert_eq!(status.light.warning, Color::from_rgb8(0x8A, 0x53, 0x00));
481        assert_ne!(material.light.warning, status.light.warning);
482
483        // Light: info (MaterialTokens aliases `tertiary`; StatusPalette does
484        // not).
485        assert_eq!(material.light.info, Color::from_rgb8(0x7D, 0x52, 0x60));
486        assert_eq!(status.light.info, Color::from_rgb8(0x00, 0x61, 0xA4));
487        assert_ne!(material.light.info, status.light.info);
488
489        // Dark: warning.
490        assert_eq!(material.dark.warning, Color::from_rgb8(0xFF, 0xB7, 0x4D));
491        assert_eq!(status.dark.warning, Color::from_rgb8(0xFF, 0xC4, 0x6B));
492        assert_ne!(material.dark.warning, status.dark.warning);
493
494        // Dark: info.
495        assert_eq!(material.dark.info, Color::from_rgb8(0xEF, 0xB8, 0xC8));
496        assert_eq!(status.dark.info, Color::from_rgb8(0x9F, 0xCA, 0xFF));
497        assert_ne!(material.dark.info, status.dark.info);
498
499        // success is the one role where the two vocabularies happen to
500        // agree in the light branch — still asserted explicitly so a
501        // divergence there is caught too, and to document that agreement is
502        // coincidence, not a contract.
503        assert_eq!(material.light.success, Color::from_rgb8(0x2E, 0x7D, 0x32));
504        assert_eq!(status.light.success, Color::from_rgb8(0x2E, 0x7D, 0x32));
505        assert_eq!(material.light.success, status.light.success);
506
507        // Dark success diverges like the other two roles.
508        assert_eq!(material.dark.success, Color::from_rgb8(0x81, 0xC7, 0x84));
509        assert_eq!(status.dark.success, Color::from_rgb8(0xA6, 0xF1, 0xA1));
510        assert_ne!(material.dark.success, status.dark.success);
511    }
512}