Skip to content

[UX] Playout — Screens ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 20 Patterns: user-feedback/empty-states (only — data-display/card-grid was not consulted; the screen "grid" is a four-up selector strip of buttons, and the baseline covers it adequately)

Summary ​

The admin editor (screens) is well-structured — a real EmptyState, a live preview, a copyable URL — but its destructive paths are unguarded: deleting a screen writes straight to Firestore with no confirmation, and creating or renaming a screen silently overwrites an existing one. The bigger problem is the unattended side. Both public routes render through views/LiveScreen.vue, and every failure mode on that page collapses into the same 40px spinner in the corner of a black screen — wrong ?pass=, typo'd screenId, no live event. A venue operator standing in front of a dead projector gets no text, no error, and no way to tell which of the three it is. That is the single most important thing to fix.

Findings ​

1. A wrong or rotated ?pass= leaves the kiosk permanently blank, with no error and no login form — Blocker · MSG-04 ​

Where: src/views/LiveScreen.vue:28-36 (and template :1-8) What: onAuthStateChanged sets isLoggedIn = true when a user exists, and false only when there is no pass query param. When passInQuery is present and signInWithEmailAndPassword rejects, the .catch sets loginError but isLoggedIn stays undefined. The template is v-if="isLoggedIn === false" / v-else, so undefined falls to the v-else branch and renders LiveScreen — which, unauthenticated, has no screens.configuration and shows only the corner spinner (src/components/output/LiveScreen.vue:8-13). The loginError that was set is never rendered, because ScreenLogin is never mounted. Firebase fires no further auth-state change on a failed sign-in, so this state is terminal until someone reloads the page. Why it matters: The tenant service-account password is a single shared secret. The day it is rotated, every physical screen in the venue simultaneously goes to a black page with a small spinner. Nothing on screen says "wrong password", and the sign-in form that would let a technician fix it in place is unreachable. The only recovery is editing the URL on a machine that usually has no keyboard. Fix: In the catch, set isLoggedIn.value = false so ScreenLogin renders with the error visible. Better: always render ScreenLogin when not authenticated regardless of how the attempt failed, and show the error text there.

2. Every other failure on the public screen is also just a spinner — the "Screen Not Found" state is unreachable — High · MSG-03, CONTENT-04 ​

Where: src/components/output/LiveScreen.vue:2-13; src/components/output/Screen.vue:7-20What: Screen.vue already contains a designed 404 / SCREEN NOT FOUND branch for v-if="!screen" — but LiveScreen.vue guards with v-if="screen && screens.configuration", so Screen is never mounted with a missing screen and that branch is dead code. Three distinct conditions therefore render identically as an indefinite spinner: (a) screenId does not exist in the event's screens collection (typo in the URL, or the screen was renamed — see finding 4); (b) the production unit has no currentEvent, so useEvent().module("screens") is null (src/stores/event.store.ts:36-49) and there is simply no event on air; (c) genuine loading. The spinner carries no text at all, so CONTENT-04 ("waiting states name what is being waited on") fails even in the legitimate loading case. Why it matters: These are the three things that actually go wrong in a venue, and the screen distinguishes none of them. An operator cannot tell "you typed the wrong URL" from "no event is live yet" from "still connecting", so the first response is always a reboot. Fix: Drop screen from the v-if and let Screen.vue render its 404 branch; distinguish "no live event on this production unit" with its own message naming the production unit; and give the loading state a line of text ("Connecting to Playout…") after a short delay.

3. Deleting a screen is immediate and unconfirmed — High · MSG-05 ​

Where: src/components/output/ScreenButton.vue:12; src/stores/overlay/screens.store.ts:115What: The context-menu item is @click.stop="emit('delete'), screens.remove(screen)", which calls deleteDoc directly. There is no confirmation dialog, no undo, and no indication of what is lost (background, component binding, language/bible selection and the whole Extra CSS block). Why it matters: The delete item sits directly under "Rename" in a small teleported menu, one row apart. A misclick during a live service permanently destroys the configuration of a screen that a projector is displaying at that moment — the public page then falls into finding 2's spinner. This is the one irreversible action in the feature and it is the least protected. Fix: Route it through the same confirm dialog used elsewhere, naming the screen and stating that its component, background and CSS will be lost. Consider also warning when the screen being deleted is currently on air.

4. Creating or renaming a screen silently overwrites an existing one, and mangles ids containing more than one space — High · MSG-05, FORM-04 ​

Where: src/stores/overlay/screens.store.ts:101-113; src/views/Events/Overlay/Screens.vue:33-44What: Three separate defects in one path:

  • add() uses setDoc(doc(screensCol, id), {...}). setDoc overwrites. Typing the name of an existing screen resets that screen to defaults with no warning.
  • rename() does batch.set(doc(col, options.name), {...}) then deletes the old doc — same overwrite hazard, plus renaming changes the document id, which is the public URL segment. Every physical display pointed at the old id immediately falls into finding 2. Nothing in the rename dialog says so.
  • add() sanitises with newScreenId.toLowerCase().replace(" ", "-") — a string argument, so only the first space is replaced. "front of house left" becomes front-of house left, an id with literal spaces that has to be percent-encoded in the screen URL. rename() applies no sanitisation whatsoever.

The new-screen field (Screens.vue:33-36) states none of these constraints before the user types: no label, no "letters and dashes", no "must be unique". Why it matters: The rename case is the nastiest — it is presented as a cosmetic action but silently invalidates URLs already saved into the browsers of unattended display machines, and nobody discovers it until a service starts. Fix: Use replaceAll(/\s+/g, "-") (or a shared slugify) in both add and rename; check for an existing doc and reject with "A screen called X already exists"; and in the rename dialog warn that the screen's URL will change and the old one will stop working. Show the id rules under the field before typing.

5. The screen card's menu is unreachable by keyboard, and the card has no visible focus — High · A11Y-03 ​

Where: src/components/output/ScreenButton.vue:3, 10-13What: Rename and Delete are bare <a> elements with no href, so they are not in the tab order and do not respond to Enter — the two management actions for a screen are mouse-only. They also sit inside a ContextMenu whose trigger is itself a <button> (packages/ui/src/components/ContextMenu.vue:6-13) nested inside ScreenButton's outer <button>; interactive-inside-interactive is invalid and screen readers announce the card unpredictably. Separately the outer button sets focus:outline-hidden with no focus-visible replacement, so a keyboard user tabbing across the strip sees nothing move (the ring-2 styling on line 4 is bound to selected, not focus). Why it matters: A keyboard-only or screen-reader admin can select a screen but cannot rename or delete one at all, and cannot see where they are in the strip. Fix: Make the menu items <button type="button"> (the .context-menu button styles already exist in ContextMenu.vue:64-83); lift the menu and the rename dialog out of the outer <button> into a sibling wrapper; and replace focus:outline-hidden with the focus-visible:outline-2 outline-accent-ring treatment VButton already uses.

6. The screen URL carries the tenant service-account password in the query string, with no Referrer-Policy or noindex — High · NAV-04 ​

Where: src/views/Events/Overlay/Screens.vue:268-274; firebase.json:16-60What: screenUrl appends "?pass=" + tenant.settings?.serviceAccountPassword and the UI invites the admin to copy it (Screens.vue:74-89). Firebase Hosting sends no Referrer-Policy: no-referrer and no X-Robots-Tag. The page that URL opens then loads an operator-supplied external asset as both a CSS background-image and a <video src> (src/components/output/ScreenBackground.vue:8-21). NAV-04 requires such a page to set noindex and a no-referrer policy by response header; neither exists. Why it matters: The password lands in browser history and bookmarks on shared venue machines, in any proxy or hosting access log that records query strings, and in whatever the admin pastes it into (chat, email, a shared doc). Modern browsers default to strict-origin-when-cross-origin, which limits the cross-origin referrer leak, but that is a browser default the app does not control and does not cover same-origin requests. Because the secret is tenant-wide and shared across every screen, one leak means rotating it — which triggers finding 1 on every display at once. Fix: Add Referrer-Policy: no-referrer and X-Robots-Tag: noindex headers for /:tenant/screens/** in firebase.json. Longer term, issue a per-screen single-purpose token rather than putting the shared account password in a URL that is designed to be copied around. Also note screenUrl silently produces a trailing ?pass= when serviceAccountPassword is unset — a copied URL that cannot work.

7. The kiosk sign-in form fails the basic form rules — Medium · FORM-01, FORM-03, FORM-11 ​

Where: src/components/output/ScreenLogin.vue:9-35What: The <label> on line 11 has no for and does not wrap the input on line 17, which has no id — no programmatic association (FORM-01). It is a raw <input type="password"> with no reveal toggle, where the per-project note calls for PasswordField (FORM-03). The field is not focused on mount even though this is a single-purpose form (FORM-11). And because VButton renders a <button> with no type (packages/ui/src/components/VButton.vue:2-5) it defaults to submit, so pressing Enter in the password field triggers the form's @submit.prevent="" and nothing happens — the button's @click handler never runs. Sign-in is mouse-only. Why it matters: This form is used on venue machines that often have only an on-screen keyboard or a remote. A long shared password typed blind, with no reveal and no Enter-to-submit, on a form that does not focus its own field, is several avoidable minutes at the worst possible moment. Fix: Associate the label; swap in PasswordField; autofocus on mount; and either give the button type="submit" semantics with @submit.prevent="login" on the form, or type="button" plus an explicit keydown handler.

8. Error and success feedback is not announced — Medium · MSG-01 ​

Where: src/components/output/ScreenLogin.vue:25-28; src/views/Events/Overlay/Screens.vue:83-88What: The sign-in error is a bare <span class="text-red text-xs"> with no role="alert", so it appears silently in the DOM. The copy-URL button swaps MdiContentCopy for a green MdiCheck with no role="status" and no text, so "copied" is conveyed by an icon colour change alone. Why it matters: A screen-reader user gets no feedback that their password was rejected, and no confirmation that the URL was copied. Fix: role="alert" on the error span; a visually-hidden role="status" message ("Screen URL copied") alongside the check icon.

9. Roughly half the feature's copy never reaches i18n at all — Medium · CONTENT-01 ​

Where: src/views/Events/Overlay/Screens.vue:35, 103, 115, 126, 146, 152, 156, 171, 203; src/components/output/settings/Background.vue:40-44; src/components/output/ScreenLogin.vue:12-15, 22, 34; src/components/output/RenameScreen.vue:15What: These are hardcoded English literals, not untranslated keys: "New screen…", "Preview", "Background", "Component", "Extra CSS", "Save changes", label="Language", label="Bible", "Transparent"/"Color"/"URL", "Service Account Password", "(You can find it in your tenant settings page)", "Wrong password", "Sign In", "Screen name". Notably screens.component, screens.language and screens.bible already exist in src/locales/en.yml:484-486 and are simply not used. pnpm check:locales cannot catch this class of defect — there is no key to be missing. Why it matters: A French or Norwegian admin configuring a screen gets an English settings panel, and a technician at a French venue gets an English password prompt on the projector. Fix: Route all of the above through $t, reusing the three keys that already exist. Separately, screens.empty.* is present but # TODO: translate in no.yml:243-247 and absent from the translated block in fr.yml — that belongs to the project-level CONTENT-01 finding recorded in the queue, not here.

10. The background type control is not a radio group — Medium · A11Y-03 ​

Where: src/components/output/settings/Background.vue:7-19What: Three <input type="radio"> with no shared name, no fieldset/ legend, and no accessible group label. Without name the browser treats them as three unrelated radios: arrow keys do not move between them, and they are not announced as "1 of 3". Why it matters: Choosing a background type is keyboard-hostile — the user must Tab through each option individually and cannot tell they are alternatives. Fix: Add name="background-type" to all three and wrap them in a fieldset with a legend (or role="radiogroup" + aria-label). Also on this component: the URL field (line 27-32) has no label and no format guidance (FORM-01/FORM-04), and ScreenBackground.vue:11-21 renders both a CSS background-image div and a <video> for type: "url", so an image URL always produces a failed video load and vice versa.

11. Unsaved screen edits are discarded silently when you click another screen — Medium · MSG-05 ​

Where: src/views/Events/Overlay/Screens.vue:252-256, 198-205What: draft is a cloneDoc of the selected screen, and the watch on selectedScreenId replaces it wholesale on selection change. Nothing is persisted until "Save changes" is pressed (the comment on line 184-185 confirms there is no auto-save). Clicking another screen card, or navigating away, throws away the edit — including a long hand-written or AI-generated CSS block — with no prompt and no dirty indicator on the Save button. Why it matters: The Extra CSS box is where the most expensive-to-recreate work in this feature lives, and a single click on the wrong card destroys it. Fix: Track dirtiness against the source screen, mark the Save button (and the card) as having unsaved changes, and confirm before switching selection or leaving the route (onBeforeRouteLeave).

12. Save surfaces the raw exception message — Medium · MSG-02 ​

Where: src/views/Events/Overlay/Screens.vue:278-286What: layout.showRawError(err?.message ?? "Failed to save screen") puts the provider string in front of the user. screens.update runs validateDoc(ScreenSchema, …) (screens.store.ts:119) before writing, so the realistic failure modes here are a Zod issue string and a Firestore permission-denied / unavailable message. Why it matters: "Missing or insufficient permissions." or a Zod path dump tells an admin nothing about what to change. MSG-03 fails alongside it — no next step. Fix: Map the known cases (validation → name the offending field; permission → "You no longer have admin rights on this event"; network → "Couldn't reach Playout — your changes are still here, try again") and fall back to one generic sentence.

13. The first-use empty state offers no action, and its title is not a heading — Medium · A11Y-04 ​

Where: src/views/Events/Overlay/Screens.vue:50-63, 114-116, 126, 170; packages/ui/src/components/EmptyState.vue:8-12What: The user-feedback/empty-states anatomy is state message + supporting detail + primary action + recovery path + visual. The hasScreens === false branch passes no #action slot at all, so a first-time admin gets prose pointing at an unlabelled text field elsewhere on the page. The hasScreens === true branch's only "action" is a decorative animated MdiChevronUp (line 61) with no accessible name and nothing to activate. Separately, EmptyState renders its title as <p> and the three settings sections use <p class="section-label"> styled to look like headings — the page has its <h1> from VTitle (line 8) but no <h2> level beneath it, so the whole editor is one flat block to a screen reader. Why it matters: The "no screens yet" state is exactly the moment a new admin needs a button, and the settings panel is unnavigable by heading. Fix: Put a real "Create your first screen" button in the #action slot that focuses (or is) the new-screen field; make the section labels <h2> and the EmptyState title a configurable heading level; drop the decorative chevron or give it aria-hidden="true" alongside a real control.

14. Add-screen button is disabled while empty, with nothing explaining it — Low · FORM-05 ​

Where: src/views/Events/Overlay/Screens.vue:37-44, 33-36What: :disabled="newScreen.id.length === 0" on an icon-only + button next to a field whose only affordance is the placeholder "New screen…" (no label — FORM-01). The button also has no accessible name (A11Y-05) — it is a bare MdiPlus. Why it matters: A greyed-out + with no message gives the user nothing to act on, and a screen-reader user hears an unnamed disabled button. Fix: Keep it enabled and show "Enter a name for the screen" on submit; add aria-label / visually-hidden text "Add screen"; label the field.

15. Icon-only copy button has no accessible name — Low · A11Y-05 ​

Where: src/views/Events/Overlay/Screens.vue:78-88What: <button type="button"> containing only MdiCheck/MdiContentCopy. Fix: aria-label="$t('common.copyLink')".

16. The screens route sets no document title — Low · NAV-03 ​

Where: src/router/index.ts (no title handling anywhere); src/views/Events/Overlay/Screens.vueWhat: Only src/components/output/LiveScreen.vue:26-27 sets a title (Screen <id> | Playout) — the router has no afterEach title logic, so the admin screens editor keeps whatever title the previous route left. This is repo-wide, not specific to this feature, and is best fixed once in the router rather than per view. Fix: Set the title from to.meta.title (or the existing menu.* i18n keys) in the router's afterEach.

17. The public screen's auth listener is never unsubscribed — Low · NAV-02 ​

Where: src/views/LiveScreen.vue:28-36What: onAuthStateChanged(auth, …) is called at setup and its returned unsubscribe is discarded. (src/router/index.ts:41-44 does this correctly.) Why it matters: Low impact in practice — this component is effectively the whole app on a kiosk — but the callback keeps firing against a disposed component if the route is ever left, and it can re-run the sign-in path. Fix: Capture the unsubscribe and call it in onScopeDispose/onUnmounted.

Unverified ​

  • A11Y-01 (contrast) — needs a rendered page. Several elements are candidates worth checking: text-faint on bg-panel for the URL bar (Screens.vue:74, text-xs), the text-faint empty-state description (EmptyState.vue:13-16, text-xs), the section-label uppercase micro-copy (Screens.vue:296-298), the unselected radio labels (Background.vue:18), and the 404 branch's text-muted on bg-gray-900 (Screen.vue:11). None can be settled from class names.
  • A11Y-06 (short viewport / responsive) — needs a rendered viewport. The editor's lg:sticky lg:top-4 preview (Screens.vue:93) and the fixed padding-top: 56.25% aspect box interact in ways worth checking at ~700px height, as does the max-w-[70vw] truncated URL on mobile.
  • The 1920×1080 fixed-size output (Screen.vue:1-5) scaled by width / 1920 (Screens.vue:261) is presumably correct for the real display, but its behaviour on non-16:9 projectors was not verifiable by reading.
  • I did not exercise a live Firestore instance, so the "no currentEvent → infinite spinner" path in finding 2 is read from event.store.ts:36-49 and productionUnit.store.ts:10-14, not observed.

Baseline additions ​

  • MSG-06 — An unattended or kiosk surface must render its failure and connection states as legible text, not as an indefinite spinner. A page shown on a wall with no keyboard cannot rely on the viewer opening devtools or reloading; loading, "not found", "not authorised" and "offline" must be visually distinct and readable across a room. This feature is the motivating case (findings 1 and 2), and nothing in the current baseline covers it: MSG-03 and CONTENT-04 assume a user who can act on the message. Related: neither public route surfaces network loss at all — Firestore silently serves the last snapshot, so a disconnected screen shows stale content indefinitely with no indicator.
  • FORM-12 — A form with unsaved changes warns before a navigation or selection change discards them. MSG-05 covers destructive actions; it does not obviously cover destructive navigation, which is what finding 11 is.
  • FORM-13 — Enter submits a single-purpose form. Finding 7's Enter-key defect is a genuine baseline gap: FORM-05/06 govern the submit button's state but nothing requires implicit submission to work. The VButton-inside-<form> shape that breaks it (no type attribute, so it defaults to submit and is then swallowed by @submit.prevent="") will recur anywhere @playout/ui is used inside a form.
  • SEC-06 — A shared secret is never placed in a URL that the UI encourages users to copy and distribute. Finding 6 is filed under NAV-04, which covers the header mitigations but not the underlying "don't put it there" rule.

Cross-project note ​

  • MSG-05 (unconfirmed delete) and MSG-02 (raw provider errors) are the most likely to recur everywhere. Worth checking customer-portal, tt-time-tracker and members — all three have list/table features with row-level destructive actions.
  • A11Y-03 (<a> without href as a menu item; removed focus ring) is a @playout/ui ContextMenu call-site pattern; every playout feature using ContextMenu should be checked, and the fix (styles for .context-menu button already exist) is one-line per call site. The equivalent risk in the PrimeVue and Flowbite projects is lower, since their menu components emit real buttons.
  • FORM-13 / the VButton implicit-submit trap is playout-specific (@playout/ui), but the same class of bug appears wherever a component library button is dropped into a native <form>.
  • NAV-04 / secret-in-URL — check playout's Sharing links feature (queue #15, claim-sharing-link) which is the other copy-this-URL surface in this repo. Not obviously applicable to the other three projects.
  • The kiosk findings (1, 2, and the proposed MSG-06) are unique to playout — none of the other three has an unattended display surface.