Appearance
[UX] Playout — Event dashboard
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 20 Patterns:data-display/dashboard
Summary
The event dashboard is a well-composed operational overview — event identity, live status, "Now on air" drill-downs, recent activity and the playlist all read clearly, and the destructive paths (off air, delete) are gated by a confirm dialog. The problems are in the non-happy states, which is exactly the anti-pattern data-display/dashboard calls out ("ignoring non-happy states"). Every write on this page is fired without being awaited or caught: restore tells the user the event was restored and navigates away before the write has resolved, and going on/off air fails silently. Separately, VDialog hides a closed dialog with opacity-0 alone, so the dashboard's Delete confirmation is permanently in the tab order and the accessibility tree — a keyboard user can delete an event without ever seeing a dialog.
Findings
1. A closed confirm dialog stays focusable and announced — High · A11Y-03
Where: packages/ui/src/components/VDialog.vue:20-40 (consumed at src/views/Events/Dashboard.vue:81-100) What: VDialog never removes a closed dialog from the DOM. The dialog element carries role="dialog" and aria-modal="true" unconditionally, and "closed" is expressed only as opacity-0 scale-75 pointer-events-none (desktop) / opacity-0 pointer-events-none (mobile). There is no v-if, hidden, display:none or inert. pointer-events-none blocks the mouse; it does not remove anything from the tab order, and an opacity: 0 control is still focusable and still exposed to assistive tech. The dashboard mounts two of these permanently (off-air confirm and delete confirm), plus EditEventInfo and the layout's GlobalSearch. Why it matters: Tabbing through the dashboard walks into invisible Cancel / Off air and Cancel / Delete buttons with no visible focus ring (the element is transparent). Pressing Enter on the invisible confirm runs confirmDelete — the event is permanently deleted and the user is redirected to the event list, having seen no prompt at all. Screen-reader users additionally meet two always-present "modal dialogs" on a page that has none open. Fix: Gate the dialog subtree on showModal — v-if="showModal" on the inner .dialog wrapper, or keep the node for the transition and add inert + aria-hidden="true" while closed. Fix belongs in @playout/ui, so it repairs every dialog in the app at once.
2. Restore reports success before the write has resolved — High · MSG-02
Where: src/views/Events/Dashboard.vue:160-165What: handleRestore calls events.restore(event.selected) — which returns the updateDoc promise (src/stores/events.store.ts:76) — without await and without a catch, then unconditionally calls layout.showSuccess("event.restored") and router.push({ name: "events" }). Why it matters: If the write is rejected (stale permissions, offline, rules failure) the user is told "The event has been restored!", is navigated off the page, and the event is still archived. The failure surfaces only as an unhandled promise rejection in the console. Success copy that is not tied to a successful write is worse than no copy. Fix: await events.restore(...) inside try/catch; show the success toast and navigate only in the success path, and a specific error toast in the catch.
3. A missing or deleted event renders a skeleton forever — High · MSG-04
Where: src/views/Events/Single.vue:3,23What: loaded is event.selected != null && event.selected.id == event.id. event.selected is a vuefire useDocument (src/stores/event.store.ts:18), which resolves to null for a document that does not exist or that rules deny. Neither the pending flag nor the error is consulted, so a bad :event id, a revoked permission, or an event deleted by someone else pins the shell on PageSkeleton permanently. Why it matters: An indefinite skeleton reads as "still loading" — the user waits, reloads, waits again. There is no message and no route out except the browser Back button. This is directly reachable from this feature: two operators on the same event, one deletes it (Finding 2's sibling flow), and the other's dashboard becomes a permanent grey page. Fix: Consume vuefire's pending/error alongside the value; when settled and empty, render a "This event no longer exists" state with a link back to { name: 'events' }.
4. Toast feedback is never announced — Medium · MSG-01
Where: src/components/common/Error.vue:11-14, src/components/common/Success.vue:11-14What: Both toasts are plain <div v-if="..."> inserts with no role="alert", no role="status" and no aria-live anywhere in the subtree, and they auto-dismiss after 5s (Error.vue:51, Success.vue:50). These two components are the only outcome channel the dashboard has — "event archived", "event restored", "event deleted", "archive failed" all go through layout.showSuccess / layout.showError (src/stores/layout.store.ts:16-17). Why it matters: A screen-reader user archives or deletes an event and hears nothing; the message has vanished before they could navigate to it manually (WCAG 4.1.3). Because the same flows also navigate away, there is no residual UI to infer the outcome from. Fix: Render the live-region container unconditionally (it must exist in the DOM before the text is inserted) and put role="alert" on the error region, role="status" aria-live="polite" on the success region. Also localise the hardcoded English sr-only "Close" at Error.vue:31 / Success.vue:30.
5. Archive failure shows a VOD message on the non-VOD path — Medium · MSG-03
Where: src/views/Events/Dashboard.vue:145-158 (catch at 155-157) What: handleArchive has two branches — api.archiveStreamingVod(...) for a replay, events.putInArchive(...) for everything else — but a single catch that always shows alert.error.vod.publish_failed, which reads "Failed to publish/archive VOD." (src/locales/en.yml:475). Why it matters: Archiving an ordinary event that fails tells the operator a VOD publish failed. They did not publish a VOD, there is no VOD, and the message says nothing about what to do next. Mismatched error copy sends people to the wrong place to fix the wrong thing. Fix: Distinct messages per branch, each with a next step (retry, or who to contact), and keep the raw cause out of the user-facing string.
6. Going on air, off air and deleting fail silently — Medium · MSG-02
Where: src/views/Events/Dashboard.vue:42 (events.start), 139-143 (confirmOffAir → events.end), 167-174 (confirmDelete) What: events.start and events.end both return an updateDoc promise (src/stores/events.store.ts:69-72) that is neither awaited nor caught. confirmDelete awaits deleteEvent but has no try/catch. None of the three shows any success message either — they rely on the Firestore subscription flipping the live badge. Why it matters: These are the most consequential controls on the page. If the write fails, the button appears to do nothing at all: no toast, no state change, no console-visible explanation for the user. During a live broadcast the operator presses "On air" again and again with no idea why nothing is happening. For delete, the confirm dialog simply stays open. Fix: Await each write in try/catch, surface a specific error on failure, and keep the implicit success signal (badge flip) — but close the dialog only after the promise resolves.
7. Heading levels skip, and the page exposes several h1s — Medium · A11Y-04
Where: src/components/events/dashboard/NowOnAir.vue:3, src/components/events/dashboard/RecentActivity.vue:4, src/components/events/dashboard/Playlist.vue:3; packages/ui/src/components/VTitle.vue:2What: The page's h1 is the event name (EventInfo.vue:13-18, VTitle renders <h1>). Every panel below it is an h3 — "Now on air", "Recent activity", "Playlist" — with no h2 in between. Separately, VTitle is also the dialog title element (VDialog.vue:56-61), and because a closed dialog is not removed from the DOM (Finding 1) the two ConfirmDialog titles are always-present additional h1s. The connections stat (Card.vue:12-21) has no heading at all, only a dt. Why it matters: Screen-reader users navigate a dashboard by heading. A skipped level implies a missing section, and three competing h1s make it impossible to tell what the page is actually about. Fix: Promote the panel headings to h2, and give VTitle an as/level prop so a dialog title renders h2 rather than a second h1.
8. The edit-event button has no accessible name — Medium · A11Y-05
Where: src/components/events/dashboard/EventInfo.vue:57-63What: The round VButton that opens the event editor contains only <MdiPencil />. VButton (packages/ui/src/components/VButton.vue:1-8) renders a bare <button> with a slot and forwards no label, and unplugin-icons components emit a plain <svg> with no title or aria-label. Grepping src/components/events and src/views/Events returns exactly one aria-label in the whole area (Overlay/Queue.vue:170), so this is not an isolated omission. Why it matters: The control is announced as "button", with no indication that it edits the event's name, date, location or colour. It is the only way to change those fields from the dashboard. Fix: :aria-label="$t('dashboard.editEvent.title')" on the button — the key already exists in all three locales.
9. The Norwegian dashboard is entirely untranslated — Medium · CONTENT-01
Where: src/locales/no.yml:503-539, src/locales/fr.yml:198, src/locales/fr.yml:66, src/locales/no.yml:395What: Key parity holds (so pnpm check:locales passes) but every one of the ~30 dashboard.* values in no.yml is the English string plus # TODO: translate — including confirmDelete.title / .description. fr.yml is translated except dashboard.playlist.empty:198. The connections card renders menu.streaming-connections (Dashboard.vue:14 → Card.vue:14), which is untranslated in both fr and no. Related: PresetChip.vue:222,237 uses native window.confirm, whose OK/Cancel buttons follow the browser locale, not the app's, and cannot be styled or translated — unlike the ConfirmDialog used everywhere else on this page. Why it matters: A Norwegian operator gets a fully English dashboard, including the sentence explaining that deleting an event is permanent. Parity-by-key gives CI a green tick while the user-visible parity the rule is about does not exist. Fix: Translate the dashboard.* block in no.yml and the two stragglers; consider making check:locales report # TODO: translate counts per locale so the gap is visible. Replace the two window.confirm calls with ConfirmDialog. (Note: OBSStats.vue and StatCard.vue carry hardcoded English — "OBS Stats", "Missed frames", "FPS" — but neither is imported by any view, so this is dead code, not a live CONTENT-01 failure.)
10. Every route shares one document title — Low · NAV-03
Where: index.html:20; no document.title assignment exists anywhere under src/What: The title is the static <title>Playout</title>; the router (src/router/routes.ts:86) sets no meta.title and there is no afterEach hook writing one. Why it matters: Operators routinely keep several events open in tabs. All tabs read "Playout", so the only way to find the right event is to click through them, and browser history is equally undifferentiated. Fix: meta.title per route plus a router afterEach that composes it — for the dashboard, the event name.
11. Small touch/click targets — Low · A11Y-02
Where: src/components/events/dashboard/RecentActivity.vue:7-12; src/components/common/Error.vue:27-33What: The "See all →" link is an inline text-xs link with no padding — a ~16px-tall box, well under the 24×24 CSS px floor. The toast close buttons are an inline-flex w-5 icon with no padding. Why it matters: WCAG 2.5.8 failure; on a touch device in a control room these are genuinely hard to hit, and the toast close button is the only way to dismiss an error before its 5s timer. Fix: Give both min-h-6 min-w-6 (or p-1 plus the icon size) and keep the visible text as-is.
12. No in-flight guard or waiting state on archive/delete — Low · FORM-06, CONTENT-04
Where: src/views/Events/Dashboard.vue:145-158, 167-174What: Correctly, no button is disabled during submission — but neither handler has an in-flight guard, an aria-busy, or any visible pending state, and both await a network round trip (archiveStreamingVod, deleteEvent). The confirm dialog stays fully interactive while its own confirm is in flight. Why it matters: A double-click or an impatient second Enter fires two archive or two delete calls, and nothing tells the user the first one is still running. Fix: A module-level busy ref that early-returns in the handler, plus aria-busy="true" and a "Deleting…"-style label on the confirm button.
13. Global key bindings are never unbound — Low · NAV-02
Where: packages/ui/src/components/VDialog.vue:123-126; src/views/Layout.vue:97-100What: VDialog binds Mousetrap's esc in onMounted with no matching onUnmounted unbind, and the binding is not scoped to showModal. Layout.vue does the same for command+k / ctrl+k. The dashboard mounts at least three VDialog instances, so at least three esc callbacks are registered simultaneously, and every subsequent navigation adds more. Why it matters: Pressing Esc anywhere on the dashboard emits close on all mounted dialogs, not just the visible one, and stale callbacks keep firing into unmounted component instances after navigation — a leak that grows with session length in an app designed to stay open for a whole broadcast. Fix: onUnmounted(() => Mousetrap.unbind("esc")) and gate the handler on props.showModal (or bind/unbind in the showModal watcher).
Unverified
- A11Y-01 (contrast). Not assessable from code. Needs checking on a rendered page:
text-faintonbg-panelfor the empty states (Playlist.vue:10,RecentActivity.vue:14-19) and the "Now on air" placeholder values (NowOnAir.vue:17,33,49,67); thetext-muted"See all" link; and the event-colour rail (EventInfo.vue:7-10), whose colour is user-chosen and sits against the panel with no contrast constraint. - A11Y-06 (short viewport / responsive). Not assessable from code. Worth checking that the two-column split (
Dashboard.vue:3,79) and the mobile bottom-drawer variant ofConfirmDialogstill work at ~700px height, and that the single-cardgrid-cols-2 md:grid-cols-3streaming row (Dashboard.vue:10-16) does not look broken with one item in it. - Not read:
EditEventInfo.vue(the modal behind the pencil button — it is its own form and belongs to a form-focused audit), and the audit/streaming stores beyond the members this view touches. - Whether the always-mounted
GlobalSearchdialog adds furtherh1elements to the dashboard depends onv-ifs inside that component that were not reviewed; Finding 7 rests only on the twoConfirmDialogs, which are unconditional.
Baseline additions
- A11Y-07 — A dialog, drawer or overlay that is closed is removed from the DOM, or marked
inert/hidden. Visual hiding alone (opacity,pointer-events-none, off-screen transforms) leaves its controls in the tab order and in the accessibility tree. Finding 1 is not really an A11Y-03 case — the control is keyboard-reachable, which is the opposite of that rule's failure mode — and this class of defect is common enough in transition-driven dialog components to deserve its own ID. - MSG-06 — An action reports the outcome it actually got: a success message is emitted only after the write it describes has resolved, and every write has a failure path that reaches the user. MSG-02 covers the wording of errors, not the absence of them; Findings 2 and 6 are both "the promise was never awaited", which is the single most repeated defect in this view.
Cross-project note
- A11Y-07 / Finding 1 is confined to
@playout/ui'sVDialog, so within the four projects it is playout-only — but it affects every playout feature with a dialog, which is most of the queue. Fix it once upstream. customer-portal and tt-time-tracker use PrimeVueDialogand members uses the BCC library, all of which unmount on close by default. - MSG-01 (toasts not announced) is a whole-app finding in playout —
Success.vueandError.vueare the single global toast channel — and is worth checking in the other three, since hand-rolled toast components skip live regions by default. PrimeVue'sToastsetsaria-live, so customer-portal and tt-time-tracker are likely clear; members is worth a look. - MSG-06 / unawaited writes is a Vue + Firestore habit rather than a playout-specific one; expect the same "fire the write, show the toast, navigate" shape anywhere else in playout that mutates a document, and in members' Directus calls.
- NAV-03 (no per-route title) is almost certainly true in all four SPAs — worth one project-level finding each rather than repeating it per feature.