OXYGENHUB
◆ v0.3.0 oxygenhub / crates / hub / tests / contrast.rs
2.8 KiB · Rust · last changed Accessibility and narrow screens: focus rings, a skip link, named landmarks and aria-current, scrolling tab bars and tables, and WCAG AA text contrast in both themes checked by a test 1 month ago source blame history raw
1
//! Text contrast (hub spec §6.13): every text colour of each theme meets
2
//! WCAG AA (4.5:1) on every surface it is drawn on, and text never uses
3
//! `--dim`, which is for rules and decoration.
4
5
use std::collections::HashMap;
6
7
fn css() -> String {
8
    std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/style.css")).unwrap()
9
}
10
11
/// `--name:#rrggbb` pairs of the block that starts with `selector`.
12
fn theme(css: &str, selector: &str) -> HashMap<String, String> {
13
    let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing"));
14
    let body = &css[start..start + css[start..].find('}').unwrap()];
15
    let mut out = HashMap::new();
16
    for part in body.split("--").skip(1) {
17
        let Some((name, rest)) = part.split_once(':') else { continue };
18
        let value = rest.trim_start();
19
        let valid = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
20
        if valid && value.len() >= 7 && value.starts_with('#') && value[1..7].chars().all(|c| c.is_ascii_hexdigit()) {
21
            out.insert(name.to_string(), value[..7].to_lowercase());
22
        }
23
    }
24
    out
25
}
26
27
fn luminance(hex: &str) -> f64 {
28
    let c = |i: usize| {
29
        let x = u8::from_str_radix(&hex[i..i + 2], 16).unwrap() as f64 / 255.0;
30
        if x <= 0.03928 {
31
            x / 12.92
32
        } else {
33
            ((x + 0.055) / 1.055).powf(2.4)
34
        }
35
    };
36
    0.2126 * c(1) + 0.7152 * c(3) + 0.0722 * c(5)
37
}
38
39
fn ratio(a: &str, b: &str) -> f64 {
40
    let (x, y) = (luminance(a), luminance(b));
41
    (x.max(y) + 0.05) / (x.min(y) + 0.05)
42
}
43
44
#[test]
45
fn text_meets_aa_in_both_themes() {
46
    let css = css();
47
    let mut failures = Vec::new();
48
    for (name, sel) in [("dark", ":root {"), ("light", ":root[data-theme=\"light\"] {")] {
49
        let t = theme(&css, sel);
50
        let mut text: Vec<&str> = vec!["ink", "mute", "acc", "ok", "bad", "warn"];
51
        text.extend(t.keys().filter(|k| k.starts_with("sy-")).map(String::as_str));
52
        for fg in text {
53
            for bg in ["bg", "panel", "soft"] {
54
                let r = ratio(&t[fg], &t[bg]);
55
                if r < 4.5 {
56
                    failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1"));
57
                }
58
            }
59
        }
60
        for (fg, bg) in [("band-ink", "band"), ("add-ink", "add"), ("del-ink", "del"), ("now-ink", "now")] {
61
            let r = ratio(&t[fg], &t[bg]);
62
            if r < 4.5 {
63
                failures.push(format!("{name}: --{fg} on --{bg} is {r:.2}:1"));
64
            }
65
        }
66
    }
67
    assert!(failures.is_empty(), "below WCAG AA:\n{}", failures.join("\n"));
68
}
69
70
#[test]
71
fn text_does_not_use_dim() {
72
    let css = css();
73
    let offenders: Vec<&str> = css.lines().filter(|l| l.contains("color: var(--dim)") && !l.contains("border")).collect();
74
    assert!(offenders.is_empty(), "--dim is too faint for text:\n{}", offenders.join("\n"));
75
}