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
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:
@@ -79,6 +79,21 @@ and never re-enables a disabled internal.
|
||||
trigger, we are paying a permanent behaviour cost (docked externals never
|
||||
sleep) and the brick is still live — so confirming the cause outranks
|
||||
#135/#136.
|
||||
- **Symptom 2 is SOLVED, and it was not DPMS (ff5017e, 2026-07-16).** Bernardo
|
||||
reproduced "unplugging did not turn the laptop panel back on" live, minutes
|
||||
after TEST A — with **no blanking involved at all** (the mitigation had kept
|
||||
the external lit). Cause: `awaiting_lid_open` is set by every successful
|
||||
undock and cleared only when the external is gone **and** the lid is open, so
|
||||
a clamshell undock **latches** it; a re-dock clears its sibling
|
||||
`undock_pending` but not the latch, and the next departure is then silently
|
||||
dropped by the `[ -z "$awaiting_lid_open" ]` guard. His journal shows the
|
||||
first undock logging *nothing*, a release the instant he lifted the lid, then
|
||||
a clean undock. Replaying the sequence with the lid never opened: 1 of 3
|
||||
undocks ran; fixed, 3 of 3. **What this does to the theory:** the original
|
||||
brick may simply have been an ordinary blank plus a recovery that could not
|
||||
run — two mundane bugs, not one exotic DRM state. TEST B is now the only
|
||||
thing standing between that hypothesis and the truth, and it is a much safer
|
||||
test with the latch fixed (an undock is a real escape hatch again).
|
||||
|
||||
**Pass (current mitigation):** clamshell-docked on AC, idle past 10+ min →
|
||||
external stays lit under hyprlock (journal: `dpms-off skipped: clamshell`);
|
||||
@@ -173,37 +188,6 @@ the key in the same commit.
|
||||
trigger, the mitigation is reverted wholesale and this key never exists —
|
||||
building it first risks shipping an option for a policy we withdraw.
|
||||
|
||||
### 129. Permanent guard for the GTK button-colour trap (#98 follow-up)
|
||||
|
||||
The #98 regression (destructive-action at 1.03:1, invisible) was caught only
|
||||
by rendering a dialog — every scripted check was green, because the bug lives
|
||||
in adw-gtk3's `mix(@destructive_color, alpha(currentColor,…))` background, not
|
||||
in our CSS. `tools/theme-shot.nix` is deliberately not a CI gate (full-desktop
|
||||
VM), so add the cheap static half: assert that for every theme, any rule that
|
||||
pins a button label colour also pins that button's `background-color` — i.e.
|
||||
no pinned label may sit on a `currentColor`-derived background. Guards the
|
||||
class, not the instance. The scratch render harness that found it is worth
|
||||
promoting to `tools/dialog-shot.nix` as a maintainer tool alongside theme-shot.
|
||||
|
||||
### 130. GTK accent buttons sit at ~2.7:1 in light themes
|
||||
|
||||
Fallout noted while fixing #98: in light palettes the suggested/destructive
|
||||
labels are base00 (cream) on saturated accent/bad — summer-day measures
|
||||
**2.72:1** (suggested) and **2.74:1** (destructive), under AA 4.5 and even
|
||||
under the 3:1 large-text floor. Not a regression: upstream adw-gtk3 does the
|
||||
same (`color: white` on the accent), so we currently match GNOME.
|
||||
|
||||
**Decided 2026-07-16 (Bernardo): fix the contrast — we hold ourselves higher
|
||||
than GNOME here.** Matching upstream is not a defence when upstream is under
|
||||
even the large-text floor; "everyone ships it" is how 2.7:1 text becomes
|
||||
normal. Two shapes to pick between while building: darken the *label* (the
|
||||
label is ours; the accent is the user's palette and the thing they chose the
|
||||
theme for), or darken the *accent mix* under the label in light palettes only.
|
||||
Prefer the first unless it looks wrong — it leaves the palette alone.
|
||||
Note `tools/check-theme-contrast.py` covers palette pairings only, never GTK
|
||||
widget surfaces, so whatever lands needs its own assertion or it rots exactly
|
||||
like #98 did.
|
||||
|
||||
### 143. Rofi keeps the selected *row number* when the search changes, so the highlight lands on an unrelated entry
|
||||
|
||||
Bernardo 2026-07-16, and his repro is exact: open the app menu, type `ca`, move
|
||||
|
||||
@@ -19,6 +19,36 @@ Template:
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — #129/#130 buttons; #133; #132; branding; and a latch bug from TEST A
|
||||
- **Task:** Bernardo triaged the queue (#136 settled, #130 decided, #143 → wait,
|
||||
#120 → PROPOSED), then #133, #132, the codename drop, and #129+#130.
|
||||
- **The one to read: a latch bug, found by his TEST A.** He undocked, the panel
|
||||
stayed dark, re-docking+undocking fixed it — *opening the lid* was the actual
|
||||
cure. `awaiting_lid_open` is cleared only when the external is gone AND the
|
||||
lid is open, so a clamshell undock latches it and the NEXT undock is silently
|
||||
dropped. His journal proved it (first undock logged nothing). 1 of 3 undocks
|
||||
ran; now 3 of 3. **This is very likely #127's second symptom, and it is not
|
||||
DPMS** — so the brick may be two mundane bugs, not one exotic one. TEST B is
|
||||
now the only thing between that and the truth.
|
||||
- **#129/#130:** the planned fix ("darken the label") was **impossible** —
|
||||
measuring all 24 palettes showed nothing clears 4.5 on a saturated mid-tone
|
||||
accent (cream 2.72, dark text 1.65). The chip had to move: it is now the
|
||||
accent pulled away from the label (white anchor for dark themes, black for
|
||||
light), factor 0.70, worst 4.86/5.59. The old chip failed **seven** themes,
|
||||
not the four the item assumed. #129 became arithmetic rather than a CSS lint:
|
||||
pin both ends and the pair is checkable — the checker models GTK's real
|
||||
`mix()` (read from gtkcsscolorvalue.c:234) and its negative test re-derives
|
||||
the item's own 2.72/2.74 figures independently.
|
||||
- **Verified:** every change negative-tested — the chip guard by reverting to
|
||||
the old 0.90 chip (9 failures across 7 themes, matching the filed numbers),
|
||||
#133's shim check by breaking lib.nix twice, the latch by replaying his
|
||||
sequence. V0 green throughout; the branding by reading the built os-release.
|
||||
- **Pending:** Bernardo runs TEST B tonight (**relogin first** — the latch fix
|
||||
is exec-once). Buttons want an eyeball on a light theme at some point, but the
|
||||
numbers are pinned and the render harness was declined on purpose.
|
||||
- **Next suggestion:** whatever TEST B says. If input wakes the screen, revert
|
||||
the #127 mitigation wholesale and #135 dies with it.
|
||||
|
||||
## 2026-07-16 — #137 + #145: the splash follows the canvas, and says your layout
|
||||
- **Task:** #137 (splash off-centre docked) + #145 (keyboard layout on the LUKS
|
||||
prompt, with Fedora's icon).
|
||||
|
||||
@@ -569,6 +569,59 @@ Design/decision records and a running log of shipped work (items marked
|
||||
file already ships `ANSI_COLOR=""`. **`nixos-version` still prints "(Yarara)"
|
||||
by design** — that command answers "which nixpkgs was this built from?", the
|
||||
one question the codename genuinely answers.
|
||||
- ✓ **Accent buttons: the chip moves, not the label (#129/#130, 2026-07-16):**
|
||||
Light-theme suggested/destructive labels sat at **2.72:1** — under AA and under
|
||||
even the 3:1 large-text floor. Bernardo: fix it; matching upstream is no
|
||||
defence when upstream is illegible. The plan on file — "darken the label" —
|
||||
turned out to be **impossible**, and measuring the whole palette set is what
|
||||
showed it: on a saturated mid-tone accent *nothing* clears 4.5, neither the
|
||||
cream base (2.72) nor the dark text (1.65). That is why adw-gtk3 ships
|
||||
white-on-accent at ~2.7 and calls it done. **The chip has to move.**
|
||||
So 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, and "solid accent button, base label" survives as the design.
|
||||
Measured across all 24 palettes: 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 — so this was never only a light-theme bug, which is
|
||||
what the item assumed.
|
||||
**#129 rides along, and the two items turn out to be 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`. The guard is
|
||||
therefore 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 every theme. Its own negative
|
||||
test reproduces the item's reported figures exactly (summer-day 2.72/2.74)
|
||||
from an independent model, which is the strongest evidence available that the
|
||||
arithmetic matches the pixels. The invariant to keep: **pin the pair, or pin
|
||||
neither** — a pinned label on a sheet-derived background is unknowable, and
|
||||
that is precisely how #98 passed review.
|
||||
(`tools/dialog-shot.nix` deliberately not built: the checker covers the
|
||||
numbers, and a render harness that only ever confirms arithmetic is a VM to
|
||||
maintain for nothing. Render if a *new* widget class appears.)
|
||||
- ✓ **A clamshell undock latched the next one (2026-07-16, ff5017e):**
|
||||
Bernardo undocked and the panel stayed dark; re-docking and undocking again
|
||||
brought it back. Re-docking was not the cure — **opening the lid** was.
|
||||
`awaiting_lid_open` is set by every successful undock and cleared only when
|
||||
the external is gone *and* the lid is open, so a clamshell undock latches it;
|
||||
the `added_ext` branch clears its sibling `undock_pending` ("a new external
|
||||
supersedes any undock still queued for an older departure") and forgot this
|
||||
one, and the clear cannot fire while an external is present. The next
|
||||
departure then hits `[ -z "$awaiting_lid_open" ]`, queues nothing, and the
|
||||
panel never returns. His journal is the proof: the first undock logged
|
||||
**nothing at all**, then `lid-inhibitor=released` the moment he lifted the
|
||||
lid, then a clean undock. Fix: clear the latch where `undock_pending` already
|
||||
is — a new external ends the departure the latch describes. Replaying the
|
||||
sequence with the lid never opened: **1 of 3 undocks ran; now 3 of 3.**
|
||||
Two lessons. **A latch that outlives what it describes is a bug waiting for a
|
||||
rare-looking sequence** — and "dock/undock twice without opening the lid" only
|
||||
*looks* rare until you notice it is what a week at a desk does. And this is
|
||||
very likely **#127's second symptom** ("unplugging did not restore the
|
||||
panel"), which means the brick may have been an ordinary blank plus a recovery
|
||||
that could not run — two mundane bugs, not one exotic DRM state. The shipped
|
||||
DPMS mitigation may therefore be treating the wrong cause; #127's TEST B is
|
||||
what decides, and it is far safer now that an undock works again.
|
||||
- ✓ **The #107 compat shim is pinned (#133, 2026-07-16):** `mkFlake` prefers
|
||||
`state.json` and still accepts the legacy `theme-state.json`, because a
|
||||
machine only migrates on its next menu write — but nothing evaluated a
|
||||
|
||||
@@ -16,6 +16,31 @@ let
|
||||
c = t.colors;
|
||||
hex = lib.removePrefix "#";
|
||||
|
||||
# Accent-button chips (#130). The label is the palette's base — dark on a
|
||||
# dark theme, cream on a light one — so the chip has to be pulled AWAY from
|
||||
# the label until the label is legible: toward white when the label is dark,
|
||||
# toward black when it is cream. Same factor both ways; only the anchor
|
||||
# flips, so "solid accent button, base label" stays the design and just
|
||||
# shifts tone.
|
||||
#
|
||||
# Why a nudge at all: on a saturated mid-tone accent NOTHING clears 4.5 —
|
||||
# neither the cream base (summer-day: 2.72) nor the dark text (1.65) — which
|
||||
# is why upstream adw-gtk3 ships white-on-accent at ~2.7 and why "darken the
|
||||
# label" was a dead end. The chip must move.
|
||||
#
|
||||
# 0.70 measured across all 24 palettes (tools/check-theme-contrast.py asserts
|
||||
# it): worst dark 4.86 (nord/bad), worst light 5.59 (summer-day). Today's 0.90
|
||||
# fails seven themes — miasma/bad 3.43, rose-pine 2.70 — so this is not only a
|
||||
# light-theme fix, which is what the item assumed.
|
||||
#
|
||||
# GTK's mix(a, b, f) = a + (b - a) * f (gtkcsscolorvalue.c), i.e. f is the
|
||||
# weight of the SECOND colour: 0.70 = 70% accent, 30% anchor. Read it that way
|
||||
# or the ladder below looks inverted.
|
||||
chipAnchor = if t.mode == "light" then "black" else "white";
|
||||
# Each state pulls further from the label, so contrast only ever improves —
|
||||
# rest is the worst case and the one the check pins.
|
||||
chip = role: f: "mix(${chipAnchor}, ${role}, ${f})";
|
||||
|
||||
# Map the Nomarchy palette onto base16 roles.
|
||||
base16 = {
|
||||
base00 = hex c.base; # default background
|
||||
@@ -116,31 +141,57 @@ in
|
||||
button:disabled, button:disabled label {
|
||||
color: alpha(#${base16.base05}, 0.5);
|
||||
}
|
||||
/* Suggested/destructive sit on pastel accent/bad — dark label
|
||||
is intentional and contrast-checked (7.0:1 suggested, 6.2:1
|
||||
destructive on Boreal). */
|
||||
/* Accent buttons: the palette's base as the label, on a chip
|
||||
pulled away from it (#130 — see `chip` above for the why and
|
||||
the measured numbers).
|
||||
|
||||
BOTH chips are pinned, not just destructive as in #98. Two
|
||||
reasons, and the second is the durable one:
|
||||
1. adw-gtk3 builds the destructive background from
|
||||
currentColor — mix(@destructive_color,
|
||||
alpha(currentColor,…)) — so pinning the label alone drags
|
||||
the background with it and the button renders dark-on-dark
|
||||
(1.03:1, invisible). That is the #98 bug.
|
||||
2. A pinned label on an UNPINNED background is unknowable:
|
||||
no static check can see what it renders on, which is
|
||||
exactly why every check was green while #98 shipped. A
|
||||
pinned pair is arithmetic, and
|
||||
tools/check-theme-contrast.py now asserts it for all 24
|
||||
palettes. The rule for anything added here: pin the pair,
|
||||
or pin neither.
|
||||
|
||||
Enabled only — :disabled keeps the sheet's own chrome and
|
||||
already renders legibly. .default is GTK's dialog default and
|
||||
adw-gtk3 styles it as an accent button, which is why it shares
|
||||
the suggested chip rather than inheriting a plain one. */
|
||||
button.suggested-action, button.suggested-action label,
|
||||
button.destructive-action, button.destructive-action label,
|
||||
button.default, button.default label {
|
||||
color: #${base16.base00};
|
||||
}
|
||||
/* adw-gtk3 builds ONLY the destructive background from
|
||||
currentColor — mix(@destructive_color, alpha(currentColor,…)) —
|
||||
where suggested-action uses an opaque mix(white,@accent_bg…).
|
||||
So pinning the label above drags the background down with it
|
||||
and the button renders dark-on-dark (1.03:1, invisible). Pin
|
||||
the background to the same opaque pastel ladder suggested-action
|
||||
uses. Enabled only: :disabled keeps the sheet's dark chrome and
|
||||
already renders legibly. */
|
||||
button.suggested-action:not(.flat):not(:disabled),
|
||||
button.default:not(.flat):not(:disabled) {
|
||||
background-color: ${chip "@accent_bg_color" "0.70"};
|
||||
}
|
||||
button.suggested-action:not(.flat):not(:disabled):hover,
|
||||
button.default:not(.flat):not(:disabled):hover {
|
||||
background-color: ${chip "@accent_bg_color" "0.65"};
|
||||
}
|
||||
button.suggested-action:not(.flat):not(:disabled):active,
|
||||
button.suggested-action:not(.flat):not(:disabled):checked,
|
||||
button.default:not(.flat):not(:disabled):active,
|
||||
button.default:not(.flat):not(:disabled):checked {
|
||||
background-color: ${chip "@accent_bg_color" "0.55"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled) {
|
||||
background-color: mix(white, @destructive_bg_color, 0.9);
|
||||
background-color: ${chip "@destructive_bg_color" "0.70"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled):hover {
|
||||
background-color: mix(white, @destructive_bg_color, 0.85);
|
||||
background-color: ${chip "@destructive_bg_color" "0.65"};
|
||||
}
|
||||
button.destructive-action:not(.flat):not(:disabled):active,
|
||||
button.destructive-action:not(.flat):not(:disabled):checked {
|
||||
background-color: mix(white, @destructive_bg_color, 0.7);
|
||||
background-color: ${chip "@destructive_bg_color" "0.55"};
|
||||
}
|
||||
button.suggested-action.flat, button.suggested-action.flat label {
|
||||
color: #${base16.base0D};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user