use std::fmt::Write as _;
use crate::color::Color;
use crate::engine::ThemeTokens;
pub fn emit(tokens: &ThemeTokens) -> String {
let mut s = String::new();
s.push_str("/* Generated by rio-theme. Do not edit by hand. */\n");
s.push_str(":root {\n");
s.push_str(" /* canonical brand-* tokens (engine output) */\n");
line(&mut s, "--rio-brand-light", &tokens.brand_light.to_hex());
line(&mut s, "--rio-brand-dark", &tokens.brand_dark.to_hex());
s.push_str(" --rio-brand-adaptive: var(--rio-brand-light);\n");
line(
&mut s,
"--rio-brand-surface",
&tokens.brand_surface.to_hex(),
);
line(&mut s, "--rio-brand-accent", &tokens.brand_accent.to_hex());
line(
&mut s,
"--rio-brand-secondary",
&tokens.brand_secondary.to_hex(),
);
line(&mut s, "--rio-brand-hover", &tokens.brand_hover.to_hex());
line(&mut s, "--rio-brand-active", &tokens.brand_active.to_hex());
line(&mut s, "--rio-brand-tint", &tokens.brand_tint.to_hex());
line(&mut s, "--rio-brand-text", &tokens.brand_text.to_hex());
line(&mut s, "--rio-muted", &tokens.muted.to_hex());
s.push('\n');
s.push_str(" /* drop-in aliases for the live admin template */\n");
line(&mut s, "--rio-accent", &tokens.brand_surface.to_hex());
line(&mut s, "--rio-accent-hover", &tokens.brand_hover.to_hex());
line(
&mut s,
"--rio-accent-rgb",
&rgb_triple(&tokens.brand_surface),
);
line(&mut s, "--rio-accent-soft", &tokens.brand_tint.to_hex());
line(
&mut s,
"--rio-accent-border",
&tokens.brand_surface.lighten(0.65).to_hex(),
);
line(&mut s, "--rio-bg", &tokens.bg.to_hex());
line(&mut s, "--rio-surface", "#ffffff");
line(&mut s, "--rio-surface-2", "#f8fafc");
line(&mut s, "--rio-surface-3", "#f1f5f9");
line(&mut s, "--rio-surface-chrome", "#0f172a");
line(&mut s, "--rio-surface-elevated", "#ffffff");
line(&mut s, "--rio-text-strong", "#0f172a");
line(&mut s, "--rio-text", "#1e293b");
line(&mut s, "--rio-text-muted", "#475569");
line(&mut s, "--rio-text-subtle", "#64748b");
line(&mut s, "--rio-border-soft", "#e2e8f0");
line(&mut s, "--rio-border", &tokens.border.to_hex());
line(&mut s, "--rio-border-strong", "#94a3b8");
line(&mut s, "--rio-success", &tokens.success.to_hex());
line(&mut s, "--rio-warning", &tokens.warning.to_hex());
line(&mut s, "--rio-danger", &tokens.danger.to_hex());
line(
&mut s,
"--rio-success-bg",
&soft_bg(&tokens.success).to_hex(),
);
line(
&mut s,
"--rio-warning-bg",
&soft_bg(&tokens.warning).to_hex(),
);
line(&mut s, "--rio-danger-bg", &soft_bg(&tokens.danger).to_hex());
line(&mut s, "--rio-info-bg", &tokens.brand_tint.to_hex());
for (i, c) in tokens.chart.iter().enumerate() {
let name = format!("--rio-chart-{}", i + 1);
line(&mut s, &name, &c.to_hex());
}
s.push_str("}\n\n");
s.push_str(":root[data-theme=\"dark\"] {\n");
s.push_str(" --rio-brand-adaptive: var(--rio-brand-dark);\n");
s.push_str("}\n");
s
}
fn line(s: &mut String, name: &str, value: &str) {
let _ = writeln!(s, " {name}: {value};");
}
fn rgb_triple(color: &Color) -> String {
let hex = color.to_hex();
let r = u8::from_str_radix(&hex[1..3], 16).expect("emitted hex is valid");
let g = u8::from_str_radix(&hex[3..5], 16).expect("emitted hex is valid");
let b = u8::from_str_radix(&hex[5..7], 16).expect("emitted hex is valid");
format!("{r} {g} {b}")
}
fn soft_bg(fg: &Color) -> Color {
let white = Color::from_hex("#ffffff").expect("constant");
let mut amount = 0.92_f64;
loop {
let bg = fg.mix(&white, amount);
if amount >= 0.99
|| crate::contrast::contrast_ratio(fg, &bg) >= crate::contrast::AA_NON_TEXT
{
return bg;
}
amount += 0.01;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::{resolve_theme, ThemeInput};
#[test]
fn emit_contains_every_canonical_brand_token() {
let css = emit(&resolve_theme(ThemeInput::empty()));
for name in [
"--rio-brand-light",
"--rio-brand-dark",
"--rio-brand-adaptive",
"--rio-brand-surface",
"--rio-brand-accent",
"--rio-brand-secondary",
"--rio-brand-hover",
"--rio-brand-active",
"--rio-brand-tint",
"--rio-brand-text",
"--rio-muted",
] {
assert!(css.contains(name), "missing canonical {name}");
}
}
#[test]
fn emit_contains_every_live_template_token() {
let css = emit(&resolve_theme(ThemeInput::empty()));
for name in [
"--rio-accent",
"--rio-accent-hover",
"--rio-accent-rgb",
"--rio-accent-soft",
"--rio-accent-border",
"--rio-bg",
"--rio-surface",
"--rio-surface-2",
"--rio-surface-3",
"--rio-surface-chrome",
"--rio-surface-elevated",
"--rio-text-strong",
"--rio-text",
"--rio-text-muted",
"--rio-text-subtle",
"--rio-border-soft",
"--rio-border",
"--rio-border-strong",
"--rio-success",
"--rio-warning",
"--rio-danger",
"--rio-success-bg",
"--rio-warning-bg",
"--rio-danger-bg",
"--rio-info-bg",
] {
assert!(css.contains(name), "missing drop-in alias {name}");
}
}
#[test]
fn accent_rgb_triple_agrees_with_accent_hex() {
let tokens = resolve_theme(ThemeInput::empty());
let css = emit(&tokens);
let hex = tokens.brand_surface.to_hex();
let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
let expected = format!("--rio-accent-rgb: {r} {g} {b};");
assert!(css.contains(&expected), "expected `{expected}` in:\n{css}");
}
#[test]
fn dark_block_is_always_emitted() {
let css = emit(&resolve_theme(ThemeInput::empty()));
assert!(css.contains(":root[data-theme=\"dark\"]"));
}
#[test]
fn soft_bg_always_clears_aa_non_text_against_its_foreground() {
use crate::color::Color;
use crate::contrast::{contrast_ratio, AA_NON_TEXT};
for brand_hex in [
"#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888", "#dc2626",
] {
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![Color::from_hex(brand_hex).unwrap()],
});
for (name, fg) in [
("success", tokens.success),
("warning", tokens.warning),
("danger", tokens.danger),
] {
let bg = super::soft_bg(&fg);
let r = contrast_ratio(&fg, &bg);
assert!(
r >= AA_NON_TEXT - 0.01,
"brand {brand_hex}: {name} {} on derived bg {} only {r:.2}",
fg.to_hex(),
bg.to_hex(),
);
}
}
}
#[test]
fn chart_tokens_index_from_one() {
use crate::color::Color;
let css = emit(&resolve_theme(ThemeInput {
brand_colors: vec![
Color::from_hex("#3f6089").unwrap(),
Color::from_hex("#c9572e").unwrap(),
Color::from_hex("#2e7d5b").unwrap(),
],
}));
assert!(css.contains("--rio-chart-1"));
assert!(!css.contains("--rio-chart-0"));
}
}