`.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.
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 norole="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 noaria-liveand noaria-busyon 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-951 — aria-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:378 — role="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 — notitle.frontend/src/components/track-info/track-info.ts:92,100vs. 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 toMIN_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 anaria-label, shuffle and repeat carryaria-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-168andvolume-control.ts:198—wa-sliderwitharia-labeland avalueFormatter, so the seek position is announced as3:42rather than222.- All five
wa-dialogusages are genuinely modal and restore focus —track-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 nativeshowModal(),lockBodyScrollingandactiveElementrestore (chunk.ZUIYLL2X.js), and every one of them passes alabel. 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>witharia-expanded, and the CSS keys off the attribute (:465, :520, :680) rather than a duplicate class. This is exactly the patternconfig-section.ts(finding 1) is missing.keyboard-shortcut-service.ts:73-83, 106-121—getDeepActiveElementcorrectly walks the shadow-root chain andisTextInputFocusedcoverscontentEditableand the empty-typeinput 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 bothtitleandaria-label, andhandleKeydown(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: #888on--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 inbackend/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.