# 008 — The last audit, and the one binding that outlived six phases **Status:** complete — all four phases shipped. `a11y.md` is closed, and with it all four audits from 2026-08-11. **Branch:** main **Created:** 2026-08-12 **Follows:** 007-ui-reconciliation **Source:** `.planning/audits/2026-08-11-ui/a11y.md` (34 findings), plus one item inherited through all six phases of 007. ## Problem Three of the four audits from 2026-08-11 are closed. `a11y.md` is not, and it is **the least verified material in the repo** — 007's own notes say so twice, and every pass that touched it found the audit wrong about something. Two things follow from that, and they are the shape of this plan. **The coverage map lies, and it lies in the direction of more work than exists.** 007's map assigns `a11y.7` (top-results cards are click-only divs) to Phase 6; the code has carried `role="button" tabindex="0"` and an Enter/Space handler since Phase 1. It is not alone. A grep pass over all 34 findings against the current tree closes at least five the map still shows open, and three of those (`17`, `19`, `27`) were closed by phases that were not about them. **And what survives is not evenly distributed.** Of the ~13 that look open, three are genuine loss of function and the rest are Minor or Polish. One of the three — `15` — is a hard WCAG conformance failure that has been sitting under a "Major" heading being read as a nice-to- have. Cutting across both: **two items in this audit were never measured at all.** Colour contrast is flagged borderline (`--yj-text-tertiary` on `--yj-bg-surface` ≈ 4.1:1 against 11 px text) with "that needs a real measurement" written next to it, and 007 parked it under "deliberately not planned — worth measuring before planning". The mouse-only resize handles (`28`) were dropped as "cosmetic preference, no function lost", which is a judgement made by reading. Both are claims with no number behind them, which by this repo's own standard is not a finding yet. Separately, and not from any audit: **`tracklist.delete`**. Advertised in Settings as configurable, bound to nothing, and carried through six phases because it needs an operation that does not exist. ## The triage, as of 2026-08-12 Grep-level against `1e4a4e6`. **Every row is a hypothesis** — this is where the audit's claims are, not where the code is. Nothing here is fixed until it has been reproduced in the running app. **Closed** (verified present in code): `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `12`, `13`, `16`, `17`, `19`, `27`, `33`. **Closed by argument rather than by code**, to confirm by reading: `20` (the type-scale/`_itemSize` coupling is now documented in `tokens.css.ts`, which is what the finding asked for), `31` (one of `cover-grid`'s two ``s has an `alt`; the finding named one). **Open:** | # | Level | What the grep says | |---|---|---| | `15` | Major | `now-playing` is not among the four files carrying `prefers-reduced-motion`. WCAG 2.2.2: moving content over 5 s with no pause mechanism. | | `14` | Major | `combobox.ts` has no `aria-controls`, no `aria-activedescendant`, no option ids. | | `11` | Major | No `altKey` handler in `queue-panel`. The *other* half of this finding — "no keyboard path to add a track to the queue or a playlist" — was closed by Phase 5's `MenuKeyboard`. | | `21` | Minor | `body { height: 100vh; overflow: hidden }` unchanged. WCAG 1.4.10. **Shipped — and the stated mechanism was wrong; the failure is horizontal.** | | `22` | Minor | `queue-panel` gained `aria-current`; `track-list` did not, and neither has a non-colour marker. **Shipped.** | | `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. **Shipped.** | | `25` | Minor | `` with no label, verbatim as filed. **Shipped — it was named "Progress", not unnamed.** | | — | ~~new~~ | ~~**Two unnamed native ``s — one of them the page header's sort control, on nine views. Not fixed here; it is a sweep of every form control, not a one-liner, and it belongs with `a11y.26` in Phase 3. - **The reproduction of the *fix* was wrong twice, on the probe side both times.** Reading `activedescendant` out of the AX tree as `relatedNodes[0].text` returned `(none)` on a working build — the property is there, with `value.type: "idref"`. And `last('QueueChanged')` returned a stale payload, so a reorder that had happened looked like one that had not. Ask `GetState`, dump the whole property. - **`11`'s stated scope is half done and the other half was already closed.** The finding is "drag-and-drop has no keyboard equivalent anywhere" and lists four sites; its stated *symptom* — "there is no keyboard path to add a track to the queue or a playlist" — was closed by Phase 5's `MenuKeyboard`. What was left is the queue's order, which is the one the menu cannot express. Album→queue drag and drop-on-nav-item remain, and are menu commands, not reorder. - **The plan said a backend panel binding; it should not be one.** The queue panel already handles Enter and the roving arrows in its own *delegated* (not document) keydown, which is the sanctioned pattern. Alt+Arrow joins them: it cannot collide with the global Up/Down volume bindings (measured — 0 `VolumeChanged` events from a focused row), and it keeps a reordering key out of a user-editable table where it could be rebound onto something unmodified. - **The index arithmetic is not symmetric, and the symmetric version fails silently.** `MoveQueueTracks` takes an index into the array *before* the move, so down-by-one must ask for `i + 2`; `i + 1` is where the row already is once its own removal is accounted for, and the backend's contiguous-block guard correctly returns without doing anything. Pinned in both tiers. - **`focusedIndex` was only ever moved by an arrow key.** A row reached by a click or by Tab left it at 0, so `Enter` played the first track in the queue from any focused row. Pre-existing, invisible until a key moved something, fixed by reading the index off the row the event came from. And one that is not about the audit: **the backtick-in-a-`css`-comment trap cost a cycle again**, in the same session as reading the warning about it twice. It is worth treating as a lint rule rather than a piece of knowledge. --- ## Phase 2 — The two that were never measured Neither is a fix. Both are a number, and the number decides whether there is work. **Colour contrast.** Measured against the rendered app, not against the token file: the tokens are what a component *may* use, and what matters is the pairs that actually appear. Sample the real computed colours at the real sizes, report the ratios, and only then decide. The audit's own number (≈ 4.1:1) is a hand calculation from two hex values and has the status of a hypothesis. **`a11y.28`, the resize handles.** Four of them: the sidebar, the queue panel, the now-playing column, and the track-list column resizers. "Cosmetic preference, no function lost" is the claim to test. The track-list one is the suspicious member — a column narrowed to its floor clips its label (007 phase 5 found "Durat…" at 800 px), so widening a column may be the only way to read a value, which is function. **Record both outcomes either way.** A measurement that closes a finding 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.55–3.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 were bigger than what they were found under. Both are now fixed — see the third landing below. - **The semantic colours were fixed across ramps, and a fixed colour cannot serve a near-black and a near-white background.** `--yj-error` measured 3.42:1 on dark's surface and 2.55:1 on its elevated; `--yj-info` 3.10:1 and 2.31:1; success and warning failed on dark and light both. As *backgrounds* under white text, success (3.45) and warning (3.58) failed too. - **The light ramp was not a usable theme.** With the greyscale fixed it still had **50 failing nodes**: the accent yellow under white text (1.43:1) and the autotag diff's pale greens and reds on white (1.36–2.59:1). ### Phase 2, third landing — the ramp reaches the semantic colours **2237 nodes across three ramps and twelve views, 0 failing.** The split is by the question a colour answers. A **fill** is "what colour is a danger button" — red in every theme, unchanged. A **text** colour is "what colour is the word *failed* on this background" — per ramp, because one value cannot clear 4.5:1 against both a near-black and a near-white surface. `bgOverlay` keeps the exception it already had on the dark ramp. And every fill now carries a **computed foreground**, because the accent is a colour picker and no fixed answer survives one: white if white clears 4.5:1, else black. That keeps a red danger button white and flips a green or amber one to black. Accent-as-text goes through `accentTextOn()`, which mixes along the hue until it clears the ramp's surface and stops — returning the accent *unchanged* on both dark ramps, so the dark themes are visually untouched by that half. `make ui-test` 608 → **649**. #### Where this pass was wrong - **"The chrome stays dark while the body goes light" was mine, and it was false.** I read it off a screenshot; the DOM says `.top-bar` is `#e9ecef` and `.sidebar` `#f8f9fa` under the light ramp, and a re-taken screenshot agrees. The first one was captured before the theme had propagated. Third time in two passes that a screenshot read at the wrong moment produced a confident wrong claim — and the second time this pass that **the picture and the number disagreed and the number was mine**. - **A `color:` regex matches `border-color:`.** Twice: once rewriting semantic text colours (3 borders) and once rewriting accent text (30 more). A border is a fill, not text. Caught by grepping the result rather than by any test, because nothing renders differently enough to fail. - **Two accent buttons took their foreground from `--yj-bg-base`**, which inverts with the ramp — white on yellow at 1.43:1. That is not a colour that was chosen badly; it is a token used for the wrong meaning, and it only shows up in the theme nobody looks at. --- ## Phase 3 — The tail `21`, `22`, `24`, `25`, `29`, `30`, `32`, `34`, plus whatever Phase 2 promotes or closes. One landing, batched, each item confirmed against the code before it is touched. Two of them are not one-liners and should be treated as such: - **`21`** (the shell is `100vh; overflow: hidden`) is a layout change 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 ``s from Phase 1 were one `