Files
yellowjacket/.planning/audits/2026-08-11-ui/a11y.md
T
logan 9e92721bb7
Build & publish Arch package / arch-package (push) Successful in 1m58s
CI / check (push) Successful in 2m47s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Failing after 2m52s
docs: record plan 007, its four audits, and what measuring corrected
`.planning/audits/2026-08-11-ui/` is the pass this work came from: the
app driven by hand headless plus three static reviews, ~118 findings
that are really five problems, each spread by being copied rather than
fixed. `.planning/plans/active/007-ui-reconciliation.md` sequences them
by blast radius and records what each of the six passes actually
shipped — including twenty-five entries under "where the plan was
wrong", which is the point of writing it down.

The discipline those entries add up to, now in NOTES.md: a finding is
three hypotheses — how big it is, why it is that big, and what to do
about it — and they can be independently right and wrong. Three of the
audit's recommended fixes would have shipped a bug (`m1` stops the
card grids repainting, `m6`'s index-ordered selection goes stale on
any re-sort, `m5`'s guard leaves the marquee short), all three because
they reasoned from the shape of the code and not from what the rest of
the file already knew about it. Five findings evaporated or inverted
on contact.

CLAUDE.md gains the invariants that came out of it, and the skill
gains the fourteen measurement traps, each of which produced a wrong
number first — the newest being that a longtask entry arrives after
the task that produced it, so two numbers that must agree are worth
more than one you have to be sceptical about.
2026-08-12 01:20:13 -04:00

24 KiB

Frontend accessibility & interaction-model audit — YellowJacket

Scope: frontend/src/components/**, frontend/src/services/keyboard-shortcut-service.ts, frontend/index.html, frontend/index.ts, frontend/index.css, frontend/src/styles/tokens.css.ts. Read-only; nothing was changed.

Already confirmed by hand and not re-reported: track rows / sidebar <li> not focusable, 14 tab stops app-wide, closed queue panel still focusable, global Space/arrow/S/N/P hijack, data-shortcut-scope never set. Adjacent consequences of those are marked (adjacent).


Critical

1. frontend/src/components/config-page/config-section.ts:98-104 — the entire Settings page is unreachable by keyboard The disclosure header is a bare <div class="header" @click=${this.toggle}> with no <button>, no tabindex, no role, no aria-expanded, no aria-controls. Sections default to expanded = false (line 84/88), so every setting in the app is behind a control that cannot be tabbed to or activated. Symptom: a keyboard or screen-reader user can open Settings and see nothing but collapsed headings they can never expand. Fix: make the header a <button type="button" aria-expanded=${this.expanded} aria-controls="body"> and give the body an id.

2. frontend/src/components/downloads-view/downloads-view.ts:258-271 — tab switching is mouse-only and has no tab semantics <div class="tabs"> containing two <div class="tab" @click>; no role="tablist"/role="tab", no aria-selected, no tabindex, no arrow-key handling, no aria-controls on the panel. Symptom: the Downloads tab of the Downloads view can never be reached without a mouse; AT announces two unlabelled generic containers. Fix: role="tablist" on the wrapper, <button role="tab" aria-selected=... aria-controls=...> per tab with roving tabindex.

3. frontend/src/components/track-list/track-list.ts:1967, frontend/src/components/queue-panel/queue-panel.ts:1543, frontend/src/components/cover-grid/cover-grid.ts — context menus have no menu semantics, no focus, no keyboard <div class="context-menu-panel"> holds wa-dropdown-items inside a raw <wa-popup>. The items do carry role="menuitem" (Web Awesome sets it — verified in node_modules/@awesome.me/webawesome/dist/chunks/chunk.MCDD6PFW.js), but the container has no role="menu", so the menuitems are orphaned. Because they are in a bare wa-popup rather than a wa-dropdown, nothing moves focus into the menu, nothing handles Up/Down/Escape, and nothing restores focus on close. The menu only opens on contextmenu (mouse right-click); there is no Shift+F10 / Menu-key path. Symptom: Play, Add to Queue, Play Next, Add to Playlist, Favourite and Track Details are completely unavailable without a mouse — this is the only path to most of those actions. Fix: wrap in role="menu", open on keydown Shift+F10/ContextMenu, focus the first item, handle Arrow/Escape/Tab, restore focus to the originating row on close.

4. frontend/src/components/autotag-view/autotag-view.ts:2824-2950 — four hand-rolled modal dialogs with no dialog semantics, no focus trap, no focus restore renderPasteDialog (2824), renderWarningDialog (2856), renderLeaveDialog (2891), renderSearchDialog (2922) each render <div class="dialog-overlay"><div class="dialog"> with no role="dialog", no aria-modal="true", no aria-labelledby pointing at the <h3>, and no focus management. Only the paste and search dialogs set autofocus; the Warning and Leave dialogs — the two that gate an irreversible on-disk metadata rewrite — leave focus wherever it was. Symptom: a screen-reader user is never told a dialog opened, can Tab straight out of it into the page behind, and can confirm "this rewrites audio files" without ever hearing the warning. Fix: use <wa-dialog> (which already does showModal() + activeElement restore — see chunk.ZUIYLL2X.js), or add role/aria-modal/labelledby + a Tab trap + focus save/restore.

5. frontend/src/components/autotag-view/autotag-view.ts:1706-1746 — bare single-letter shortcuts on document, including a destructive one, with an incomplete guard A = Apply (rewrites tags on every track on disk, explicitly "not automatically reversible" per the warning copy at 2866-2872), S = Skip, L = Leave as-is, U/F = dialogs. The suppression check at 1707-1712 only tests tagName === 'INPUT' | 'TEXTAREA' | isContentEditable. Events originating inside a Web Awesome control's shadow DOM are retargeted to the host (WA-SELECT, WA-INPUT, YJ-COMBOBOX), so the guard passes and A fires while the user is typing. Buttons, checkboxes and <select> are likewise unguarded — pressing S on a focused <select> triggers Skip and jumps the option list. Symptom: typing an artist name into a Web Awesome field, or type-ahead on a select, silently rewrites metadata on an entire album. Fix: reuse isTextInputFocused from keyboard-shortcut-service.ts (which resolves through shadow roots via getDeepActiveElement) and require a confirm/modifier for A.

6. frontend/src/components/search-bar/search-bar.ts:166-174 and frontend/src/components/explore-view/explore-view.ts:1317-1323 — clear buttons have no accessible name at all Both are <button class="clear-button"> containing only <wa-icon name="xmark">. No aria-label, no title, no text. (A systematic scan of every <button> in components/** found these two as the only truly unnamed controls; the rest have text or at least a title fallback.) Symptom: announced as "button" with no name; unusable via voice control. Fix: aria-label="Clear search".

7. frontend/src/components/top-results-row/top-results-row.ts:267 — result cards are click-only divs <div class="card" @click=${() => this.handleClick(r)}> — the only role/tabindex/keydown-free card renderer in the codebase (every other card view added at least role="button" tabindex="0"). Symptom: the top-results row on the Explore page cannot be activated by keyboard. Fix: role="button" tabindex="0" + Enter/Space handler, matching home-view.ts:305-309.

8. frontend/index.html:34 + frontend/index.ts:263-275 — queue toggle has no state, and the closed panel is not inert (adjacent) The button carries aria-label="Toggle queue" but never aria-expanded or aria-controls. The toggle just adds/removes the open attribute; the closed state is purely :host { width: 0; overflow: hidden } (queue-panel.ts:214-217), which hides nothing from the accessibility tree. Symptom: the button never reports open/closed, and a screen-reader's virtual cursor walks the entire queue (title, artist, remove button for every track) while the panel is visually closed. This is the same root cause as the already-confirmed "closed queue panel is still focusable". Fix: set aria-expanded/aria-controls on the button and inert (or aria-hidden="true" plus visibility: hidden) on the panel when closed.


Major

9. frontend/src/components/track-list/track-list.ts:1906-1926 — column headers are not headers and never expose sort state <div class="header-row"> with <div class="header-cell" @click> per column. No role="grid"/ row/columnheader, no aria-sort, no tabindex, no keydown. The sort direction is conveyed only by a / glyph in a <span class="sort-arrow"> at 10px (track-list.ts:900-901). Symptom: AT cannot tell which column the list is sorted by or in which direction, and clicking a header to sort is mouse-only. (There is a redundant keyboard-reachable sort dropdown at 1806-1841, so this is not a total loss of function.) Fix: role="columnheader" aria-sort=${'ascending'|'descending'|'none'} on each header cell and make it a <button>.

10. frontend/src/components/track-list/track-list.ts:1746-1755 — the per-row favourite toggle is an unlabelled, unfocusable div <div class=${classMap({'fav-icon': true, favorited: isFav})}> with an inline <svg> and cursor: pointer (track-list.ts:1034-1043); the click is delegated off the virtualizer. No role, no tabindex, no accessible name, no aria-pressed. Symptom: favouriting a track from the list is mouse-only, and the current favourite state of every row is invisible to AT (heart/star fill is a shape-and-colour change with no text equivalent). Fix: <button role="switch" aria-checked=${isFav} aria-label="Favourite ${track.TrackName}">.

11. frontend/src/components/queue-panel/queue-panel.ts:1417 + cover-grid.ts:1798, album-dropdown.ts:385, app-sidebar.ts:222-232 — drag-and-drop has no keyboard equivalent anywhere Queue reordering (draggable="true" on .track-item, drop index computed from cursor Y at queue-panel.ts:1093-1140), album→queue/playlist drag, expanded-album track drag, and drop-on-nav-item are all pointer-only. There is no Alt+Up/Down reorder, no "move to…" command, and no aria-grabbed/ aria-dropeffect substitute. Symptom: queue order can never be changed without a mouse. Combined with finding 3 (the context menu is mouse-only too), there is no keyboard path to add a track to the queue or a playlist. Fix: add Alt+ArrowUp/Down reorder on the focused queue item, and expose the drag targets as context-menu commands once the menu is keyboard-reachable.

12. No aria-live region anywhere for async status — scan/job progress, toasts, search results, now-playing A repo-wide grep finds exactly one live region: catalog-scope-notice.ts:110 (role="status"), and even that is conditionally rendered with its content already present, which most ATs do not announce. Specific gaps:

  • frontend/src/components/config-page/config-page.ts:2137-2139<div class="toast"> with no role="status"/aria-live; it is the only feedback that a setting saved or failed, and it auto-dismisses after a timer (1174-1176).
  • frontend/src/components/jobs/job-indicator.ts:359-370 — the trigger label swings between "Scanning Music", "3 background jobs" and "Finished" with no live region.
  • frontend/src/components/now-playing/now-playing.ts:340-357 — track title/artist change on every auto-advance with no announcement.
  • frontend/src/components/explore-view/explore-view.ts:1270-1278 — "Searching…" and the error block are silent.
  • frontend/src/components/track-list/track-list.ts:1901, 1930-1933 — "Loading tracks…" / "No tracks match your search." with no aria-live and no aria-busy on the list. Symptom: a screen-reader user gets no feedback that a scan started or finished, that a setting saved, that a search returned nothing, or that the track changed. Fix: one <div role="status" aria-live="polite" class="sr-only"> per surface, populated after the region already exists in the DOM.

13. frontend/src/components/artists-view/artists-view.ts:1059-1063 and frontend/src/components/genres-view/genres-view.ts:947-951aria-selected on role="button" is invalid and dropped Both cards render role="button" aria-selected="${isSelected}". aria-selected is only valid on gridcell, option, row, tab and treeitem; on button it is ignored outright. These grids are genuinely multi-select (ctrl/shift-click via SelectionController). Symptom: selection state — the thing the whole ctrl/shift interaction exists to produce — is invisible to AT; visually it is a background-colour change only. Fix: role="listbox" aria-multiselectable="true" on the grid, role="option" aria-selected on the cards.

14. frontend/src/components/combobox/combobox.ts:288-303 — combobox has no aria-controls / aria-activedescendant role="combobox" aria-expanded aria-autocomplete="list" on the input, role="listbox" on the <ul>, role="option" on the <li>s — but no id on the listbox, no aria-controls, no aria-activedescendant, and no id on the options. aria-selected is used to mean "highlighted" (302), not "chosen". Symptom: arrowing through suggestions moves the visual highlight but announces nothing; the user hears only their own typing. Fix: give the listbox and each option an id, add aria-controls and aria-activedescendant=${optionId(highlightedIndex)}.

15. frontend/src/components/now-playing/now-playing.ts:203-212, 391-408 — marquee text auto-scrolls with no reduced-motion guard and no pause transition: transform var(--scroll-duration, 5s) linear re-armed in a loop by onScrollCycleEnd; when scrollMode === 'always' (persisted in localStorage, line 388-395) the title and artist scroll continuously for as long as the track plays. Only four files in the repo have a prefers-reduced-motion guard (job-indicator.ts:126, job-row.ts:154, autotag-view.ts:471,599) and this is not one of them. Symptom: WCAG 2.2.2 — moving content longer than 5s with no mechanism to pause it, and a vestibular-trigger risk with no reduced-motion opt-out. Fix: @media (prefers-reduced-motion: reduce) { .scroll-content { transition: none } } and treat always as never under that query.

16. frontend/src/components/config-page/config-page.ts:2091-2131 — the "Remove Library" confirmation is not a dialog <div class="cancel-dialog-overlay"> / <div class="cancel-dialog"> with a <div class="cancel-dialog-title"> — no role="dialog", no aria-modal, no aria-labelledby, no focus move, no focus trap, no Escape handler, no focus restore. This gates deleting tracks, playlists and queue entries. Symptom: the destructive confirmation is never announced and can be Tab-escaped. Fix: same as finding 4 — wa-dialog, or role + trap + restore.

17. frontend/src/components/jobs/job-indicator.ts:378role="dialog" on an unmanaged popover The panel declares role="dialog" (and the trigger aria-haspopup="dialog", line 362) but nothing moves focus into it, traps Tab, handles Escape, or restores focus. It is a non-modal popover, not a dialog. Symptom: AT announces a dialog that never receives focus and cannot be dismissed by keyboard; tabbing past the trigger lands in the page behind while the panel is open. Fix: drop role="dialog" (use role="group" aria-label="Background jobs" and aria-haspopup="true"), or implement real dialog behaviour.

18. frontend/src/components/explore-view/explore-view.ts:1289-1305 — search-mode "tabs" convey the active mode by colour class only <button class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}"> — no role="tab"/aria-selected, no aria-pressed, no text or icon difference between active and inactive. Symptom: the user cannot tell whether they are searching the catalog or lyrics. Fix: aria-pressed=${this.searchMode === 'catalog'} (or a proper tablist).


Minor

19. frontend/src/styles/tokens.css.ts:18-22 — the entire type scale is hardcoded px --yj-text-xs: 11px--yj-text-xl: 18px, consumed by essentially every component. Combined with ~50 further literal font-size: Npx declarations (e.g. job-indicator.ts:138 at 9px, explore-view.ts:518 at 10px, track-list.ts:901 at 10px, and inline style="font-size: 12px" at queue-panel.ts:1518 and playlist-view.ts:1865). Symptom: text-only resize (WCAG 1.4.4) does nothing — a user who raises their OS/browser font size sees no change. 9-11px body text is below any reasonable floor to begin with. Fix: express the scale in rem so it tracks the root font size.

20. frontend/src/components/track-list/track-list.ts:972-985 and frontend/src/components/queue-panel/queue-panel.ts:164-166 — fixed row heights with contain: strict .track-row { height: 33px; contain: strict } and the matching virtualizer _itemSize (track-list.ts:222, queue-panel.ts:165, 49px). contain: strict clips overflow rather than growing the row. Symptom: any increase in text size (finding 19, or a user stylesheet) clips row text mid-glyph instead of reflowing; the virtualizer's scroll math also desynchronises. Fix: out of scope for a quick change, but at minimum document that the type scale and _itemSize are coupled.

21. frontend/index.css:12-20 — the app shell is height: 100vh; overflow: hidden body { height: 100vh; grid-template: "top-bar top-bar" 4em ... "bottom-bar bottom-bar" 4em; overflow: hidden }. Symptom: at high zoom the 4em bars grow while the viewport does not, and anything that no longer fits is clipped with no scrollbar — WCAG 1.4.10 Reflow. The bottom bar's grid-template-columns: var(--now-playing-width, 200px) 1fr auto keeps a fixed 200px column while its text scales. Fix: allow the shell to scroll (min-height: 100vh + overflow: auto) below a breakpoint.

22. frontend/src/components/track-list/track-list.ts:1000-1017 — "now playing" and "selected" rows are colour-only .track-row.active { background-color: var(--yj-accent-bg); color: var(--yj-accent) } and .track-row.selected { background-color: var(--yj-selection-bg) }; the row markup (track-list.ts:1736-1745) carries no aria-current, aria-selected or non-colour marker. Symptom: WCAG 1.4.1 — a colour-blind user cannot distinguish the playing row, and AT has no signal at all. Same pattern in queue-panel.ts:1406-1409. Fix: add a ▶ marker (or the existing play icon) to the active row and aria-current="true" once rows carry role="row".

23. frontend/src/components/jobs/job-indicator.ts:150-156, 369 — the failure indicator is a bare 6px red dot <span class="alert-dot"> with background: #ff6b6b and no text, aria-label or title; the trigger's own name (title="Background jobs", 363) does not change when it appears. Symptom: "a background job failed" is communicated by colour alone and not at all to AT. Fix: <span class="alert-dot" role="img" aria-label="A background job failed"></span>.

24. Ellipsis truncation without title in the highest-density lists text-overflow: ellipsis appears in 40+ places. cover-grid.ts:1821,1832 and home-view.ts:308 do add title; these do not:

  • frontend/src/components/queue-panel/queue-panel.ts:389,401 (.track-title, .track-artist) vs. the markup at 1422-1428 — no title.
  • frontend/src/components/track-info/track-info.ts:92,100 vs. markup at 118-126.
  • frontend/src/components/track-list/track-list.ts:1018-1022 (.cell) vs. 1782-1788.
  • frontend/src/components/playlist-view/playlist-view.ts:355,360. Symptom: long titles are clipped with no way to read the full value — acute in the queue panel, whose width is user-resizable down to MIN_WIDTH. Fix: title=${value} on the truncating element.

25. frontend/src/components/jobs/job-row.ts:270-272 — progress bar has no accessible name <wa-progress-bar value=...>; Web Awesome renders role="progressbar" + aria-valuenow (chunk.WDFK5BNW.js:42,47) but no label is supplied. Symptom: announced as an unnamed "progress bar, 45%" with no indication of what is progressing. Fix: aria-label=${job.title} (or WA's label attribute).

26. frontend/src/components/search-bar/search-bar.ts:157-163 and explore-view.ts:1308-1314 — search inputs are labelled by placeholder only No aria-label, no <label>, no role="searchbox", no aria-describedby pointing at the result count. Fix: aria-label="Search library" / "Search catalog".

27. frontend/src/components/sidebar/app-sidebar.ts:202-241 — nav list has no landmark or item role (adjacent) <ul> of <li> with aria-current (219) but no role, so aria-current sits on a non-interactive item and the whole thing is not inside a <nav> (frontend/index.html:22 is a plain <div class="sidebar">). Fix: <nav aria-label="Main"> in index.html and make each item a <button>/<a> — which also resolves the already-confirmed focusability gap.

28. Mouse-only resize handles with no keyboard equivalent app-sidebar.ts:200, queue-panel.ts:1447, now-playing.ts:377, and the track-list column resizers at track-list.ts:1945-1953 are all @mousedown-only <div>s with no role="separator", tabindex or arrow-key handling. Symptom: panel and column widths cannot be adjusted without a mouse. Low impact (cosmetic preference), but the pattern repeats four times.


Polish

29. frontend/index.html:14-16 — heading hierarchy skips h1 → h3 <h1 class="title"> immediately followed by <h3 class="subtitle">, styled at 0.8em (index.css:52-55) — using a heading level for type size. Fix: make the subtitle a <p>.

30. frontend/index.html — no skip link <main id="main-content"> exists (line 26) but nothing links to it, so keyboard users traverse the top bar and sidebar on every navigation. Fix: add a visually-hidden <a href="#main-content">Skip to content</a> as the first body child.

31. frontend/src/components/cover-grid/cover-grid.ts:509<img> with no alt The only alt-less <img> in the codebase (every other one is either descriptive or correctly alt=""). Fix: alt="" if decorative.

32. frontend/src/components/queue-panel/queue-panel.ts:1431-1437 — per-row remove button is named by title only, and the name is not unique title="Remove from queue" on every row provides an accname fallback, but it never identifies which track and is invisible to touch users. Fix: aria-label="Remove ${track.title} from queue".

33. frontend/src/components/cover-grid/cover-grid.ts:1793-1797 — every album card is tabindex="0" (adjacent) role="button" tabindex="0" on each virtualised card means the tab sequence length equals the number of rendered cards, with no roving tabindex. This is the opposite failure mode to the confirmed "only 14 tab stops" finding and will surface as soon as the other views are made focusable. Fix: roving tabindex (one tabindex="0", the rest -1) once the grid gets role="listbox" per finding 13.

34. frontend/src/components/track-list/track-list.ts:900-901 — 10px sort arrow font-size: 10px; /* intentionally sub-token: tiny sort indicator */ — the comment acknowledges it. Combined with finding 9 (no aria-sort), the sort direction is a 10px glyph or nothing.


What is already correct

  • frontend/src/components/audio-player/controls/player-controls.ts:121-148 — every transport button has an aria-label, shuffle and repeat carry aria-pressed, and repeat's three-state mode is spelled into the label (Repeat: one) rather than left to the CSS class. This is the model the rest of the app should follow.
  • frontend/src/components/audio-player/seekbar/seek-bar.ts:160-168 and volume-control.ts:198wa-slider with aria-label and a valueFormatter, so the seek position is announced as 3:42 rather than 222.
  • All five wa-dialog usages are genuinely modal and restore focustrack-details.ts:735, duplicate-tracks-dialog.ts:278, download-picker.ts:180, phantom-resolver.ts:927, first-run-wizard.ts:170. Web Awesome's dialog uses native showModal(), lockBodyScrolling and activeElement restore (chunk.ZUIYLL2X.js), and every one of them passes a label. The hand-rolled dialogs in findings 4 and 16 are the outliers, and both have a working component to migrate to.
  • frontend/src/components/explore-artist-details/explore-artist-details.ts:2152, 2178, 2201, 2327, 2457 — every disclosure toggle is a real <button> with aria-expanded, and the CSS keys off the attribute (:465, :520, :680) rather than a duplicate class. This is exactly the pattern config-section.ts (finding 1) is missing.
  • keyboard-shortcut-service.ts:73-83, 106-121getDeepActiveElement correctly walks the shadow-root chain and isTextInputFocused covers contentEditable and the empty-type input case. The suppression logic is sound; the problems the parent already found are in what it does with the result, not in the resolution itself. Finding 5 is the autotag view failing to reuse it.
  • library-status-indicator.ts:186-196 — status is conveyed by three distinct icons and a full sentence in both title and aria-label, and handleKeydown (175-180) stops Enter/Space from double-firing on the wrapping card. Correct on every axis.

Residual risks / not covered

  • Colour-contrast ratios were not measured (no rendering); the token palette (--yj-text-tertiary: #888 on --yj-bg-surface: #212529 ≈ 4.1:1) is borderline for the 11-12px text it is most often paired with, but that needs a real measurement.
  • templ-rendered HTMX fragments in backend/config/ were out of scope and are not audited.
  • WebKit2GTK-specific behaviour (whether Ctrl+= page zoom is even reachable in the Wails shell, and how Orca traverses lit-virtualizer's windowed DOM) can only be confirmed on a running app.