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 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 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. 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 # 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 **Branch:** main
**Created:** 2026-08-12 **Created:** 2026-08-12
**Follows:** 007-ui-reconciliation **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`. | | `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. | | `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`. | | — | 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. | | `29` | Polish | `<h3 class="subtitle">` for type size. |
| `30` | Polish | No skip link anywhere. | | `30` | Polish | No skip link anywhere. |
| `32` | Polish | `title="Remove from queue"`, not identifying the track. | | `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 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. 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 ## 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 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 minimum at 800×600. Reflow at high zoom is the same question one
variable over. It may want its own landing. 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 - **`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. 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 "Scanning Music", "3 background jobs" and "Finished". The notification
surface already had one from Phase 3. 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 **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 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), ran for as long as a track played with no way to pause it (WCAG 2.2.2),