From a643391d3d8ab33ce78a8193f190d417056125a7 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 4 Jul 2026 18:53:01 +0100 Subject: [PATCH] =?UTF-8?q?fix(swaync):=20palette-safe=20pairings=20?= =?UTF-8?q?=E2=80=94=20text=20was=20invisible=20on=20summer-day=20(item=20?= =?UTF-8?q?25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette roles aren't uniform: summer-day and flexoki-light use subtext as text-ON-surface (== base) and surface as a dark chip (== text), so swaync's @subtext-on-@base body text and @surface/@text buttons were self-colored (caught on the Latitude). - body/summary: @text on @base — the one text pairing that passes in every palette (survey: worst WCAG ratio 5.18 across all 21 themes; subtext/base bottomed at 1.00 twice, muted/base at 1.27). - hover rows + widget-title buttons: alpha(@text, 0.1) tints — contrast by construction; subtext/surface defines dropped. - NEW checks.theme-contrast (tools/check-theme-contrast.py): asserts the hex-on-hex text pairings across themes/*.json, cheap (no VM). The audit also found the generated waybar/rofi CSS shares the bug class, live on flexoki-light (no whole-swap) → BACKLOG item 27. Verified: V0; V1 (checks.theme-contrast builds green, 21 themes; rendered services.swaync.style has zero @subtext/@surface). Honesty: NOT visually verified — V3 queued (notify-send on summer-day). Co-Authored-By: Claude Fable 5 --- agent/BACKLOG.md | 38 ++++++++++--------- agent/HARDWARE-QUEUE.md | 5 +++ agent/JOURNAL.md | 16 ++++++++ docs/ROADMAP.md | 11 ++++++ flake.nix | 12 ++++++ modules/home/swaync.nix | 15 +++++--- tools/check-theme-contrast.py | 69 +++++++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 24 deletions(-) create mode 100644 tools/check-theme-contrast.py diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 8574830..2074184 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -22,26 +22,28 @@ next, in what order*. ## NOW -*Items 21–26: real-hardware QA findings from the Latitude 5410 +*Items 21–27: real-hardware QA findings from the Latitude 5410 (Bernardo, 2026-07-04) — bugs found on metal outrank the queue.* -### 25. swaync text unreadable on summer-day (light-theme contrast) -Reported: on summer-day, notification/control-centre text is invisible -(contrast). Root cause (traced 2026-07-04): swaync.nix renders `.body` -as `@subtext` on `alpha(@base, 0.95)` — but in summer-day (and -flexoki-light) `subtext` *equals/≈ base* because those palettes use -`surface` as a dark contrast surface and `subtext` as text-*on*-surface, -not "dimmer text" (catppuccin-latte-style). summer-day also has -`surface == text` (#5c6a72), so `.widget-title > button` -(@surface bg, @text fg) is self-colored too. Fix direction: swaync.nix -should pair roles that contrast by construction — `muted` (dim-text -role in every palette) for body text on @base, and button fg that -contrasts @surface (`subtext` is arguably the *right* role there) — -or normalize the palette semantics across the light themes; audit the -other `@subtext`/`@surface` consumers (waybar css, rofi) for the same -mispairing before choosing. **Verify:** V1 + a contrast assert over all -themes (text≠bg for each swaync pairing); V2 themed-VM screenshot with -a `notify-send`; V3 re-check on the Latitude. +### 27. Generated waybar/rofi CSS invisible on on-surface palettes +Found by item 25's audit: the generated Waybar CSS colors `#window` and +the whole module row `@subtext`, and the generated rofi theme puts `@fg` +text on `@surface` backgrounds (inputbar, textbox, `element alternate`). +In palettes where subtext/surface mean "on-surface" (flexoki-light: +subtext==base AND surface==text) those are invisible. summer-day/night +escape via their whole-swap waybar.css/rofi.rasi — **flexoki-light does +not** and runs the generated CSS. Same class of fix as the shipped +swaync one (63136a8-follow-on): move the generated surfaces to pairings +that contrast by construction (alpha(@text) tints; @text for module +text with per-module accents kept), then extend +tools/check-theme-contrast.py's PAIRINGS to cover waybar/rofi so +`checks.theme-contrast` guards them too. Waybar changes must respect +the parity rule (generated bar only — the summer whole-swaps are +hand-authored and unaffected). Alternative worth weighing in-iteration: +normalize the flexoki-light palette itself (make subtext dim-text, +surface elevated-bg) — smaller diff but changes the theme's look +everywhere. **Verify:** V1 + the extended contrast check across all 21 +themes; V2 themed-VM screenshot on flexoki-light if cheap; V3 eyeball. ### 22. Network menu shows nameless/weird entries Reported: `networkmanager_dmenu` lists entries with no names (likely diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index e17cb9e..b54063a 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -39,6 +39,11 @@ QA machine), the **T14s** (webcam case). `home-update` + relogin (or `hyprctl reload`), SUPER+? must open the keybindings cheatsheet, whose row still reads `SUPER + ?` (not `SUPER + SHIFT + ?`). +- [ ] **swaync readable on summer-day** (item 25 fix) — on summer-day + after `home-update`: `notify-send "title" "body text"` shows + readable body text; open the control centre (SUPER+N), hover a + notification row and check the Clear button — all text legible + (body/buttons now @text on tinted chips, not subtext/surface). - [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** — plug an external keyboard post-login, pick a layout in rofi, confirm it applies per-device only, persists in `settings.keyboard.devices`, diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index fe43b6c..ee61c0d 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,22 @@ Template: --- +## 2026-07-04 — swaync contrast fix + theme-contrast check (iteration #9, item 25) +- **Task:** BACKLOG NOW#25 — swaync text invisible on summer-day. +- **Did:** swaync.nix now uses only palette-safe pairings: body/summary + = @text on @base (worst ratio 5.18 across all 21 themes; subtext/base + was 1.00 on summer-day + flexoki-light), hover rows + widget buttons + = alpha(@text, 0.1) tints (contrast by construction); dropped the + subtext/surface defines. NEW permanent check `checks.theme-contrast` + (tools/check-theme-contrast.py) asserts the hex-on-hex text pairings + across every theme JSON. Audit found the same mispairing in generated + waybar/rofi CSS, live on flexoki-light (no whole-swap) → NOW item 27. +- **Verified:** V0; V1 — checks.theme-contrast builds green (21 themes), + rendered services.swaync.style contains zero @subtext/@surface. + NOT V2/V3-verified visually — queued (Bernardo is at the Latitude). +- **Pending:** V3 notify-send readability check on summer-day; item 27. +- **Next suggestion:** item 27 (same bug class, waybar/rofi surfaces). + ## 2026-07-04 — SUPER+? bind fix (iteration #8, item 26) - **Task:** BACKLOG NOW#26 — cheatsheet bind never fires on hardware. - **Did:** keybinds.nix: `$mod, question` → `$mod SHIFT, question` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3fc8eb..29dcda7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -849,6 +849,17 @@ how to override it. Items marked ✓ are shipped. your theme / essentials" flow (ties into the branding work). ## Known issues & follow-ups +- ✓ **swaync text invisible on summer-day** (Latitude QA, 2026-07-04): + the themed CSS paired @subtext on @base, but palette roles are not + uniform across themes — summer-day/flexoki-light use subtext as + text-*on*-surface (== base) and surface as a dark chip (== text), so + body text and widget buttons self-colored. Fix: swaync uses only + pairings safe in every palette — @text on @base for text (survey: + worst ratio 5.18 across 21 themes) and alpha(@text, 0.1) tints for + chips/hovers — guarded permanently by `checks.theme-contrast` + (tools/check-theme-contrast.py, WCAG ratios over themes/*.json). + The same audit found generated waybar/rofi CSS shares the bug class, + exposed on flexoki-light (BACKLOG item 27). - ✓ **Waybar crash on theme switch left the session bar-less** (Latitude hardware QA, 2026-07-04): exec-once has no supervisor, so any crash orphaned the session until relogin — and the switch path itself was diff --git a/flake.nix b/flake.nix index 94f01eb..7b8f396 100644 --- a/flake.nix +++ b/flake.nix @@ -135,6 +135,18 @@ downstream-template-home = downstream.homeConfigurations.me.activationPackage; + # Every hex-on-hex text pairing the generated swaync CSS uses + # must contrast in EVERY palette — summer-day's body text was + # invisible on hardware (item 25: subtext==base there). Cheap + # (no VM); extend the script's PAIRINGS as more generated + # surfaces move to palette-safe roles (item 27: waybar/rofi). + theme-contrast = pkgs.runCommand "nomarchy-theme-contrast" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./tools/check-theme-contrast.py} ${./themes} + touch $out + ''; + # Config-level VM assertions for the nomarchy.hardware.* toggles — # what they SET (kernel cmdline, fprintd, PAM). Real hardware # behaviour (firmware/driver/device) needs bare metal and is out of diff --git a/modules/home/swaync.nix b/modules/home/swaync.nix index ed2cba9..f2f3114 100644 --- a/modules/home/swaync.nix +++ b/modules/home/swaync.nix @@ -29,11 +29,14 @@ in }; style = '' - /* Palette baked from theme-state.json */ + /* Palette baked from theme-state.json. Only roles guaranteed to + contrast @base in EVERY palette are used: subtext/surface mean + "on-surface" in some light themes (summer-day: subtext==base, + surface==text — body text was invisible on hardware, item 25). + Chips/hovers are alpha(@text) tints — contrast by construction. + tools/check-theme-contrast.py guards the hex-on-hex pairings. */ @define-color base ${c.base}; - @define-color surface ${c.surface}; @define-color text ${c.text}; - @define-color subtext ${c.subtext}; @define-color accent ${c.accent}; @define-color bad ${c.bad}; @@ -50,7 +53,7 @@ in } .notification-content .body { - color: @subtext; + color: @text; } .notification.critical { @@ -66,7 +69,7 @@ in .control-center .notification-row:focus, .control-center .notification-row:hover { - background: alpha(@surface, 0.6); + background: alpha(@text, 0.1); border-radius: ${r}px; } @@ -76,7 +79,7 @@ in } .widget-title > button { - background: @surface; + background: alpha(@text, 0.1); color: @text; border: none; border-radius: ${r}px; diff --git a/tools/check-theme-contrast.py b/tools/check-theme-contrast.py new file mode 100644 index 0000000..a188857 --- /dev/null +++ b/tools/check-theme-contrast.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Guard against invisible-text palettes (BACKLOG item 25). + +Asserts, for every themes/*.json, the WCAG contrast ratio of each +hex-on-hex fg/bg pairing the generated swaync CSS uses. Pairings built +from alpha(@text) tints over @base are contrast-by-construction and +need no assert. Extend PAIRINGS as more generated surfaces are fixed to +palette-safe roles (item 27 tracks waybar/rofi, which still use +subtext/surface and would fail here today). + +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/summary/titles on the toast"), + ("base", "accent", 3.0, "swaync widget-title button:hover"), +] + + +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())