All checks were successful
Check / eval (push) Successful in 3m10s
18 hex retunes, hue-preserving OKLCH minimal raises except where the upstream palette defines a canonical value: muted lifted to >= 2.0 on lumon/white/retro-82/everforest (solved), gruvbox (bg4 #7c6f64), nord (#616e88, the community comment-brightening), catppuccin-latte (overlay1 #8c8fa1); text-on-surface >= 4.5 via surface nudges (ristretto darker, latte lighter); accentAlt >= 3.0 (latte, summer-day darkened); warn >= 2.5 — the bar's battery tint — darkened on the light bases (latte, rose-pine dawn, summer-day) and flexoki-light's four status colors moved to their canonical light-mode 600 series. check-theme-contrast.py now gates all four adopted floors (7 pairings x 21 themes, green). good/bad stay audit-only — identity themes sit at 2.0-2.3 there by design, so no exemption mechanism was needed. The design audit's contrast findings drop 20 -> 1 (miasma's earthy `bad`, kept as identity). Item 28 slice (b) complete. Verified: V0, checks.theme-contrast green, flake check green, re-audit. Rendering is V3 -> HARDWARE-QUEUE eyeball entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.3 KiB
Python
83 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
||
"""Guard against invisible-text palettes (BACKLOG items 25 + 27).
|
||
|
||
Asserts, for every themes/*.json, the WCAG contrast ratio of each
|
||
hex-on-hex fg/bg pairing the generated swaync/waybar/rofi CSS uses.
|
||
Pairings built from alpha(@text) / #RRGGBBAA text tints are
|
||
contrast-by-construction and need no assert. Status glyph accents
|
||
(good/warn/bad on base) are deliberately NOT held to a text ratio —
|
||
several palettes sit at 2.0–2.7 there by design.
|
||
|
||
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 / waybar modules / rofi rows"),
|
||
# symmetric ratio also covers accent-on-base (waybar updates, rofi prompt)
|
||
("base", "accent", 3.0, "swaync button:hover / waybar active ws / rofi selected row"),
|
||
# Adopted from the item-28 design audit: summer-day/flexoki-light
|
||
# shipped subtext == base (ratio 1.0 — invisible secondary text; the
|
||
# palette-level cause of items 25/27). Never again.
|
||
("subtext", "base", 3.0, "secondary text (tooltips, hints, fastfetch labels)"),
|
||
# Item-28 P2 floors (all 21 themes retuned to pass, 2026-07-05).
|
||
# warn IS held to a floor — it's the bar's 25%-battery color and the
|
||
# low-battery toast tint; good/bad stay audit-only (identity themes
|
||
# sit at 2.0–2.3 there by design — see tools/audit-theme-design.py).
|
||
("muted", "base", 2.0, "dimmed text (inactive workspaces, muted volume)"),
|
||
("text", "surface", 4.5, "text on chips / raised rows"),
|
||
("accentAlt", "base", 3.0, "alt-accent glyphs"),
|
||
("warn", "base", 2.5, "battery warning tint (bar + toast)"),
|
||
]
|
||
|
||
|
||
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())
|