Add AGENTS.md and move the verification tools into the repo

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.
This commit is contained in:
Rain 2026-09-15 17:41:48 -04:00
parent a63606b5c8
commit fbb5f02f49
6 changed files with 530 additions and 17 deletions

125
tools/theme-contrast.py Normal file
View file

@ -0,0 +1,125 @@
#!/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())