Appearance
[UX] Playout — Custom overlay components
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 24 Patterns: forms/rich-text-editor
Summary
The custom-overlay editor is one of the most carefully built views in the repo — auto-save is debounced and diffed, the off-stage warning, the checkerboard preview and the "live preview mirrors air" wiring are all thoughtful, and the comments show the failure modes were reasoned about. The problem is underneath it: an overlay's user-editable name is the join key between the tenant-wide template, the per-event instance (data + on-air flag) and any screen displaying it, so renaming an overlay in the editor's name field silently orphans its data, its on-air state and every screen assigned to it — mid-broadcast, the screen just goes blank. Second theme: not one of the ~10 async writes in this view has an error path, in a repo that ships useAsyncAction precisely so "failures never disappear silently".
Sibling-audit cross-references. This view does not repeat the two recurring playout defects: it binds no Mousetrap shortcuts (so no unbalanced ctrl+f bind — that is Layout.vue and the Songs/LowerThird/Bible views), and its "Saved" indicator flips only after the write resolves (Components.vue:701-703), so the "success feedback before the write" shape is absent here. It does repeat the proposed LIVE-03 shape — Show is replaced in place by Hide at Components.vue:158-172, same position, so a double-tap reverses the action just taken. NAV-03 (no document title) and the project CONTENT-01 problem fail here as they do everywhere — see PROJECT-LEVEL.md; feature-specific i18n gaps are noted in findings 10 and 11.
Findings
1. Renaming an overlay silently breaks its data, its on-air state and every screen showing it — Blocker · MSG-05 (+ proposed LIVE-KEY-BY-ID)
Where: src/views/Events/Overlay/Components.vue:140-143 (the name field), src/stores/overlay/components.store.ts:26-38, src/components/output/lower-third/AbstractComponent.vue:85-87, src/views/Events/Overlay/Screens.vue:241What: The overlay name is a free-text VInput that auto-saves 600 ms after the last keystroke. Everything else joins on that string: the per-event instance map is keyed by name (instances[name], so dataFor/isShown/setShow all take a name), the live renderer resolves the design with components.list.find(c => c.name == props.component), and a screen stores its overlay as the name string (Screens.vue:241 builds the option ids from components.list.map(c => c.name)). Nothing migrates the old key. Renaming therefore: (a) drops the event's data for that overlay — the Data tab empties; (b) strands the show: true flag under the old name, so the editor's button flips back to "Show on screen" while the instance still claims it is up; and (c) breaks every screen pointing at the old name — find() returns undefined and the screen renders nothing. Why it matters: This is a live-broadcast surface. Correcting a typo in an overlay's name while it is on air blanks the output, with no warning, no confirmation and no undo; the operator's only clue is that the Hide button turned back into Show. Fix: Key instances and Screen.component by the document id, not the name (the ids already exist — selectById, component.id). Failing that, make rename an explicit action that rewrites the instance key and every referencing screen in one transaction, refuse it while isShown(name) is true, and confirm first. Severity call: Blocker rather than High under the normalisation rule — this is not a hardening gap or friction, it is an ordinary control (an editable name field, presented like any other) doing the wrong thing to live output.
2. Rename allows duplicate names, and duplicates share one instance — High · FORM-04 (+ proposed LIVE-KEY-BY-ID)
Where: src/views/Events/Overlay/Components.vue:140-143; compare :806-839, 884-905 where every create path calls uniqueComponentNameWhat: Create, duplicate, start-from-template and AI-generate all de-duplicate names via uniqueComponentName (starters.ts:199-205). The rename field applies no such check and no validation. Two overlays may therefore carry the same name, at which point they share a single per-event instance (data and show), and list.find(c => c.name == …) silently resolves to whichever happens to be first in the collection. Why it matters: Showing overlay "Sponsor" puts a different overlay's design on screen, and editing one's data edits the other's. The constraint that the code clearly depends on is never stated to the user (FORM-04: constraints are shown before the user violates them, not after). Fix: Validate on rename against existingNames(), show the conflict inline, and refuse to persist a colliding name.
3. Auto-hide is a browser timer that dies when you leave the page — High · NAV-02 (+ proposed LIVE-TIMER-SERVER)
Where: src/views/Events/Overlay/Components.vue:918-944What: "Auto-hide after" arms setTimeout(… setShow(name, false), seconds*1000) in the view, and onBeforeUnmount(clearAutoHide) cancels it. Cleanup on unmount satisfies NAV-02 literally, but it also means the promised hide never happens if the operator navigates to another overlay tab, reloads, or closes the laptop — the overlay stays on air indefinitely. Why it matters: The copy promises "Automatically remove the overlay from screen after a set time" (en.yml, abstract-component.autoHide.hint). An operator who sets 10 s, shows a sponsor bar and switches to the Songs tab leaves that bar on the broadcast permanently, believing it self-dismissed. Fix: Persist the deadline on the instance (e.g. hideAt) and let the output screen or a scheduled function enforce it; the editor timer becomes a display detail rather than the mechanism.
4. No write in this view has an error path — a failed save is invisible — High · MSG-01, MSG-02 (MSG-06 family)
Where: src/views/Events/Overlay/Components.vue:693-704 (auto-save), :806-815, 817-821, 823-839, 857-882, 884-905, 908-912, 924-942; src/composables/useAsyncAction.ts:23-44; src/composables/useValidation.ts:18-25What: Every call — components.add, update, remove, setData, setShow — is awaited with no try/catch. In the auto-save timer, saving.value = false and lastSaved = snapshot sit after the await, so a rejected updateDoc (offline, permission-denied) produces an unhandled rejection, pins the indicator on "Saving…" forever and never retries until the next keystroke. validate() (useValidation.ts:23) fails loudly in the other direction: it calls layout.showRawError(error.message), putting a developer string — Validation failed on "layout.scale": Too big: expected number to be <=5 — in front of the user (MSG-02). Show/Hide failing on air says nothing at all. The repo already has the right tool: useAsyncAction, documented as "Wrap a user-initiated async action so failures never disappear silently", used in 24 other files, none of them this one. Why it matters: This editor has no Save button by design. The amber dot is the only signal, and it cannot distinguish "still writing" from "this write failed and your work is not saved". An operator can author an overlay for ten minutes against a revoked permission and lose all of it without a single message. Fix: Route each write through useAsyncAction with an errorKey; on auto-save failure show a retry affordance next to the status line instead of a permanent "Saving…".
5. AI "Refine" overwrites the template and CSS with no preview and no undo — High · MSG-05
Where: src/views/Events/Overlay/Components.vue:857-866; contrast src/components/overlay-templates/AiStyleAssistant.vue:49-71What: handleAiOverlay assigns template, css, enterAnimation and leaveAnimation straight onto the selected overlay; the deep watcher then persists it 600 ms later. There is no diff, no preview, no confirmation and no undo — the previous template is gone from Firestore. The sibling AI control in the same feature gets this right: AiStyleAssistant renders the generated CSS in a <pre> with explicit Apply / Discard buttons. Why it matters: Refining a hand-written overlay is exactly the case where the user has something worth losing, and the dialog's own disclaimer (aiOverlay.disclaimer: "verify the template, fields and style before going on air") asks the user to review output they are never shown before it lands. Fix: Use the AiStyleAssistant shape — show the generated template/CSS, then Apply or Discard. At minimum, confirm with "this replaces your current template".
6. Switching overlays cancels a pending save, then reports "All changes saved" — Medium · MSG-01 (MSG-06 family; proposed FORM-AUTOSAVE-FLUSH)
Where: src/views/Events/Overlay/Components.vue:682-686What: The id watcher runs clearTimeout(saveTimer), sets saving = false and re-baselines lastSaved to the newly selected overlay. Any edit made in the 600 ms before clicking a different overlay in the list is discarded, unwritten — and because saving is now false, the status line renders the green check and abstract-component.saved = "All changes saved". Why it matters: Silent loss of the last edit, actively contradicted by the one indicator the user has. Pasting a template and immediately clicking the next overlay is a realistic sequence. Fix: Flush the pending write (await components.update on the outgoing overlay) before re-baselining, and add a beforeunload guard while a save is pending.
7. The delete confirmation names nothing and hides its blast radius — Medium · MSG-05
Where: src/views/Events/Overlay/Components.vue:584-588 and :908-912; src/components/common/Confirm.vue:3-8What: <Confirm> is rendered with no title and no subtitle, so it falls back to "Are you sure?" / "Confirm". It does not name the overlay, does not say that the template is tenant-wide (deleting it during one event removes it from every event, not just this one), and does not warn when the overlay is currently on air. handleDelete also leaves the per-event instance behind: the map keeps { data, show: true } under that name. Why it matters: Two distinct harms. The operator believes they are removing an overlay from this event and removes it from the tenant. And because the stale instance survives, a later overlay created with the same name inherits the old data and its show: true — appearing on the broadcast the instant it is created. Fix: Pass :title and :subtitle naming the overlay and the tenant-wide scope; block or warn while isShown; delete the instance entry alongside the template doc.
8. Controls are labelled by placeholder or by a <label> bound to nothing — Medium · FORM-01
Where: src/views/Events/Overlay/Components.vue:39-44 (search), :84-90 (new name), :137-143 (overlay name — <label> with no for, VInput with no id), :315-332 (Position, plus <span>X</span> / <span>Y</span> as the only per-input labels), :353-363 (Scale), :366-378 (Anchor), :180-182 (Render mode), :500-508 (animation); src/components/overlay-templates/AiOverlayDialog.vue:17-26; src/components/overlay-templates/OverlayBindingsGrid.vue:24-28What: Not one input in the feature has a programmatically associated label. The visible <label> elements carry neither for nor a wrapping relationship (VInput — packages/ui/src/components/VInput.vue:2-6 — forwards attrs, so an id/aria-label would work but none is passed), and search, new-name, the binding fields and the AI prompt rely on placeholders alone. Why it matters: A screen-reader user tabbing into the X field hears "edit, blank"; the placeholder-only fields lose their label the moment text is entered. The consulted pattern names this directly ("separating labels, hints and errors" — keep them connected with for/aria-describedby). Fix: Give each input an id and point the label's for at it; use aria-label for the X/Y spans and the search field.
9. Save status and every error block are silent to assistive tech — Medium · MSG-01
Where: src/views/Events/Overlay/Components.vue:236-249 (Saving…/Saved), :257-259 (parse error); packages/ui/src/components/VAlert.vue:2-9; src/components/overlay-templates/OverlayFilePicker.vue:51-61; AiOverlayDialog.vue:27-32; AiStyleAssistant.vue:39-44What: The auto-save status is a plain <p> with no aria-live — and it is the only save feedback in a view with no Save button. VAlert is a bare <div> with no role="alert" (verified from in-repo source at packages/ui, so the node_modules caveat does not apply here), and it is what renders both the template parse error and upload failures. The AI dialogs' errors are undecorated <p> elements. Why it matters: A screen-reader user gets no announcement that their work saved, that the template stopped rendering, or that an upload failed (WCAG 4.1.3). Fix: aria-live="polite" on the status line; role="alert" inside VAlert and on the two AI error paragraphs.
10. Anchor picker: 20 px targets named only by a raw English enum — Medium · A11Y-02, A11Y-05
Where: src/views/Events/Overlay/Components.vue:368-378; packages/schemas/src/component.schema.ts:22-26What: The nine anchor buttons are size-5 (20 × 20 CSS px, below the 24 px floor), have no text content, and their only accessible name is :title="a" — the raw schema value "top-left", "center-right" …, which never passes through i18n. Why it matters: Sub-target-size controls in a 3 × 3 cluster are hard to hit accurately (WCAG 2.5.8), and French/Norwegian users get English identifiers as the control's only name. Fix: Raise to at least 24 px (or add padding while keeping the visual swatch small), and give each button a translated aria-label.
11. Upload failures show the raw Firebase message, or an English literal — Medium · MSG-02, CONTENT-01
Where: src/components/overlay-templates/useOverlayFileEditor.ts:60-68, rendered by OverlayFilePicker.vue:59-61What: error.value = e instanceof Error ? e.message : "Upload failed". The first branch puts SDK prose in the UI ("Firebase Storage: User does not have permission to access … (storage/unauthorized)"); the second is a hardcoded English string in a repo with CI-enforced locale parity. Why it matters: Neither branch tells the user what to do next (MSG-03 also implicated), and neither is translated. Fix: Map the known storage/* codes to abstract-component.lottie.* / .rive.* keys with a single generic fallback.
12. On-air state in the overlay list is colour-only, and the rows are half-semantic — Medium · A11Y-03 (proposed LIVE-02)
Where: src/views/Events/Overlay/Components.vue:48-63What: The on-air marker is a 6 px dot switching between bg-green and bg-faint/50, with a :title set only when shown, on a <span> — no text, no aria-label, no non-colour differentiator. The rows themselves are <li> with tabindex="0" and @keydown.enter but no role="option"/button, no Space handling, and they wrap a nested <button> (Duplicate) inside a focusable element. (Focus visibility itself is fine — packages/ui/src/styles/base.css:20-24 gives a global :focus-visible ring.) Why it matters: On a broadcast surface, "which overlay is currently on air" is the single most important state in the list and it is conveyed to colour-blind and screen-reader users not at all. Fix: Add a visible "On screen" text/badge alongside the dot and an aria-label; give the rows role="option" within a role="listbox" and handle Space as well as Enter.
13. A disabled feature flag lands the user on 404 with no explanation — Medium · MSG-04
Where: src/router/index.ts:81-85, src/router/routes.ts:92; src/composables/useFeatureFlags.ts:9-13What: custom is gated on meta.featureFlag: "components". A tenant without the flag is redirected to not-found — a generic 404 that neither names the feature nor says it is unavailable for this tenant nor offers a way to ask for it. Separately, hasFeatureAsync has no catch: a getDoc failure rejects inside beforeEach, aborting the navigation with nothing rendered and no message. Why it matters: A user following a colleague's link, or a tenant admin who just had the flag toggled off, sees "page not found" for a page that exists — a dead end with no route out (MSG-04). The error path is worse: the click simply does nothing. Fix: Redirect to a "not enabled for this tenant" state (or unauthorized, which already exists) naming the feature and how to enable it; wrap the flag lookup so a lookup failure surfaces an error rather than a dropped navigation.
14. Two <h1> elements, and no headings below them — Low · A11Y-04
Where: src/views/Events/Overlay/Components.vue:6-9 and :102-105; packages/ui/src/components/VTitle.vue:2What: VTitle always renders <h1> regardless of size, so the empty state puts a second <h1> ("Create a custom overlay") on the page, and each open VDialog adds another. Meanwhile the real section headings — the list panel title, "Position & size", "Animation", the Advanced sections — are <p> with uppercase styling, so the page has no heading outline at all. Fix: Give VTitle an as/level prop (fix belongs in @playout/ui); use <h2>/<h3> for the section labels.
15. Tabs and the render-mode switch expose selection through styling only — Low · A11Y-03
Where: src/views/Events/Overlay/Components.vue:184-205 (render mode), :214-232 (Preview/Data/Template tabs) What: Both are plain <button> groups. The active one differs by background and border colour only — no aria-pressed, no role="tab"/aria-selected/ aria-controls, and the panels (v-show, :253/:385/:412/:432/:449) carry no role="tabpanel". Why it matters: Keyboard operable, but a screen-reader user cannot tell which tab or render mode is active — the pattern's own guidance is "do not rely on colour alone to convey … selection state". Fix: role="tablist"/tab/tabpanel with aria-selected on the tabs; aria-pressed on the render-mode segments.
16. Submit buttons disabled while empty and during submission — Low · FORM-05, FORM-06
Where: src/views/Events/Overlay/Components.vue:91-96 (Add); AiOverlayDialog.vue:48-51; AiStyleAssistant.vue:23-27; packages/ui/src/components/VButton.vue:4 (v-bind="{ disabled }") and :17 (disabled:pointer-events-none) What: Add is disabled until a name is typed, with no message saying why. Both AI Generate buttons are disabled while loading, and VButton binds the native disabled attribute — so pressing Generate disables the element under the user's focus and drops focus to <body>, exactly the FORM-06 case. Fix: Keep the buttons enabled; guard inside the handler (both handlers already early-return on empty/loading) and use aria-busy for the pending state.
Unverified
- A11Y-01 (contrast) — needs a rendered page. Candidates worth measuring:
text-fainthelp text attext-xs/text-[11px], thetext-[10px]Beta badges (:197-203), the amber off-stage warning onbg-bg(:345-351), and the white/60 and white/70 overlay captions on the checkerboard (:272-281). - A11Y-06 (short viewport / responsive) — the editor is a 4-column
lg:grid-cols-4with a fixedh-112code editor and a 56.25%-padding preview stage; unclear how it behaves at ~700 px height or on a tablet, and the drag layer's pointer capture on touch was not exercised. - Whether Firestore security rules independently reject a rename or a duplicate name (finding 1/2 assume they do not; only the client was read).
- Rive and Lottie runtime behaviour (
RiveEditor.vue,LottieLayer) was not exercised; both tabs are labelled Beta.
Baseline additions
Descriptive IDs with definitions — the orchestrator should renumber and reconcile these against the existing MSG-06 / LIVE-* collisions noted in PROJECT-LEVEL.md.
- LIVE-KEY-BY-ID — records that drive live output are joined by an immutable id, never by a user-editable display name; if a name is the key, renaming is an explicit, migrating, confirmed action.
- LIVE-TIMER-SERVER — an automatic state change promised to the user (auto-hide, auto-advance) must not depend on the authoring tab staying open on that route.
- FORM-AUTOSAVE-FLUSH — a debounced auto-save flushes before the edited record is deselected, unloaded or navigated away from, and the status indicator never reports "saved" for a write that was cancelled or rejected. (Merge with the MSG-06 family: "a failed or cancelled write is never rendered as success".)
- MSG-05 extension — a destructive confirmation names the object being destroyed and its scope (this event vs. every event), and refuses or warns when the object is currently on air.
Also confirms the already-proposed LIVE-03 (inverse control replacing its counterpart in place — Components.vue:158-172) and LIVE-02 (live state by colour alone — finding 12).
Cross-project note
- Finding 4 (writes with no error path, autosave that cannot report failure) is the playout instance of the MSG-06 family confirmed in all three other projects; the twist here is that the project has the right helper (
useAsyncAction) and this view simply does not use it. Worth checking the other playout overlay views for the same omission. - Finding 8 (placeholder-as-label / detached
<label>) — expect the same in customer-portal and tt-time-tracker wherever inputs are hand-rolled rather than taken from PrimeVue'sFloatLabel. - Finding 7 (unnamed destructive confirm) is likely generic:
Confirm.vuedefaults to "Are you sure?" and every caller that omitstitleinherits it — a repo-wide grep for<Confirmwithout:titlewould size it. - Findings 1, 2, 3 and 12 are playout-specific: no other project in the programme has an on-air state.