Skip to main content

jolt_theme/
contrast.rs

1use crate::{NamedTheme, ThemeColors};
2
3const AA_NORMAL: f64 = 4.5;
4const AA_LARGE: f64 = 3.0;
5const AAA_NORMAL: f64 = 7.0;
6
7pub struct ContrastResult {
8    pub theme_name: String,
9    pub variant: String,
10    pub pair: String,
11    pub bg_hex: String,
12    pub fg_hex: String,
13    pub ratio: f64,
14    pub grade: String,
15    pub pass: bool,
16}
17
18fn wcag_grade(ratio: f64) -> String {
19    if ratio >= AAA_NORMAL {
20        "AAA".to_string()
21    } else if ratio >= AA_NORMAL {
22        "AA".to_string()
23    } else if ratio >= AA_LARGE {
24        "AA-large".to_string()
25    } else {
26        "FAIL".to_string()
27    }
28}
29
30fn check_theme_colors(
31    theme_name: &str,
32    variant: &str,
33    colors: &ThemeColors,
34) -> Vec<ContrastResult> {
35    let checks = [
36        ("bg", "fg", colors.bg, colors.fg),
37        ("bg", "accent", colors.bg, colors.accent),
38        ("bg", "accent_secondary", colors.bg, colors.accent_secondary),
39        ("bg", "muted", colors.bg, colors.muted),
40        ("bg", "success", colors.bg, colors.success),
41        ("bg", "warning", colors.bg, colors.warning),
42        ("bg", "danger", colors.bg, colors.danger),
43        ("bg", "highlight", colors.bg, colors.highlight),
44        ("dialog_bg", "fg", colors.dialog_bg, colors.fg),
45        ("dialog_bg", "accent", colors.dialog_bg, colors.accent),
46        (
47            "selection_bg",
48            "selection_fg",
49            colors.selection_bg,
50            colors.selection_fg,
51        ),
52    ];
53
54    checks
55        .iter()
56        .map(|(bg_name, fg_name, bg, fg)| {
57            let ratio = bg.contrast_ratio(fg);
58            let grade = wcag_grade(ratio);
59            let pass = ratio >= AA_NORMAL;
60
61            ContrastResult {
62                theme_name: theme_name.to_string(),
63                variant: variant.to_string(),
64                pair: format!("{} ↔ {}", bg_name, fg_name),
65                bg_hex: bg.to_hex(),
66                fg_hex: fg.to_hex(),
67                ratio,
68                grade,
69                pass,
70            }
71        })
72        .collect()
73}
74
75pub fn check_all_themes(themes: &[NamedTheme]) -> Vec<ContrastResult> {
76    let mut results = Vec::new();
77
78    for theme in themes {
79        if let Some(ref dark) = theme.variants.dark {
80            results.extend(check_theme_colors(&theme.name, "dark", dark));
81        }
82        if let Some(ref light) = theme.variants.light {
83            results.extend(check_theme_colors(&theme.name, "light", light));
84        }
85    }
86
87    results
88}
89
90pub fn print_results(results: &[ContrastResult], verbose: bool) {
91    let failures: Vec<_> = results.iter().filter(|r| !r.pass).collect();
92    let passes: Vec<_> = results.iter().filter(|r| r.pass).collect();
93
94    println!("{}", "=".repeat(80));
95    println!("WCAG CONTRAST CHECK RESULTS");
96    println!("Target: {}:1 (WCAG AA Normal Text)", AA_NORMAL);
97    println!("{}", "=".repeat(80));
98
99    if failures.is_empty() {
100        println!(
101            "\n✅ All {} color pairs pass WCAG AA requirements!",
102            results.len()
103        );
104    } else {
105        println!("\n❌ FAILURES ({} issues)\n", failures.len());
106        println!(
107            "{:<20} {:<8} {:<30} {:>8} {:<10}",
108            "Theme", "Variant", "Pair", "Ratio", "Grade"
109        );
110        println!("{}", "-".repeat(80));
111
112        let mut current_theme = String::new();
113        for r in &failures {
114            let theme_label = if r.theme_name != current_theme {
115                current_theme = r.theme_name.clone();
116                &r.theme_name
117            } else {
118                ""
119            };
120            println!(
121                "{:<20} {:<8} {:<30} {:>7.2}:1 {:<10}",
122                theme_label, r.variant, r.pair, r.ratio, r.grade
123            );
124            println!("{:20} {:8} bg: {}  fg: {}", "", "", r.bg_hex, r.fg_hex);
125        }
126    }
127
128    if verbose && !passes.is_empty() {
129        println!("\n✅ PASSING ({} checks)\n", passes.len());
130        for r in &passes {
131            println!(
132                "{:<20} {:<8} {:<30} {:>7.2}:1 {}",
133                r.theme_name, r.variant, r.pair, r.ratio, r.grade
134            );
135        }
136    }
137
138    println!("\n{}", "=".repeat(80));
139    println!("SUMMARY BY THEME");
140    println!("{}", "=".repeat(80));
141
142    let mut theme_stats: std::collections::HashMap<(String, String), (usize, usize)> =
143        std::collections::HashMap::new();
144
145    for r in results {
146        let key = (r.theme_name.clone(), r.variant.clone());
147        let entry = theme_stats.entry(key).or_insert((0, 0));
148        if r.pass {
149            entry.0 += 1;
150        } else {
151            entry.1 += 1;
152        }
153    }
154
155    println!(
156        "\n{:<20} {:<8} {:>6} {:>6} {:<10}",
157        "Theme", "Variant", "Pass", "Fail", "Status"
158    );
159    println!("{}", "-".repeat(55));
160
161    let mut keys: Vec<_> = theme_stats.keys().collect();
162    keys.sort();
163
164    for (theme, variant) in keys {
165        let (pass, fail) = theme_stats.get(&(theme.clone(), variant.clone())).unwrap();
166        let status = if *fail == 0 {
167            "✅ OK".to_string()
168        } else {
169            format!("❌ {} issues", fail)
170        };
171        println!(
172            "{:<20} {:<8} {:>6} {:>6} {}",
173            theme, variant, pass, fail, status
174        );
175    }
176}