Skip to main content

frust_material/tokens/
extension.rs

1//! [`MaterialTokens`]: the typed `ThemeExtensions` payload carrying the nine
2//! M3E color roles `ColorScheme` has no field for — `emphasis`/`onEmphasis`,
3//! `info`, `success`, `warning`, `danger`, `surfaceStrong`/`onSurfaceStrong`,
4//! and `outlineStrong`. See [`crate::tokens`]'s module docs for the full
5//! 43-role partition table (and its "Two semantic vocabularies" section) and
6//! [`crate::tokens::color`]'s module docs for how each field here is
7//! sourced.
8//!
9//! **`info`/`success`/`warning` also name fields on `frust::StatusPalette`**
10//! (the framework-wide status trio resolved by sibling design-system
11//! catalogs — Glyph's badge/alert/toast family, Cupertino — and app code;
12//! baseline `frust-widgets` itself has no `StatusPalette` consumer) — at
13//! deliberately different values. The two are different
14//! vocabularies that share role names by coincidence, not one role table
15//! with two readers: `MaterialTokens` is the M3E reference's own semantic
16//! set (this crate's catalog code resolves only this one), `StatusPalette`
17//! is the pre-existing framework contract (unrelated to the M3E port).
18//! Neither overrides the other. See [`crate::tokens`]'s module docs for the
19//! full rule and the value-pinning test.
20//!
21//! Mirrors `frust-shadcn`'s `ShadcnTokens` shape: a `Clone + Debug +
22//! PartialEq` payload carrying both brightnesses at once (a design-system
23//! theme may flip brightness live without rebuilding), attached by
24//! [`super::baseline`] via the framework's `ThemeExtensions` bus, with a
25//! brightness-aware getter ([`MaterialTokens::colors`]) and the framework's
26//! documented resolution ladder — **explicit builder value > theme
27//! extension > fallback constant** — spelled out here for
28//! [`resolve_emphasis`](MaterialTokens::resolve_emphasis) and
29//! [`resolve_danger`](MaterialTokens::resolve_danger), the two roles a
30//! component is most likely to need a direct override for (an accent-style
31//! role and a semantic-alert role, respectively). A future component wanting
32//! the ladder for another field follows the same two-rung shape these two
33//! establish.
34
35use frust::{Brightness, Color, Theme};
36
37use super::color::{MaterialSemanticColors, semantic_dark, semantic_light};
38
39/// The unthemed fallback for [`MaterialTokens::resolve_emphasis`] — the
40/// light baseline's `emphasis` role (== `primary`, seed `#6750A4`).
41///
42/// The bottom rung of that method's ladder — used when no theme is threaded
43/// into the pass *and* no explicit value was given.
44pub const FALLBACK_EMPHASIS: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
45
46/// The unthemed fallback for [`MaterialTokens::resolve_danger`] — the light
47/// baseline's `danger` role (== `error`, the M3 baseline light error tone).
48pub const FALLBACK_DANGER: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
49
50/// The Material 3 Expressive semantic-role theme extension:
51/// [`MaterialSemanticColors`] for both brightnesses at once, exactly like
52/// [`frust::Theme`]'s own `light`/`dark` pair. Recovered from a themed app
53/// with `theme.extension::<MaterialTokens>()`.
54///
55/// A `Theme` is cloned across the framework's two delivery paths, so an
56/// extension must be `Any + Send + Sync` — this is plain `Color`-only data
57/// and satisfies that for free.
58///
59/// **Vocabulary note**: [`super::baseline`] attaches this *and* a
60/// `frust::StatusPalette` (via [`super::status_palette`]), and both carry
61/// `info`/`success`/`warning` fields at different values. This is by design
62/// — see [module docs](self) — and frust-material catalog code must resolve
63/// this type, never `StatusPalette`.
64#[derive(Clone, Debug, PartialEq)]
65pub struct MaterialTokens {
66    /// The nine semantic roles, light mode.
67    pub light: MaterialSemanticColors,
68    /// The nine semantic roles, dark mode.
69    pub dark: MaterialSemanticColors,
70}
71
72impl MaterialTokens {
73    /// The Material 3 Expressive baseline extension (seed `#6750A4`) — what
74    /// [`super::baseline`] attaches.
75    pub fn material() -> Self {
76        Self {
77            light: semantic_light(),
78            dark: semantic_dark(),
79        }
80    }
81
82    /// The semantic-role table for `brightness` — mirrors
83    /// [`frust::Theme::scheme`]'s light/dark selector.
84    pub fn colors(&self, brightness: Brightness) -> &MaterialSemanticColors {
85        match brightness {
86            Brightness::Light => &self.light,
87            Brightness::Dark => &self.dark,
88        }
89    }
90
91    /// Resolve the `emphasis` role under the framework's documented
92    /// precedence: **explicit builder value > theme extension > fallback
93    /// constant**. The theme rung reads the extension *and* the theme's own
94    /// brightness, so `emphasis` follows a live light/dark flip with no
95    /// component involvement — the same shape `ShadcnTokens::resolve_ring`
96    /// documents for its own catalog.
97    pub fn resolve_emphasis(explicit: Option<Color>, theme: Option<&Theme>) -> Color {
98        if let Some(color) = explicit {
99            return color;
100        }
101        match theme.and_then(|t| t.extension::<MaterialTokens>().map(|x| (x, t.brightness))) {
102            Some((tokens, brightness)) => tokens.colors(brightness).emphasis,
103            None => FALLBACK_EMPHASIS,
104        }
105    }
106
107    /// Resolve the `danger` role under the same ladder as
108    /// [`resolve_emphasis`](Self::resolve_emphasis).
109    pub fn resolve_danger(explicit: Option<Color>, theme: Option<&Theme>) -> Color {
110        if let Some(color) = explicit {
111            return color;
112        }
113        match theme.and_then(|t| t.extension::<MaterialTokens>().map(|x| (x, t.brightness))) {
114            Some((tokens, brightness)) => tokens.colors(brightness).danger,
115            None => FALLBACK_DANGER,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn material_tokens_attach_round_trips_from_baseline() {
126        let theme = super::super::baseline();
127        let tokens = theme
128            .extension::<MaterialTokens>()
129            .expect("baseline() attaches MaterialTokens");
130        assert_eq!(
131            tokens.colors(Brightness::Light).emphasis,
132            tokens.light.emphasis
133        );
134        assert_ne!(
135            tokens.colors(Brightness::Light).emphasis,
136            tokens.colors(Brightness::Dark).emphasis
137        );
138    }
139
140    #[test]
141    fn material_matches_the_hand_baked_semantic_tables() {
142        assert_eq!(MaterialTokens::material().light, semantic_light());
143        assert_eq!(MaterialTokens::material().dark, semantic_dark());
144    }
145
146    #[test]
147    fn colors_selects_by_brightness() {
148        let tokens = MaterialTokens::material();
149        assert_eq!(tokens.colors(Brightness::Light), &tokens.light);
150        assert_eq!(tokens.colors(Brightness::Dark), &tokens.dark);
151    }
152
153    #[test]
154    fn emphasis_precedence_is_explicit_then_extension_then_fallback() {
155        let explicit = Color::from_rgb8(0x00, 0x00, 0xFF);
156        let theme = super::super::baseline();
157
158        // 1. explicit wins over everything.
159        assert_eq!(
160            MaterialTokens::resolve_emphasis(Some(explicit), Some(&theme)),
161            explicit
162        );
163        // 2. no explicit value: the theme extension wins over the fallback,
164        //    at the theme's own brightness.
165        assert_eq!(
166            MaterialTokens::resolve_emphasis(None, Some(&theme)),
167            MaterialTokens::material().colors(theme.brightness).emphasis
168        );
169        // 3. no theme at all: the fallback constant.
170        assert_eq!(
171            MaterialTokens::resolve_emphasis(None, None),
172            FALLBACK_EMPHASIS
173        );
174    }
175
176    #[test]
177    fn emphasis_follows_the_theme_s_brightness() {
178        let light = super::super::baseline().with_brightness(Brightness::Light);
179        let dark = super::super::baseline().with_brightness(Brightness::Dark);
180        assert_eq!(
181            MaterialTokens::resolve_emphasis(None, Some(&light)),
182            MaterialTokens::material().light.emphasis
183        );
184        assert_eq!(
185            MaterialTokens::resolve_emphasis(None, Some(&dark)),
186            MaterialTokens::material().dark.emphasis
187        );
188    }
189
190    #[test]
191    fn danger_precedence_is_explicit_then_extension_then_fallback() {
192        let explicit = Color::from_rgb8(0x00, 0xFF, 0x00);
193        let theme = super::super::baseline();
194        assert_eq!(
195            MaterialTokens::resolve_danger(Some(explicit), Some(&theme)),
196            explicit
197        );
198        assert_eq!(
199            MaterialTokens::resolve_danger(None, Some(&theme)),
200            MaterialTokens::material().colors(theme.brightness).danger
201        );
202        assert_eq!(MaterialTokens::resolve_danger(None, None), FALLBACK_DANGER);
203    }
204
205    #[test]
206    fn a_theme_with_the_extension_cleared_falls_through_to_the_fallbacks() {
207        // An app is free to build a theme carrying no MaterialTokens; both
208        // resolvers must degrade, not panic.
209        let bare = Theme::neutral();
210        assert!(bare.extension::<MaterialTokens>().is_none());
211        assert_eq!(
212            MaterialTokens::resolve_emphasis(None, Some(&bare)),
213            FALLBACK_EMPHASIS,
214            "a non-Material theme resolves the fallback emphasis color"
215        );
216        assert_eq!(
217            MaterialTokens::resolve_danger(None, Some(&bare)),
218            FALLBACK_DANGER,
219            "a non-Material theme resolves the fallback danger color"
220        );
221    }
222}