Files
Nomarchy/tools/check-theme-contrast.py
Bernardo Magri c32b51897e
All checks were successful
Check / eval (push) Successful in 3m44s
fix(theme): #130 the chip moves, not the label; #129 pins the pair so it is checkable
Light-theme accent buttons sat at 2.72:1 — under AA and under even the 3:1
large-text floor. Bernardo: fix it. The plan on file ("darken the label")
turned out to be IMPOSSIBLE, and measuring all 24 palettes is what showed
it: on a saturated mid-tone accent nothing clears 4.5 — cream base 2.72,
dark text 1.65. That is why adw-gtk3 ships white-on-accent at ~2.7. The
chip has to move.

The chip is now the accent pulled AWAY from the label: toward white when
the label is dark, toward black when it is cream. One factor (0.70), only
the anchor flips, so "solid accent button + base label" stays the design
and just shifts tone. Worst dark 4.86 (nord/bad), worst light 5.59
(summer-day). The old 0.90 chip failed SEVEN themes (miasma/bad 3.43,
rose-pine 2.70) — never only a light-theme bug, contrary to the item.

#129 rides along, and the two are one idea. #98 shipped an invisible button
with every check green because the background was not a palette role —
adw-gtk3 computed it from currentColor. So the guard is not a CSS lint
(which would flag the harmless generic-button rule and get muted) but
arithmetic: pin both ends and the pair becomes checkable.
check-theme-contrast.py now models GTK's own mix(a,b,f) = a + (b-a)*f —
READ from gtkcsscolorvalue.c:234, not assumed — and asserts base-on-chip
>= 4.5 for all 24 themes. Its negative test reproduces the item's reported
2.72/2.74 exactly from an independent model, which is the best evidence
available that the arithmetic matches the pixels.

Invariant to keep: pin the pair, or pin neither. Both chips are pinned now
(suggested + .default + destructive), where #98 pinned only destructive.

tools/dialog-shot.nix deliberately not built: the checker covers the
numbers, and a VM that only ever confirms arithmetic is maintenance for
nothing. Render if a new widget class appears.

Also sweeps #127 with the latch finding (ff5017e): symptom 2 solved, not
DPMS — which raises the odds the mitigation treats the wrong cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:55:17 +01:00

138 lines
5.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)"),
# 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.02.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)"),
]
# GTK accent-button chips (#129/#130). Everything above is role-on-role, which
# is why #98 shipped an invisible button with every check green: the button's
# background is not a palette role at all, it is a colour GTK computes —
# mix(anchor, accent, f) — from modules/home/stylix.nix. So this half models
# that computation instead of ignoring it.
#
# It only works because the CSS pins BOTH ends. A pinned label on a background
# adw-gtk3 derives from currentColor is unknowable from here — that is the #98
# trap, and the rule this check enforces by construction: pin the pair, or pin
# neither. If a future rule pins a label and leaves the background to the sheet,
# this check will keep passing and mean nothing; that is the one way to defeat
# it, so do not.
#
# Keep in lockstep with stylix.nix: same anchor rule, same factor, same roles.
CHIP_FACTOR = 0.70 # `chip` in stylix.nix — rest state, the worst case
CHIP_MIN = 4.5 # WCAG AA body text; button labels are body text
CHIP_ROLES = [("accent", "suggested-action / .default"), ("bad", "destructive-action")]
def mix(c1: str, c2: str, f: float) -> str:
"""GTK's mix(): color1 + (color2 - color1) * factor (gtkcsscolorvalue.c).
Note the direction — f is the weight of the SECOND colour, so
mix(white, accent, 0.7) is 70% accent, not 70% white.
"""
a = [int(c1.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
b = [int(c2.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(
round(a[i] + (b[i] - a[i]) * f) for i in range(3)
)
def chip(colors: dict, role: str, mode: str) -> str:
"""The rendered button background: the accent pulled away from the label.
The label is the palette's base — dark on a dark theme, cream on a light
one — so the chip moves toward white or black accordingly.
"""
anchor = "#000000" if mode == "light" else "#FFFFFF"
return mix(anchor, colors[role], CHIP_FACTOR)
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:
theme = json.loads(path.read_text())
colors = theme["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]})"
)
mode = theme.get("mode", "dark")
for role, consumer in CHIP_ROLES:
bg = chip(colors, role, mode)
r = ratio(colors["base"], bg)
if r < CHIP_MIN:
failures.append(
f"{path.stem}: base on the {role} chip = {r:.2f} < {CHIP_MIN}"
f" (GTK {consumer}; {colors['base']} on {bg}"
f" = mix({'black' if mode == 'light' else 'white'},"
f" {colors[role]}, {CHIP_FACTOR}))"
)
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"
f" + {len(CHIP_ROLES)} GTK accent chips: all pass")
return 0
if __name__ == "__main__":
sys.exit(main())