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", "on-primary", "primary-container", "on-primary-container",
99        "secondary", "on-secondary", "secondary-container", "on-secondary-container",
100        "tertiary", "on-tertiary", "tertiary-container", "on-tertiary-container",
101        "error", "on-error", "error-container", "on-error-container",
102        "background", "on-background", "surface", "on-surface",
103        "surface-variant", "on-surface-variant", "outline", "outline-variant",
104        "inverse-surface", "inverse-on-surface", "inverse-primary",
105        "surface-dim", "surface-bright", "surface-container-lowest",
106        "surface-container-low", "surface-container", "surface-container-high",
107        "surface-container-highest",
108    ];
109
110    #[test]
111    fn emits_every_md3_system_color_pleme_mui_consumes() {
112        let out = render(&TokenSet::pleme());
113        for prop in MD3_SYSTEM_COLORS {
114            let needle = format!("--md-sys-color-{prop}:");
115            assert!(out.contains(&needle), "missing MD3 color: {prop}");
116        }
117    }
118
119    #[test]
120    fn every_role_resolves_to_a_real_token_hex() {
121        let out = render(&TokenSet::pleme());
122        // The "#000000" sentinel only appears if a role failed to resolve.
123        assert!(
124            !out.contains("#000000"),
125            "an MD3 role failed to resolve from ishou tokens"
126        );
127        // Exactly 34 resolved hex values (one per system color), no more.
128        assert_eq!(out.matches('#').count(), 34, "expected 34 resolved hex values");
129    }
130
131    #[test]
132    fn is_deterministic() {
133        assert_eq!(render(&TokenSet::pleme()), render(&TokenSet::pleme()));
134    }
135
136    #[test]
137    fn steel_theme_renders_the_metallic_surface() {
138        let out = render(&TokenSet::steel());
139        // primary ← frost_1 = the blued-steel #5E8CC4
140        assert!(
141            out.contains("--md-sys-color-primary: #5E8CC4"),
142            "steel primary should be blued-steel"
143        );
144        // background ← polar_night_0 = the machined near-black
145        assert!(out.contains("--md-sys-color-background: #0B0E12"));
146        // still fully resolved (no sentinel)
147        assert!(!out.contains("#000000"), "steel md3 should fully resolve");
148    }
149}