`.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.
356 lines
24 KiB
Markdown
356 lines
24 KiB
Markdown
# 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-item`s 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-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 — 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:198`** — `wa-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 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 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-121`** — `getDeepActiveElement` 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.
|