#!/usr/bin/env python3 """WCAG AA gate for the design tokens — runs as part of `make lint`. Parses the light-dark() token definitions straight out of frontend/src/app.css, so palette edits are validated without keeping a copy of the values in sync here. Text pairings must reach 4.5:1, the focus ring 3:1, in BOTH modes. Exits non-zero on any violation. """ import math import re import sys from pathlib import Path APP_CSS = Path(__file__).resolve().parents[1] / "src" / "app.css" # Short names used in the pair lists → --pb-* token names. ALIASES = { "raised": "surface-raised", "sunken": "surface-sunken", } # (foreground, background) — needs >= 4.5 TEXT_PAIRS = [ ("ink", "surface"), ("ink", "raised"), ("ink", "sunken"), ("ink-muted", "surface"), ("ink-muted", "raised"), ("ink-muted", "sunken"), ("primary-fg", "primary"), ("primary-fg", "primary-hover"), ("secondary-fg", "secondary"), ("secondary-fg", "secondary-hover"), ("accent-fg", "accent"), ("accent-fg", "accent-hover"), ("secondary", "surface"), ("secondary", "sunken"), # primary is a button surface (warm near-black), never body text, so it is # only checked as a background — see the primary-fg pairings above. ("success", "surface"), ("warning", "surface"), ("danger", "surface"), ("success", "success-muted"), ("warning", "warning-muted"), ("danger", "danger-muted"), ("success-fg", "success"), ("warning-fg", "warning"), ("danger-fg", "danger"), ("danger-fg", "danger-hover"), ] # Focus indicator vs adjacent surface — needs >= 3.0 RING_PAIRS = [("secondary", "surface"), ("secondary", "raised")] COLOR = r"(#[0-9a-fA-F]{6}|oklch\([^)]*\))" def parse_palettes() -> tuple[dict[str, str], dict[str, str]]: css = APP_CSS.read_text() light: dict[str, str] = {} dark: dict[str, str] = {} pattern = rf"--pb-([a-z-]+):\s*light-dark\(\s*{COLOR}\s*,\s*{COLOR}\s*\)" for name, light_value, dark_value in re.findall(pattern, css): light[name] = light_value dark[name] = dark_value return light, dark def _hex_luminance(hex_color: str) -> float: hex_color = hex_color.lstrip("#") r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (0, 2, 4)) def linear(channel: float) -> float: if channel <= 0.04045: return channel / 12.92 return ((channel + 0.055) / 1.055) ** 2.4 return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) def _oklch_luminance(value: str) -> float: """oklch(L C H) → relative luminance. Goes OKLab → LMS → linear sRGB, which is already the space WCAG's luminance formula wants, so no gamma round-trip is needed. Out-of-gamut channels are clamped, the same way a browser renders them. """ body = value[value.index("(") + 1 : value.rindex(")")] parts = body.replace("/", " ").split() lightness = float(parts[0].rstrip("%")) / (100 if "%" in parts[0] else 1) chroma = float(parts[1]) hue = math.radians(float(parts[2])) a = chroma * math.cos(hue) b = chroma * math.sin(hue) l_ = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3 m_ = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3 s_ = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3 red = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_ green = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_ blue = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_ red, green, blue = (min(1.0, max(0.0, channel)) for channel in (red, green, blue)) return 0.2126 * red + 0.7152 * green + 0.0722 * blue def luminance(color: str) -> float: if color.startswith("oklch"): return _oklch_luminance(color) return _hex_luminance(color) def ratio(a: str, b: str) -> float: la, lb = luminance(a), luminance(b) hi, lo = max(la, lb), min(la, lb) return (hi + 0.05) / (lo + 0.05) def main() -> int: light, dark = parse_palettes() if not light: print(f"contrast-check: no light-dark() tokens found in {APP_CSS}") return 1 failures = 0 checked = 0 for mode, palette in (("light", light), ("dark", dark)): for pairs, minimum, kind in ( (TEXT_PAIRS, 4.5, "text"), (RING_PAIRS, 3.0, "ring"), ): for fg, bg in pairs: fg_hex = palette[ALIASES.get(fg, fg)] bg_hex = palette[ALIASES.get(bg, bg)] r = ratio(fg_hex, bg_hex) checked += 1 if r < minimum: failures += 1 print(f"FAIL [{mode}] {fg} on {bg}: {r:.2f} < {minimum} ({kind})") if failures: print(f"contrast-check: {failures} of {checked} pairings violate WCAG AA") return 1 print(f"contrast-check: {checked} token pairings pass WCAG AA") return 0 if __name__ == "__main__": sys.exit(main())