OXYGENHUB
# a549ddd5941f oxygenhub / crates / hub / tests / contrast.rs
4.8 KiB · Rust · last changed feat(web): Backdrops, the engine in Rust and WASM, and the dashboard's Your sky (refs #157) 23 days 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
}
76
77
/// `--name:rgba(r,g,b,a)` of the block that starts with `selector`.
78
fn rgba(css: &str, selector: &str, name: &str) -> (f64, f64, f64, f64) {
79
    let start = css.find(selector).unwrap_or_else(|| panic!("{selector} missing"));
80
    let body = &css[start..start + css[start..].find('}').unwrap()];
81
    let at = body.find(&format!("--{name}:rgba(")).unwrap_or_else(|| panic!("--{name} missing in {selector}"));
82
    let inner = &body[at + name.len() + 8..];
83
    let v: Vec<f64> = inner[..inner.find(')').unwrap()].split(',').map(|x| x.trim().parse().unwrap()).collect();
84
    (v[0], v[1], v[2], v[3])
85
}
86
87
/// Backdrops (docs/backgrounds.md §3): a resting mark is barely there,
88
/// under 1.3:1 against the page in every look and theme, and a lit one is
89
/// plainly visible.
90
#[test]
91
fn backdrop_marks_rest_barely_there() {
92
    let classic = css();
93
    let github = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/github.css")).unwrap();
94
    let blocks = [
95
        (&classic, ":root {", ":root {"),
96
        (&classic, ":root[data-theme=\"light\"] {", ":root[data-theme=\"light\"] {"),
97
        (&github, ":root[data-look=\"github\"] {", ":root[data-look=\"github\"] {"),
98
        (&github, ":root[data-look=\"github\"][data-theme=\"dark\"] {", ":root[data-look=\"github\"][data-theme=\"dark\"] {"),
99
    ];
100
    let over = |c: (f64, f64, f64, f64), bg: &str| {
101
        let b = |i: usize| u8::from_str_radix(&bg[i..i + 2], 16).unwrap() as f64;
102
        let mix = |x: f64, y: f64| (x * c.3 + y * (1.0 - c.3)).round() as u8;
103
        format!("#{:02x}{:02x}{:02x}", mix(c.0, b(1)), mix(c.1, b(3)), mix(c.2, b(5)))
104
    };
105
    for (css, sel, bg_sel) in blocks {
106
        let bg = &theme(css, bg_sel)["bg"];
107
        let rest = ratio(&over(rgba(css, sel, "bd-mark"), bg), bg);
108
        let lit = ratio(&over(rgba(css, sel, "bd-lit"), bg), bg);
109
        assert!(rest < 1.3, "{sel}: a resting mark is {rest:.2}:1, too strong");
110
        assert!(lit >= 2.0, "{sel}: a lit mark is {lit:.2}:1, too faint");
111
    }
112
}