Skip to main content

frust_material/tokens/
color.rs

1//! Material's baseline light/dark [`ColorScheme`]s, plus the nine M3E
2//! "semantic" color roles frust-theme's `ColorScheme` has no baseline slot
3//! for.
4//!
5//! [`color_scheme_light`]/[`color_scheme_dark`] carry the 34 M3E roles that
6//! *do* map onto `ColorScheme`'s own field set — unchanged from this crate's
7//! original hand-baked Material Web v0.192 tokens (seed `#6750A4`). The
8//! remaining 9 — [`MaterialSemanticColors`]'s fields, built by
9//! [`semantic_light`]/[`semantic_dark`] — round the baseline out to the
10//! Material 3 Expressive reference's full 43-role `M3EColorScheme`. See
11//! [`super`]'s module docs for the complete 43-row role-partition table.
12//!
13//! Source: `paadevelopments/material_3_expressive` v1.0.8 (pub.dev;
14//! `github.com/paadevelopments/material_3_expressive`),
15//! `lib/foundations/m3e_color_scheme.dart`'s `M3EColorScheme.fromColorScheme`
16//! factory, retrieved 2026-08-19.
17//!
18//! **Porting decisions** (the nine semantic roles):
19//! - `emphasis`/`onEmphasis`, `info`, `danger`, `onSurfaceStrong`, and
20//!   `outlineStrong` are the reference's own *derived* roles — it defines
21//!   them as `scheme.primary`/`scheme.onPrimary`, `scheme.tertiary`,
22//!   `scheme.error`, `scheme.onSurface`, and `scheme.outline` respectively,
23//!   never as independent values — so [`semantic_light`]/[`semantic_dark`]
24//!   read them straight off [`color_scheme_light`]/[`color_scheme_dark`]'s
25//!   matching fields rather than re-baking a second copy of the same hex.
26//! - `success`/`warning` are the reference's only two genuinely independent
27//!   semantic constants (`#FF2E7D32`/`#FFEF6C00` light, `#FF81C784`/
28//!   `#FFFFB74D` dark) — transcribed verbatim.
29//! - `surfaceStrong` is the one *computed* role:
30//!   `Color.alphaBlend(primary.withValues(alpha: 0.06), surface)`, a 6%
31//!   alpha-wash of `primary` flattened over `surface` to opaque. This module
32//!   applies the same blend formula (and rounding) as
33//!   `frust_theme::color::blend_accent_over_surface` — this crate's existing
34//!   precedent for exactly this kind of accent-over-surface wash, just at a
35//!   6% mix instead of that helper's 10% — and bakes the result as a
36//!   hand-computed constant rather than blending at runtime (this task's
37//!   values are hand-baked throughout; a later HCT task must reproduce
38//!   them).
39
40use frust::ColorScheme;
41use peniko::Color;
42
43/// The Material 3 baseline light [`ColorScheme`] (seed `#6750A4`).
44///
45/// Source: material-components/material-web tokens v0.192
46/// `_md-sys-color.scss` (light), resolved 2026-07-17.
47///
48/// Carries 34 of the M3E reference's 43 color roles — see [module
49/// docs](self) for the porting notes and [`super`]'s module docs for the
50/// full role-partition table.
51pub fn color_scheme_light() -> ColorScheme {
52    ColorScheme {
53        primary: Color::from_rgb8(0x67, 0x50, 0xA4),
54        on_primary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
55        primary_container: Color::from_rgb8(0xEA, 0xDD, 0xFF),
56        on_primary_container: Color::from_rgb8(0x21, 0x00, 0x5D),
57        primary_fixed: Color::from_rgb8(0xEA, 0xDD, 0xFF),
58        primary_fixed_dim: Color::from_rgb8(0xD0, 0xBC, 0xFF),
59        on_primary_fixed: Color::from_rgb8(0x21, 0x00, 0x5D),
60        on_primary_fixed_variant: Color::from_rgb8(0x4F, 0x37, 0x8B),
61
62        secondary: Color::from_rgb8(0x62, 0x5B, 0x71),
63        on_secondary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
64        secondary_container: Color::from_rgb8(0xE8, 0xDE, 0xF8),
65        on_secondary_container: Color::from_rgb8(0x1D, 0x19, 0x2B),
66        secondary_fixed: Color::from_rgb8(0xE8, 0xDE, 0xF8),
67        secondary_fixed_dim: Color::from_rgb8(0xCC, 0xC2, 0xDC),
68        on_secondary_fixed: Color::from_rgb8(0x1D, 0x19, 0x2B),
69        on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x44, 0x58),
70
71        tertiary: Color::from_rgb8(0x7D, 0x52, 0x60),
72        on_tertiary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
73        tertiary_container: Color::from_rgb8(0xFF, 0xD8, 0xE4),
74        on_tertiary_container: Color::from_rgb8(0x31, 0x11, 0x1D),
75        tertiary_fixed: Color::from_rgb8(0xFF, 0xD8, 0xE4),
76        tertiary_fixed_dim: Color::from_rgb8(0xEF, 0xB8, 0xC8),
77        on_tertiary_fixed: Color::from_rgb8(0x31, 0x11, 0x1D),
78        on_tertiary_fixed_variant: Color::from_rgb8(0x63, 0x3B, 0x48),
79
80        error: Color::from_rgb8(0xB3, 0x26, 0x1E),
81        on_error: Color::from_rgb8(0xFF, 0xFF, 0xFF),
82        error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
83        on_error_container: Color::from_rgb8(0x41, 0x0E, 0x0B),
84
85        surface: Color::from_rgb8(0xFE, 0xF7, 0xFF),
86        on_surface: Color::from_rgb8(0x1D, 0x1B, 0x20),
87        on_surface_variant: Color::from_rgb8(0x49, 0x45, 0x4F),
88        surface_dim: Color::from_rgb8(0xDE, 0xD8, 0xE1),
89        surface_bright: Color::from_rgb8(0xFE, 0xF7, 0xFF),
90        surface_container_lowest: Color::from_rgb8(0xFF, 0xFF, 0xFF),
91        surface_container_low: Color::from_rgb8(0xF7, 0xF2, 0xFA),
92        surface_container: Color::from_rgb8(0xF3, 0xED, 0xF7),
93        surface_container_high: Color::from_rgb8(0xEC, 0xE6, 0xF0),
94        surface_container_highest: Color::from_rgb8(0xE6, 0xE0, 0xE9),
95
96        outline: Color::from_rgb8(0x79, 0x74, 0x7E),
97        outline_variant: Color::from_rgb8(0xCA, 0xC4, 0xD0),
98        shadow: Color::from_rgb8(0x00, 0x00, 0x00),
99        scrim: Color::from_rgb8(0x00, 0x00, 0x00),
100        inverse_surface: Color::from_rgb8(0x32, 0x2F, 0x35),
101        inverse_on_surface: Color::from_rgb8(0xF5, 0xEF, 0xF7),
102        inverse_primary: Color::from_rgb8(0xD0, 0xBC, 0xFF),
103        surface_tint: Color::from_rgb8(0x67, 0x50, 0xA4),
104    }
105}
106
107/// The Material 3 baseline dark [`ColorScheme`] (seed `#6750A4`).
108///
109/// Source: material-components/material-web tokens v0.192
110/// `_md-sys-color.scss` (dark), resolved 2026-07-17.
111///
112/// Carries 34 of the M3E reference's 43 color roles — see [module
113/// docs](self) for the porting notes and [`super`]'s module docs for the
114/// full role-partition table.
115pub fn color_scheme_dark() -> ColorScheme {
116    ColorScheme {
117        primary: Color::from_rgb8(0xD0, 0xBC, 0xFF),
118        on_primary: Color::from_rgb8(0x38, 0x1E, 0x72),
119        primary_container: Color::from_rgb8(0x4F, 0x37, 0x8B),
120        on_primary_container: Color::from_rgb8(0xEA, 0xDD, 0xFF),
121        primary_fixed: Color::from_rgb8(0xEA, 0xDD, 0xFF),
122        primary_fixed_dim: Color::from_rgb8(0xD0, 0xBC, 0xFF),
123        on_primary_fixed: Color::from_rgb8(0x21, 0x00, 0x5D),
124        on_primary_fixed_variant: Color::from_rgb8(0x4F, 0x37, 0x8B),
125
126        secondary: Color::from_rgb8(0xCC, 0xC2, 0xDC),
127        on_secondary: Color::from_rgb8(0x33, 0x2D, 0x41),
128        secondary_container: Color::from_rgb8(0x4A, 0x44, 0x58),
129        on_secondary_container: Color::from_rgb8(0xE8, 0xDE, 0xF8),
130        secondary_fixed: Color::from_rgb8(0xE8, 0xDE, 0xF8),
131        secondary_fixed_dim: Color::from_rgb8(0xCC, 0xC2, 0xDC),
132        on_secondary_fixed: Color::from_rgb8(0x1D, 0x19, 0x2B),
133        on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x44, 0x58),
134
135        tertiary: Color::from_rgb8(0xEF, 0xB8, 0xC8),
136        on_tertiary: Color::from_rgb8(0x49, 0x25, 0x32),
137        tertiary_container: Color::from_rgb8(0x63, 0x3B, 0x48),
138        on_tertiary_container: Color::from_rgb8(0xFF, 0xD8, 0xE4),
139        tertiary_fixed: Color::from_rgb8(0xFF, 0xD8, 0xE4),
140        tertiary_fixed_dim: Color::from_rgb8(0xEF, 0xB8, 0xC8),
141        on_tertiary_fixed: Color::from_rgb8(0x31, 0x11, 0x1D),
142        on_tertiary_fixed_variant: Color::from_rgb8(0x63, 0x3B, 0x48),
143
144        error: Color::from_rgb8(0xF2, 0xB8, 0xB5),
145        on_error: Color::from_rgb8(0x60, 0x14, 0x10),
146        error_container: Color::from_rgb8(0x8C, 0x1D, 0x18),
147        on_error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
148
149        surface: Color::from_rgb8(0x14, 0x12, 0x18),
150        on_surface: Color::from_rgb8(0xE6, 0xE0, 0xE9),
151        on_surface_variant: Color::from_rgb8(0xCA, 0xC4, 0xD0),
152        surface_dim: Color::from_rgb8(0x14, 0x12, 0x18),
153        surface_bright: Color::from_rgb8(0x3B, 0x38, 0x3E),
154        surface_container_lowest: Color::from_rgb8(0x0F, 0x0D, 0x13),
155        surface_container_low: Color::from_rgb8(0x1D, 0x1B, 0x20),
156        surface_container: Color::from_rgb8(0x21, 0x1F, 0x26),
157        surface_container_high: Color::from_rgb8(0x2B, 0x29, 0x30),
158        surface_container_highest: Color::from_rgb8(0x36, 0x34, 0x3B),
159
160        outline: Color::from_rgb8(0x93, 0x8F, 0x99),
161        outline_variant: Color::from_rgb8(0x49, 0x45, 0x4F),
162        shadow: Color::from_rgb8(0x00, 0x00, 0x00),
163        scrim: Color::from_rgb8(0x00, 0x00, 0x00),
164        inverse_surface: Color::from_rgb8(0xE6, 0xE0, 0xE9),
165        inverse_on_surface: Color::from_rgb8(0x32, 0x2F, 0x35),
166        inverse_primary: Color::from_rgb8(0x67, 0x50, 0xA4),
167        surface_tint: Color::from_rgb8(0xD0, 0xBC, 0xFF),
168    }
169}
170
171/// The nine M3E roles [`ColorScheme`] has no field for — one brightness's
172/// worth. Carried by [`super::extension::MaterialTokens`] as its
173/// `light`/`dark` fields; see [module docs](self) for how each is sourced.
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct MaterialSemanticColors {
176    /// `emphasis` — the reference's high-emphasis accent (`scheme.primary`).
177    pub emphasis: Color,
178    /// `onEmphasis` — content color on [`Self::emphasis`] (`scheme.onPrimary`).
179    pub on_emphasis: Color,
180    /// `info` — informational role (`scheme.tertiary`). Same name, different
181    /// value from `frust::StatusPalette`'s `info` — see [module docs](self)
182    /// and [`super`]'s "Two semantic vocabularies" section: this is the
183    /// M3E-parity value, `StatusPalette`'s is the framework's own
184    /// community-approximate status trio, deliberately divergent.
185    pub info: Color,
186    /// `success` — independent semantic constant, not derived from `scheme`.
187    /// Same name as `frust::StatusPalette`'s `success` field; the two
188    /// vocabularies happen to agree in the light branch but diverge in dark
189    /// — coincidence, not a contract (see [`super`]'s module docs).
190    pub success: Color,
191    /// `warning` — independent semantic constant, not derived from `scheme`.
192    /// Same name, different value from `frust::StatusPalette`'s `warning` —
193    /// see [`super`]'s "Two semantic vocabularies" section.
194    pub warning: Color,
195    /// `danger` — destructive/error role (`scheme.error`).
196    pub danger: Color,
197    /// `surfaceStrong` — a slightly emphasized grouped-container surface;
198    /// `primary` alpha-blended over `surface` at 6%.
199    pub surface_strong: Color,
200    /// `onSurfaceStrong` — content color on [`Self::surface_strong`]
201    /// (`scheme.onSurface`).
202    pub on_surface_strong: Color,
203    /// `outlineStrong` — a stronger outline for high-contrast separators
204    /// (`scheme.outline`).
205    pub outline_strong: Color,
206}
207
208/// [`MaterialSemanticColors`] for [`color_scheme_light`] — see [module
209/// docs](self)'s porting decisions for how each field is sourced.
210pub fn semantic_light() -> MaterialSemanticColors {
211    let scheme = color_scheme_light();
212    MaterialSemanticColors {
213        emphasis: scheme.primary,
214        on_emphasis: scheme.on_primary,
215        info: scheme.tertiary,
216        // `Color(0xFF2E7D32)` in the reference's light branch.
217        success: Color::from_rgb8(0x2E, 0x7D, 0x32),
218        // `Color(0xFFEF6C00)` in the reference's light branch.
219        warning: Color::from_rgb8(0xEF, 0x6C, 0x00),
220        danger: scheme.error,
221        // `Color.alphaBlend(primary.withValues(alpha: 0.06), surface)`,
222        // hand-computed — see [module docs](self).
223        surface_strong: Color::from_rgb8(0xF5, 0xED, 0xFA),
224        on_surface_strong: scheme.on_surface,
225        outline_strong: scheme.outline,
226    }
227}
228
229/// [`MaterialSemanticColors`] for [`color_scheme_dark`] — the dark-mode
230/// mirror of [`semantic_light`]; see that function's doc comment.
231pub fn semantic_dark() -> MaterialSemanticColors {
232    let scheme = color_scheme_dark();
233    MaterialSemanticColors {
234        emphasis: scheme.primary,
235        on_emphasis: scheme.on_primary,
236        info: scheme.tertiary,
237        // `Color(0xFF81C784)` in the reference's dark branch.
238        success: Color::from_rgb8(0x81, 0xC7, 0x84),
239        // `Color(0xFFFFB74D)` in the reference's dark branch.
240        warning: Color::from_rgb8(0xFF, 0xB7, 0x4D),
241        danger: scheme.error,
242        surface_strong: Color::from_rgb8(0x1F, 0x1C, 0x26),
243        on_surface_strong: scheme.on_surface,
244        outline_strong: scheme.outline,
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// A missing field on either struct is a compile error, not a test
253    /// failure — this test exists as a documentation anchor for the
254    /// "34 baseline-mapped + 9 semantic-only = 43" role-count claim (see
255    /// [`super`]'s module docs' partition table), the same pattern
256    /// `frust_theme::color`'s `deprecated_roles_absent` test uses.
257    #[test]
258    fn all_43_m3e_roles_are_reachable() {
259        let scheme = color_scheme_light();
260        let baseline_roles: [Color; 34] = [
261            scheme.primary,
262            scheme.on_primary,
263            scheme.primary_container,
264            scheme.on_primary_container,
265            scheme.secondary,
266            scheme.on_secondary,
267            scheme.secondary_container,
268            scheme.on_secondary_container,
269            scheme.tertiary,
270            scheme.on_tertiary,
271            scheme.tertiary_container,
272            scheme.on_tertiary_container,
273            scheme.error,
274            scheme.on_error,
275            scheme.error_container,
276            scheme.on_error_container,
277            scheme.surface,
278            scheme.on_surface,
279            scheme.on_surface_variant,
280            scheme.surface_container_lowest,
281            scheme.surface_container_low,
282            scheme.surface_container,
283            scheme.surface_container_high,
284            scheme.surface_container_highest,
285            scheme.surface_dim,
286            scheme.surface_bright,
287            scheme.inverse_surface,
288            scheme.inverse_on_surface,
289            scheme.inverse_primary,
290            scheme.outline,
291            scheme.outline_variant,
292            scheme.shadow,
293            scheme.scrim,
294            scheme.surface_tint,
295        ];
296        let semantic = semantic_light();
297        let semantic_roles: [Color; 9] = [
298            semantic.emphasis,
299            semantic.on_emphasis,
300            semantic.info,
301            semantic.success,
302            semantic.warning,
303            semantic.danger,
304            semantic.surface_strong,
305            semantic.on_surface_strong,
306            semantic.outline_strong,
307        ];
308        assert_eq!(baseline_roles.len() + semantic_roles.len(), 43);
309    }
310
311    #[test]
312    fn semantic_light_and_dark_are_distinct() {
313        assert_ne!(semantic_light(), semantic_dark());
314        assert_ne!(semantic_light().emphasis, semantic_dark().emphasis);
315        assert_ne!(
316            semantic_light().surface_strong,
317            semantic_dark().surface_strong
318        );
319    }
320
321    #[test]
322    fn semantic_derived_roles_mirror_the_baseline_scheme() {
323        let scheme = color_scheme_light();
324        let semantic = semantic_light();
325        assert_eq!(semantic.emphasis, scheme.primary);
326        assert_eq!(semantic.on_emphasis, scheme.on_primary);
327        assert_eq!(semantic.info, scheme.tertiary);
328        assert_eq!(semantic.danger, scheme.error);
329        assert_eq!(semantic.on_surface_strong, scheme.on_surface);
330        assert_eq!(semantic.outline_strong, scheme.outline);
331
332        let dark_scheme = color_scheme_dark();
333        let dark_semantic = semantic_dark();
334        assert_eq!(dark_semantic.emphasis, dark_scheme.primary);
335        assert_eq!(dark_semantic.on_emphasis, dark_scheme.on_primary);
336        assert_eq!(dark_semantic.info, dark_scheme.tertiary);
337        assert_eq!(dark_semantic.danger, dark_scheme.error);
338        assert_eq!(dark_semantic.on_surface_strong, dark_scheme.on_surface);
339        assert_eq!(dark_semantic.outline_strong, dark_scheme.outline);
340    }
341
342    #[test]
343    fn semantic_independent_constants_match_the_reference() {
344        assert_eq!(semantic_light().success, Color::from_rgb8(0x2E, 0x7D, 0x32));
345        assert_eq!(semantic_light().warning, Color::from_rgb8(0xEF, 0x6C, 0x00));
346        assert_eq!(semantic_dark().success, Color::from_rgb8(0x81, 0xC7, 0x84));
347        assert_eq!(semantic_dark().warning, Color::from_rgb8(0xFF, 0xB7, 0x4D));
348    }
349
350    #[test]
351    fn surface_strong_matches_the_alpha_blend_formula() {
352        // Cross-check against `frust_theme::color`'s own blend precedent
353        // (`ColorScheme::with_accent`'s `blend_accent_over_surface` helper)
354        // at a 6% alpha instead of that helper's 10% — same formula, a
355        // different mix, verifying the hand-baked constant above wasn't
356        // mis-transcribed.
357        fn blend(accent: Color, surface: Color, alpha: f64) -> Color {
358            let [ar, ag, ab, _] = accent.to_rgba8().to_u8_array();
359            let [sr, sg, sb, _] = surface.to_rgba8().to_u8_array();
360            let mix = |a: u8, s: u8| -> u8 {
361                ((a as f64 / 255.0 * alpha + s as f64 / 255.0 * (1.0 - alpha)) * 255.0).round()
362                    as u8
363            };
364            Color::from_rgb8(mix(ar, sr), mix(ag, sg), mix(ab, sb))
365        }
366
367        let light = color_scheme_light();
368        assert_eq!(
369            semantic_light().surface_strong,
370            blend(light.primary, light.surface, 0.06)
371        );
372        let dark = color_scheme_dark();
373        assert_eq!(
374            semantic_dark().surface_strong,
375            blend(dark.primary, dark.surface, 0.06)
376        );
377    }
378}