Skip to content

[UX] Playout — Event list ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 18 Patterns: data-display/list-view

Summary ​

The event list is the landing surface after sign-in, and it is the least localised screen in a repo whose CI enforces locale parity: the primary action button, all six tab labels, three of four status badges, the month names and the weekday header are hardcoded English, and the empty-state sentence splices an English word into a translated string ("Aucun évènement archived"). Beyond copy, the two most serious defects are that a failed event load is rendered as the "no events yet" empty state — so a permissions or connectivity failure tells a first-time user their account is empty — and that the create-event dialog closes before its write resolves, discarding everything the user typed if the write fails. The calendar half of the feature is effectively keyboard-inaccessible.

Findings ​

1. A failed event load is indistinguishable from having no events — High · MSG-03 ​

Where: src/stores/events.store.ts:19-20, src/views/Events/List.vue:30-50What: The store binds useCollection(eventsQuery) and re-exports only list.pending as loading. Vuefire's error ref is never read, so there is no error path anywhere in the feature. List.vue branches loading → empty → list: if the Firestore subscription rejects (permission denied, offline, bad tenant claim), list stays [], pending goes false, and the user gets the empty state — "No events created / Events you create will appear here." Why it matters: This is the first screen after sign-in. A user whose tenant membership has not propagated, or who is offline, is confidently told they have no events and invited to create one — and the create will fail too, with the generic alert.error.default toast. There is no retry, no diagnostic, and nothing to distinguish "empty" from "broken". The data-display/list-view pattern names this directly under Common Mistakes: "Ignoring non-happy states — a polished default view still feels broken when loading, empty, and error states are inconsistent. Design the data lifecycle up front, including empty, partial, stale, and failed results."Fix: Export error from useEvents alongside loading, and add a fourth branch in List.vue before the empty check: an error panel with role="alert", a human sentence, and a Retry control. Keep the copy actionable per MSG-03 ("We couldn't load your events. Check your connection and try again.").

2. Creating an event discards the draft before the write resolves — High · FORM-09 ​

Where: src/components/events/NewEvent.vue:8, src/views/Events/Index.vue:62-64What: The submit handler is @submit="emit('create', event), emit('close')" — the close fires synchronously, so the dialog unmounts its draft before events.create() has done anything. Index.vue then destructures only { run } from useAsyncAction(), throwing away the pending flag, and FormDialog exposes a loading prop (documented at FormDialog.vue:56 as "Disables both buttons (async submit in flight)") that NewEvent never passes. create() is not cheap: a Firestore transaction on the counter, a multi-document batch, and an awaited applyPreset. Why it matters: On failure the user sees a generic red toast and the dialog is already gone — every field they filled (name, date, start time, responsible, preset, production unit, location) is lost, and @open="reset" guarantees a blank form when they reopen. That is straightforward data loss on the feature's primary action. On success there is no confirmation at all; on a slow connection there is a multi-second window where the dialog has closed and nothing has appeared. Fix: Keep the dialog open until the promise settles — @submit="onSubmit" where onSubmit awaits run(...) and only closes on success; bind :loading="pending"; pass successKey to useAsyncAction so the existing layout.showSuccess toast fires. Preserve the draft on failure rather than resetting.

3. Calendar events cannot be opened with a keyboard — High · A11Y-03 ​

Where: src/views/Events/Calendar.vue:59-74What: Each event chip in the month grid is a bare <div ... @click="router.push({ name: 'dashboard', params: { event: event.id } })">. It has no tabindex, no role, no key handler and no accessible name — only a truncated <p> with the event name. Why it matters: The calendar is one of the two co-equal views of this screen (Index.vue:9-12). A keyboard or screen-reader user who lands on it has no way to reach any event: the chips are not in the tab order, are not announced as interactive, and the only escape is to notice the view toggle and switch back to the list. The same navigation is a proper RouterLink in the list view (EventElement.vue:3-9), so this is an inconsistency, not a platform limit. Fix: Replace the chip <div> with the same <RouterLink :to="{ name: 'dashboard', ... }">. Right-click/open-in-new-tab and focus styling then come for free, and colour-coding stays as a class on the link.

4. The landing screen renders in English regardless of locale — High · CONTENT-01 ​

Where: src/views/Events/Index.vue:16, src/views/Events/List.vue:76,85-90, src/components/events/EventElement.vue:31,37,43, src/views/Events/Calendar.vue:93-94, packages/ui/src/components/Tabs.vue:19,40What: Seven distinct sites of untranslated copy on one screen:

  • Index.vue:16 — the primary action reads Create event as a literal, even though events.empty.create exists and is translated in all three locales (used correctly 30 lines away at List.vue:47).
  • List.vue:85-90 — label returns the English strings "created", "in recording", "put in replay", "archived" and interpolates them into the translated events.empty.title. French renders "Aucun évènement archived". This is worse than untranslated: it is visibly broken grammar.
  • List.vue:76 + Tabs.vue:19,40 — the four status tabs are passed as raw keys ("active", "recording", "replay", "archive") and rendered via capitalize(tab). Same for TabSwitch at Index.vue:52-55 → "List", "Calendar". Per packages/ui/CLAUDE.md the UI package is i18n-free by design and call sites must pass $t(...) results — so these are app-side defects.
  • EventElement.vue:31,37,43 — the Archived / Recording / Replay badges are literals sitting immediately below a correct $t('dashboard.status.onAir') at line 25. dashboard.status.archived|recording|replay already exist and are translated in fr.yml:183-185.
  • Calendar.vue:93-94 — MONTH_NAMES and DAYS are hardcoded English arrays. Why it matters: This is the first authenticated screen. A French operator sees an English call-to-action, English tabs, an English calendar header and a mangled empty-state sentence. Note that pnpm check:locales cannot catch any of it: scripts/check-locales.mjs checks (1) keys used in code but missing from en.yml, (2) key parity across locales, (3) placeholder drift — none of which sees a literal in a template. The CI gate is passing while the screen is untranslated. Fix: Route every string above through $t; reuse events.empty.create and dashboard.status.* rather than adding keys. Add events.tabs.* and events.view.* and pass label through Tabs/TabSwitch (both already accept a label on the tab object — TabSwitch.vue:36). For the empty state, replace the {label} splice with four per-tab keys (events.empty.title.active, …). Derive month and weekday names from Intl.DateTimeFormat(locale) instead of literal arrays. Consider extending check-locales.mjs with a literal-text heuristic over src/views and src/components so this cannot regress.

5. Calendar month navigation has no accessible name and is silent — Medium · A11Y-05 ​

Where: src/views/Events/Calendar.vue:9-26What: The previous/next month controls are icon-only <button>s containing MdiChevronLeft / MdiChevronRight with no aria-label, title or visually hidden text. Changing month also swaps the whole grid with no live region, so the month/year heading change (Calendar.vue:5-6, a <span>, not a heading) is never announced. The back button silently hard-stops at January of last year (canGoBack, line 101) with only opacity-25 to explain it. Why it matters: A screen-reader user hears "button, button" and, after activating one, gets no confirmation that anything changed. The undocumented one-year backstop reads as a broken control rather than a deliberate limit. Fix: Add :aria-label="$t('events.calendar.previousMonth')" / nextMonth; promote the month/year label to a heading with aria-live="polite" so the new month is announced; give the disabled state a tooltip or helper text naming the limit.

6. Missing focus indicator on the list/calendar view toggle — Medium · A11Y-03 ​

Where: packages/ui/src/components/TabSwitch.vue:6What: The tab buttons carry focus:outline-hidden, which suppresses the global focus ring set at packages/ui/src/styles/base.css:21 (*:focus-visible). Nothing replaces it — the only visual cue for the active tab is the animated bg-accent pill, which tracks model, not focus. Why it matters: A keyboard user tabbing across the header loses track of where they are on the control that switches the entire view. This is in @playout/ui, so the defect is shared by every consumer of TabSwitch. Fix: Drop focus:outline-hidden, or replace it with an explicit focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2. Add type="button" while there (both Tabs.vue:33 and TabSwitch.vue:3 omit it, so they submit any enclosing form).

7. No page title for the events route — Medium · NAV-03 ​

Where: src/router/routes.ts:73, index.html:20What: The route is { path: "", name: "events", component: Events } with no meta.title, and there is no title handling anywhere in the router or app entry (no document.title, useTitle, or meta.title reference in src/router/ or src/*.ts). Every route in the app is titled Playout. Why it matters: Browser tabs, history entries and bookmarks are indistinguishable, and screen readers announce the same title on every navigation. Operators running an event typically have several Playout tabs open. Fix: Add meta.title per route and a global afterEach that sets document.title from $t(meta.title); for this route reuse menu.events, which already titles the page at Index.vue:5.

8. Event name is not required and start time is unvalidated — Medium · FORM-04 ​

Where: src/components/events/NewEvent.vue:18-38, packages/schemas/src/event.schema.ts:15,18What: No field in the create dialog declares a constraint. name has no validation="required" and the schema is z.string(), which accepts ""; startTime is a free-text input whose only hint is the placeholder="HH:mm" and which the schema types as z.string().nullish(). No required markers, no help text stating the format, no inline validation. Why it matters: A user can submit the dialog immediately and create a nameless event, which then renders as a blank row in the list (EventElement.vue:12-14) identifiable only by its date — and the list has no search or rename affordance to recover from it. A start time of "7pm" is stored verbatim and silently misreads downstream. Per FORM-04, constraints must be visible before the user types. Fix: Add validation="required" to name and validation="matches:/^\d{2}:\d{2}$/" (or a proper time input) to startTime, with validation-visibility="live"; tighten the schema to z.string().min(1) and a time regex so the API cannot be bypassed; mark required fields visibly.

9. Loading and waiting states name nothing — Medium · CONTENT-04 ​

Where: src/views/Events/List.vue:11-29, src/views/Events/Calendar.vue:41-77What: The list shows three skeleton rows with no role="status", aria-busy or text — a purely visual placeholder. The calendar view has no loading state at all: the grid renders with empty day cells while the collection is pending, which is pixel-identical to a month with no events. Neither view announces the transition from loading to loaded. Why it matters: A screen-reader user gets silence, then content appears without notice (WCAG 4.1.3). On the calendar every user, sighted included, sees what looks like an empty month until data lands. Fix: Wrap the skeleton in role="status" with a visually hidden {{ $t('events.loading') }} ("Loading events…"), add aria-busy on the list container, and give Calendar.vue the same pending branch — it already receives the store, so events.loading is available.

10. Empty-state copy is wrong on three of the four tabs — Low · CONTENT-03 ​

Where: src/views/Events/List.vue:34-49, src/locales/en.yml:145-148What: events.empty.description — "Events you create will appear here." — is shown under Archive, Recording and Replay as well as Active. Creating an event will never make it appear in Archive; it arrives there by being archived from the dashboard. The Create event button is correctly suppressed for those tabs (line 43), so the copy contradicts the UI beside it. Why it matters: An operator looking at an empty Archive tab is told the wrong thing about how to fill it, and is given no route to the action that would (archiving from the event dashboard). Fix: Per-tab description keys. Archive: "Events you archive will appear here." Recording/Replay: name the state that puts an event there, and link to it.

Unverified ​

  • A11Y-01 (contrast). The screen leans hard on text-faint and text-muted for the metadata row (EventElement.vue:48-49), the empty-state description (List.vue:39), the calendar date numerals (Calendar.vue:54) and inactive tab labels (Tabs.vue:37) — four places where the design deliberately reduces emphasis on small text. Needs a contrast tool against the resolved OKLCH tokens in packages/ui/src/styles/tokens.css; cannot be settled from class names.
  • A11Y-06 (responsive / short viewport). The calendar grid uses fixed-height cells (h-30) with an inner h-20 overflow-y-auto list of chips (Calendar.vue:48-58), and the header row is grid grid-cols-2 with the create button right-aligned (Index.vue:2,14). Needs a rendered viewport at ~700px height and at mobile widths.
  • A11Y-02 (target size). Calendar date circles are w-6 h-6 — exactly 24px, passing only if nothing shrinks them; event chips are px-2 py-1 text-sm, likely ~26px tall. Both are borderline and need measurement, not inference.
  • Whether the Firestore rules actually produce a permission-denied on the events collection for a partially-provisioned member (the trigger for finding
    1. was not tested — the code path is unhandled either way.

Baseline additions ​

Four proposals, each seen concretely in this feature:

  • MSG-06 — A failed data load is visually distinct from an empty result set. A view that binds a remote collection must surface the subscription's error state; rendering an error as the empty state is a defect, not a fallback. (Seen at events.store.ts:19-20 — vuefire's error ref is dropped at the store boundary, so no consumer can handle it. This is a store-shape problem that will repeat on every useCollection in the app.)
  • A11Y-07 — Selected/current state is exposed programmatically, not only by colour. Tab sets expose role="tablist"/aria-selected or aria-current; the same applies to segmented controls and nav items. (Seen at Tabs.vue:33-41 — <button>s in a <nav aria-label="Tabs"> whose only selected cue is bg-panel text-white, and TabSwitch.vue:3-27. Backed by the data-display/list-view visual-accessibility guidance: "do not rely on color alone to convey severity, completion, or selection state.")
  • NAV-06 — A view mode, tab or filter that changes what the page shows is reflected in the URL. (Seen at Index.vue:57 and List.vue:76: both the list/calendar toggle and the active status tab are component-local refs. The user cannot bookmark or share "the archive tab", a refresh silently returns them to Active, and browser Back skips straight off the page.)
  • PERF-01 — An unbounded collection is paginated, virtualised or filtered before it is rendered. (Seen at events.store.ts:18-19: query(ref, orderBy("date","desc")) with no limit, feeding an Archive tab that grows forever. The queue lists forms/search-field for this feature and there is no search field anywhere on the screen — for a tenant with several years of events the only way to find one is to scroll. The list-view pattern's Performance section calls for virtualisation or progressive disclosure "when the layout would otherwise render too many items at once.")

One further observation that did not reach a rule: Calendar.vue:90 declares a currentEvent prop and never uses it, while Index.vue:36 dutifully passes event.current. The consequence is that the on-air highlight the list view gives an event (EventElement.vue:20-26, the pulsing "On air" badge) simply vanishes when the operator switches to the calendar — two views of the same data disagreeing about which event is live, on a broadcast tool. Worth fixing whether or not it becomes a rule.

Cross-project note ​

  • MSG-06 / finding 1 — most likely to recur wherever a live-query binding drops its error ref. In playout this is a store-level pattern (useCollection + pending only), so every list view in the app (songs, screens, custom songs, audit) probably shares it. customer-portal and tt-time-tracker fetch through Prisma/REST with explicit try/catch, so they are less exposed; members is worth checking, since Directus SDK calls are often awaited without an error branch.
  • A11Y-07 / finding 6 — the defect lives in @playout/ui (Tabs.vue, TabSwitch.vue), so it is shared by every playout feature that uses tabs and by any external consumer of the git-dep pin. members should be checked against its shared @bcc-code/component-library-vue for the same reason: per the baseline's per-project note, the fix may belong upstream. customer-portal and tt-time-tracker use PrimeVue Tabs, which ships correct tab semantics — likely clean.
  • NAV-03 / finding 7 — playout has no per-route title mechanism at all, so this is one project-level fix, not 21 feature fixes. Worth confirming in the other three before filing per feature.
  • CONTENT-01 / finding 4 — playout-specific in this form, but the underlying cause generalises: check-locales.mjs validates key parity, not template literals, so a CI gate is green over an untranslated screen. customer-portal (en/nb) is held to CONTENT-01 too and almost certainly has the same blind spot in whatever checks it runs. Separately, no.yml carries 639 # TODO: translate lines out of 969 — Norwegian is mostly English throughout the app, including this feature's block at no.yml:498-502. That is a project-level finding, not an Event-list one, and should be raised once.