fix(theme): meet WCAG-AA contrast in light theme; gate contrast + token distinctness
Computing actual WCAG ratios surfaced two real accessibility defects in the shipped v0.2.0 light theme: tok_string (4.42:1) and tok_flag (3.15:1) fell below the 4.5:1 the scheme promises for normal text (NFR-5). Darken both to clear it with headroom (5.18 / 5.98). Also widen two dark-theme token pairs that were perceptually close despite distinct hex (hard to separate on good screens): tok_type and tok_function move from ΔE2000 ~14 to ~18-19 vs their neighbours. Add four gated tests in theme.rs: - all_text_colours_meet_wcag_aa_contrast: every text fg >= 4.5:1 on bg, both themes (the regression gate that would have caught this). - advanced_mode_border_meets_ui_contrast: 3:1 for the mode-alert border; plain border is decorative chrome, exempt. - delta_e_2000_matches_reference_vectors: validates the CIEDE2000 metric against Sharma et al. reference data. - syntax_token_colours_are_perceptually_distinct: ΔE2000 >= 15 for every token-class pair, both themes. Persist scripts/palette-preview.py: the true-colour swatch + contrast + ΔE2000 audit tool used to drive these changes (reads the live palette from theme.rs), for future palette work. Tests: 2513 passed / 0 failed / 1 ignored (was 2509; +4). clippy and fmt clean. Relates to NFR-5/NFR-7 (ADR-0057).
This commit is contained in:
+276
-5
@@ -106,13 +106,13 @@ impl Theme {
|
||||
// distinct from the mode-banner blue.
|
||||
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
|
||||
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
|
||||
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
|
||||
tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4)
|
||||
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
|
||||
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
|
||||
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
||||
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
||||
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
|
||||
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
|
||||
tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ impl Theme {
|
||||
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
|
||||
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
|
||||
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
|
||||
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
|
||||
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
|
||||
tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1)
|
||||
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||
tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1)
|
||||
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
|
||||
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
|
||||
}
|
||||
@@ -264,4 +264,275 @@ mod tests {
|
||||
assert_ne!(t.tok_function, t.tok_type);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ----
|
||||
//
|
||||
// Every text-bearing foreground must clear WCAG-AA 4.5:1 against
|
||||
// the panel background, in BOTH themes, so the colour scheme stays
|
||||
// legible on light and dark terminals (no dark-on-dark / light-on-
|
||||
// light failures). Borders are non-text structural chrome and are
|
||||
// handled by `advanced_mode_border_meets_ui_contrast` below; the
|
||||
// plain `border` is intentionally exempt (decorative — see ADR-0057).
|
||||
//
|
||||
// These checks rely on every `Theme` colour being `Color::Rgb`
|
||||
// (true-colour). `rgb_channels` panics on any other variant, which
|
||||
// also guards against a future regression to a named palette colour
|
||||
// (whose real contrast can't be known).
|
||||
|
||||
fn rgb_channels(c: Color) -> (f64, f64, f64) {
|
||||
match c {
|
||||
Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)),
|
||||
other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB colour.
|
||||
// The luminance/Lab/CIEDE2000 maths below are published standards; we
|
||||
// keep them in canonical `a*x + b*y` form (verified against reference
|
||||
// vectors) rather than refactoring into `mul_add`, which would obscure
|
||||
// the formula for a negligible test-only gain.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn relative_luminance(c: Color) -> f64 {
|
||||
let chan = |v: f64| {
|
||||
let v = v / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let (r, g, b) = rgb_channels(c);
|
||||
0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0).
|
||||
fn contrast_ratio(a: Color, b: Color) -> f64 {
|
||||
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
fn theme_label(t: &Theme) -> &'static str {
|
||||
match t.background {
|
||||
Background::Dark => "dark",
|
||||
Background::Light => "light",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every text foreground (incl. dimmed `muted`/`tok_punct` and the
|
||||
/// syntax-token classes) on `bg`, both themes.
|
||||
fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] {
|
||||
[
|
||||
("fg", t.fg),
|
||||
("muted", t.muted),
|
||||
("mode_simple", t.mode_simple),
|
||||
("mode_advanced", t.mode_advanced),
|
||||
("system", t.system),
|
||||
("error", t.error),
|
||||
("warning", t.warning),
|
||||
("plan_efficient", t.plan_efficient),
|
||||
("tok_keyword", t.tok_keyword),
|
||||
("tok_identifier", t.tok_identifier),
|
||||
("tok_type", t.tok_type),
|
||||
("tok_number", t.tok_number),
|
||||
("tok_string", t.tok_string),
|
||||
("tok_punct", t.tok_punct),
|
||||
("tok_flag", t.tok_flag),
|
||||
("tok_error", t.tok_error),
|
||||
("tok_function", t.tok_function),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_text_colours_meet_wcag_aa_contrast() {
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
for (name, c) in text_foregrounds(&t) {
|
||||
let ratio = contrast_ratio(c, t.bg);
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_border_meets_ui_contrast() {
|
||||
// The advanced-mode border carries a mode-warning signal, so it
|
||||
// meets WCAG's 3:1 non-text / UI-component threshold in both
|
||||
// themes. The plain `border` is decorative structural chrome and
|
||||
// is intentionally exempt (recorded in ADR-0057).
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
let ratio = contrast_ratio(t.border_advanced, t.bg);
|
||||
assert!(
|
||||
ratio >= 3.0,
|
||||
"{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NFR-5 perceptual distinctness (ADR-0057) ----
|
||||
//
|
||||
// Contrast-against-background is necessary but not sufficient: two
|
||||
// token colours can each clear 4.5:1 yet be hard to tell APART from
|
||||
// each other (a real pain reported on high-quality screens). We lock
|
||||
// that in with a CIEDE2000 (ΔE2000) floor between every pair of
|
||||
// syntax-token classes that can share a line. The metric itself is
|
||||
// validated against the Sharma et al. reference vectors.
|
||||
|
||||
/// sRGB `Color` → CIELAB (D65), for perceptual-difference checks.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
|
||||
let lin = |v: f64| {
|
||||
let v = v / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let (r0, g0, b0) = rgb_channels(c);
|
||||
let (r, g, b) = (lin(r0), lin(g0), lin(b0));
|
||||
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47;
|
||||
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83;
|
||||
let f = |t: f64| {
|
||||
if t > 0.008_856 {
|
||||
t.cbrt()
|
||||
} else {
|
||||
7.787 * t + 16.0 / 116.0
|
||||
}
|
||||
};
|
||||
let (fx, fy, fz) = (f(x), f(y), f(z));
|
||||
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
|
||||
}
|
||||
|
||||
/// CIEDE2000 colour difference between two CIELAB values.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 {
|
||||
let (l1, a1, b1) = lab1;
|
||||
let (l2, a2, b2) = lab2;
|
||||
let pow7 = |v: f64| v.powi(7);
|
||||
let k_l = pow7(25.0);
|
||||
|
||||
let avg_lp = (l1 + l2) / 2.0;
|
||||
let c1 = a1.hypot(b1);
|
||||
let c2 = a2.hypot(b2);
|
||||
let avg_c = (c1 + c2) / 2.0;
|
||||
let g = if avg_c > 0.0 {
|
||||
0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt())
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let a1p = (1.0 + g) * a1;
|
||||
let a2p = (1.0 + g) * a2;
|
||||
let c1p = a1p.hypot(b1);
|
||||
let c2p = a2p.hypot(b2);
|
||||
let avg_cp = (c1p + c2p) / 2.0;
|
||||
|
||||
let hp = |ap: f64, b: f64| -> f64 {
|
||||
if ap == 0.0 && b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let ang = b.atan2(ap).to_degrees();
|
||||
if ang < 0.0 { ang + 360.0 } else { ang }
|
||||
};
|
||||
let h1p = hp(a1p, b1);
|
||||
let h2p = hp(a2p, b2);
|
||||
|
||||
let dlp = l2 - l1;
|
||||
let dcp = c2p - c1p;
|
||||
let dhp = if c1p * c2p == 0.0 {
|
||||
0.0
|
||||
} else if (h2p - h1p).abs() <= 180.0 {
|
||||
h2p - h1p
|
||||
} else if h2p - h1p > 180.0 {
|
||||
h2p - h1p - 360.0
|
||||
} else {
|
||||
h2p - h1p + 360.0
|
||||
};
|
||||
let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
|
||||
|
||||
let avg_hp = if c1p * c2p == 0.0 {
|
||||
h1p + h2p
|
||||
} else if (h1p - h2p).abs() <= 180.0 {
|
||||
(h1p + h2p) / 2.0
|
||||
} else if h1p + h2p < 360.0 {
|
||||
(h1p + h2p + 360.0) / 2.0
|
||||
} else {
|
||||
(h1p + h2p - 360.0) / 2.0
|
||||
};
|
||||
let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
|
||||
+ 0.24 * (2.0 * avg_hp).to_radians().cos()
|
||||
+ 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
|
||||
- 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
|
||||
let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
|
||||
let rc = if avg_cp > 0.0 {
|
||||
2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
|
||||
let sc = 1.0 + 0.045 * avg_cp;
|
||||
let sh = 1.0 + 0.015 * avg_cp * t;
|
||||
let rt = -(2.0 * d_ro.to_radians()).sin() * rc;
|
||||
|
||||
((dlp / sl).powi(2)
|
||||
+ (dcp / sc).powi(2)
|
||||
+ (d_big_hp / sh).powi(2)
|
||||
+ rt * (dcp / sc) * (d_big_hp / sh))
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_e_2000_matches_reference_vectors() {
|
||||
// Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the
|
||||
// metric so the distinctness gate below rests on a correct base.
|
||||
let cases = [
|
||||
((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425),
|
||||
((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615),
|
||||
((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792),
|
||||
((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669),
|
||||
];
|
||||
for (l1, l2, exp) in cases {
|
||||
let got = delta_e_2000(l1, l2);
|
||||
assert!(
|
||||
(got - exp).abs() < 0.01,
|
||||
"ΔE2000 {got:.4} != reference {exp:.4}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_token_colours_are_perceptually_distinct() {
|
||||
// Every pair of syntax-token classes that can appear together on
|
||||
// one line must be perceptually separable, not merely unequal in
|
||||
// hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18).
|
||||
// Excludes tok_punct (== muted) and tok_error (== error), which
|
||||
// deliberately alias other roles.
|
||||
const MIN_DE: f64 = 15.0;
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
let toks = [
|
||||
("tok_keyword", t.tok_keyword),
|
||||
("tok_identifier", t.tok_identifier),
|
||||
("tok_type", t.tok_type),
|
||||
("tok_number", t.tok_number),
|
||||
("tok_string", t.tok_string),
|
||||
("tok_flag", t.tok_flag),
|
||||
("tok_function", t.tok_function),
|
||||
];
|
||||
for (i, (name_a, ca)) in toks.iter().enumerate() {
|
||||
for (name_b, cb) in toks.iter().skip(i + 1) {
|
||||
let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb));
|
||||
assert!(
|
||||
de >= MIN_DE,
|
||||
"{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user