use std::fmt::Write as _;
use crate::color::Color;
use crate::contrast::{contrast_ratio, AA_TEXT};
use crate::engine::ThemeTokens;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum DarkPolicy {
#[default]
Auto,
LightOnly,
Explicit {
accent: Color,
},
}
pub fn emit(tokens: &ThemeTokens) -> String {
emit_with(tokens, &DarkPolicy::Auto)
}
pub fn emit_with(tokens: &ThemeTokens, dark: &DarkPolicy) -> 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");
let palette = dark_palette(tokens, dark);
s.push_str(":root[data-theme=\"dark\"] {\n");
write_palette(&mut s, &palette, " ");
s.push_str("}\n\n");
s.push_str("@media (prefers-color-scheme: dark) {\n");
s.push_str(" :root {\n");
write_palette(&mut s, &palette, " ");
s.push_str(" }\n");
s.push_str("}\n");
s
}
const DARK_BG: &str = "#0f172a";
const DARK_SURFACE: &str = "#1e293b";
fn dark_palette(tokens: &ThemeTokens, dark: &DarkPolicy) -> Vec<(&'static str, String)> {
let dark_surface = Color::from_hex(DARK_SURFACE).expect("constant");
let dark_bg = Color::from_hex(DARK_BG).expect("constant");
let (accent, accent_hover) = match dark {
DarkPolicy::LightOnly => (
Color::from_hex("#2dd4bf").expect("constant"),
Color::from_hex("#5eead4").expect("constant"),
),
DarkPolicy::Auto => {
let a = lift_for_dark(&tokens.brand_surface, &dark_surface);
(a, a.lighten(0.12))
}
DarkPolicy::Explicit { accent } => {
let a = lift_for_dark(accent, &dark_surface);
(a, a.lighten(0.12))
}
};
let success = Color::from_hex("#6fb98a").expect("constant");
let warning = Color::from_hex("#d6a65a").expect("constant");
let danger = Color::from_hex("#ef4444").expect("constant");
vec![
("--rio-brand-adaptive", "var(--rio-brand-dark)".to_string()),
("--rio-accent", accent.to_hex()),
("--rio-accent-hover", accent_hover.to_hex()),
("--rio-accent-rgb", rgb_triple(&accent)),
("--rio-accent-soft", accent.mix(&dark_bg, 0.86).to_hex()),
(
"--rio-accent-border",
accent.mix(&dark_surface, 0.35).to_hex(),
),
("--rio-info-bg", accent.mix(&dark_bg, 0.86).to_hex()),
("--rio-bg", DARK_BG.to_string()),
("--rio-surface", DARK_SURFACE.to_string()),
("--rio-surface-2", "#0b1120".to_string()),
("--rio-surface-3", "#334155".to_string()),
("--rio-surface-chrome", "#0f172a".to_string()),
("--rio-surface-elevated", "#1e293b".to_string()),
("--rio-text-strong", "#f1f5f9".to_string()),
("--rio-text", "#cbd5e1".to_string()),
("--rio-text-muted", "#94a3b8".to_string()),
("--rio-text-subtle", "#64748b".to_string()),
("--rio-border-soft", "#334155".to_string()),
("--rio-border", "#334155".to_string()),
("--rio-border-strong", "#64748b".to_string()),
("--rio-success", success.to_hex()),
("--rio-warning", warning.to_hex()),
("--rio-danger", danger.to_hex()),
("--rio-success-bg", success.mix(&dark_bg, 0.84).to_hex()),
("--rio-warning-bg", warning.mix(&dark_bg, 0.84).to_hex()),
("--rio-danger-bg", danger.mix(&dark_bg, 0.84).to_hex()),
]
}
fn write_palette(s: &mut String, palette: &[(&'static str, String)], indent: &str) {
for (name, value) in palette {
let _ = writeln!(s, "{indent}{name}: {value};");
}
}
fn lift_for_dark(accent: &Color, dark_surface: &Color) -> Color {
let mut a = *accent;
for _ in 0..40 {
if contrast_ratio(&a, dark_surface) >= AA_TEXT {
return a;
}
a = a.lighten(0.05);
}
a
}
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 dark_emits_both_explicit_and_auto_blocks() {
for policy in [
DarkPolicy::Auto,
DarkPolicy::LightOnly,
DarkPolicy::Explicit {
accent: Color::from_hex("#0E6B5B").unwrap(),
},
] {
let css = emit_with(&resolve_theme(ThemeInput::empty()), &policy);
assert!(
css.contains(":root[data-theme=\"dark\"] {"),
"{policy:?}: missing explicit dark block"
);
assert!(
css.contains("@media (prefers-color-scheme: dark) {"),
"{policy:?}: missing auto dark block"
);
}
}
#[test]
fn dark_never_leaks_light_background() {
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
});
for policy in [DarkPolicy::Auto, DarkPolicy::LightOnly] {
let css = emit_with(&tokens, &policy);
assert!(
css.matches("--rio-bg: #0f172a;").count() >= 2,
"{policy:?}: dark blocks must pin --rio-bg to #0f172a (got:\n{css})"
);
}
}
#[test]
fn dark_auto_accent_clears_aa_on_dark_surface() {
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
});
let pal = dark_palette(&tokens, &DarkPolicy::Auto);
let accent_hex = pal
.iter()
.find(|(n, _)| *n == "--rio-accent")
.map(|(_, v)| v.clone())
.unwrap();
let accent = Color::from_hex(&accent_hex).unwrap();
let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
assert!(
contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01,
"auto dark accent {accent_hex} only {:.2} on dark surface",
contrast_ratio(&accent, &dark_surface)
);
}
#[test]
fn dark_light_only_pins_framework_accent() {
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
});
let css = emit_with(&tokens, &DarkPolicy::LightOnly);
assert_eq!(
css.matches("--rio-accent: #2dd4bf;").count(),
2,
"LightOnly must pin both dark blocks to the framework accent"
);
}
#[test]
fn dark_explicit_uses_supplied_accent() {
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
});
let explicit = Color::from_hex("#0d9488").unwrap();
let pal = dark_palette(&tokens, &DarkPolicy::Explicit { accent: explicit });
let accent_hex = pal
.iter()
.find(|(n, _)| *n == "--rio-accent")
.unwrap()
.1
.clone();
let accent = Color::from_hex(&accent_hex).unwrap();
let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
assert!(contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01);
assert_ne!(accent_hex, "#2dd4bf");
}
#[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"));
}
}