use crate::{NamedTheme, ThemeColors};
const AA_NORMAL: f64 = 4.5;
const AA_LARGE: f64 = 3.0;
const AAA_NORMAL: f64 = 7.0;
pub struct ContrastResult {
pub theme_name: String,
pub variant: String,
pub pair: String,
pub bg_hex: String,
pub fg_hex: String,
pub ratio: f64,
pub grade: String,
pub pass: bool,
}
fn wcag_grade(ratio: f64) -> String {
if ratio >= AAA_NORMAL {
"AAA".to_string()
} else if ratio >= AA_NORMAL {
"AA".to_string()
} else if ratio >= AA_LARGE {
"AA-large".to_string()
} else {
"FAIL".to_string()
}
}
fn check_theme_colors(
theme_name: &str,
variant: &str,
colors: &ThemeColors,
) -> Vec<ContrastResult> {
let checks = [
("bg", "fg", colors.bg, colors.fg),
("bg", "accent", colors.bg, colors.accent),
("bg", "accent_secondary", colors.bg, colors.accent_secondary),
("bg", "muted", colors.bg, colors.muted),
("bg", "success", colors.bg, colors.success),
("bg", "warning", colors.bg, colors.warning),
("bg", "danger", colors.bg, colors.danger),
("bg", "highlight", colors.bg, colors.highlight),
("dialog_bg", "fg", colors.dialog_bg, colors.fg),
("dialog_bg", "accent", colors.dialog_bg, colors.accent),
(
"selection_bg",
"selection_fg",
colors.selection_bg,
colors.selection_fg,
),
];
checks
.iter()
.map(|(bg_name, fg_name, bg, fg)| {
let ratio = bg.contrast_ratio(fg);
let grade = wcag_grade(ratio);
let pass = ratio >= AA_NORMAL;
ContrastResult {
theme_name: theme_name.to_string(),
variant: variant.to_string(),
pair: format!("{} ↔ {}", bg_name, fg_name),
bg_hex: bg.to_hex(),
fg_hex: fg.to_hex(),
ratio,
grade,
pass,
}
})
.collect()
}
pub fn check_all_themes(themes: &[NamedTheme]) -> Vec<ContrastResult> {
let mut results = Vec::new();
for theme in themes {
if let Some(ref dark) = theme.variants.dark {
results.extend(check_theme_colors(&theme.name, "dark", dark));
}
if let Some(ref light) = theme.variants.light {
results.extend(check_theme_colors(&theme.name, "light", light));
}
}
results
}
pub fn print_results(results: &[ContrastResult], verbose: bool) {
let failures: Vec<_> = results.iter().filter(|r| !r.pass).collect();
let passes: Vec<_> = results.iter().filter(|r| r.pass).collect();
println!("{}", "=".repeat(80));
println!("WCAG CONTRAST CHECK RESULTS");
println!("Target: {}:1 (WCAG AA Normal Text)", AA_NORMAL);
println!("{}", "=".repeat(80));
if failures.is_empty() {
println!(
"\n✅ All {} color pairs pass WCAG AA requirements!",
results.len()
);
} else {
println!("\n❌ FAILURES ({} issues)\n", failures.len());
println!(
"{:<20} {:<8} {:<30} {:>8} {:<10}",
"Theme", "Variant", "Pair", "Ratio", "Grade"
);
println!("{}", "-".repeat(80));
let mut current_theme = String::new();
for r in &failures {
let theme_label = if r.theme_name != current_theme {
current_theme = r.theme_name.clone();
&r.theme_name
} else {
""
};
println!(
"{:<20} {:<8} {:<30} {:>7.2}:1 {:<10}",
theme_label, r.variant, r.pair, r.ratio, r.grade
);
println!("{:20} {:8} bg: {} fg: {}", "", "", r.bg_hex, r.fg_hex);
}
}
if verbose && !passes.is_empty() {
println!("\n✅ PASSING ({} checks)\n", passes.len());
for r in &passes {
println!(
"{:<20} {:<8} {:<30} {:>7.2}:1 {}",
r.theme_name, r.variant, r.pair, r.ratio, r.grade
);
}
}
println!("\n{}", "=".repeat(80));
println!("SUMMARY BY THEME");
println!("{}", "=".repeat(80));
let mut theme_stats: std::collections::HashMap<(String, String), (usize, usize)> =
std::collections::HashMap::new();
for r in results {
let key = (r.theme_name.clone(), r.variant.clone());
let entry = theme_stats.entry(key).or_insert((0, 0));
if r.pass {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
println!(
"\n{:<20} {:<8} {:>6} {:>6} {:<10}",
"Theme", "Variant", "Pass", "Fail", "Status"
);
println!("{}", "-".repeat(55));
let mut keys: Vec<_> = theme_stats.keys().collect();
keys.sort();
for (theme, variant) in keys {
let (pass, fail) = theme_stats.get(&(theme.clone(), variant.clone())).unwrap();
let status = if *fail == 0 {
"✅ OK".to_string()
} else {
format!("❌ {} issues", fail)
};
println!(
"{:<20} {:<8} {:>6} {:>6} {}",
theme, variant, pass, fail, status
);
}
}