#!/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())