Skip to main content

rio_theme/
emit.rs

1//! Serialise `ThemeTokens` into a `tokens.css` string.
2//!
3//! Output is plain hex — all `color-mix()` math has already been done
4//! by the engine. A static file is faster than nested `color-mix()`
5//! at parse time and has zero browser-support risk.
6//!
7//! The `[data-theme="dark"]` block is always emitted even when light
8//! and dark are identical; the *structure* anticipates the dark
9//! theme's return (§8).
10//!
11//! The output has two sections inside the same `:root` block:
12//!
13//! 1. **Canonical brand-* tokens** — the engine's primary output per
14//!    §11 of the implementation brief. These are the names the
15//!    decision layer reasons in: `--rio-brand-light`, `-dark`,
16//!    `-adaptive`, `-surface`, `-accent`, `-hover`, `-active`,
17//!    `-tint`, `-text`.
18//!
19//! 2. **Drop-in compatibility tokens** — the names the live admin
20//!    templates already consume (`--rio-accent*`, the surface ladder,
21//!    the text ladder, the border ladder, semantic backgrounds).
22//!    Without this block the generated file would not actually drop
23//!    into the framework's CSS bundle. Brand-derived tokens
24//!    (`--rio-accent`, `--rio-accent-hover`, `--rio-accent-soft`,
25//!    `--rio-accent-border`, `--rio-bg`, `--rio-border`,
26//!    `--rio-info-bg`) come from the resolved `ThemeTokens`; the
27//!    slate scaffold (`--rio-surface-*`, `--rio-text-*`,
28//!    `--rio-border-soft`, `--rio-border-strong`) is brand-agnostic
29//!    by design — mirrors the live `colors.css` so a brand swap does
30//!    not move the chrome ladder out from under existing components.
31
32use std::fmt::Write as _;
33
34use crate::color::Color;
35use crate::engine::ThemeTokens;
36
37/// Render a drop-in `tokens.css` from a fully resolved `ThemeTokens`.
38///
39/// The single `:root` block carries both the canonical `--rio-brand-*`
40/// vocabulary the engine reasons in, and the legacy `--rio-*` names
41/// the live admin templates already consume. See the module docs for
42/// the rationale behind keeping both.
43pub fn emit(tokens: &ThemeTokens) -> String {
44    let mut s = String::new();
45    s.push_str("/* Generated by rio-theme. Do not edit by hand. */\n");
46    s.push_str(":root {\n");
47
48    // --- Canonical engine output (DESIGN_THEME §11) ---
49    s.push_str("  /* canonical brand-* tokens (engine output) */\n");
50    line(&mut s, "--rio-brand-light", &tokens.brand_light.to_hex());
51    line(&mut s, "--rio-brand-dark", &tokens.brand_dark.to_hex());
52    s.push_str("  --rio-brand-adaptive: var(--rio-brand-light);\n");
53    line(
54        &mut s,
55        "--rio-brand-surface",
56        &tokens.brand_surface.to_hex(),
57    );
58    line(&mut s, "--rio-brand-accent", &tokens.brand_accent.to_hex());
59    line(
60        &mut s,
61        "--rio-brand-secondary",
62        &tokens.brand_secondary.to_hex(),
63    );
64    line(&mut s, "--rio-brand-hover", &tokens.brand_hover.to_hex());
65    line(&mut s, "--rio-brand-active", &tokens.brand_active.to_hex());
66    line(&mut s, "--rio-brand-tint", &tokens.brand_tint.to_hex());
67    line(&mut s, "--rio-brand-text", &tokens.brand_text.to_hex());
68    line(&mut s, "--rio-muted", &tokens.muted.to_hex());
69
70    // --- Drop-in compatibility for the live admin template ---
71    s.push('\n');
72    s.push_str("  /* drop-in aliases for the live admin template */\n");
73
74    // Brand-derived aliases. The live admin uses `--rio-accent` for
75    // BUTTONS and other large affordances, so it must track the
76    // tamed `brand_surface` (canonical "large fills" role), not the
77    // raw `brand_accent` (canonical "small touches"). For non-vivid
78    // inputs the two are equal so this is a no-op; for neon inputs
79    // it's the difference between a usable button and an unreadable
80    // one.
81    line(&mut s, "--rio-accent", &tokens.brand_surface.to_hex());
82    line(&mut s, "--rio-accent-hover", &tokens.brand_hover.to_hex());
83    line(
84        &mut s,
85        "--rio-accent-rgb",
86        &rgb_triple(&tokens.brand_surface),
87    );
88    line(&mut s, "--rio-accent-soft", &tokens.brand_tint.to_hex());
89    // accent-border: a mid-light brand tint. Live framework uses
90    // this for focus rings + input borders, where the visual job is
91    // "lighter than the brand but the same family". Always lightened
92    // brand-surface — secondary brand colors don't fit this role
93    // (they're for badges/dots, surfaced as `--rio-brand-secondary`).
94    line(
95        &mut s,
96        "--rio-accent-border",
97        &tokens.brand_surface.lighten(0.65).to_hex(),
98    );
99    line(&mut s, "--rio-bg", &tokens.bg.to_hex());
100
101    // Slate scaffold — brand-agnostic. Values lifted from the live
102    // `colors.css` so generated themes inherit the same depth metaphor
103    // and chrome relationship.
104    line(&mut s, "--rio-surface", "#ffffff");
105    line(&mut s, "--rio-surface-2", "#f8fafc");
106    line(&mut s, "--rio-surface-3", "#f1f5f9");
107    line(&mut s, "--rio-surface-chrome", "#0f172a");
108    line(&mut s, "--rio-surface-elevated", "#ffffff");
109
110    line(&mut s, "--rio-text-strong", "#0f172a");
111    line(&mut s, "--rio-text", "#1e293b");
112    line(&mut s, "--rio-text-muted", "#475569");
113    line(&mut s, "--rio-text-subtle", "#64748b");
114
115    line(&mut s, "--rio-border-soft", "#e2e8f0");
116    line(&mut s, "--rio-border", &tokens.border.to_hex());
117    line(&mut s, "--rio-border-strong", "#94a3b8");
118
119    // Semantic foreground + matching soft backgrounds. Backgrounds
120    // are computed from the (possibly hue-shifted) foregrounds, so a
121    // shifted danger keeps a matching shifted danger-bg.
122    line(&mut s, "--rio-success", &tokens.success.to_hex());
123    line(&mut s, "--rio-warning", &tokens.warning.to_hex());
124    line(&mut s, "--rio-danger", &tokens.danger.to_hex());
125    line(
126        &mut s,
127        "--rio-success-bg",
128        &soft_bg(&tokens.success).to_hex(),
129    );
130    line(
131        &mut s,
132        "--rio-warning-bg",
133        &soft_bg(&tokens.warning).to_hex(),
134    );
135    line(&mut s, "--rio-danger-bg", &soft_bg(&tokens.danger).to_hex());
136    line(&mut s, "--rio-info-bg", &tokens.brand_tint.to_hex());
137
138    // Chart series.
139    for (i, c) in tokens.chart.iter().enumerate() {
140        let name = format!("--rio-chart-{}", i + 1);
141        line(&mut s, &name, &c.to_hex());
142    }
143
144    s.push_str("}\n\n");
145    s.push_str(":root[data-theme=\"dark\"] {\n");
146    s.push_str("  --rio-brand-adaptive: var(--rio-brand-dark);\n");
147    s.push_str("}\n");
148    s
149}
150
151fn line(s: &mut String, name: &str, value: &str) {
152    // Fixed two-space indent and a single space after the colon. No
153    // alignment by length — golden-file stability beats prettiness.
154    let _ = writeln!(s, "  {name}: {value};");
155}
156
157/// Space-separated R G B 0..255 triple, matching the live
158/// `--rio-accent-rgb` convention (so `rgb(var(--rio-accent-rgb) / 0.2)`
159/// keeps working in alpha-tinted overlays).
160fn rgb_triple(color: &Color) -> String {
161    // Reparse the hex to get the quantized channels — guarantees the
162    // RGB triple agrees with the hex value the file already prints.
163    let hex = color.to_hex();
164    let r = u8::from_str_radix(&hex[1..3], 16).expect("emitted hex is valid");
165    let g = u8::from_str_radix(&hex[3..5], 16).expect("emitted hex is valid");
166    let b = u8::from_str_radix(&hex[5..7], 16).expect("emitted hex is valid");
167    format!("{r} {g} {b}")
168}
169
170/// Soft pill background derived from a semantic foreground.
171///
172/// Starts at 92% white (visually a soft tint in the foreground's hue
173/// family) and walks lighter in 1% increments until the foreground
174/// clears `AA_NON_TEXT` (3.0) against the resulting background.
175/// Bounded at 99% so we never collapse to pure white. The
176/// hand-tuned tailwind `-50` colors (`#ECFDF5`, `#FFFBEB`,
177/// `#FEF2F2`) pass at slightly lighter mixes than a flat 92% would
178/// produce — without the loop, amber warning-on-bg lands at 2.94,
179/// below the 3.0 threshold for pill text.
180fn soft_bg(fg: &Color) -> Color {
181    let white = Color::from_hex("#ffffff").expect("constant");
182    let mut amount = 0.92_f64;
183    loop {
184        let bg = fg.mix(&white, amount);
185        if amount >= 0.99
186            || crate::contrast::contrast_ratio(fg, &bg) >= crate::contrast::AA_NON_TEXT
187        {
188            return bg;
189        }
190        amount += 0.01;
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::engine::{resolve_theme, ThemeInput};
198
199    #[test]
200    fn emit_contains_every_canonical_brand_token() {
201        let css = emit(&resolve_theme(ThemeInput::empty()));
202        for name in [
203            "--rio-brand-light",
204            "--rio-brand-dark",
205            "--rio-brand-adaptive",
206            "--rio-brand-surface",
207            "--rio-brand-accent",
208            "--rio-brand-secondary",
209            "--rio-brand-hover",
210            "--rio-brand-active",
211            "--rio-brand-tint",
212            "--rio-brand-text",
213            "--rio-muted",
214        ] {
215            assert!(css.contains(name), "missing canonical {name}");
216        }
217    }
218
219    #[test]
220    fn emit_contains_every_live_template_token() {
221        // If this list ever drifts from the live `colors.css`, the
222        // generated file stops being a drop-in. Update both together.
223        let css = emit(&resolve_theme(ThemeInput::empty()));
224        for name in [
225            "--rio-accent",
226            "--rio-accent-hover",
227            "--rio-accent-rgb",
228            "--rio-accent-soft",
229            "--rio-accent-border",
230            "--rio-bg",
231            "--rio-surface",
232            "--rio-surface-2",
233            "--rio-surface-3",
234            "--rio-surface-chrome",
235            "--rio-surface-elevated",
236            "--rio-text-strong",
237            "--rio-text",
238            "--rio-text-muted",
239            "--rio-text-subtle",
240            "--rio-border-soft",
241            "--rio-border",
242            "--rio-border-strong",
243            "--rio-success",
244            "--rio-warning",
245            "--rio-danger",
246            "--rio-success-bg",
247            "--rio-warning-bg",
248            "--rio-danger-bg",
249            "--rio-info-bg",
250        ] {
251            assert!(css.contains(name), "missing drop-in alias {name}");
252        }
253    }
254
255    #[test]
256    fn accent_rgb_triple_agrees_with_accent_hex() {
257        // The triple is what `rgb(var(--rio-accent-rgb) / α)` uses,
258        // so it must quantize to the same bytes as `--rio-accent`'s
259        // hex. Drop-in `--rio-accent` aliases `brand_surface` (see
260        // the comment in `emit`).
261        let tokens = resolve_theme(ThemeInput::empty());
262        let css = emit(&tokens);
263        let hex = tokens.brand_surface.to_hex();
264        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
265        let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
266        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
267        let expected = format!("--rio-accent-rgb: {r} {g} {b};");
268        assert!(css.contains(&expected), "expected `{expected}` in:\n{css}");
269    }
270
271    #[test]
272    fn dark_block_is_always_emitted() {
273        let css = emit(&resolve_theme(ThemeInput::empty()));
274        assert!(css.contains(":root[data-theme=\"dark\"]"));
275    }
276
277    #[test]
278    fn soft_bg_always_clears_aa_non_text_against_its_foreground() {
279        // Property test for the contrast-aware `soft_bg` loop.
280        // Regression for the bug verification surfaced: a flat 92%
281        // white mix left amber warning at 2.94 (below AA-large 3.0)
282        // against its derived background. Every semantic bg must
283        // now clear 3.0 against its fg.
284        use crate::color::Color;
285        use crate::contrast::{contrast_ratio, AA_NON_TEXT};
286        for brand_hex in [
287            "#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888", "#dc2626",
288        ] {
289            let tokens = resolve_theme(ThemeInput {
290                brand_colors: vec![Color::from_hex(brand_hex).unwrap()],
291            });
292            for (name, fg) in [
293                ("success", tokens.success),
294                ("warning", tokens.warning),
295                ("danger", tokens.danger),
296            ] {
297                let bg = super::soft_bg(&fg);
298                let r = contrast_ratio(&fg, &bg);
299                assert!(
300                    r >= AA_NON_TEXT - 0.01,
301                    "brand {brand_hex}: {name} {} on derived bg {} only {r:.2}",
302                    fg.to_hex(),
303                    bg.to_hex(),
304                );
305            }
306        }
307    }
308
309    #[test]
310    fn chart_tokens_index_from_one() {
311        use crate::color::Color;
312        let css = emit(&resolve_theme(ThemeInput {
313            brand_colors: vec![
314                Color::from_hex("#3f6089").unwrap(),
315                Color::from_hex("#c9572e").unwrap(),
316                Color::from_hex("#2e7d5b").unwrap(),
317            ],
318        }));
319        assert!(css.contains("--rio-chart-1"));
320        assert!(!css.contains("--rio-chart-0"));
321    }
322}