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:
Executable
+168
@@ -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()
|
||||
Reference in New Issue
Block a user