From fd32ce71d266a626b8787a0fc0f86165f9939c2b Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 13 Aug 2026 00:36:09 -0400 Subject: [PATCH] docs: record phase 2, and what a parked measurement was hiding The audit's one 'borderline ~4.1:1' pair was nine of twelve failing combinations across three ramps, 110 nodes on screen, worst 2.31:1. The other never-measured item closed on measurement and stays dropped, now for a reason with a number behind it. Two findings larger than either are recorded and deliberately not fixed: the semantic colours are fixed across ramps, and the light ramp is not a supported theme. --- .planning/NOTES.md | 82 ++++++++++++++++++++ .planning/plans/active/008-the-last-audit.md | 70 ++++++++++++++++- CLAUDE.md | 17 ++++ 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 59c7874..523a71e 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -1872,3 +1872,85 @@ inside a comment in a `css` tagged template literal ends the literal.** Third session running. It is written in `CLAUDE.md`, in the skill, and in `NOTES.md`, and it was read twice in the session it then cost a cycle in. Knowledge is not working here; it wants a lint rule. + +## A parked measurement is a finding of unknown size, and this one was nine times bigger + +Plan 008 phase 2: the two items `a11y.md` never measured. One closed on +measurement; the other turned out to be nine times the size of its own +description and to contain two findings larger than itself. + +The generalisation: **"worth measuring before planning" is a debt with +no stated size, and the estimate attached to it is not a bound.** The +audit said `--yj-text-tertiary` on `--yj-bg-surface` is "≈ 4.1:1, +borderline", from a hand calculation over two hex values in a file that +does not contain them. Every part of that sentence was approximately +true and the conclusion it invited — *borderline, low priority* — was +wrong by an order of magnitude: + +| | audit | measured | +|---|---|---| +| pairs considered | 1 | 12 (three ramps × four surfaces) | +| failing | "borderline" | 9 of 12 | +| worst ratio | ≈ 4.1 | **2.31** (dark overlay), **2.55** (light) | +| failing nodes on screen | — | **110** across twelve views | + +Nine things worth keeping: + +- **A number quoted from the wrong file is still a number, and it + travels.** The audit cites the palette as `tokens.css.ts`. That file + holds the type scale and icon sizes and no colours at all; the ramps + live in `theme-store`, applied to `:root` at runtime — which also + means the `var(--yj-…, #fallback)` at ~500 call sites is dead code, + and four different fallbacks behind one name never mattered. I spent + twenty minutes concluding the tokens "are never defined" before + asking the *running app* what `:root` carried. Ask the app. +- **Measuring one state of three answers one third of the question.** + The whole first sweep was the `dark` ramp, because that is the + default. `light` was the worst of the three and had never been looked + at by the audit or by me. A palette is data — enumerate it. +- **A generated colour is a family, not a colour.** The avatar + background is `hsl(nameToHue(name), 45%, 35%)`, and 35 of the 360 + hues put white text below 4.5:1. The rendered sweep found *two*, + because two artists happened to hash into the yellow-green band. Had + I fixed the two, the bug would have returned with the next search. + The unit of the fix is the generator; the unit of the test is all 360. +- **A fix that makes the ramp pass can also destroy the ramp.** Sizing + tertiary to clear 4.5:1 on `bgOverlay` needs a grey *lighter than + secondary*. Passing an automated check by inverting the visual + hierarchy is the kind of accessibility fix that makes the product + worse, so `bgOverlay` is documented as not a text surface and the one + component using it that way now uses primary. The test encodes the + exception rather than pretending it away, and a second case asserts + the ramp stays ordered. +- **My probe was wrong before the code was, twice, and a screenshot + caught both.** Source-over compositing that forces `a: 1` makes two + stacked `rgba(255,255,255,0.05)` surfaces composite to opaque white — + which reported a perfectly readable button as white-on-white at + 1.00:1. And later I read a screenshot taken *after* a sweep had left + the app on a different ramp, and concluded the light theme was not + applying at all. Both times the tell was the same: **the picture and + the number disagreed**, and both times the number was mine. +- **The cheapest tier is blind to a whole class of change.** `make + ui-visual` passed unchanged across a palette rewrite, because the + component tier has no `:root` and renders the fallbacks. Six stored + screenshots said nothing at all about the change they most looked + like they were about. +- **A finding that closes on measurement is worth the measurement.** + `a11y.28` (mouse-only resize handles) was dropped by reading. At + 800×600 the track list clips exactly one thing — the *Duration header + label* — and zero data cells, and that sort has a keyboard-reachable + dropdown anyway. Same conclusion, now with a number, and the next + reader does not have to re-derive it. +- **The measurement found two things larger than what it was measuring.** + The semantic colours are fixed across ramps, and one fixed colour + cannot serve both a near-black and a near-white surface — `--yj-error` + is 2.55:1 on dark's elevated. And with the greyscale fixed the light + ramp still fails 50 nodes: an invisible warning banner, a + white-on-yellow primary button, chrome that stays dark while the body + goes light. Recorded, not fixed. "Does the light theme ship?" is not + a question a contrast pass gets to answer on its own. +- **Fixing the ubiquitous case makes the rare ones visible.** With + tertiary raised, the remaining dark-ramp failures were three nodes + and every one was a *different* mechanism. A finding at 110 nodes + hides them; at 3 they are individually obvious. Cheap tail, only + reachable from the other side of the main fix. diff --git a/.planning/plans/active/008-the-last-audit.md b/.planning/plans/active/008-the-last-audit.md index b5f9289..89d6052 100644 --- a/.planning/plans/active/008-the-last-audit.md +++ b/.planning/plans/active/008-the-last-audit.md @@ -1,6 +1,6 @@ # 008 — The last audit, and the one binding that outlived six phases -**Status:** active — Phase 1 shipped (three landings). +**Status:** active — Phases 1 and 2 shipped. **Branch:** main **Created:** 2026-08-12 **Follows:** 007-ui-reconciliation @@ -69,7 +69,7 @@ fixed until it has been reproduced in the running app. | `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. | | `25` | Minor | `` with no label, verbatim as filed. | | — | new | **Two unnamed native ``s** from Phase 1, with `26`. +- **The semantic palette** and **the light ramp**, from Phase 2. Both + are larger than the rest of this tail put together and may not belong + in it at all — the light ramp in particular is a question about + whether that theme ships, not a contrast fix. - **`22`** asks for a non-colour marker on the playing row, which is a visual change to the densest list in the app and moves a baseline. diff --git a/CLAUDE.md b/CLAUDE.md index 342df75..cb0aa11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,23 @@ first track arrives) and `job-indicator`, whose label swings between "Scanning Music", "3 background jobs" and "Finished". The notification surface already had one from Phase 3. +**Contrast is a property of the ramp, and the ramps are data.** +`theme-store`'s `SHADE_PALETTES` — not `tokens.css.ts`, which holds only +the type scale and icon sizes — is where the colours live, applied to +`:root` at runtime, which is why the `var(--yj-…, #fallback)` at every +call site is dead in practice. Every text colour clears 4.5:1 against +every surface it can sit on, and `theme-contrast.test.ts` computes that +from the table rather than trusting it. Three rules hold it up. +**`bgOverlay` is not a text surface on the dark ramp** — sizing tertiary +to clear it needs a grey lighter than *secondary*, and an inverted ramp +is a worse answer than the problem, so the one component that put text +there uses primary. **A generated colour is a family, not a colour**: +`utils/avatar-color.ts` exists because `hsl(hue, 45%, 35%)` behind white +initials failed for 35 of the 360 hues, so the failure came and went +with how an artist's name hashed — the test walks all 360. And +**`make ui-visual` cannot see any of this**: the component tier renders +the fallbacks, because the theme only reaches `:root` in the real app. + **A stated motion preference outranks an app setting, and the state a fix lands in is a state nobody has looked at.** `now-playing`'s marquee ran for as long as a track played with no way to pause it (WCAG 2.2.2),