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 }