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!("  --ishou-{}: {};\n", k.replace('_', "-"), v.hex()));
27    }
28}
29
30fn role_vars(out: &mut String, t: &TokenSet) {
31    out.push_str("  /* semantic roles */\n");
32    for (role, palette_key) in t.roles.pairs() {
33        out.push_str(&format!(
34            "  --ishou-color-{role}: var(--ishou-{});\n",
35            palette_key.replace('_', "-")
36        ));
37    }
38}
39
40fn typography_vars(out: &mut String, t: &TokenSet) {
41    let f = &t.typography.families;
42    out.push_str("  /* typography */\n");
43    out.push_str(&format!("  --ishou-font-serif: {};\n", f.serif));
44    out.push_str(&format!("  --ishou-font-sans: {};\n", f.sans));
45    out.push_str(&format!("  --ishou-font-mono: {};\n", f.mono));
46    out.push_str(&format!("  --ishou-font-display: {};\n", f.display));
47    let s = &t.typography.scale;
48    for (k, v) in [
49        ("xs", s.xs), ("sm", s.sm), ("base", s.base), ("md", s.md),
50        ("lg", s.lg), ("xl", s.xl), ("2xl", s.x2), ("3xl", s.x3), ("4xl", s.x4),
51    ] {
52        out.push_str(&format!("  --ishou-text-{k}: {v}rem;\n"));
53    }
54    let w = &t.typography.weight;
55    for (k, v) in [
56        ("light", w.light), ("regular", w.regular), ("medium", w.medium),
57        ("semibold", w.semibold), ("bold", w.bold),
58    ] {
59        out.push_str(&format!("  --ishou-weight-{k}: {v};\n"));
60    }
61    let lh = &t.typography.line_height;
62    for (k, v) in [
63        ("tight", lh.tight), ("base", lh.base), ("relaxed", lh.relaxed), ("prose", lh.prose),
64    ] {
65        out.push_str(&format!("  --ishou-leading-{k}: {v};\n"));
66    }
67}
68
69fn spacing_vars(out: &mut String, t: &TokenSet) {
70    out.push_str("  /* spacing (px) */\n");
71    for (k, v) in t.spacing.pairs() {
72        out.push_str(&format!("  --ishou-space-{k}: {v}px;\n"));
73    }
74}
75
76fn radius_vars(out: &mut String, t: &TokenSet) {
77    out.push_str("  /* radius */\n");
78    for (k, v) in t.radius.pairs() {
79        let suffix = if v >= 9999 { "9999px".to_string() } else { format!("{v}px") };
80        out.push_str(&format!("  --ishou-radius-{k}: {suffix};\n"));
81    }
82}
83
84fn shadow_vars(out: &mut String, palette: &ColorPalette, shadow: &Shadow) {
85    out.push_str("  /* shadow (elevation) */\n");
86    for (k, spec) in shadow.pairs() {
87        out.push_str(&format!(
88            "  --ishou-shadow-{k}: {};\n",
89            shadow_css(spec, palette.shadow_tone)
90        ));
91    }
92}
93
94fn shadow_css(spec: &ShadowSpec, tone: Rgb) -> String {
95    if spec.alpha_pct == 0 {
96        return "none".into();
97    }
98    let alpha = f32::from(spec.alpha_pct) / 100.0;
99    format!(
100        "{}px {}px {}px {}px rgba({}, {}, {}, {:.2})",
101        spec.offset_x, spec.offset_y, spec.blur, spec.spread, tone.r, tone.g, tone.b, alpha
102    )
103}
104
105fn motion_vars(out: &mut String, t: &TokenSet) {
106    out.push_str("  /* motion */\n");
107    let d = &t.motion.duration;
108    for (k, v) in [
109        ("instant", d.instant_ms), ("fast", d.fast_ms), ("base", d.base_ms),
110        ("slow", d.slow_ms), ("hero", d.hero_ms),
111    ] {
112        out.push_str(&format!("  --ishou-duration-{k}: {v}ms;\n"));
113    }
114    let e = &t.motion.easing;
115    for (k, c) in [
116        ("standard", e.standard), ("decelerate", e.decelerate), ("accelerate", e.accelerate),
117        ("sonic-boom", e.sonic_boom), ("saber", e.saber),
118    ] {
119        out.push_str(&format!(
120            "  --ishou-ease-{k}: cubic-bezier({:.3}, {:.3}, {:.3}, {:.3});\n",
121            c.0, c.1, c.2, c.3
122        ));
123    }
124}
125
126fn utility_classes(out: &mut String) {
127    out.push_str(
128r#"/* Minimal utility layer — brand-critical only. Use Tailwind for everything else. */
129
130.ishou-shadow-brand {
131  box-shadow: var(--ishou-shadow-brand-bold);
132}
133
134.ishou-serif { font-family: var(--ishou-font-serif); }
135.ishou-sans { font-family: var(--ishou-font-sans); }
136.ishou-mono { font-family: var(--ishou-font-mono); }
137
138.ishou-surface {
139  background: var(--ishou-color-surface);
140  color: var(--ishou-color-text);
141  border-radius: var(--ishou-radius-md);
142}
143
144.ishou-ink { color: var(--ishou-color-ink); background: var(--ishou-color-paper); }
145.ishou-paper { color: var(--ishou-color-paper); background: var(--ishou-color-ink); }
146"#,
147    );
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use ishou_tokens::TokenSet;
154
155    #[test]
156    fn render_is_deterministic() {
157        let a = render(&TokenSet::pleme());
158        let b = render(&TokenSet::pleme());
159        assert_eq!(a, b);
160    }
161
162    #[test]
163    fn render_contains_every_role() {
164        let t = TokenSet::pleme();
165        let out = render(&t);
166        for (role, _) in t.roles.pairs() {
167            let prop = format!("--ishou-color-{role}:");
168            assert!(out.contains(&prop), "missing role: {role}");
169        }
170    }
171}