tests/theme_contrast.rs 5.2 KiB 182 lines Raw
1 //! f0rge #76: every text color must pass WCAG AA (4.5:1) in both themes.2 //! Parsed from `static/style.css` so the palette cannot drift from the3 //! rule set without this test noticing.4 5 use std::path::PathBuf;6 7 fn css() -> String {8     let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static/style.css");9     std::fs::read_to_string(p).expect("style.css")10 }11 12 /// Body of a `{...}` block starting at `at` (brace-matched).13 fn block(css: &str, at: usize) -> &str {14     let open = css[at..].find('{').expect("open brace") + at;15     let bytes = css.as_bytes();16     let mut depth = 0;17     let mut i = open;18     while i < bytes.len() {19         match bytes[i] {20             b'{' => depth += 1,21             b'}' => {22                 depth -= 1;23                 if depth == 0 {24                     return &css[open + 1..i];25                 }26             }27             _ => {}28         }29         i += 1;30     }31     panic!("unclosed block");32 }33 34 fn var(block: &str, name: &str) -> String {35     let needle = format!("{name}:");36     let start = block37         .find(&needle)38         .unwrap_or_else(|| panic!("{name} missing"));39     let rest = &block[start + needle.len()..];40     let end = rest.find([';', '}']).unwrap_or(rest.len());41     rest[..end].trim().to_string()42 }43 44 fn channel(c: f64) -> f64 {45     if c <= 0.039_28 {46         c / 12.9247     } else {48         ((c + 0.055) / 1.055).powf(2.4)49     }50 }51 52 fn lum(hex: &str) -> f64 {53     let h = hex.trim_start_matches('#');54     let r = u8::from_str_radix(&h[0..2], 16).unwrap() as f64 / 255.0;55     let g = u8::from_str_radix(&h[2..4], 16).unwrap() as f64 / 255.0;56     let b = u8::from_str_radix(&h[4..6], 16).unwrap() as f64 / 255.0;57     0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)58 }59 60 fn contrast(a: &str, b: &str) -> f64 {61     let (hi, lo) = {62         let (la, lb) = (lum(a), lum(b));63         if la > lb {64             (la, lb)65         } else {66             (lb, la)67         }68     };69     (hi + 0.05) / (lo + 0.05)70 }71 72 fn check(theme: &str, block: &str, pairs: &[(&str, &str)]) {73     for (fg, bg) in pairs {74         let f = var(block, fg);75         let b = var(block, bg);76         let ratio = contrast(&f, &b);77         assert!(78             ratio >= 4.5,79             "{theme}: {fg} {f} on {bg} {b} = {ratio:.2}:1 (AA needs 4.5)"80         );81     }82 }83 84 #[test]85 fn both_themes_pass_aa_for_text_colors() {86     let css = css();87     let dark_at = css.find(":root{").expect("dark root");88     let dark = block(&css, dark_at);89     let light_at = css90         .find("@media (prefers-color-scheme:light)")91         .expect("light block");92     let light = block(&css, light_at);93 94     let pairs = [95         ("--fg", "--bg"),96         ("--fg", "--bg-alt"),97         ("--fg", "--surface"),98         ("--fg", "--surface-2"),99         ("--fg-dim", "--bg"),100         ("--fg-dim", "--bg-alt"),101         ("--fg-dim", "--bg-th"),102         ("--muted", "--bg"),103         ("--muted", "--bg-th"),104         ("--blue", "--bg"),105         ("--blue", "--surface"),106         ("--cyan", "--bg"),107         ("--green", "--bg"),108         ("--red", "--bg"),109         ("--yellow", "--bg"),110         ("--state-open", "--bg"),111         ("--state-merged", "--bg"),112         ("--state-closed", "--bg"),113         ("--tok-kw", "--surface"),114         ("--tok-str", "--surface"),115         ("--tok-num", "--surface"),116         ("--tok-com", "--surface"),117         ("--tok-com", "--surface-2"),118         ("--tok-fn", "--surface"),119         ("--tok-type", "--surface"),120         ("--diff-file", "--surface"),121         ("--diff-hunk", "--surface"),122         ("--diff-add", "--diff-add-bg"),123         ("--diff-del", "--diff-del-bg"),124     ];125     check("dark", dark, &pairs);126     check("light", light, &pairs);127 }128 129 #[test]130 fn themes_define_the_same_vars() {131     let css = css();132     let dark = block(&css, css.find(":root{").expect("dark root"));133     let light = block(134         &css,135         css.find("@media (prefers-color-scheme:light)")136             .expect("light block"),137     );138     // Every dark color var has a light override (scale vars may be shared).139     for line in dark.lines() {140         let Some(name) = line141             .trim()142             .strip_prefix("--")143             .and_then(|l| l.split(':').next())144         else {145             continue;146         };147         if name.starts_with("gap-")148             || name.starts_with('s')149                 && name.len() <= 2150                 && name[1..].chars().all(|c| c.is_ascii_digit())151             || name.starts_with("fs-")152             || name == "radius"153         {154             continue;155         }156         assert!(157             light.contains(&format!("--{name}:")),158             "light theme must override --{name}"159         );160     }161 }162 163 #[test]164 fn field_boundaries_pass_ui_contrast() {165     // f0rge #90: a control boundary needs 3:1 against its own fill.166     let css = css();167     let dark = block(&css, css.find(":root{").expect("dark root"));168     let light = block(169         &css,170         css.find("@media (prefers-color-scheme:light)")171             .expect("light block"),172     );173     for (theme, block) in [("dark", dark), ("light", light)] {174         let border = var(block, "--field-border");175         let fill = var(block, "--surface");176         let ratio = contrast(&border, &fill);177         assert!(178             ratio >= 3.0,179             "{theme}: --field-border {border} on --surface {fill} = {ratio:.2}:1 (UI needs 3)"180         );181     }182 }