1use std::fmt::Write as _;
33
34use crate::color::Color;
35use crate::engine::ThemeTokens;
36
37pub 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 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 s.push('\n');
72 s.push_str(" /* drop-in aliases for the live admin template */\n");
73
74 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 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 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 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 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 let _ = writeln!(s, " {name}: {value};");
155}
156
157fn rgb_triple(color: &Color) -> String {
161 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
170fn 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 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 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 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}