Skip to main content

ishou_render/
css.rs

1//! CSS renderer — emits `:root { ... }` custom properties + a small set of
2//! opinionated utility classes (brand typography, shadow modifiers, swerve
3//! backdrop). The utility layer is narrow on purpose — consumers use Tailwind
4//! for general utility, ishou owns the brand-critical pieces.
5
6use ishou_tokens::{ColorPalette, Rgb, Shadow, ShadowSpec, TokenSet};
7
8pub fn render(t: &TokenSet) -> String {
9    let mut out = String::from("/* ishou — pleme-io design tokens (generated; do not edit) */\n\n");
10    out.push_str(":root {\n");
11    palette_vars(&mut out, &t.color);
12    role_vars(&mut out, t);
13    typography_vars(&mut out, t);
14    spacing_vars(&mut out, t);
15    radius_vars(&mut out, t);
16    shadow_vars(&mut out, &t.color, &t.shadow);
17    motion_vars(&mut out, t);
18    out.push_str("}\n\n");
19    utility_classes(&mut out);
20    out
21}
22
23fn palette_vars(out: &mut String, c: &ColorPalette) {
24    out.push_str("  /* palette */\n");
25    for (k, v) in c.entries() {
26        out.push_str(&format!(
27            "  --ishou-{}: {};\n",
28            k.replace('_', "-"),
29            v.hex()
30        ));
31    }
32}
33
34fn role_vars(out: &mut String, t: &TokenSet) {
35    out.push_str("  /* semantic roles */\n");
36    for (role, palette_key) in t.roles.pairs() {
37        out.push_str(&format!(
38            "  --ishou-color-{role}: var(--ishou-{});\n",
39            palette_key.replace('_', "-")
40        ));
41    }
42}
43
44fn typography_vars(out: &mut String, t: &TokenSet) {
45    let f = &t.typography.families;
46    out.push_str("  /* typography */\n");
47    out.push_str(&format!("  --ishou-font-serif: {};\n", f.serif));
48    out.push_str(&format!("  --ishou-font-sans: {};\n", f.sans));
49    out.push_str(&format!("  --ishou-font-mono: {};\n", f.mono));
50    out.push_str(&format!("  --ishou-font-display: {};\n", f.display));
51    let s = &t.typography.scale;
52    for (k, v) in [
53        ("xs", s.xs),
54        ("sm", s.sm),
55        ("base", s.base),
56        ("md", s.md),
57        ("lg", s.lg),
58        ("xl", s.xl),
59        ("2xl", s.x2),
60        ("3xl", s.x3),
61        ("4xl", s.x4),
62    ] {
63        out.push_str(&format!("  --ishou-text-{k}: {v}rem;\n"));
64    }
65    let w = &t.typography.weight;
66    for (k, v) in [
67        ("light", w.light),
68        ("regular", w.regular),
69        ("medium", w.medium),
70        ("semibold", w.semibold),
71        ("bold", w.bold),
72    ] {
73        out.push_str(&format!("  --ishou-weight-{k}: {v};\n"));
74    }
75    let lh = &t.typography.line_height;
76    for (k, v) in [
77        ("tight", lh.tight),
78        ("base", lh.base),
79        ("relaxed", lh.relaxed),
80        ("prose", lh.prose),
81    ] {
82        out.push_str(&format!("  --ishou-leading-{k}: {v};\n"));
83    }
84}
85
86fn spacing_vars(out: &mut String, t: &TokenSet) {
87    out.push_str("  /* spacing (px) */\n");
88    for (k, v) in t.spacing.pairs() {
89        out.push_str(&format!("  --ishou-space-{k}: {v}px;\n"));
90    }
91}
92
93fn radius_vars(out: &mut String, t: &TokenSet) {
94    out.push_str("  /* radius */\n");
95    for (k, v) in t.radius.pairs() {
96        let suffix = if v >= 9999 {
97            "9999px".to_string()
98        } else {
99            format!("{v}px")
100        };
101        out.push_str(&format!("  --ishou-radius-{k}: {suffix};\n"));
102    }
103}
104
105fn shadow_vars(out: &mut String, palette: &ColorPalette, shadow: &Shadow) {
106    out.push_str("  /* shadow (elevation) */\n");
107    for (k, spec) in shadow.pairs() {
108        out.push_str(&format!(
109            "  --ishou-shadow-{k}: {};\n",
110            shadow_css(spec, palette.shadow_tone)
111        ));
112    }
113}
114
115fn shadow_css(spec: &ShadowSpec, tone: Rgb) -> String {
116    if spec.alpha_pct == 0 {
117        return "none".into();
118    }
119    let alpha = f32::from(spec.alpha_pct) / 100.0;
120    format!(
121        "{}px {}px {}px {}px rgba({}, {}, {}, {:.2})",
122        spec.offset_x, spec.offset_y, spec.blur, spec.spread, tone.r, tone.g, tone.b, alpha
123    )
124}
125
126fn motion_vars(out: &mut String, t: &TokenSet) {
127    out.push_str("  /* motion */\n");
128    let d = &t.motion.duration;
129    for (k, v) in [
130        ("instant", d.instant_ms),
131        ("fast", d.fast_ms),
132        ("base", d.base_ms),
133        ("slow", d.slow_ms),
134        ("hero", d.hero_ms),
135    ] {
136        out.push_str(&format!("  --ishou-duration-{k}: {v}ms;\n"));
137    }
138    let e = &t.motion.easing;
139    for (k, c) in [
140        ("standard", e.standard),
141        ("decelerate", e.decelerate),
142        ("accelerate", e.accelerate),
143        ("sonic-boom", e.sonic_boom),
144        ("saber", e.saber),
145    ] {
146        out.push_str(&format!(
147            "  --ishou-ease-{k}: cubic-bezier({:.3}, {:.3}, {:.3}, {:.3});\n",
148            c.0, c.1, c.2, c.3
149        ));
150    }
151}
152
153fn utility_classes(out: &mut String) {
154    out.push_str(
155        r#"/* Minimal utility layer — brand-critical only. Use Tailwind for everything else. */
156
157.ishou-shadow-brand {
158  box-shadow: var(--ishou-shadow-brand-bold);
159}
160
161.ishou-serif { font-family: var(--ishou-font-serif); }
162.ishou-sans { font-family: var(--ishou-font-sans); }
163.ishou-mono { font-family: var(--ishou-font-mono); }
164
165.ishou-surface {
166  background: var(--ishou-color-surface);
167  color: var(--ishou-color-text);
168  border-radius: var(--ishou-radius-md);
169}
170
171.ishou-ink { color: var(--ishou-color-ink); background: var(--ishou-color-paper); }
172.ishou-paper { color: var(--ishou-color-paper); background: var(--ishou-color-ink); }
173"#,
174    );
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use ishou_tokens::TokenSet;
181
182    #[test]
183    fn render_is_deterministic() {
184        let a = render(&TokenSet::pleme());
185        let b = render(&TokenSet::pleme());
186        assert_eq!(a, b);
187    }
188
189    #[test]
190    fn render_contains_every_role() {
191        let t = TokenSet::pleme();
192        let out = render(&t);
193        for (role, _) in t.roles.pairs() {
194            let prop = format!("--ishou-color-{role}:");
195            assert!(out.contains(&prop), "missing role: {role}");
196        }
197    }
198}