From 65eab714397259b6dd2b4e06c1f33c0cb73334eb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 22 Jun 2026 12:26:34 +0000 Subject: [PATCH] fix(theme): meet WCAG-AA contrast in light theme; gate contrast + token distinctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- scripts/palette-preview.py | 168 ++++++++++++++++++++++ src/theme.rs | 281 ++++++++++++++++++++++++++++++++++++- 2 files changed, 444 insertions(+), 5 deletions(-) create mode 100755 scripts/palette-preview.py diff --git a/scripts/palette-preview.py b/scripts/palette-preview.py new file mode 100755 index 0000000..82b6253 --- /dev/null +++ b/scripts/palette-preview.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Palette preview + WCAG contrast + CIEDE2000 perceptual audit. + +A dev tool for working on the colour palette (`src/theme.rs`). It reads +the live `dark()` / `light()` constructors so it never drifts from the +code, renders a true-colour swatch + sample for every palette entry on +the theme background, prints the WCAG-AA contrast ratio, and reports the +pairwise CIEDE2000 (ΔE2000) distance between the syntax-token colours so +near-duplicates (distinct hex, indistinguishable on screen) are obvious. + +Usage: + scripts/palette-preview.py + scripts/palette-preview.py dark:tok_type=F58AAE light:tok_flag=7A5C00 + +Each `theme:key=HEX` argument previews a change without editing the +source — handy for trying candidate colours. The gates mirrored here are +enforced for real by the tests in `src/theme.rs` (NFR-5/NFR-7, ADR-0057): +text foregrounds must clear 4.5:1; token pairs must clear ΔE2000 15. +""" +import math +import os +import re +import sys + +THEME_RS = os.path.join(os.path.dirname(__file__), "..", "src", "theme.rs") + +# --- WCAG contrast ----------------------------------------------------- +def _lin(c): + c = c / 255.0 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + +def _luminance(rgb): + r, g, b = rgb + return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b) + +def contrast(fg, bg): + a, b = _luminance(fg), _luminance(bg) + hi, lo = max(a, b), min(a, b) + return (hi + 0.05) / (lo + 0.05) + +# --- sRGB -> CIELAB (D65) + CIEDE2000 ---------------------------------- +def _f(t): + return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116 + +def rgb_to_lab(rgb): + r, g, b = (_lin(c) for c in rgb) + x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047 + y = r * 0.2126 + g * 0.7152 + b * 0.0722 + z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883 + fx, fy, fz = _f(x), _f(y), _f(z) + return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)) + +def de2000(lab1, lab2): + L1, a1, b1 = lab1 + L2, a2, b2 = lab2 + avg_Lp = (L1 + L2) / 2 + C1, C2 = math.hypot(a1, b1), math.hypot(a2, b2) + avg_C = (C1 + C2) / 2 + G = 0.5 * (1 - math.sqrt(avg_C ** 7 / (avg_C ** 7 + 25 ** 7))) if avg_C > 0 else 0 + a1p, a2p = (1 + G) * a1, (1 + G) * a2 + C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2) + avg_Cp = (C1p + C2p) / 2 + def hp(ap, b): + if ap == 0 and b == 0: + return 0 + ang = math.degrees(math.atan2(b, ap)) + return ang + 360 if ang < 0 else ang + h1p, h2p = hp(a1p, b1), hp(a2p, b2) + dLp, dCp = L2 - L1, C2p - C1p + if C1p * C2p == 0: + dhp = 0 + elif abs(h2p - h1p) <= 180: + dhp = h2p - h1p + elif h2p - h1p > 180: + dhp = h2p - h1p - 360 + else: + dhp = h2p - h1p + 360 + dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp) / 2) + if C1p * C2p == 0: + avg_hp = h1p + h2p + elif abs(h1p - h2p) <= 180: + avg_hp = (h1p + h2p) / 2 + elif h1p + h2p < 360: + avg_hp = (h1p + h2p + 360) / 2 + else: + avg_hp = (h1p + h2p - 360) / 2 + T = (1 - 0.17 * math.cos(math.radians(avg_hp - 30)) + + 0.24 * math.cos(math.radians(2 * avg_hp)) + + 0.32 * math.cos(math.radians(3 * avg_hp + 6)) + - 0.20 * math.cos(math.radians(4 * avg_hp - 63))) + d_ro = 30 * math.exp(-((avg_hp - 275) / 25) ** 2) + Rc = 2 * math.sqrt(avg_Cp ** 7 / (avg_Cp ** 7 + 25 ** 7)) if avg_Cp > 0 else 0 + Sl = 1 + (0.015 * (avg_Lp - 50) ** 2) / math.sqrt(20 + (avg_Lp - 50) ** 2) + Sc, Sh = 1 + 0.045 * avg_Cp, 1 + 0.015 * avg_Cp * T + Rt = -math.sin(math.radians(2 * d_ro)) * Rc + return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2 + + Rt * (dCp / Sc) * (dHp / Sh)) + +# --- parse the live palette from src/theme.rs -------------------------- +def parse_palette(path): + text = open(path, encoding="utf-8").read() + field = re.compile( + r"(\w+):\s*Color::Rgb\(0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2})\)" + ) + def block(start, end): + s = text.index(start) + e = text.index(end, s) + return {m.group(1): (m.group(2) + m.group(3) + m.group(4)).upper() + for m in field.finditer(text[s:e])} + return {"dark": block("fn dark()", "fn light()"), + "light": block("fn light()", "fn highlight_class_color")} + +def h(s): + return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4)) + +def fg(rgb): + r, g, b = rgb + return f"\x1b[38;2;{r};{g};{b}m" + +def bg(rgb): + r, g, b = rgb + return f"\x1b[48;2;{r};{g};{b}m" + +R = "\x1b[0m" +TOKENS = ["tok_keyword", "tok_identifier", "tok_type", "tok_number", + "tok_string", "tok_flag", "tok_function"] +NONTEXT = {"border", "border_advanced"} +SAMPLE = { + "tok_keyword": "create table", "tok_identifier": "Customers", + "tok_type": "serial", "tok_number": "42", "tok_string": "'hello'", + "tok_flag": "--all-rows", "tok_function": "count(", "tok_punct": ", ;", + "tok_error": "bad", "fg": "body text", "muted": "(none yet)", + "system": "done.", "error": "error: nope", "warning": "[WRN] slow", + "plan_efficient": "SEARCH idx", "mode_simple": "SIMPLE", + "mode_advanced": "ADVANCED", "border": "──────", "border_advanced": "──────", +} + +def main(): + themes = parse_palette(THEME_RS) + for arg in sys.argv[1:]: + th, rest = arg.split(":") + k, v = rest.split("=") + themes[th][k] = v.upper() + for tn, t in themes.items(): + B = h(t["bg"]) + print(f"\n{'=' * 64}\n {tn.upper()} THEME (bg #{t['bg']})\n{'=' * 64}") + for k, hexv in t.items(): + if k == "bg": + continue + c = h(hexv) + cr = contrast(c, B) + swatch = f"{bg(c)} {R}" + text = f"{bg(B)}{fg(c)} {SAMPLE.get(k, k):14}{R}" + floor = 3.0 if k in NONTEXT else 4.5 + warn = "" if cr >= floor else f" !! < {floor}" + print(f" {swatch} {text} #{hexv} {cr:5.2f}:1{warn} {k}") + print("\n -- token ΔE2000 (<12 hard to distinguish; gate is >=15) --") + labs = {k: rgb_to_lab(h(t[k])) for k in TOKENS if k in t} + pairs = sorted( + (de2000(labs[a], labs[b]), a, b) + for i, a in enumerate(labs) for b in list(labs)[i + 1:] + ) + for d, a, b in pairs[:6]: + flag = " !! TOO CLOSE" if d < 15 else (" ~ close" if d < 18 else "") + print(f" {d:5.1f} {a:14} vs {b:14}{flag}") + +if __name__ == "__main__": + main() diff --git a/src/theme.rs b/src/theme.rs index ef43d8e..908695b 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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", + ); + } + } + } + } }