docs: record phase 1, and the state a fix lands in
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m27s

Three a11y findings shipped. The generalisation is the mirror of 'a
finding creates the conditions for the next one': that one is about the
code path a fix opens, this one about the path it sends people to. The
reduced-motion guard is two lines and both bugs behind it were in the
fallback it routes users into -- one of which had been wrong in every
mode, including the default, since the component was written.
This commit is contained in:
2026-08-12 23:35:40 -04:00
parent 8af26fee94
commit 31144e5dc7
3 changed files with 226 additions and 1 deletions
+90
View File
@@ -1782,3 +1782,93 @@ Nine more things worth keeping, and the first four are all one theme —
not inherit `box-sizing: border-box` from the UA stylesheet the way a
`<button>` does, so swapping the tag grew the badge 36→38px. Nothing
but the stored screenshot would have noticed.
## The state a fix lands in is a state nobody has looked at
Plan 008 phase 1: the three `a11y.md` findings that lose function — a
marquee that cannot be stopped, a combobox that announces nothing, and a
queue whose order needs a mouse.
The generalisation, and it is the mirror of "a finding creates the
conditions for the next one": that note is about the code path a fix
*opens*. This one is about the code path a fix **sends people to**. A
guard, a fallback, an empty state, a disabled variant — the branch a fix
makes people live in has usually never been looked at by anyone,
precisely because until now nobody arrived there.
The reduced-motion guard is two lines. Both bugs it exposed were in the
place it sends you:
- **The non-scrolling fallback hard-clipped**, and always had.
`text-overflow: ellipsis` was on the outer span while the box that
overflows is the `inline-block` child — so it produced an ellipsis in
**no mode**, including `hover`, which is the default every user has.
A title read "An Exhaustively Overlong Trac|". Found by reading the
screenshot of the fix, which is the fourth regression in two plans
that only a PNG has caught.
- **And fixing *that* broke overflow detection.** Giving the child its
own `overflow: hidden` stops the parent overflowing, so
`titleOverflows` went false and nothing would have scrolled again for
anybody. Caught by the new test's *positive* case — which existed
only because a guard that suppresses everything passes the negative
case for free, which is this repo's oldest rule wearing its eighth
costume.
Eight more things worth keeping:
- **A grep triage is a good answer to "is it still there" and no answer
to "why".** Checking all 34 findings against the tree took ten
minutes and closed at least five the coverage map still showed open,
including three (`17`, `19`, `27`) fixed by phases that were not about
them. It said nothing about mechanism, and mechanism is what decided
that `15`'s obvious CSS-only fix is wrong.
- **A finding's stated scope can be half-closed by an unrelated phase.**
`a11y.11` is "drag-and-drop has no keyboard equivalent anywhere" and
its stated symptom is "there is no keyboard path to add a track to
the queue or a playlist" — which Phase 5's `MenuKeyboard` closed. What
was actually left is the queue's *order*, the one thing a menu cannot
express. Fixing the sentence rather than the residue would have built
three menu commands that already exist.
- **A count in an audit is scoped by how it was taken.** `a11y.6` says
two buttons are "the only truly unnamed controls" — and says, in the
same line, that it scanned every `<button>`. The AX tree has two
unnamed `combobox` roles that are native `<select>`s, one of them the
page header's sort control on nine views. The claim was never wrong;
it was answering a narrower question than it reads as.
- **The probe was wrong, not the fix — twice more, both on the *after*
side.** Reading `activedescendant` out of `getFullAXTree` as
`relatedNodes[0].text` reported `(none)` against a working build,
because the property carries `value.type: "idref"`. And
`__yjEvents.last('QueueChanged')` returned a stale payload, so a
reorder that had happened read as one that had not. Dump the whole
property; ask `GetState`.
- **An operation's index convention is part of its contract, and the
symmetric-looking version fails silently.** `MoveQueueTracks` takes an
index into the array *before* the move, so up-by-one asks for `i - 1`
and down-by-one has to 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. The
first version moved rows up and did nothing at all downward, with no
error anywhere.
- **A roving tab stop that only moves on arrow keys is not where the
focus is.** `focusedIndex` was never synced from a click or a Tab, so
`Enter` played the first track in the queue from *any* focused row —
pre-existing, invisible for as long as the keys only read state, and
obvious the moment a key moved something.
- **Watch the new spec fail on the old build.** Done for all three
landings, by neutering one line rather than by stashing (which
reverts every uncommitted change in the file). Two of the three would
have passed against the broken build in at least one case if the
positive direction had been left out.
- **CI's concurrency cancels the previous run's `e2e` when you push
again**, and `cancelled` sits one line from `success` in the run
list. The first landing's e2e never ran; the signal came from the
second push's run, read step by step through
`/api/v1/repos/{owner}/{repo}/actions/runs/{run}/jobs`. Same family as
the `skipped` WebKit step, one layer out.
And the one that is no longer worth calling a lesson: **a backtick
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.
+89 -1
View File
@@ -1,6 +1,6 @@
# 008 — The last audit, and the one binding that outlived six phases
**Status:** active
**Status:** active — Phase 1 shipped (three landings).
**Branch:** main
**Created:** 2026-08-12
**Follows:** 007-ui-reconciliation
@@ -68,6 +68,7 @@ fixed until it has been reproduced in the running app.
| `22` | Minor | `queue-panel` gained `aria-current`; `track-list` did not, and neither has a non-colour marker. |
| `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. |
| `29` | Polish | `<h3 class="subtitle">` for type size. |
| `30` | Polish | No skip link anywhere. |
@@ -169,6 +170,93 @@ An e2e case for `11`, because the queue panel's animated width means a
click issued while it moves lands on whatever slid under the pointer.
A manual pass per landing, with a screenshot read.
### Phase 1 — what actually shipped
Three landings, one per finding, each reproduced in the running app
before anything was written and each watched failing on the pre-fix
build before being believed.
- **`15`.** `shouldScroll()` returns false under
`prefers-reduced-motion: reduce`, live (a `matchMedia` listener, so
changing the OS setting is honoured without a reload — verified).
It covers `hover` as well as `always`.
- **`14`.** Ids on the listbox and every option, `aria-controls`,
`aria-activedescendant`, and `aria-selected` meaning *chosen* rather
than *highlighted*.
- **`11`.** Alt+ArrowUp/Down moves the focused queue row, with a live
region saying where it went.
Pinned by `now-playing.test.ts` (+2), `combobox-aria.test.ts` (5),
`queue-reorder.test.ts` (7), `e2e/specs/reduced-motion.spec.ts` (2) and
`e2e/specs/queue-reorder.spec.ts` (4). `make ui-test` 558 → **572**;
`make e2e` 68 → **74**.
#### Where the plan was wrong — Phase 1
Nine things, and the first group is the triage being right for the
wrong reason.
- **The grep triage was accurate about *what* is open and wrong about
*why* two of them are.** It is a good first pass and it cannot see
mechanism. `15` is filed as "no reduced-motion guard", which is true;
what makes a CSS-only guard wrong is that the cycle is a transition
out, a `transitionend` and a transition back, so suppressing the
animation strands the text off its own box with nothing to bring it
back. That is only visible by reading the cycle.
- **A fix routes people into a state nobody has looked at.** With the
marquee off, the fallback hard-clipped — "Overlong Trac|", no
ellipsis — because `text-overflow` was on the outer span while the
overflowing box is the inline-block child. It had never produced an
ellipsis **in any mode**, including the default, and no test saw it.
Found by reading the screenshot of the fix.
- **…and fixing that broke the measurement it depends on.** Giving the
child its own `overflow: hidden` stops the *parent* overflowing, so
`titleOverflows` went false and nothing would ever have scrolled
again, for anyone. Caught by the new test's positive case, which is
the whole reason it has one.
- **`a11y.6` scanned `<button>`, and says so.** "The only truly
unnamed controls" is a claim about buttons. The AX tree has two
unnamed `combobox` roles that are native `<select>`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
+47
View File
@@ -505,6 +505,53 @@ 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.
**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),
and the guard is in `shouldScroll()` rather than in CSS: the cycle is a
transition out, a `transitionend` and a transition back, so suppressing
the animation strands the text off its own box with nothing to bring it
back. It covers `hover` as well as `always` — `reduce` is a request
about motion, not about autoplay. The two bugs behind it were both in
the *fallback*: `text-overflow` sat on the outer span while the box that
overflows is the inline-block child, so the non-scrolling state had
never produced an ellipsis **in any mode**, including the default; and
moving the ellipsis to the child stops the parent overflowing, which
silently disabled overflow *detection* and would have stopped anything
scrolling ever again. Both measurements come from the child now. The
first was found by reading a screenshot, the second by the new test's
positive case.
**Roles have to be wired to each other.** `combobox`, `listbox` and
`option` were all present on `<yj-combobox>` and nothing connected them,
so arrowing through nineteen options moved a highlight and announced
nothing. Ids on the listbox and every option, `aria-controls`,
`aria-activedescendant` — and `aria-selected` meaning *chosen*, which is
the distinction the pattern rests on: the highlight is what
`activedescendant` points at. Unlike `config-section`'s disclosure this
IDREF may dangle while closed, because the popup genuinely does not
exist then and `aria-expanded` says so. Checked against
`Accessibility.getFullAXTree`, not against a snapshot — and read the
whole property, since `activedescendant` reports `value.type: "idref"`
and an extraction expecting a string reports `(none)` on a working
build.
**The queue's order is reachable from the keyboard.** Alt+ArrowUp/Down
moves the focused row, with a live region saying where it went. It is
in `queue-panel`'s own *delegated* keydown beside Enter and the roving
arrows, not a backend panel binding: it cannot collide with the global
Up/Down volume bindings, and a reordering key does not belong in a
user-editable table where it could be rebound onto something
unmodified. Two things in it are load-bearing. **The index arithmetic
is not symmetric** — `MoveQueueTracks` takes an index into the array
*before* the move, so down-by-one asks for `i + 2`, because `i + 1` is
where the row already is once its own removal is accounted for and the
backend's contiguous-block guard correctly treats it as a no-op. And
**the index comes off the row the event came from**: `focusedIndex` was
only ever moved by an arrow key, so a row reached by a click or by Tab
left it at 0 and `Enter` played the first track in the queue from any
focused row.
**A selectable grid is a listbox.** The four grids that ctrl/shift-select
(`artists-view`, `genres-view`, `cover-grid`, and the queue) are
`role="listbox" aria-multiselectable` over `role="option"` cards, not