//! 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")); } /// `--name:rgba(r,g,b,a)` of the block that starts with `selector`. fn rgba(css: &str, selector: &str, name: &str) -> (f64, f64, f64, f64) { let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing")); let body = &css[start..start + css[start..].find('}').unwrap()]; let at = body.find(&format!("--{name}:rgba(")).unwrap_or_else(|| panic!("--{name} missing in {selector}")); let inner = &body[at + name.len() + 8..]; let v: Vec = inner[..inner.find(')').unwrap()].split(',').map(|x| x.trim().parse().unwrap()).collect(); (v[0], v[1], v[2], v[3]) } /// Backdrops (docs/backgrounds.md §3): a resting mark is barely there, /// under 1.3:1 against the page in every look and theme, and a lit one is /// plainly visible. #[test] fn backdrop_marks_rest_barely_there() { let classic = css(); let github = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/github.css")).unwrap(); let blocks = [ (&classic, ":root {", ":root {"), (&classic, ":root[data-theme=\"light\"] {", ":root[data-theme=\"light\"] {"), (&github, ":root[data-look=\"github\"] {", ":root[data-look=\"github\"] {"), (&github, ":root[data-look=\"github\"][data-theme=\"dark\"] {", ":root[data-look=\"github\"][data-theme=\"dark\"] {"), ]; let over = |c: (f64, f64, f64, f64), bg: &str| { let b = |i: usize| u8::from_str_radix(&bg[i..i + 2], 16).unwrap() as f64; let mix = |x: f64, y: f64| (x * c.3 + y * (1.0 - c.3)).round() as u8; format!("#{:02x}{:02x}{:02x}", mix(c.0, b(1)), mix(c.1, b(3)), mix(c.2, b(5))) }; for (css, sel, bg_sel) in blocks { let bg = &theme(css, bg_sel)["bg"]; let rest = ratio(&over(rgba(css, sel, "bd-mark"), bg), bg); let lit = ratio(&over(rgba(css, sel, "bd-lit"), bg), bg); assert!(rest < 1.3, "{sel}: a resting mark is {rest:.2}:1, too strong"); assert!(lit >= 2.0, "{sel}: a lit mark is {lit:.2}:1, too faint"); } }