The checks that were being run by hand lived in /tmp and would have been lost, and the colour-contrast pass was ad-hoc enough that it missed --dim2 entirely. - tools/verify.js: headless smoke test. Boots the page against a stubbed DOM/canvas and checks all 9 tabs render, all 10 maps draw with no non-finite geometry and no squashed labels, dog-tag sub-checkboxes cover every tag in every list, no photo appears in two steps, every theme has a CSS block, and no personal data shipped. - tools/theme-contrast.py: WCAG check across all themes, now also covering --on-acc on --acc. Running the stricter check found real problems: --dim2 (small captions) was 2.3-3.0:1 in four themes and --acc-d was 2.7-2.8:1 in two. Lightened those. Worst theme is now 3.2:1, all on-accent pairs are 4.8:1 or better. - AGENTS.md documents the architecture, build stages, assertions, data provenance, deployment and the traps already hit, so a future session does not rediscover them the hard way.
125 lines
4 KiB
Python
125 lines
4 KiB
Python
#!/usr/bin/env python3
|
|
"""theme-contrast.py — check every theme's foreground colours against its panels.
|
|
|
|
python3 tools/theme-contrast.py
|
|
|
|
Themes are easy to get subtly wrong: Solarized Light shipped with accent colours that
|
|
scored 2.6:1 on its own panels, which read as broken rather than stylish. This walks
|
|
every theme block in build/template.html, resolves the variables the page actually
|
|
uses, and reports WCAG contrast against --panel.
|
|
|
|
Fails (exit 1) if anything drops below MIN_CONTRAST.
|
|
"""
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
MIN_CONTRAST = 3.0
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = ROOT / "build" / "template.html"
|
|
|
|
# every variable used as a foreground somewhere in the page
|
|
FOREGROUNDS = [
|
|
"--txt", "--dim", "--dim2", "--bright", "--acc", "--acc-d", "--acc2",
|
|
"--warn", "--miss", "--ok", "--steam",
|
|
"--k-tag", "--k-item", "--k-ach", "--k-miss", "--k-boss", "--k-area",
|
|
"--k-mode", "--k-extra",
|
|
]
|
|
|
|
|
|
def block(css, selector):
|
|
m = re.search(re.escape(selector) + r"\{(.*?)\}", css, re.S)
|
|
return m.group(1) if m else ""
|
|
|
|
|
|
def variables(text):
|
|
return dict(re.findall(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text))
|
|
|
|
|
|
def to_rgb(value):
|
|
v = value.strip()
|
|
m = re.fullmatch(r"#([0-9a-fA-F]{6})", v)
|
|
if m:
|
|
n = int(m.group(1), 16)
|
|
return ((n >> 16) & 255, (n >> 8) & 255, n & 255)
|
|
m = re.fullmatch(r"#([0-9a-fA-F]{3})", v)
|
|
if m:
|
|
return tuple(int(c * 2, 16) for c in m.group(1))
|
|
return None
|
|
|
|
|
|
def luminance(rgb):
|
|
def channel(x):
|
|
x /= 255
|
|
return x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4
|
|
r, g, b = rgb
|
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
|
|
|
|
def contrast(a, b):
|
|
la, lb = luminance(a), luminance(b)
|
|
hi, lo = max(la, lb), min(la, lb)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
|
|
def main():
|
|
css = SRC.read_text(encoding="utf-8")
|
|
root = variables(block(css, ":root"))
|
|
themes = {m.group(1): variables(m.group(2))
|
|
for m in re.finditer(r'html\[data-theme="([^"]+)"\]\{(.*?)\}', css, re.S)}
|
|
|
|
if not themes:
|
|
print("no theme blocks found in", SRC)
|
|
return 1
|
|
|
|
panel = to_rgb(root["--panel"])
|
|
failures = []
|
|
print(f"{'theme':<18}{'worst':>7} {'':<3}colours below {MIN_CONTRAST}:1")
|
|
print("-" * 72)
|
|
for name, overrides in themes.items():
|
|
merged = dict(root)
|
|
merged.update(overrides)
|
|
rows = []
|
|
for fg in FOREGROUNDS:
|
|
value = merged.get(fg, "")
|
|
if not value.startswith("#"):
|
|
continue
|
|
rgb = to_rgb(value)
|
|
if rgb:
|
|
rows.append((contrast(rgb, panel), fg))
|
|
rows.sort()
|
|
bad = [f"{fg}={r:.1f}" for r, fg in rows if r < MIN_CONTRAST]
|
|
if bad:
|
|
failures.append((name, bad))
|
|
worst, worst_name = rows[0]
|
|
print(f"{name:<18}{worst:>6.1f} {'' if not bad else '<- ' + ', '.join(bad)}")
|
|
|
|
# text drawn on top of an accent fill (buttons, chips, tick boxes)
|
|
print()
|
|
print(f"{'theme':<18}{'on-acc/acc':>11} status")
|
|
print("-" * 72)
|
|
for name, overrides in themes.items():
|
|
merged = dict(root)
|
|
merged.update(overrides)
|
|
a, b = to_rgb(merged.get("--on-acc", "")), to_rgb(merged.get("--acc", ""))
|
|
if not a or not b:
|
|
continue
|
|
ratio = contrast(a, b)
|
|
bad = ratio < MIN_CONTRAST
|
|
if bad:
|
|
failures.append((name, [f"--on-acc on --acc={ratio:.1f}"]))
|
|
print(f"{name:<18}{ratio:>10.1f} {'' if not bad else '<- LOW'}")
|
|
|
|
print()
|
|
if failures:
|
|
print("FAILED — these themes have unreadable foreground/background pairs:")
|
|
for name, bad in failures:
|
|
print(f" {name}: {', '.join(bad)}")
|
|
print("\nDarken the offending colour for that theme, then re-run ./deploy.sh")
|
|
return 1
|
|
print(f"all {len(themes)} themes are at or above {MIN_CONTRAST}:1 against --panel")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|