docs: record phase 2, and what a parked measurement was hiding
Build & publish Arch package / arch-package (push) Successful in 2m7s
CI / check (push) Successful in 2m14s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m23s

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.
This commit is contained in:
2026-08-13 00:36:09 -04:00
parent 533c084f8a
commit fd32ce71d2
3 changed files with 167 additions and 2 deletions
+82
View File
@@ -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.
+68 -2
View File
@@ -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 | `<wa-progress-bar value=…>` with no label, verbatim as filed. |
| — | new | **Two unnamed native `<select>`s**, one of them `page-header`'s sort control on nine views. Not in the audit: `a11y.6` scanned `<button>`. Found in the AX tree while reproducing `14`. Belongs with `26`. |
| `28` | dropped | Four `@mousedown` `<div>`s with no `role="separator"`. Never measured. |
| `28` | ~~dropped~~ | **Measured, stays dropped.** One header *label* clips at 800×600; zero data cells do. |
| `29` | Polish | `<h3 class="subtitle">` for type size. |
| `30` | Polish | No skip link anywhere. |
| `32` | Polish | `title="Remove from queue"`, not identifying the track. |
@@ -282,6 +282,67 @@ column may be the only way to read a value, which is function.
is worth as much as one that opens it, and this plan's predecessor got
about a third of its value from findings that evaporated.
### Phase 2 — what the measurements said
One opened much wider than filed; one closed.
#### Contrast: worse than "borderline", and it was never one token
The audit's ≈ 4.1:1 was a hand calculation from two hex values, and
plan 007 filed it under "deliberately not planned — worth measuring
before planning". Measured against the rendered app across twelve views
and then across all three ramps: **110 failing nodes**, and
`textTertiary` failing AA in **nine of twelve** text/surface
combinations — 4.35:1 on dark's surface, 3.25:1 on its elevated,
2.31:1 on its overlay, and 2.553.32:1 on *every* surface of the light
ramp, which the audit never considered.
Fixed, and now **0 of 659 nodes** on dark and darker. Three mechanisms,
only the first of which is the finding:
- **The ramps.** `textTertiary` per ramp — `#a6a6a6` / `#949494` /
`#5c636a` — sized to the lightest surface it actually sits on and
keeping its hue.
- **The avatar generator**, which is not a colour but a *family* of
them: `hsl(hue, 45%, 35%)` behind white initials failed for **35 of
360 hues**, so which artists were unreadable depended on how their
names hashed. 32% clears every hue.
- **Jobs' local `#ff6b6b`**, 4.15:1 on elevated.
Pinned by `theme-contrast.test.ts` and `avatar-color.test.ts` — unit
tests over the data, not sweeps of the DOM. `make ui-test` 572 →
**608**.
#### `a11y.28`: the drop was right, and now for a measured reason
"Cosmetic preference, no function lost" holds. At the window minimum
(800×600, which is where the shell was measured in 007) the track list
clips exactly one thing: the **Duration header label**. Zero data cells
clip, and the sort that label names has a redundant keyboard-reachable
dropdown. The queue panel at its default 321px clips nothing either.
A keyboard-only user cannot change a panel width; they do not lose
access to any value by not being able to. **Stays dropped.**
#### Two things the measurements found that are not in the audit
Both are bigger than what they were found under, and neither is fixed:
- **The semantic colours are fixed across ramps, and a fixed colour
cannot serve a near-black and a near-white background.** `--yj-error`
is 3.42:1 on dark's surface and 2.55:1 on its elevated; `--yj-info`
is 3.10:1 and 2.31:1; success and warning fail on dark and light
both. As *backgrounds* under white text, success (3.45) and warning
(3.58) fail too. The fix is a per-ramp semantic palette, which is a
decision about the app's colour identity rather than a value.
- **The light ramp is not a supported theme.** With the greyscale fixed
it still has **50 failing nodes**: the accent yellow under white text
(1.43:1), the autotag diff's pale greens and reds on white
(1.362.59:1), and the header and player chrome staying dark while
the body goes light. Read in a screenshot — the "Low confidence pick"
banner is invisible and the primary button is white-on-yellow. This
is a design job, and the honest question it raises is whether the
light theme should ship at all in its current state.
---
## Phase 3 — The tail
@@ -296,6 +357,11 @@ Two of them are not one-liners and should be treated as such:
to the app frame, and 007 phase 5 already measured the frame's real
minimum at 800×600. Reflow at high zoom is the same question one
variable over. It may want its own landing.
- **The unnamed `<select>`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.
+17
View File
@@ -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),