use crate::adaptive::{adaptive_brand, AdaptiveBrand};
use crate::color::Color;
use crate::contrast::{contrast_ratio, AA_NON_TEXT, AA_TEXT, DARK_BG, LIGHT_BG};
use crate::derive::{derive_palette, DerivedPalette};
use crate::guard::{readable_text, resolve_text_token};
use crate::hierarchy::{assign_roles, RoleAssignment};
use crate::semantic::{resolve_semantics, SemanticPalette};
use crate::vivid::{split_vivid_roles, VividSplit};
pub const DEFAULT_BRAND: &str = "#3f6089";
#[derive(Debug, Clone)]
pub struct ThemeInput {
pub brand_colors: Vec<Color>,
}
impl ThemeInput {
pub fn empty() -> Self {
ThemeInput {
brand_colors: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ThemeTokens {
pub brand_light: Color,
pub brand_dark: Color,
pub brand_surface: Color,
pub brand_accent: Color,
pub brand_secondary: Color,
pub brand_hover: Color,
pub brand_active: Color,
pub brand_tint: Color,
pub brand_text: Color,
pub bg: Color,
pub border: Color,
pub muted: Color,
pub success: Color,
pub warning: Color,
pub danger: Color,
pub chart: Vec<Color>,
}
#[derive(Debug, Clone, Default)]
pub struct ResolveReport {
pub default_brand_used: bool,
pub vivid_tamed: bool,
pub light_adjusted: bool,
pub dark_adjusted: bool,
pub light_still_failing: bool,
pub dark_still_failing: bool,
pub text_substituted: bool,
pub accent_substituted: bool,
pub light_contrast: f64,
pub dark_contrast: f64,
pub text_on_bg_contrast: f64,
pub accent_on_bg_contrast: f64,
pub success_contrast: f64,
pub warning_contrast: f64,
pub danger_contrast: f64,
}
pub fn resolve_theme(input: ThemeInput) -> ThemeTokens {
let (tokens, _report) = resolve_theme_with_report(input);
tokens
}
pub fn resolve_theme_with_report(input: ThemeInput) -> (ThemeTokens, ResolveReport) {
let mut report = ResolveReport::default();
let brand_colors: Vec<Color> = if input.brand_colors.is_empty() {
report.default_brand_used = true;
vec![Color::from_hex(DEFAULT_BRAND).expect("constant")]
} else {
input.brand_colors
};
let RoleAssignment {
primary,
secondary,
chart,
} = assign_roles(&brand_colors);
let VividSplit {
accent: raw_accent,
surface: surface_brand,
was_tamed,
} = split_vivid_roles(&primary);
report.vivid_tamed = was_tamed;
let AdaptiveBrand {
light: brand_light,
dark: brand_dark,
light_adjusted,
dark_adjusted,
light_clears_aa,
dark_clears_aa,
} = adaptive_brand(&surface_brand);
report.light_adjusted = light_adjusted;
report.dark_adjusted = dark_adjusted;
report.light_still_failing = !light_clears_aa;
report.dark_still_failing = !dark_clears_aa;
let DerivedPalette {
brand: _,
brand_tint,
brand_hover,
brand_active,
brand_text,
bg,
border,
muted,
} = derive_palette(&surface_brand);
let SemanticPalette {
success,
warning,
danger,
} = resolve_semantics(&primary);
let safe_brand_text = resolve_text_token(&bg, &brand_text);
if safe_brand_text.to_hex() != brand_text.to_hex() {
report.text_substituted = true;
}
let safe_brand_accent = if contrast_ratio(&bg, &raw_accent) >= AA_NON_TEXT {
raw_accent
} else {
log::warn!(
"rio-theme: brand_accent {} fails AA_NON_TEXT on bg {} (ratio {:.2}); substituting tamed surface {}",
raw_accent.to_hex(),
bg.to_hex(),
contrast_ratio(&bg, &raw_accent),
surface_brand.to_hex(),
);
report.accent_substituted = true;
surface_brand
};
let brand_secondary = if secondary.to_hex() == primary.to_hex() {
brand_hover
} else {
secondary
};
let light_bg = Color::from_hex(LIGHT_BG).expect("constant");
let dark_bg = Color::from_hex(DARK_BG).expect("constant");
report.light_contrast = contrast_ratio(&light_bg, &brand_light);
report.dark_contrast = contrast_ratio(&dark_bg, &brand_dark);
report.text_on_bg_contrast = contrast_ratio(&bg, &safe_brand_text);
report.accent_on_bg_contrast = contrast_ratio(&bg, &safe_brand_accent);
report.success_contrast = contrast_ratio(&light_bg, &success);
report.warning_contrast = contrast_ratio(&light_bg, &warning);
report.danger_contrast = contrast_ratio(&light_bg, &danger);
let tokens = ThemeTokens {
brand_light,
brand_dark,
brand_surface: surface_brand,
brand_accent: safe_brand_accent,
brand_secondary,
brand_hover,
brand_active,
brand_tint,
brand_text: safe_brand_text,
bg,
border,
muted,
success,
warning,
danger,
chart,
};
debug_assert!(
contrast_ratio(&tokens.bg, &tokens.brand_text) >= AA_TEXT - 0.01,
"brand_text {} fails AA on bg {}",
tokens.brand_text.to_hex(),
tokens.bg.to_hex(),
);
let _ = readable_text;
(tokens, report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_uses_default_brand() {
let (tokens, report) = resolve_theme_with_report(ThemeInput::empty());
assert!(report.default_brand_used);
assert!(!report.vivid_tamed);
assert!(!report.light_adjusted);
assert!(tokens.bg.l > 0.95);
}
#[test]
fn neon_input_trips_vivid_taming_and_guards_accent() {
let lime = Color::from_hex("#39ff14").unwrap();
let (tokens, report) = resolve_theme_with_report(ThemeInput {
brand_colors: vec![lime],
});
assert!(report.vivid_tamed);
let raw_accent_contrast = contrast_ratio(&tokens.bg, &lime);
if raw_accent_contrast < AA_NON_TEXT {
assert_ne!(
tokens.brand_accent.to_hex(),
lime.to_hex(),
"neon should have been substituted"
);
}
}
#[test]
fn two_color_input_populates_brand_secondary_distinctly() {
let (tokens, _) = resolve_theme_with_report(ThemeInput {
brand_colors: vec![
Color::from_hex("#3f6089").unwrap(),
Color::from_hex("#c9572e").unwrap(),
],
});
let surface_hex = tokens.brand_surface.to_hex();
let secondary_hex = tokens.brand_secondary.to_hex();
assert_ne!(surface_hex, secondary_hex);
}
#[test]
fn report_contrast_fields_are_populated() {
let (_, report) = resolve_theme_with_report(ThemeInput::empty());
assert!(report.light_contrast > 0.0);
assert!(report.dark_contrast > 0.0);
assert!(report.text_on_bg_contrast > 0.0);
assert!(report.accent_on_bg_contrast > 0.0);
assert!(report.success_contrast > 0.0);
assert!(report.warning_contrast > 0.0);
assert!(report.danger_contrast > 0.0);
}
#[test]
fn brand_text_always_clears_aa_on_bg() {
for hex in [
"#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888",
] {
let brand = Color::from_hex(hex).unwrap();
let tokens = resolve_theme(ThemeInput {
brand_colors: vec![brand],
});
let ratio = contrast_ratio(&tokens.bg, &tokens.brand_text);
assert!(
ratio >= AA_TEXT - 0.01,
"brand={hex}: brand_text {} on bg {} only {ratio:.2}",
tokens.brand_text.to_hex(),
tokens.bg.to_hex(),
);
}
}
}