Skip to content

[UX] Playout — Queue overlay ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 16 Patterns: content-management/drag-and-drop

Summary ​

The queue overlay is the operator's live playlist: it selects what goes on air, reorders it by drag, and pushes elements to the program output. Functionally it is in good shape and the recently added "Clear playlist" action (#429) is a model of MSG-05 — it confirms, names exactly what will be lost, states that it is irreversible, and is one of the very few strings in this view actually translated into fr/no. The dominant problem is that the whole surface is pointer-only: the playlist rows are bare <li>s with a @click, and the drag handle is a non-focusable icon, so a keyboard-only operator cannot select an element and therefore cannot go live, stop, rename, time or delete anything. Second in line: every Firestore write in this view is unawaited-for-failure, so a "Go Live" that fails offline looks identical to one that succeeded.

Findings ​

1. Playlist rows cannot be selected from the keyboard — High · A11Y-03 ​

Where: src/views/Events/Overlay/Queue.vue:111–124What: Each playlist row is a plain <li class="… cursor-pointer" @click="selectedId = element.id">. There is no tabindex, no role="button"/role="option", no @keydown.enter/@keydown.space, and nothing else on the page sets selectedId other than onMounted (line 578) and handleNext (line 468). The only keyboard affordance anywhere in the feature is Cmd/Ctrl+F inside QueuePerson.vue, which opens a person search; the tenant keybind set (packages/schemas/src/settings.schema.ts:53) only defines songNext/songPrev. Why it matters: Selection gates everything downstream — "Go Live"/"Stop" is :disabled="!selectedQueueElement" (line 50), and the rename field, notes, duration, "Save as custom song" and "Remove" only render for the selected row. A keyboard-only or switch-device operator can therefore operate nothing on this screen during a live broadcast except the banner toggle. This is the single highest-impact defect in the feature. Fix: Make the list a real listbox or a list of buttons: role="listbox" on the <ul>, role="option" + :aria-selected + roving tabindex on each row, with Enter/Space selecting and ArrowUp/ArrowDown moving the roving index. Keep the @click behaviour identical. A visible focus ring is needed too — the row currently distinguishes selected state only by ring/border colour.

2. Drag-to-reorder has no keyboard alternative — High · A11Y-03 ​

Where: src/views/Events/Overlay/Queue.vue:99–126 (<draggable handle=".handle">, handle at line 126) What: Reordering is vuedraggable/SortableJS with handle=".handle", and the handle is <MdiDotsVertical class="text-lg text-faint handle cursor-move shrink-0" /> — an inline SVG, not a <button>, with no tabindex, no accessible name and no key handling. SortableJS ships no keyboard sorting of its own. There are no move-up/move-down controls, no "move to position" affordance, and no cut/paste-style reorder anywhere in the view or its children. Why it matters: Running order is the core purpose of a playlist. A keyboard user cannot change it at all — and mid-broadcast the running order is exactly the thing that changes. The UX Patterns content-management/drag-and-drop anatomy lists "fallback controls" as a required component and its accessibility checklist opens with "verify that drag and drop can be completed using keyboard alone"; neither holds here. Secondary defects on the same element: the handle is ~18 CSS px with no padding, below the 24×24 floor (A11Y-02), and it carries no accessible name (A11Y-05) despite cursor-move advertising it as a control. Fix: Add per-row "Move up"/"Move down" buttons (icon buttons with aria-label, ≥24×24, revealed on hover/focus-within so they don't clutter the dense list), wired to the same queue.reorder(...) call the drag path uses. Announce the result in a polite live region ("Moved Welcome to position 3 of 7"). Promote the drag handle itself to a <button type="button"> with an accessible name and real padding.

3. Every queue write fails silently — High · MSG-03 (see Baseline additions: proposed MSG-06) ​

Where: src/views/Events/Overlay/Queue.vue:396–479, 545–559; store at src/stores/overlay/queue.store.ts:18–75What: queue.toggle(), queue.push(), queue.clearCurrent(), queue.reorder(), queue.update(), queue.remove(), queue.clear(), queue.pause()/resume() all return Firestore promises. Not one call site has a catch, and none of them surface anything on failure. handleClearAll (line 553) awaits a chunked writeBatch loop that can reject halfway through; handleDelete (line 545) awaits an unguarded deleteDoc. The only error path in the whole view is handleSaveAsCustomSong (line 569). The audit log entry is written before the mutation in handleToggleBanner (line 398), handlePush (lines 410/417) and handleClearAll (line 555), so the audit trail records actions that may never have landed. Why it matters: This is a live-operation surface. If the write rejects — offline, permissions, quota — the operator sees the button react (haptic fires, audit row appears) while the element never goes to air, and the UI simply keeps showing the last-known Firestore state. A drag reorder that fails snaps back with no explanation. During a broadcast the operator's assumption "it's live" is silently wrong. Fix: Wrap each mutation in the existing useAsyncAction/layout.showError path so a rejection produces a human message with a retry ("Couldn't put Welcome on air — check your connection and try again"), and move audit.log to after the write resolves.

4. State changes on a live surface are announced to nobody — Medium · MSG-01 ​

Where: src/views/Events/Overlay/Queue.vue:15–32 (OnAirBadge group), 145–151 (countdown), src/stores/layout.store.ts:16–18What: The on-air badges appear and disappear inside a <TransitionGroup> with no aria-live; the live row's countdown, the paused/expired colour swap, and the banner-on state are all silent DOM changes. Repo-wide there is exactly one live region in the app (src/views/Tenant/Settings/Keybinds.vue:74), so layout.showSuccess / showRawError toasts are not announced either. Why it matters: WCAG 4.1.3. "This element is now on air" is the most consequential status in the product and a screen-reader operator gets no notification of it, nor of the timer expiring, nor of a save succeeding. Colour is also the sole carrier of live-vs-selected-vs-idle state on the rows (green ring vs accent ring, line 116–122), which compounds it. Fix: Wrap the badge group in <div role="status" aria-live="polite">, add aria-live="polite" to the countdown chip (or an aria-live mirror announcing at coarse intervals rather than every second), give the global toast container role="alert", and add a non-colour marker (a text "ON AIR" chip is already there for the banner — extend it to the row).

5. The per-element Remove confirmation says nothing about what is being removed — Medium · MSG-05 ​

Where: src/views/Events/Overlay/Queue.vue:271, 281–285What: Remove opens <Confirm :show-modal="showDeleteModal"> with no title, subtitle or confirm-label, so src/components/common/Confirm.vue:5–8 falls back to "Are you sure?" / "Confirm". The dialog never names the element, never says the deletion is permanent, and never says the element will disappear from the on-air program banner. The sibling clear-all dialog three lines below (lines 286–293) does all three correctly. Why it matters: Two destructive actions on the same screen behave to two different standards. Mid-broadcast, "Are you sure?" with no object named is exactly the prompt an operator clicks through — and there is no undo (queue.remove is a hard deleteDoc, store line 37). Fix: Pass :title="$t('queue.removeConfirm.title')" and :subtitle naming the element (element.customName || element.name), plus :confirm-label="$t('queue.remove')", mirroring the clear-all dialog.

6. Clearing the playlist leaves an empty coloured banner on air — Medium · MSG-05 ​

Where: src/views/Events/Overlay/Queue.vue:553–559; output at src/components/output/Program.vue:2–19What: handleClearAll clears current, the live song selection and the local selection, but never touches queue.module.show. Program.vue keys its visibility solely on queue.module?.show and renders upcoming (line 41) inside a padded, event.color-filled transition-group container. With zero elements the container still paints: a large empty coloured slab stays on the program output. The confirmation subtitle ("This permanently removes all elements…") does not mention the banner. Why it matters: The one action whose whole purpose is "reset the event" leaves a visible artefact on the broadcast, and the operator has been told the opposite. Same applies to removing the last remaining element via finding 5's dialog. Fix: In handleClearAll, also set show: false on the module (or make Program.vue render nothing when upcoming.length === 0), and extend the confirm subtitle to say the on-screen banner will be hidden.

7. Raw SDK error text is shown to the operator — Medium · MSG-02 ​

Where: src/views/Events/Overlay/Queue.vue:569–575What: handleSaveAsCustomSong's catch calls layout.showRawError(error instanceof Error ? error.message : String(error)) — showRawError (src/stores/layout.store.ts:18) bypasses the i18n alert.error.* lookup and prints the string verbatim. Why it matters: A Firestore/Firebase failure surfaces as e.g. "FirebaseError: Missing or insufficient permissions." during a broadcast. It is untranslated, unactionable, and leaks internals. Fix: Map known Firebase codes to alert.error.* keys and fall back to one generic translated sentence with a next step. Keep the raw string in console.error only.

8. Text inputs in the queue have no labels — Medium · FORM-01 ​

Where: src/views/Events/Overlay/Queue.vue:132–137 and 157–163; src/components/queue/QueueInfo.vue:16–20; src/components/queue/QueueAbstractComponent.vue:8–15What: The inline rename input (line 132) has no <label>, no aria-label and not even a placeholder. The notes <textarea> (line 157) is labelled only by its placeholder. QueueInfo's title FormKit field is placeholder-only (queue.titlePlaceholder, "e.g. Welcome, Intermission, Closing…"). QueueAbstractComponent's vselect has no label at all. The duration input (line 168) is the one field done right — it carries :aria-label="$t('queue.duration.label')". Why it matters: A screen-reader user hears "edit text, blank" for the rename field. Placeholder-only labels vanish on focus, so the notes field loses its "private, not shown on screen" warning exactly when the operator starts typing into it — and that warning is the only thing telling them the note will not go on air. Fix: Add aria-label (or a visually-hidden <label>) to the rename input and the notes textarea, and a real label prop to both FormKit fields. Keep queue.notesPlaceholder's "not shown on screen" reassurance as persistent hint text rather than a placeholder.

9. Cmd/Ctrl+F is bound globally and never released — Medium · NAV-02 ​

Where: src/components/queue/QueuePerson.vue:110–115What: onMounted(() => Mousetrap.bind(["command+f","ctrl+f"], …)) with no matching onUnmounted/Mousetrap.unbind. Mousetrap binds on document, and QueuePerson mounts and unmounts every time the operator selects a different queue element. The handler closes over that instance's showSearchModal. Why it matters: After selecting one person element, browser Find is hijacked app-wide for the rest of the session — including on Songs, Bible and settings screens — and after the component unmounts the shortcut fires into a detached instance, so it does nothing at all. The operator loses Cmd+F with no way to get it back short of a reload. Fix: onUnmounted(() => Mousetrap.unbind(["command+f","ctrl+f"])). The same omission exists at src/views/Layout.vue:97, src/views/Events/Overlay/LowerThird.vue:94, src/views/Events/Overlay/Songs.vue:113, src/views/Events/Overlay/Bible.vue:201 and src/components/songs/SongSelector.vue:60 — worth a shared useShortcut() composable that unbinds on scope dispose.

10. The page renders two <h1>s — Medium · A11Y-04 ​

Where: src/views/Events/Overlay/Queue.vue:11; src/components/queue/QueuePerson.vue:6; src/components/queue/QueueInfo.vue:10; component at packages/ui/src/components/VTitle.vue:2What: VTitle unconditionally renders <h1> regardless of its size prop. The queue page uses it for the page title ("Playlist", size="4xl") and the detail panel uses it again for "Person details"/"Info" (size="2xl"), so two <h1>s are live simultaneously whenever an element is selected. There are no landmarks around the two panes either — both columns are bare <div>s inside one <section>. Why it matters: Heading navigation, the primary way screen-reader users orient on a dense two-pane screen, gives no hierarchy: the playlist and the detail editor are indistinguishable structurally. Fix: Add a level prop (or as) to VTitle defaulting to the current behaviour, and pass level="2" at the detail-panel call sites. Wrap the two columns in <nav>/<section> (or role="region") with aria-labels so they are reachable as landmarks. This is a @playout/ui change and fixes the same defect across every multi-title view in the app.

11. Every route shares one document title — Low · NAV-03 ​

Where: index.html:20 (<title>Playout</title>); src/router/routes.ts:93What: No route sets a title — there is no meta.title, no useTitle, and no router.afterEach touching document.title anywhere in src/. Why it matters: Operators commonly keep the playlist, the songs overlay and the live screen open in parallel tabs; all three read "Playout". Browser history and tab switching are unusable, and screen readers announce the same title on every navigation. Fix: Add meta.title to each route and a router.afterEach that sets document.title from it (e.g. Playlist · <event name> · Playout). Project-wide fix, not queue-specific.

Unverified ​

  • A11Y-01 (contrast) — the row states lean on low-emphasis tokens (text-faint for the drag handle, unselected icons, notes preview and the duration hint; text-[11px]/text-xs throughout QueueAddRow.vue and the countdown chip). The bg-green/10 + text-green and bg-red/10 + text-red countdown chips (Queue.vue:148–150) are same-hue pairings and are the likeliest failures. Needs a contrast tool against the OKLCH tokens in packages/ui/src/styles/tokens.css, in both themes.
  • A11Y-06 (short viewport / responsive) — the view is a 3–4 column grid with a min-h-64 empty state, a sm:grid-cols-3 detail pane and a scaled 1080p lower-third preview (QueuePerson.vue:147, transform: scale(0.4); width: 250%; margin-bottom: -60%). Whether the negative margin and the 2×2 mobile control grid survive a ~700px-tall viewport needs a rendered page.
  • Focus behaviour of the confirm dialogs — packages/ui/CLAUDE.md states VDialog implements a focus trap and Escape handling, and I did not read VDialog.vue. Return-focus-on-close (the operator's place in the playlist after cancelling a delete) is unverified.

Baseline additions ​

MSG-06 — An action that can fail must report its failure. Every mutation call site either awaits and handles rejection, or routes through a shared async-action wrapper that surfaces one. A remote write that silently rejects leaves the user believing an action succeeded — the most damaging failure mode there is, and invisible to every other rule in MSG, which all presuppose that an error message exists. Corollary: audit/analytics logging happens after the write resolves, not before, so the trail matches reality.

Rationale: MSG-02 and MSG-03 both govern the content of an error message and have nothing to say when no message is produced at all. Finding 3 here is the most severe defect in this feature after the keyboard barrier and currently has no rule to hang on. Seen in candidate form in the queue store (9 unguarded mutations); expect it in every Firestore/Prisma-backed view in the four projects.

Cross-project note ​

  • A11Y-03 (findings 1–2) — any list-reorder surface is suspect. In playout, row #8 Songs overlay also cites content-management/drag-and-drop and very likely shares the pattern; check src/views/Events/Overlay/Songs.vue. tt-time-tracker (TanStack Table) and members are worth checking for click-only rows even where there is no drag.
  • MSG-06 / silent write failures — architectural, not queue-specific. Any view in the four projects that calls a store mutation without a catch has it. Worth one sweep per project rather than 21 feature findings.
  • A11Y-04 (finding 10) — lives in @playout/ui's VTitle, so it is a playout-wide defect that will recur in every audited feature until the level prop lands. Fix once, upstream. The members equivalent (per BASELINE.md's per-project note) would be @bcc-code/component-library-vue.
  • NAV-02 (finding 9) — playout-only in this form (Mousetrap is a playout dependency), but it recurs at six call sites within playout.
  • CONTENT-01 — not re-filed here, per the project-level i18n finding in ux/queue/playout.md. For the record, of the ~45 queue.* keys this feature uses, only queue.clearAll and queue.clearConfirm.* (added with #429) are actually translated in fr.yml/no.yml; every other key carries the verbatim English value with # TODO: translate, including goLive, stop, pause, next and bannerOn — the live-control labels. check:locales stays green because key parity holds. The #429 author translating their own strings is the right precedent; the rest of the section has not caught up.