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}