Files
JesseMarkowitz 87a40326a2 v1.1: harden recovery and control boundaries
WP-D and WP-E complete the planned v1.1 implementation packages.

WP-D — recovery honesty:
- backups verify the completed copy with PRAGMA integrity_check
- corruption missed by quick_check is detected by the full check
- existing good backups remain protected
- oversized exports are still delivered but declare whether this version can
  import them, while the 20 MB import limit remains unchanged
- backup was exercised through the real browser UI on both the normal campaign
  database and a campaign-shaped database over 100 MB

WP-E — control-boundary contrast:
- interactive control boundaries meet the WCAG 1.4.11 3:1 target
- the contrast audit is now a failing gate rather than an advisory
- rendered browser measurements pass for the composer, controls, tabs and nav
- text contrast and focus visibility remain intact
- owner reviewed and approved the before/after screenshots

Reports:
- planning/reports/v1.1/V1.1-WP-D-REPORT.md
- planning/reports/v1.1/V1.1-WP-E-REPORT.md

All planned v1.1 work packages A-E are now complete. Release validation has not
yet begun.
2026-09-16 05:37:13 -04:00

140 lines
6.4 KiB
Python

"""WCAG contrast for the design tokens, computed rather than eyeballed.
python -m tools.contrast_audit
M8 recorded contrast and visible focus as "checked by eye" and handed the
measurement to M11. There are two halves to doing that properly, and this is the
cheap half: the palette itself, checked as pairs, with no browser and no
dependency. The other half — the colours that actually reach the screen after
inheritance, opacity and layering — is measured on the rendered page by
`tools/m11_browser.py`, because a token pair says nothing about what a specific
element ended up with.
Thresholds are WCAG 2.1 AA: 4.5:1 for body text, 3:1 for large text (>=24px, or
>=18.66px bold) and for the non-text parts of a control's boundary.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
TOKENS = Path(__file__).resolve().parent.parent.parent / "frontend/src/styles/tokens.css"
#: Foreground/background pairs the design actually puts together. Written out
#: rather than combinatorial, because "every colour against every other" reports
#: pairs that never meet on screen.
#:
#: The `kind` records which success criterion a pair is measured against —
#: **text** is WCAG 1.4.3 Contrast (Minimum), **boundary** is WCAG 1.4.11
#: Non-text Contrast — and **both are pass/fail**.
#:
#: v1.1 WP-E overturned the earlier position here, which was that a boundary
#: below 3:1 could be recorded rather than failed because "a control is
#: identified by its label, not by its edge". That argument understates what
#: 1.4.11 asks: the criterion covers the visual information needed to identify
#: a component *and its boundary*, and a reader who cannot see where a text box
#: ends cannot see that there is a text box to type into, label or no label.
#: The edges were at 1.33:1 and 1.75:1 — the tokens were raised instead.
#:
#: Borders are measured against **every background they are drawn on**, and the
#: floor is the worst of them. Inputs and buttons sit on --bg-input, which is
#: lighter than --bg-panel and so the harder case; checking only --bg-panel
#: would have let a token pass the audit while the real control failed.
#: `--bg-panel-glass` is translucent and cannot be resolved from tokens alone;
#: that edge is measured on the rendered page by `tools/m11_browser.py`.
PAIRS = [
("text", "--text", "--bg", 4.5, "body text on the page"),
("text", "--text", "--bg-panel", 4.5, "body text in a panel"),
("text", "--text", "--bg-input", 4.5, "text typed into a field"),
("text", "--text-dim", "--bg", 4.5, "secondary text on the page"),
("text", "--text-dim", "--bg-panel", 4.5, "secondary text in a panel"),
("text", "--text-dim", "--bg-panel", 4.5, "a control's own label"),
("text", "--accent", "--bg", 4.5, "accent text on the page"),
("text", "--accent", "--bg-panel", 4.5, "accent text in a panel"),
("text", "--danger", "--bg-panel", 4.5, "an error message"),
("text", "--warning", "--bg-panel", 4.5, "a caution message"),
("text", "--player", "--bg", 4.5, "the player's own words"),
("boundary", "--border", "--bg-input", 3.0, "a field or button's resting edge"),
("boundary", "--border-bright", "--bg-input", 3.0, "a field or button's hover edge"),
("boundary", "--border", "--bg-panel", 3.0, "a control's resting edge in a panel"),
("boundary", "--border-bright", "--bg-panel", 3.0, "a control's hover edge in a panel"),
("boundary", "--border", "--bg", 3.0, "a divider on the page"),
("boundary", "--border-bright", "--bg", 3.0, "the scrollbar thumb"),
("boundary", "--accent-dim", "--bg-input", 3.0, "a focused field's edge"),
("boundary", "--accent-dim", "--bg-panel", 3.0, "a focused control's edge in a panel"),
("boundary", "--chart-1", "--bg-panel", 3.0, "a chart bar"),
("boundary", "--chart-2", "--bg-panel", 3.0, "a chart bar"),
("boundary", "--chart-3", "--bg-panel", 3.0, "a chart bar"),
]
def read_tokens(path: Path) -> dict[str, str]:
found = {}
for name, value in re.findall(r"(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;", path.read_text()):
found[name] = value
return found
def luminance(hex_colour: str) -> float:
r, g, b = (int(hex_colour[i:i + 2], 16) / 255 for i in (1, 3, 5))
def channel(value: float) -> float:
return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4
r, g, b = channel(r), channel(g), channel(b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
high, low = max(la, lb), min(la, lb)
return (high + 0.05) / (low + 0.05)
def main() -> int:
tokens = read_tokens(TOKENS)
# Named defensively: the tests run this against a temporary tokens file,
# which need not sit four directories deep the way the real one does.
label = TOKENS.name
if len(TOKENS.parents) > 3:
label = TOKENS.relative_to(TOKENS.parents[3])
print(f"{label}: {len(tokens)} colour tokens\n")
print(f"{'pair':44} {'kind':9} {'ratio':>7} {'floor':>6} verdict")
print("-" * 82)
text_failures, boundary_failures = 0, 0
for kind, foreground, background, floor, description in PAIRS:
if foreground not in tokens or background not in tokens:
print(f"{description:44} {kind:9} {'—':>7} {floor:>6.1f} MISSING TOKEN")
text_failures += 1
continue
measured = ratio(tokens[foreground], tokens[background])
# Rounded to the two decimals printed, so the verdict matches what the
# reader is shown: a pair reported as 3.00:1 is not failed for arithmetic
# the output does not display.
if round(measured, 2) >= floor:
verdict = "pass"
else:
verdict = "FAIL"
if kind == "text":
text_failures += 1
else:
boundary_failures += 1
print(f"{description:44} {kind:9} {measured:>6.2f}:1 {floor:>6.1f} {verdict}")
print()
if text_failures:
print(f"{text_failures} text pair(s) below WCAG AA (1.4.3) — this is a defect")
else:
print("every text pair clears WCAG AA (1.4.3)")
if boundary_failures:
print(f"{boundary_failures} boundary pair(s) below 3:1 (WCAG 1.4.11) — "
"this is a defect")
else:
print("every control boundary clears 3:1 (1.4.11)")
return 1 if (text_failures or boundary_failures) else 0
if __name__ == "__main__":
raise SystemExit(main())