"""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` matters and is not a way of grading on a curve. **text** pairs are #: WCAG 1.4.3 Contrast (Minimum) and are what §21 of the M11 brief asks about; #: they are pass/fail. **boundary** pairs are WCAG 1.4.11 Non-text Contrast, #: which applies to "visual information required to identify user interface #: components" — and in this design a control is identified by its *label*, #: which is measured above and passes, not by its edge. So a boundary below 3:1 #: is reported with its number and does not fail the run; what it would take to #: turn it into a real failure is a control with no visible label, and there is #: no such control (`tools/m11_browser.py` asserts every visible control has an #: accessible name, and the story controls are text buttons). 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-panel", 3.0, "a control's resting edge"), ("boundary", "--border-bright", "--bg-panel", 3.0, "a control's hover edge"), ("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) print(f"{TOKENS.relative_to(TOKENS.parents[3])}: {len(tokens)} colour tokens\n") print(f"{'pair':44} {'kind':9} {'ratio':>7} {'floor':>6} verdict") print("-" * 82) failures, advisories = 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") failures += 1 continue measured = ratio(tokens[foreground], tokens[background]) ok = measured >= floor if not ok: if kind == "text": failures += 1 verdict = "FAIL" else: advisories += 1 verdict = "below 1.4.11 (label carries it)" else: verdict = "pass" print(f"{description:44} {kind:9} {measured:>6.2f}:1 {floor:>6.1f} {verdict}") print() if failures: print(f"{failures} text pair(s) below WCAG AA — this is a defect") else: print("every text pair clears WCAG AA (1.4.3)") if advisories: print(f"{advisories} boundary pair(s) below 3:1 (1.4.11). Recorded rather " "than failed: every control in this design carries a visible text " "label, which is measured above and passes.") return 1 if failures else 0 if __name__ == "__main__": raise SystemExit(main())