Appearance
[UX] Playout — Lower third overlay
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 15 Patterns:content-management/tooltip
Summary
The lower-third page is the most consequential surface in the app — every button here changes what viewers are seeing right now — and it is built as if nothing could fail. No write is error-handled, the success haptic and the audit-log entry both fire before the Firestore write resolves, and a failed person search leaves the table in a permanent skeleton state with no message and no recovery short of a reload. The on-air state itself is communicated by a row background colour and a badge that lives outside any live region, so a screen-reader or colour-impaired operator cannot tell what is currently live; the auto-hide timer then removes the graphic 7 seconds later with no announcement at all. Ten findings, five of them High, plus a locale gap that CI cannot catch because the strings never reach the .yml files.
Findings
1. A failed person search leaves the table loading forever, with no error — High · MSG-01, MSG-02, MSG-03
Where: src/components/persons/PersonTable.vue:219-231What: doSearch() sets searching.value = true, then await searchPersons(...) with no try/catch/finally. searchPersons is an axios call (src/composables/useApi.ts:33) that rejects on any non-2xx or network failure. On rejection, searching.value = false is never reached, so the computed loading (line 213) stays true and the table renders five skeleton bars indefinitely. The rejection is an unhandled promise; there is no global app.config.errorHandler, no unhandledrejection listener and no toast/notification system anywhere in src/ — only Sentry (src/main.ts:27), which reports to the developers, not the operator. Same code path: the query is interpolated into the URL unescaped (`search/persons?q=${query}...`), so a name containing &, # or + silently searches for the wrong string. Why it matters: Mid-broadcast, the operator types a guest's name, the search backend hiccups, and the person list becomes a permanently animating placeholder with no explanation. There is no retry affordance and no error text — the only way out is a full page reload, during whatever is live. Fix: Wrap the request in try/catch/finally; always clear searching in finally. On failure render an inline error with role="alert" in the table body that names the failure and offers a Retry button (MSG-03). Wrap the query in encodeURIComponent().
2. On-air writes have no failure path, and success is signalled before the write resolves — High · MSG-01, MSG-02
Where: src/views/Events/Overlay/LowerThird.vue:122-130, src/stores/overlay/lowerthird.store.ts:44,64,74-94What: handleShow fires haptics.notify("success") and showPerson() logs PERSON_LOWERTHIRD_SHOW to the audit trail before the updateDoc in show() is awaited; handleHide does the same with audit.log(...) ahead of lowerthird.hide(). Neither hide() nor show() has a .catch, and neither caller awaits them, so a rejected Firestore write (offline, permissions, a stale moduleRef) is swallowed entirely. hide() also dereferences moduleRef.value! with a non-null assertion. Why it matters: Two concrete broadcast failures. (a) The operator taps Show, feels the success haptic, sees the audit log say the lower third went up — and nothing is on air, because the write failed. (b) The operator taps Hide on a graphic that must come down, gets no error, and it stays up. The audit trail, which exists precisely to reconstruct what went to air, records events that never happened. Fix: await the store calls in the handlers; move haptics.notify("success") and audit.log(...) to after a successful write; catch and surface a role="alert" message ("Couldn't put {name} on air — check your connection") with a retry. Log a distinct failure event rather than a success one.
3. The operator cannot tell what is live except by colour — High · A11Y-03, MSG-01
Where: src/components/persons/PersonTable.vue:129 (:class="{ 'bg-accent text-white': isCurrent(item) }"), 82, src/views/Events/Overlay/LowerThird.vue:10-24What: The live row is marked only by a background colour swap. There is no aria-current, no visually-hidden "on air" text, and no non-colour affordance in the row. The one textual indicator, OnAirBadge, is wrapped in a <Transition> but sits in no live region — packages/ui/src/components/OnAirBadge.vue has no role="status" / aria-live, so it appearing, changing subject, or vanishing is a silent DOM swap (WCAG 4.1.3). It vanishes on its own: show() sets a 7-second setTimeout that calls hide() (lowerthird.store.ts:65-71), with nothing in the UI indicating a countdown is running or that the graphic will self-remove. Compounding it, isCurrent() matches on person.personId == current.id while the custom-data panel hardcodes personId: 1337 (LowerThird.vue:102) — a real person whose id is 1337 will be highlighted as on-air whenever the custom lower third is up. Why it matters: On the surface where "what is on air right now" is the single most important fact, that fact is conveyed by one colour, unannounced, and it changes by itself after 7 seconds. A screen-reader operator gets nothing; a colour-impaired operator gets an ambiguous highlight. Fix: Add aria-current="true" plus a visually-hidden $t('...onAir') string to the live row and a non-colour marker (dot/border). Give OnAirBadge (or its wrapper) role="status". Surface the auto-hide timer — a countdown or progress ring on the badge — so the operator knows the graphic is about to drop. Replace the 1337 sentinel with an id namespace that cannot collide (e.g. custom: prefix) and compare on the same field the store writes.
4. Show and Hide occupy the same coordinates, with no confirm and no undo — High · MSG-05
Where: src/components/persons/PersonTable.vue:164-183 and 106-123, src/views/Events/Overlay/LowerThird.vue:57-74What: In each row's action cell, the green Show button is replaced in place by the red Hide button the instant the person goes live (same <td>, same size, same position). The custom panel does the same: one VButton swaps from intent="success" Show to intent="danger" Hide at the same spot. No action on this page confirms, and none can be undone. A double-tap, a bounced click or a touch-repeat therefore puts a person on air and immediately takes them off — a visible flash to the audience — or the reverse. The star control has the same property: persons.unstar(item) (line 138) deletes the starred document with no confirmation, dropping a curated entry from the operator's one-click list. Why it matters: MSG-05 exists for exactly this. A confirm dialog on every on-air action would be wrong for live operation, but a control that becomes its own inverse under a stationary finger is the worst of both worlds: no friction and no recoverability. Fix: Keep Show and Hide in fixed, distinct positions (Hide as a separate persistent control, disabled when nothing is live), or add a short post-action lockout (~500ms aria-busy) before the inverse becomes clickable. For unstar, confirm or offer an undo toast naming what was removed.
5. Ctrl+F is bound globally and never unbound — it stays hijacked after leaving the page — High · NAV-02, A11Y-03
Where: src/views/Events/Overlay/LowerThird.vue:93-98What: onMounted(() => Mousetrap.bind(["command+f", "ctrl+f"], ...)) with no matching onUnmounted(() => Mousetrap.unbind(...)). Mousetrap binds at document level, and the handler return falses, which preventDefaults the browser's native Find. After the operator navigates away from the lower-third page, the binding is still installed and personTable.value is null, so the optional chain does nothing and the handler still suppresses Find. The same unbalanced bind appears in src/views/Events/Overlay/Songs.vue:113, Bible.vue:201, src/components/queue/QueuePerson.vue:111 and src/components/songs/SongSelector.vue:60, so whichever mounted last wins and nobody ever restores the default. Why it matters: Visiting this page once permanently breaks Ctrl+F/Cmd+F for the rest of the session, on every other page of the app, with no visible cause. For a keyboard-driven operator that is a lost primitive during a live show. Fix: onUnmounted(() => Mousetrap.unbind(["command+f", "ctrl+f"])) in each of the five call sites, or move the binding into a shared composable that owns teardown.
6. Custom-data field labels are hardcoded English — Medium · CONTENT-01, FORM-01
Where: src/components/persons/PersonEdit.vue:11,17,26,58What: label="Display Name", label="Title", and the slot labels <span>Church Name</span> / <span>Country Name</span> are literal English. They never enter src/locales/*.yml, so pnpm check:locales cannot see them — CI locale parity passes while the panel is monolingual. src/views/Events/Overlay/LowerThird.vue:32 has the same problem (label="search", also lowercase where every other label is sentence case), as does the shared src/components/common/Table.vue:137,145,193 (aria-label="Pagination", sr-only "Previous"/"Next"). Separately, churchName and countryName pass no label prop and supply a #label slot instead, so their programmatic label association depends entirely on whether FormKit still renders its <label for> wrapper around slot content — and the slot nests a <button> inside that label region, which is a nested-interactive hazard regardless. Why it matters: A French or Norwegian operator driving a live broadcast reads four English field labels in an otherwise translated page, on the one panel used when a guest is not in the database — i.e. under time pressure. Fix: Route all four labels through $t() with new lowerthird.customData.* keys, and the Table pager strings through common.*. Move the on-air toggle buttons out of the label region and use the label prop plus a suffix/help slot so FormKit keeps generating <label for>.
7. The whole feature is untranslated in Norwegian — Medium · CONTENT-01
Where: src/locales/no.yml:800-827 (all 24 keys), src/locales/fr.yml:641-642,649-652What: Every lowerthird.* key in no.yml carries the English string with a # TODO: translate comment; French is missing personOnAir, infoOnAir and all four personTable aria strings. Key parity passes, so CI is green. Why it matters: CONTENT-01 is parity of copy, not of keys. A Norwegian operator gets a fully English lower-third page, including the aria labels a screen reader announces. The French aria gaps mean the star/show/hide buttons — which have no visible text — announce in English inside an otherwise French UI. Fix: Translate the lowerthird block in no.yml and the six outstanding fr strings. Consider making check:locales fail (or warn loudly) on # TODO: translate markers so untranslated values are visible in CI rather than only key drift.
8. The church/country on-air checkboxes have no accessible name, and title is their only explanation — Medium · FORM-01, A11Y-05
Where: src/components/persons/PersonTable.vue:27-30,38-41; packages/ui/src/components/VCheckbox.vue:1-9What: VCheckbox renders a bare <input type="checkbox"> inside an empty <label> — no text, no aria-label, no aria-labelledby. The only description is :title="$t('lowerthird.personTable.onAirToggleTitle')" on the wrapping <span>, which is not associated with the input at all. A screen reader announces "checkbox, not checked" with no name. The title tooltip is also unreachable by keyboard and invisible on touch — the content-management/tooltip pattern is explicit that tooltips must trigger on focus as well as hover, use role="tooltip" and be linked with aria-describedby, and that title is not a substitute for a label. The adjacent column header text ("Church") does not disambiguate: the checkbox does not sort or filter that column, it decides whether the field is burned into the broadcast graphic. Why it matters: Two unlabelled checkboxes that change what viewers see. Nothing on screen states that the toggles affect the output, and the one string that says so is in a tooltip a keyboard or touch operator will never see. Fix: Give VCheckbox a required label/ariaLabel prop (the package contract already takes user text via props) and pass $t('lowerthird.personTable.onAirToggleTitle'). Replace the title attribute with a visible hint or a real focus-triggered tooltip using aria-describedby. The onAirHint paragraph at LowerThird.vue:27-29 should sit next to the toggles, not above the table.
9. Two <h1> elements on the page — Medium · A11Y-04
Where: src/views/Events/Overlay/LowerThird.vue:5-9 and 43-47; packages/ui/src/components/VTitle.vue:2What: VTitle always renders <h1>, regardless of the size prop. The page uses it twice — "Persons" at size="4xl" and "Custom data" at size="2xl" — so the document has two <h1>s and no <h2>. Heading level is being driven by visual size, which VTitle does not model. Why it matters: A screen-reader operator navigating by heading gets two top-level headings and no hierarchy, so the custom-data panel reads as a second page rather than a subsection. Fix: Add an as/level prop to VTitle (defaulting to h1) and pass as="h2" for the panel title. Decoupling level from size fixes this everywhere VTitle is used, not just here.
10. The emergency "Hide" control is roughly 20px tall — Medium · A11Y-02
Where: src/views/Events/Overlay/LowerThird.vue:16-21What: The Hide button inside OnAirBadge is class="text-xs ... px-2 py-0.5" — 12px text on a 16px line box plus 2px vertical padding each side, so ~20px of target height, under the 24×24 CSS px floor (WCAG 2.5.8). It is also a raw <button> with no type, styled with text-faint hover:text-red — low visual weight for its function. Why it matters: This is the fastest "get it off air" control on the page, and it is the smallest touch target on it — on a tablet in a control room, mis-taps land on the badge instead. Fix: Use VButton (which already carries the focus-visible ring) at size="sm" or add min-h-6 min-w-6/py-1.5, and type="button".
11. The Show button is disabled with nothing explaining why — Low · FORM-05
Where: src/views/Events/Overlay/LowerThird.vue:57-66What: :disabled="!customPerson.displayName?.trim()". With an empty display name the button is 40% opacity and pointer-events-none (VButton base classes), so it cannot even be focused or hovered for a tooltip. No message says a display name is required; FORM-04 is also unmet — the requirement is never stated before the user types. Why it matters: An operator filling the panel in a hurry sees a dead button and has to infer which of the four fields is mandatory. Fix: Keep the button enabled, and on click show a role="alert" message naming the missing field; or mark Display Name as required in its label so the constraint is visible up front.
12. No route sets a document title — Low · NAV-03
Where: src/router/routes.ts:90 (name: "lowerthird", no meta.title); no document.title or useTitle handling exists in src/router, src/main.ts or src/App.vue. What: Every route shares the static index.html title. Why it matters: An operator running the lower third, songs and bible overlays in three tabs — a normal setup — cannot tell the tabs apart, and browser history is undifferentiated. This is app-wide, not specific to this feature; recording it here so it is counted once against a route that demonstrably needs it. Fix: Add meta.title per route and a router.afterEach that sets document.title from $t(meta.title).
Unverified
- A11Y-01 (contrast). The live row uses
bg-accentwithtext-white, and the secondary line drops totext-white/80(PersonTable.vue:101);text-faintis used for the on-air hint, the Hide button and the empty-state copy. All need computed values from a rendered page. Not asserted. - A11Y-06 (short viewport / responsive). The page is a
md:grid-cols-3split with a paginated table beside a form panel, andTable.vueswaps to a card list belowsm. Needs a rendered viewport; the card fallback looks deliberate in code. - Starred-list reactivity.
PersonTable.vue:214,222copiesprops.datainto a localresultsref. Whether unstarring a person immediately removes their row depends on whether vuefire'suseCollectionmutates its array in place or replaces it. If it replaces, the row survives until a search re-runs and unstar appears to do nothing. Not asserted — needs a runtime check. - Whether FormKit still emits a
<label for>wrapper when a#labelslot is supplied without alabelprop (see finding 6). The i18n half of that finding stands regardless.
Baseline additions
Proposed, arising from this being the first live-operation surface audited:
- LIVE-01 — On a surface that changes live output, a control must not be replaced in place by its inverse. Show/Hide, Start/Stop and Arm/Disarm pairs keep fixed positions, or the inverse is locked out briefly after the action, so a repeated click cannot undo what was just done.
- LIVE-02 — Live/on-air state is conveyed by more than colour (text,
aria-current, or an icon) and its transitions — including automatic ones such as an auto-hide timer — are announced in a live region and visible before they happen. - MSG-06 — A write that changes shared or broadcast state surfaces its failures to the user. Optimistic feedback (haptics, toasts, audit-log entries) must not fire before the write resolves.
- CONTENT-05 — A translation placeholder (
# TODO: translate, an English value in a non-English locale) is a content defect, not parity. Key-parity checks that pass on placeholder values do not satisfy CONTENT-01.
Cross-project note
- Finding 5 (unbalanced global key bindings) is already confirmed in four other playout views —
Overlay/Songs.vue,Overlay/Bible.vue,queue/QueuePerson.vue,songs/SongSelector.vue— so features #8, #9 and #10 in the queue will hit it too. - Finding 1 (an await with no catch/finally leaving a permanent loading state) is the generic shape of every search/list screen. Worth grepping for in customer-portal, tt-time-tracker and members: any
loading = true…await…loading = falsewithout afinally. - Findings 3, 4 and the LIVE-* rules are playout-specific; the other three projects have no live-output surface.
- Finding 9 (a title component hardcoding
<h1>) is a shared-component defect in@playout/uiand will recur on every playout page. The equivalent question in members is whether@bcc-code/component-library-vuedoes the same — check upstream before filing there. - Finding 7's root cause — CI enforcing key parity but not value translation — applies to customer-portal (en/nb) as written.