checka11y.py
WCAG contrast ratios, computed from the stylesheet
140 lines. This is the file the build actually runs, copied verbatim at build time.
- 1
"""Compute WCAG contrast ratios from the stylesheet, in both themes. - 2
- 3
The site asserts it is readable. That is measurable, so it should be measured - 4
rather than asserted — same rule as every other claim here. This parses the real - 5
custom properties out of style.css, so it cannot drift from what ships. - 6
- 7
Thresholds (WCAG 2.1 AA): - 8
4.5:1 body text - 9
3.0:1 large text (>= 24px, or >= 18.66px bold) and UI component boundaries - 10
""" - 11
- 12
import os - 13
import re - 14
import sys - 15
- 16
HERE = os.path.dirname(os.path.abspath(__file__)) - 17
ROOT = os.path.abspath(os.path.join(HERE, "..")) - 18
- 19
CSS = os.path.join(ROOT, "style.css") - 20
- 21
AA_TEXT = 4.5 - 22
AA_LARGE = 3.0 - 23
- 24
- 25
def parse_hex(value): - 26
v = value.strip().lstrip("#") - 27
if len(v) == 3: - 28
v = "".join(c * 2 for c in v) - 29
return tuple(int(v[i:i + 2], 16) for i in (0, 2, 4)) - 30
- 31
- 32
def luminance(rgb): - 33
out = [] - 34
for c in rgb: - 35
s = c / 255 - 36
out.append(s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4) - 37
return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2] - 38
- 39
- 40
def ratio(a, b): - 41
la, lb = luminance(a), luminance(b) - 42
hi, lo = max(la, lb), min(la, lb) - 43
return (hi + 0.05) / (lo + 0.05) - 44
- 45
- 46
def variables(): - 47
"""(light, dark) maps of custom property -> rgb tuple.""" - 48
css = open(CSS, encoding="utf-8").read() - 49
- 50
root = re.search(r":root\s*\{(.*?)\}", css, re.S) - 51
light = {} - 52
for name, val in re.findall(r"--([\w-]+):\s*(#[0-9a-fA-F]{3,6})\s*;", - 53
root.group(1)): - 54
light[name] = parse_hex(val) - 55
- 56
dark = dict(light) - 57
block = re.search(r'prefers-color-scheme:\s*dark.*?\{(.*?)\n \}\n\}', css, re.S) - 58
if block: - 59
for name, val in re.findall(r"--([\w-]+):\s*(#[0-9a-fA-F]{3,6})\s*;", - 60
block.group(1)): - 61
dark[name] = parse_hex(val) - 62
return light, dark - 63
- 64
- 65
# (foreground, background, minimum, what it is) - 66
PAIRS = [ - 67
("ink", "bg", AA_TEXT, "body text"), - 68
("ink", "panel", AA_TEXT, "text inside figures"), - 69
("ink-soft", "bg", AA_TEXT, "standfirst and secondary prose"), - 70
("ink-soft", "panel", AA_TEXT, "button labels"), - 71
("ink-faint", "bg", AA_TEXT, "captions, footer, status lines"), - 72
("ink-faint", "panel", AA_TEXT, "figure captions"), - 73
("link", "bg", AA_TEXT, "links in prose"), - 74
("accent", "bg", AA_LARGE, "big statistics and headings"), - 75
("accent", "accent-soft", AA_TEXT, "pressed buttons, highlighted rows"), - 76
("ink", "bg-sunk", AA_TEXT, "inline code"), - 77
("rule-strong", "bg", AA_LARGE, "form control borders"), - 78
("rule-strong", "panel", AA_LARGE, "form control borders on panels"), - 79
# --rule itself is deliberately not checked. It draws hairline dividers - 80
# between rows and around figures: decoration that carries no information - 81
# and is exempt from WCAG 1.4.11. Anything whose boundary is an affordance - 82
# — a text box, a button — uses --rule-strong, which is checked. - 83
] - 84
- 85
# Token chips: the text is --ink on each chip background. - 86
CHIPS = [f"tok-{i}" for i in range(1, 7)] - 87
- 88
- 89
def main(): - 90
light, dark = variables() - 91
problems, rows = [], [] - 92
- 93
for theme_name, theme in (("light", light), ("dark", dark)): - 94
for fg, bg, minimum, what in PAIRS: - 95
if fg not in theme or bg not in theme: - 96
problems.append(f"{theme_name}: missing variable {fg} or {bg}") - 97
continue - 98
r = ratio(theme[fg], theme[bg]) - 99
ok = r >= minimum - 100
rows.append((theme_name, f"--{fg} on --{bg}", r, minimum, ok, what)) - 101
if not ok: - 102
problems.append( - 103
f"{theme_name}: --{fg} on --{bg} is {r:.2f}:1, " - 104
f"needs {minimum}:1 ({what})") - 105
- 106
for chip in CHIPS: - 107
if chip not in theme: - 108
continue - 109
r = ratio(theme["ink"], theme[chip]) - 110
ok = r >= AA_TEXT - 111
rows.append((theme_name, f"--ink on --{chip}", r, AA_TEXT, ok, - 112
"token chip text")) - 113
if not ok: - 114
problems.append(f"{theme_name}: --ink on --{chip} is {r:.2f}:1, " - 115
f"needs {AA_TEXT}:1 (token chip text)") - 116
- 117
worst = min(rows, key=lambda r: r[2]) - 118
for theme_name in ("light", "dark"): - 119
sub = [r for r in rows if r[0] == theme_name] - 120
bad = [r for r in sub if not r[4]] - 121
status = "PASS" if not bad else "FAIL" - 122
print(f"{status} {theme_name:<6} {len(sub)} pairs, " - 123
f"lowest {min(r[2] for r in sub):.2f}:1") - 124
for r in sub: - 125
if not r[4]: - 126
print(f" {r[1]:<28} {r[2]:.2f}:1 needs {r[3]} ({r[5]})") - 127
- 128
print(f" tightest overall: {worst[1]} in {worst[0]} at {worst[2]:.2f}:1" - 129
f" ({worst[5]})") - 130
print() - 131
if problems: - 132
print(f"{len(problems)} contrast problem(s).") - 133
return 1 - 134
print(f"All {len(rows)} colour pairs meet WCAG AA in both themes.") - 135
return 0 - 136
- 137
- 138
if __name__ == "__main__": - 139
sys.exit(main())