Skip to content

[UX] Playout — Songs overlay ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 14 Patterns: forms/search-field

Summary ​

The Songs overlay is the operator's live lyric desk: every control in it writes straight to the shared song module document, so it is on air the moment it is clicked. The single most important defect is that a bare Escape keypress, from anywhere on the page, clears the song that is currently on air — with no confirmation, no undo and no on-air warning — and the same keypress is what users press to dismiss the collection dropdown and the settings popover on this very screen. Secondary to that: the advertised Ctrl+F shortcut is bound twice and never unbound, several live controls are mouse-only, and four visible strings bypass i18n in a repo with CI-enforced locale parity.

Note on scope: this route has no drag-and-drop. content-management/drag-and-drop was not consulted — SongStructure.vue is a row of toggle buttons, and the draggable list lives in the Queue overlay (queue row #10). The person-search work from #432 is in SongEdit.vue, which is mounted by Queue/CustomSongs, not by this route, so it is out of scope here too.

Findings ​

1. Escape anywhere on the page wipes the song off air — Blocker · MSG-05 ​

Where: src/components/songs/SongControl.vue:353, src/views/Events/Overlay/Songs.vue:73, src/stores/overlay/songs.store.ts:40-48What: onKeyStroke("Escape", () => emits("close")) registers on window with no focus filter and no guard. The parent handles @close with songs.select(null), which writes current: null, skipped: null and progress: {verse:0,line:0} to the module doc — the same doc the output screens render from. There is no confirmation step and no undo: the operator's skip flags and verse position are destroyed along with the song. The same screen contains two controls whose normal dismissal key is Escape: the collection vselect (Songs.vue:44-50) and the Headless UI settings Popover (SongControl.vue:80-141). Why it matters: During a broadcast, an operator closing the settings popover the way every popover closes takes the lyrics off air mid-verse and loses the skip/verse state. Recovering means re-finding the song, re-skipping verses and re-navigating to the right verse while the congregation looks at an empty screen. Fix: Scope the handler — only act when the event target is not inside a popover/select and no dialog is open (onKeyStroke("Escape", handler, { target: contentRef }) or a useActiveElement guard). Then route the clear through the existing ConfirmDialog from @playout/ui when songs.current is on air, naming what is lost ("this will clear the lyrics from the output and discard skipped verses").

2. Clearing the on-air song is unconfirmed and unrecoverable — High · MSG-05 ​

Where: src/views/Events/Overlay/Songs.vue:23-30What: The round back-arrow button calls songs.select(null) directly. It sits in the top-left, adjacent to the browser's own back affordance, is 40×40 and is the first tab stop of the view. Nothing distinguishes it from an ordinary "go back" control, yet it terminates the live output and discards skipped and progress (store lines 43-45). Why it matters: A back arrow is the one control users click reflexively. Here it is destructive and irreversible, on a live surface, with no dialog and no way to restore the discarded verse state. Fix: Confirm before clearing whenever songs.current is set (ConfirmDialog, copy naming the loss), or make the back arrow non-destructive — return to the selector while leaving the song on air, and expose "Clear output" as an explicit, separately styled intent="danger" action.

3. Ctrl+F is bound twice, never unbound, and the hint often lies — High · NAV-02 ​

Where: src/views/Events/Overlay/Songs.vue:112-117, src/components/songs/SongSelector.vue:59-64What: Both components call Mousetrap.bind(["command+f","ctrl+f"], …) in onMounted and neither calls Mousetrap.unbind in onUnmounted. Mousetrap keeps one handler per combination, so the last mount wins. Children mount before parents, so on first load Songs.vue's handler overwrites SongSelector's and targets quickNumber, which is inside the v-else branch and does not exist while the selector is showing — Ctrl+F does nothing there. After selecting a song and going back, SongSelector re-binds and now wins; selecting a song again unmounts it while its stale handler remains, so Ctrl+F in the song view (the view that renders <KbdHint label="focus">Ctrl+F</KbdHint>, Songs.vue:68-70) does nothing. Both handlers return false, which suppresses the browser's native find, and because the bindings are never removed they keep suppressing it on every other route until a full reload. Why it matters: The one shortcut the UI advertises is unreliable in exactly the state where it is advertised, and the app silently disables browser find application-wide after a visit to this page. Fix: onUnmounted(() => Mousetrap.unbind(["command+f","ctrl+f"])) in both components, and give the two views distinct combinations (or bind once in Songs.vue and dispatch to whichever field is mounted). Label the hint from useKeybinds().formatKeyLabel so macOS shows ⌘F.

4. Lyric lines are mouse-only live controls — High · A11Y-03 ​

Where: src/components/songs/SongControl.vue:39-45What: Each lyric line is a <div class="cursor-pointer" @click="control.handleLineClick(...)"> with no tabindex, no role, no key handler and no focus style. handleLineClick (src/composables/useSongControl.ts:116-120, 191-194) writes progress to the module — i.e. it jumps the live output to that line. Why it matters: Jumping directly to a line is the fastest recovery when the speaker skips ahead. A keyboard or switch-device operator cannot do it at all; they can only step next/back one unit at a time, which is unusable when a service jumps from verse 1 to verse 4. Screen readers announce nothing clickable. Fix: Render each line as a <button type="button"> (or add tabindex="0" + role="button" + @keydown.enter/.space) with an accessible name like "Jump to verse 2, line 3", and rely on the global *:focus-visible ring already in packages/ui/src/styles/base.css:21. The comment dots on the same rows (SongControl.vue:50) expose their text only via title=, which is also unreachable by keyboard — give them aria-label or visible text.

5. Song list items are non-semantic list rows — Medium · A11Y-03 ​

Where: src/components/songs/SongSelector.vue:15-23What: Each result is <li tabindex="0" @click @keydown.enter>. There is no role="button", so assistive tech announces "list item" with no indication it is activatable; Space (the expected key for a button) does nothing; and selection state is conveyed only by background colour (:class on line 19) with no aria-current/aria-selected. Songs.vue never passes the selected prop, so the highlight branch is dead code on this route. Why it matters: Choosing the wrong song is the highest-cost mistake on this screen, and the control that makes the choice is not announced as a control. Fix: Use <button type="button" class="w-full text-left"> inside each <li>, give the list role="listbox"/options or aria-current="true" on the active row, and include the collection in the accessible name (currently number + title only).

6. Quick-jump fails silently when the song does not exist — Medium · MSG-01, MSG-03 ​

Where: src/views/Events/Overlay/Songs.vue:100-109, default at :90What: goToSong looks up ${collection}_${number}; if findSong is undefined it still clears the input (quickSong.value.number = null) and blurs the field. Nothing is shown, nothing is announced, and there is no role="alert" region on the page. The default collection is the hardcoded literal "HV" (line 90), which need not exist in the tenant's songs.collections, so on such a tenant the very first quick-jump silently no-ops. Why it matters: Under time pressure the operator sees the field empty and cannot tell whether the jump was accepted, whether the number was mistyped or whether the collection is wrong — while the wrong song, or none, is on air. Fix: Show an inline role="alert" message ("No song 145 in HV") next to the field, keep the typed number so it can be corrected, and default the collection to songs.collections[0] rather than "HV".

7. Four visible strings bypass i18n — Medium · CONTENT-01 ​

Where: src/views/Events/Overlay/Songs.vue:56 and :68, src/components/songs/SongSelector.vue:46, src/components/songs/SongControl.vue:254What: placeholder="Number" (a song.number key already exists at src/locales/en.yml:495), <KbdHint label="focus">, the "Others" tab label, and lineCountOptions' `${n} line${n > 1 ? "s" : ""}`. All render as English in the fr and no locales. pnpm check:locales compares keys across files, so literals like these pass CI untouched. Why it matters: French and Norwegian operators get a partly English live console, and the pluralisation in lineCountOptions is English-grammar-specific. Fix: $t('song.number'), a new common.focus key, $t('song.otherCollection') for the Others tab, and $t('song.lineCountOption', n) using vue-i18n pluralisation.

8. Quick-jump fields have no labels — Medium · FORM-01 ​

Where: src/views/Events/Overlay/Songs.vue:39-59What: The collection vselect has neither label nor aria-label; the number field is identified only by placeholder="Number", which disappears on first keystroke. Why it matters: A screen-reader user hears an unlabeled combobox and an unlabeled spinbutton next to a lightning-bolt icon, with no way to know they form a "collection + number" jump. The placeholder also vanishes for sighted users mid-entry. Fix: Add visually-hidden labels ($t('song.collection'), $t('song.number')) or aria-label on both, and group them with an accessible group name.

9. Search results have no status, empty or loading state — Medium · MSG-01, CONTENT-04 ​

Where: src/components/songs/SongSelector.vue:14-33What: The <ul> re-renders as the query changes with no result count, no aria-live region, no empty state when filteredItems is empty, and no loading state while the Firestore useCollection (songs.store.ts:22) resolves — an empty grid is shown for "loading", "no results" and "collection has no songs" alike. @playout/ui already ships EmptyState, PageSkeleton and Spinner. The forms/search-field pattern lists a search-status region as part of the component's anatomy, alongside a clear control that VSearch also lacks. Why it matters: An operator typing a number that matches nothing sees the same blank panel as an operator whose songs have not loaded yet — one is a typo, the other is a connectivity problem, and they need opposite responses. Fix: Add a polite live region ("{n} songs"), an EmptyState for zero results that names the query and offers "clear search", and a skeleton while the collection is pending. Adding a clear button to VSearch fixes this everywhere.

10. Transport buttons disable themselves under the operator's finger — Medium · FORM-06 ​

Where: src/components/songs/SongControl.vue:182-204, packages/ui/src/components/VButton.vue:18What: Back and Next take :disabled="!control.canGoPrevious/canGoNext", and VButton's base class adds disabled:pointer-events-none. Pressing Next on the last verse disables the very button that has focus, so focus falls to <body>. Why it matters: A keyboard operator who reaches the end of a song loses their place in the tab order mid-broadcast and must tab back through the whole sidebar to reach any control. There is also no explanation of why the button is dead. Fix: Keep the buttons enabled and use aria-disabled="true" plus an early return in next/back, so focus survives and screen readers announce the state. (The keybind path already works regardless.)

11. Icon-only controls have no accessible name — Medium · A11Y-05 ​

Where: src/views/Events/Overlay/Songs.vue:23-30 (MdiArrowLeft) and :60-66 (MdiLightningBolt) What: Both VButtons contain only an unplugin-icons SVG and no text, aria-label or title. VButton renders a bare <button> with slot content (packages/ui/src/components/VButton.vue:2-7), so the accessible name is empty. Why it matters: The back arrow is the destructive clear-output control from finding 2 and the bolt is the quick-jump submit; both are announced as "button". Fix: :aria-label="$t('common.back')" and :aria-label="$t('song.goToSong')" (new key), with the icons aria-hidden.

12. The song view has no h1 — Low · A11Y-04 ​

Where: src/views/Events/Overlay/Songs.vue:20-74What: VTitle (which renders <h1>, packages/ui/src/components/VTitle.vue:2) only exists in the selector branch. Once a song is selected the whole branch is replaced and no heading remains — the song's own title renders as a plain <span> (SongControl.vue:9-10). src/views/Layout.vue:14 supplies <main> but no heading. Why it matters: Screen-reader users navigating by heading land on a page with none, and the current song — the single most important fact on screen — is not in the heading structure. Fix: Render <VTitle size="xl">{{ collection }} {{ number }} — {{ name }}</VTitle> in the song branch.

13. Skip toggles do not expose their state — Low · A11Y-03 ​

Where: src/components/songs/SongStructure.vue:3-17What: Real <button>s (good), but skipped state is opacity-25 only and current state is a background class — no aria-pressed, no accessible name beyond the prefix ("V1"), and no indication that pressing one removes a verse from the broadcast. Fix: :aria-pressed="verseIsSkipped(i)" and an aria-label such as "Verse 1 — skipped".

14. No route sets a document title — Low · NAV-03 ​

Where: src/router/routes.ts:91 (and every other route) What: Only src/components/output/LiveScreen.vue:26 touches useTitle. Every operator tab is titled identically. Why it matters: Operators commonly run several event tabs (songs, lower third, queue) side by side and cannot tell them apart from the tab strip. Fix: Project-wide meta.title + an afterEach guard; on this route include the event and the current song.

Unverified ​

  • A11Y-01 (contrast). Not determinable from code. Needs checking on a rendered page: text-muted/text-faint on bg-panel (SongControl.vue:12,92), the opacity-20/opacity-25 skipped-verse treatments (SongControl.vue:37, SongStructure.vue:8) and the opacity-40 blackout dim (SongControl.vue:27) are the likely failures — an opacity-dimmed lyric is still text the operator must read.
  • A11Y-06 (short viewport / responsive). my-content-height { height: 60vh } (SongControl.vue:413-415) is a fixed viewport-relative height with a four-column grid and a touch-padded sidebar; on a ~700px-tall tablet in landscape — the stated target device — the transport buttons may fall below the fold. Needs a rendered viewport.
  • Whether Headless UI's Popover stops propagation of Escape before it reaches the window listener in finding 1. The vselect dropdown and plain background Escape presses reach it regardless, so the finding stands either way, but the popover-specific trigger should be confirmed in a browser.

Baseline additions ​

  • LIVE-01 — On a surface that writes to a live output, the current on-air state is always visible, and any control that changes or clears it is distinguishable from navigation. This overlay shows an OnAirBadge only for the lower-third credits (Songs.vue:11-14); the lyrics themselves — which are on air whenever songs.current is set — have no persistent indicator, and the control that takes them off air is a plain back arrow. Applies to every overlay route in playout and to nothing in the other three projects.
  • NAV-06 — Global keyboard shortcuts are unbound on unmount and namespaced per view; a shortcut that shadows a browser default (Ctrl+F, Ctrl+S, Ctrl+P) must not survive navigation away from the view that owns it.NAV-02 covers timers and subscriptions but reads as not covering Mousetrap bindings, which is how finding 3 slipped in twice.

Cross-project note ​

  • Findings 3 (unbound global shortcuts) and 1 (unscoped window Escape) are most likely to recur in playout's other overlay routes — Bible, Queue and Lower third all follow the same select/clear-a-module-doc shape. Worth a repo-wide grep for Mousetrap.bind and onKeyStroke("Escape".
  • Finding 9 (no empty/loading/status state on a filtered list) and finding 5 (clickable rows that are not buttons) are generic list-view defects and are plausible in customer-portal, members and tt-time-tracker, though all three use component libraries (PrimeVue DataTable, Flowbite/BCC lists) that may supply the empty state for free.
  • Finding 10 (disabled button steals focus) is a @playout/ui VButton base-class behaviour, so it affects every playout feature, not just this one.
  • Finding 7 (i18n bypassed by literals) is playout- and customer-portal-only; members and tt-time-tracker have no i18n layer by design.