Skip to main content

ishou_render/
md3.rs

1//! Material Design 3 system-color renderer — emits the 34 `--md-sys-color-*`
2//! CSS custom properties Material consumers (e.g. `pleme-mui`) expect, every one
3//! MAPPED from the ishou [`TokenSet`]. This is the render target that lets the
4//! Material component library consume ishou instead of forking its own
5//! hard-coded MD3 palette — the move that collapses the design-system fork
6//! (`ishou` ↔ `pleme-mui`/`irodori`) the fleet audit named.
7//!
8//! ishou's token sets are dark-first (Nord / Vellum), so the mapping is a dark
9//! MD3 scheme: MD3's tonal roles are bound to ishou's semantic roles + the
10//! 4-step `polar_night` surface ladder. Render this target from a different
11//! `TokenSet` (a light or brand theme) to get that theme's MD3 surface — the
12//! *mapping* is one place, the *theme* is the input. The `on-*` (contrast) roles
13//! bind to the opposing end of the scale (dark `background` on the light
14//! accents; light `text` on the dark surfaces).
15
16use ishou_tokens::{Rgb, TokenSet};
17
18/// Resolve a semantic role name (kebab, as in [`SemanticRoles::pairs`]) to hex
19/// via the TokenSet's role→palette binding. Every pleme/vellum role is always
20/// bound, so the `background` fallback is unreachable in practice.
21fn role(t: &TokenSet, name: &str) -> String {
22    let key = t
23        .roles
24        .pairs()
25        .into_iter()
26        .find(|(r, _)| *r == name)
27        .map(|(_, k)| k)
28        .unwrap_or("background");
29    palette(t, key)
30}
31
32/// Resolve a palette key (snake_case, e.g. `polar_night_0`, `ink`) to hex.
33fn palette(t: &TokenSet, key: &str) -> String {
34    t.color
35        .get(key)
36        .map(|c: Rgb| c.hex())
37        .unwrap_or_else(|| "#000000".to_string())
38}
39
40/// Render the MD3 system-color sheet from a token set.
41#[must_use]
42pub fn render(t: &TokenSet) -> String {
43    // (md-sys-color suffix, resolved hex from ishou). The mapping is the only
44    // new design decision; the values all come from the TokenSet.
45    let pairs: [(&str, String); 34] = [
46        ("primary", role(t, "primary")),
47        ("on-primary", role(t, "background")),
48        ("primary-container", role(t, "structural")),
49        ("on-primary-container", role(t, "text")),
50        ("secondary", role(t, "structural")),
51        ("on-secondary", role(t, "background")),
52        ("secondary-container", role(t, "surface-elevated")),
53        ("on-secondary-container", role(t, "text")),
54        ("tertiary", role(t, "accent")),
55        ("on-tertiary", role(t, "background")),
56        ("tertiary-container", role(t, "surface-elevated")),
57        ("on-tertiary-container", role(t, "text")),
58        ("error", role(t, "error")),
59        ("on-error", role(t, "background")),
60        ("error-container", role(t, "surface-elevated")),
61        ("on-error-container", role(t, "text")),
62        ("background", role(t, "background")),
63        ("on-background", role(t, "text")),
64        ("surface", role(t, "surface")),
65        ("on-surface", role(t, "text")),
66        ("surface-variant", role(t, "surface-elevated")),
67        ("on-surface-variant", role(t, "text-muted")),
68        ("outline", role(t, "text-dim")),
69        ("outline-variant", role(t, "surface-elevated")),
70        ("inverse-surface", role(t, "text")),
71        ("inverse-on-surface", role(t, "background")),
72        ("inverse-primary", role(t, "structural")),
73        ("surface-dim", role(t, "background")),
74        ("surface-bright", role(t, "text-dim")),
75        ("surface-container-lowest", palette(t, "ink")),
76        ("surface-container-low", role(t, "background")),
77        ("surface-container", role(t, "surface")),
78        ("surface-container-high", role(t, "surface-elevated")),
79        ("surface-container-highest", role(t, "text-dim")),
80    ];
81
82    let mut out =
83        String::from("/* ishou — Material Design 3 system colors (generated; do not edit) */\n\n");
84    out.push_str(":root {\n");
85    for (suffix, hex) in &pairs {
86        out.push_str(&format!("  --md-sys-color-{suffix}: {hex};\n"));
87    }
88    out.push_str("}\n");
89    out
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    /// The exact `--md-sys-color-*` set `pleme-mui::theme::Md3Tokens` consumes.
97    const MD3_SYSTEM_COLORS: [&str; 34] = [
98        "primary",
99        "on-primary",
100        "primary-container",
101        "on-primary-container",
102        "secondary",
103        "on-secondary",
104        "secondary-container",
105        "on-secondary-container",
106        "tertiary",
107        "on-tertiary",
108        "tertiary-container",
109        "on-tertiary-container",
110        "error",
111        "on-error",
112        "error-container",
113        "on-error-container",
114        "background",
115        "on-background",
116        "surface",
117        "on-surface",
118        "surface-variant",
119        "on-surface-variant",
120        "outline",
121        "outline-variant",
122        "inverse-surface",
123        "inverse-on-surface",
124        "inverse-primary",
125        "surface-dim",
126        "surface-bright",
127        "surface-container-lowest",
128        "surface-container-low",
129        "surface-container",
130        "surface-container-high",
131        "surface-container-highest",
132    ];
133
134    #[test]
135    fn emits_every_md3_system_color_pleme_mui_consumes() {
136        let out = render(&TokenSet::pleme());
137        for prop in MD3_SYSTEM_COLORS {
138            let needle = format!("--md-sys-color-{prop}:");
139            assert!(out.contains(&needle), "missing MD3 color: {prop}");
140        }
141    }
142
143    #[test]
144    fn every_role_resolves_to_a_real_token_hex() {
145        let out = render(&TokenSet::pleme());
146        // The "#000000" sentinel only appears if a role failed to resolve.
147        assert!(
148            !out.contains("#000000"),
149            "an MD3 role failed to resolve from ishou tokens"
150        );
151        // Exactly 34 resolved hex values (one per system color), no more.
152        assert_eq!(
153            out.matches('#').count(),
154            34,
155            "expected 34 resolved hex values"
156        );
157    }
158
159    #[test]
160    fn is_deterministic() {
161        assert_eq!(render(&TokenSet::pleme()), render(&TokenSet::pleme()));
162    }
163
164    #[test]
165    fn steel_theme_renders_the_metallic_surface() {
166        let out = render(&TokenSet::steel());
167        // primary ← frost_1 = the blued-steel #5E8CC4
168        assert!(
169            out.contains("--md-sys-color-primary: #5E8CC4"),
170            "steel primary should be blued-steel"
171        );
172        // background ← polar_night_0 = the machined near-black
173        assert!(out.contains("--md-sys-color-background: #0B0E12"));
174        // still fully resolved (no sentinel)
175        assert!(!out.contains("#000000"), "steel md3 should fully resolve");
176    }
177}