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:
parent
a63606b5c8
commit
fbb5f02f49
6 changed files with 530 additions and 17 deletions
125
tools/theme-contrast.py
Normal file
125
tools/theme-contrast.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue