//! Text contrast (hub spec ยง6.13): every text colour of each theme meets //! WCAG AA (4.5:1) on every surface it is drawn on, and text never uses //! `--dim`, which is for rules and decoration. use std::collections::HashMap; fn css() -> String { std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/style.css")).unwrap() } /// `--name:#rrggbb` pairs of the block that starts with `selector`. fn theme(css: &str, selector: &str) -> HashMap { let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing")); let body = &css[start..start + css[start..].find('}').unwrap()]; let mut out = HashMap::new(); for part in body.split("--").skip(1) { let Some((name, rest)) = part.split_once(':') else { continue }; let value = rest.trim_start(); let valid = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); if valid && value.len() >= 7 && value.starts_with('#') && value[1..7].chars().all(|c| c.is_ascii_hexdigit()) { out.insert(name.to_string(), value[..7].to_lowercase()); } } out } fn luminance(hex: &str) -> f64 { let c = |i: usize| { let x = u8::from_str_radix(&hex[i..i + 2], 16).unwrap() as f64 / 255.0; if x <= 0.03928 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) } }; 0.2126 * c(1) + 0.7152 * c(3) + 0.0722 * c(5) } fn ratio(a: &str, b: &str) -> f64 { let (x, y) = (luminance(a), luminance(b)); (x.max(y) + 0.05) / (x.min(y) + 0.05) } #[test] fn text_meets_aa_in_both_themes() { let css = css(); let mut failures = Vec::new(); for (name, sel) in [("dark", ":root {"), ("light", ":root[data-theme=\"light\"] {")] { let t = theme(&css, sel); let mut text: Vec<&str> = vec!["ink", "mute", "acc", "ok", "bad", "warn"]; text.extend(t.keys().filter(|k| k.starts_with("sy-")).map(String::as_str)); for fg in text { for bg in ["bg", "panel", "soft"] { let r = ratio(&t[fg], &t[bg]); if r < 4.5 { failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1")); } } } for (fg, bg) in [("band-ink", "band"), ("add-ink", "add"), ("del-ink", "del"), ("now-ink", "now")] { let r = ratio(&t[fg], &t[bg]); if r < 4.5 { failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1")); } } } assert!(failures.is_empty(), "below WCAG AA:\n{}", failures.join("\n")); } #[test] fn text_does_not_use_dim() { let css = css(); let offenders: Vec<&str> = css.lines().filter(|l| l.contains("color: var(--dim)") && !l.contains("border")).collect(); assert!(offenders.is_empty(), "--dim is too faint for text:\n{}", offenders.join("\n")); }