fix(theme): #130 the chip moves, not the label; #129 pins the pair so it is checkable
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>
This commit is contained in:
2026-07-16 14:55:17 +01:00
parent ff5017ea5d
commit c32b51897e
5 changed files with 220 additions and 47 deletions

View File

@@ -37,6 +37,48 @@ PAIRINGS = [
]
# 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
@@ -61,7 +103,8 @@ def main() -> int:
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"]
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:
@@ -69,12 +112,24 @@ def main() -> int:
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: all pass")
print(f"{len(themes)} themes x {len(PAIRINGS)} pairings"
f" + {len(CHIP_ROLES)} GTK accent chips: all pass")
return 0