#!/usr/bin/env python3 """Guard against invisible-text palettes (BACKLOG item 25). Asserts, for every themes/*.json, the WCAG contrast ratio of each hex-on-hex fg/bg pairing the generated swaync CSS uses. Pairings built from alpha(@text) tints over @base are contrast-by-construction and need no assert. Extend PAIRINGS as more generated surfaces are fixed to palette-safe roles (item 27 tracks waybar/rofi, which still use subtext/surface and would fail here today). Wired as `checks.theme-contrast` in flake.nix; also runnable directly: python3 tools/check-theme-contrast.py themes/ """ import json import sys from pathlib import Path # (fg role, bg role, minimum ratio, consumer). Text pairings only — # borders (@accent/@bad on @base in swaync) are decorative and not held # to a text ratio (miasma's bad-on-base is 2.3, fine for a border). PAIRINGS = [ ("text", "base", 4.5, "swaync body/summary/titles on the toast"), ("base", "accent", 3.0, "swaync widget-title button:hover"), ] def channel(c: int) -> float: c /= 255 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 def luminance(hexstr: str) -> float: hexstr = hexstr.lstrip("#") r, g, b = (int(hexstr[i:i + 2], 16) for i in (0, 2, 4)) return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) def ratio(fg: str, bg: str) -> float: hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True) return (hi + 0.05) / (lo + 0.05) def main() -> int: themes_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "themes") failures = [] themes = sorted(themes_dir.glob("*.json")) if not themes: print(f"no theme JSONs found under {themes_dir}", file=sys.stderr) return 1 for path in themes: colors = json.loads(path.read_text())["colors"] for fg, bg, minimum, consumer in PAIRINGS: r = ratio(colors[fg], colors[bg]) if r < minimum: failures.append( f"{path.stem}: {fg} on {bg} = {r:.2f} < {minimum}" f" ({consumer}; {colors[fg]} on {colors[bg]})" ) if failures: print("theme contrast failures:", file=sys.stderr) for f in failures: print(f" {f}", file=sys.stderr) return 1 print(f"{len(themes)} themes x {len(PAIRINGS)} pairings: all pass") return 0 if __name__ == "__main__": sys.exit(main())