Files
Nomarchy/tools/check-theme-contrast.py
Bernardo Magri 47c8b6f997
All checks were successful
Check / eval (push) Successful in 3m0s
fix(themes): P1 palette retunes from the design audit + gated subtext floor (item 28b)
The audit's P1s, fixed at the palette level (items 25/27 previously
patched the consumers around these):
- summer-day: surface was the TEXT color (slate #5c6a72 on cream) and
  subtext was the BASE (ratio 1.0, invisible) — now a raised cream
  surface #efe7d0 and a slate-teal subtext #6e828a in the theme's
  family.
- flexoki-light: surface/overlay/muted were all the text black and
  subtext the paper — retuned to canonical upstream Flexoki tones
  (ui #E6E4D9, #B7B5AC, tx-2 #6F6E69, base-500 #878580).
- miasma: pure-black surface under a #222 base → raised #2e2e2e.
- kanagawa: surface was sumiInk0 (darker than the bg) → upstream's
  raised sumiInk4 #2a2a37.
check-theme-contrast.py gains the adopted rule: subtext on base >= 3.0
(all 21 themes pass), so the invisible-secondary-text class cannot
ship again. Model note in the backlog: `overlay` is a mid-tone role
(catppuccin semantics), not a third bg tier.

Verified: V0 + checks.theme-contrast green (21x3); re-audit shows all
four P1 findings gone; a scratch downstream with summer-day active
builds and the new surface hex lands in the rendered ghostty config.
Remains: V3 four-theme eyeball (HARDWARE-QUEUE) and 28b's P2 floors +
exemption list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:55:44 +01:00

75 lines
2.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.02.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)"),
]
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())