All checks were successful
Check / eval (push) Successful in 3m44s
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>
138 lines
5.8 KiB
Python
138 lines
5.8 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)"),
|
||
]
|
||
|
||
|
||
# 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())
|