Appearance
[UX] playout — Bible overlay
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 18 Patterns:forms/selection-input
Summary
The Bible overlay is the operator's live control surface for what an audience sees on the broadcast screen: every write to the bible module document is rendered verbatim by BibleFullscreen on the live-screen route (src/components/output/ScreenComponent.vue:51). That makes the picker's "commit as you go" design the dominant risk here — the verse selector writes to the live document on open and on every intermediate pick, and VDialog binds Esc globally, unconditionally, and permanently, so a single Esc keypress anywhere on the Bible page silently rewrites the on-air reference to a stale (or empty) one. The recent #427 fix correctly restores the reference on Cancel, but it built that restore on top of the same always-live commit, which is what turns a stray Esc into an on-air change. Secondarily: verse selection in the preview is pointer-only, and a large amount of this feature's copy never reaches i18n in a repo with CI-enforced locale parity.
Findings
1. Esc anywhere on the page silently rewrites the on-air verse — Blocker · MSG-05
Where: packages/ui/src/components/VDialog.vue:123 · src/components/bible/SelectVerse.vue:6 · src/components/bible/SelectVerseAlt.vue:6 · src/composables/useVerseSelection.ts:106What: VDialog binds Esc on mount with no showModal guard and no unbind: onMounted(() => Mousetrap.bind("esc", () => { emit("close"); return false; })). Both verse-picker skins are always mounted (only showModal toggles), and both route @close to a handler that ends in cancel() → bible.update(snapshot.value). So Esc pressed while the picker is closed still fires close, and cancel() writes snapshot — which is {book:null, chapter:null, verse:null, verseTo:null} until the picker has been opened at least once, and a stale reference thereafter. Mousetrap accumulates callbacks per combo, so the preview-settings dialog's Esc handler fires too. Why it matters: During a broadcast, an operator who taps Esc (to dismiss a notification, out of habit, or after having just selected a verse in the picker) blanks or reverts the verse currently on air. There is no confirmation, no undo, and no visible cause — the operator sees the audience screen change and nothing in the UI explains it. This is the single highest-consequence defect in the feature. Fix: Guard the binding on state and scope it to the instance: watch(() => props.showModal, open => open ? Mousetrap.bind("esc", …) : Mousetrap.unbind("esc")), plus onUnmounted(() => Mousetrap.unbind("esc")). Better still, use a native <dialog>/@keydown.esc on dialogEl so the handler is inherently scoped to the open dialog. Independently, make cancel() a no-op when the picker was not open.
2. Opening the verse picker immediately blanks the live output — High · MSG-05
Where: src/composables/useVerseSelection.ts:77-91 · src/views/Events/Overlay/Bible.vue:212-220What: hydrate() seeds the working copy from the preset and calls commit() — an unconditional updateDoc to the shared module document. openSelector() supplies an all-null preset, so clicking Select (or pressing Ctrl+F) writes {book:null, chapter:null, verse:null, verseTo:null} to the live document before the operator has picked anything. Each subsequent pick commits live too: selectBook (:50) and selectChapter (:55) each write a partial reference. Why it matters: The audience screen goes empty the moment the operator opens the picker, then flashes a bare book/chapter title, then the verse — the whole selection process is broadcast. The comment at useVerseSelection.ts:16-20 documents this as intentional, and #427's snapshot/cancel() machinery exists solely to undo it — but the blank is still on air for the duration of the pick. Fix: Do not commit from hydrate(), selectBook() or selectChapter(). Keep the working copy local and commit once, in selectVerse(), when the reference is complete. cancel() then becomes a plain local reset and the snapshot/restore machinery can be deleted.
3. Verses and chapter titles in the preview cannot be operated by keyboard — High · A11Y-03
Where: src/components/output/bible/BibleVerse.vue:2-16 · src/components/output/bible/BibleChapterTitle.vue:6-18What: BibleVerse is a <span> carrying @pointerdown/@pointerup/ @pointermove with no tabindex, no role, and no key handlers — the tap-to- select and hold-to-extend interactions the page advertises in its own hints (Bible.vue:85-90, "tap / hold") are pointer-only. BibleChapterTitle likewise puts @click on bare <span>s to open the chapter/verse selector. Why it matters: The preview is the primary way to change the on-air verse and to build a verse range — a keyboard or switch-device operator cannot do either, and a screen reader announces the verses as plain text with no indication they are actionable. There is no keyboard-equivalent path to range extension anywhere else in the feature. Fix: Render each verse as a <button> (or <span role="button" tabindex="0">) with @keydown.enter/@keydown.space for select and a modifier or long-press equivalent (e.g. Shift+Enter) for range extension; give it aria-pressed/aria-current for selected state. Make the chapter-title affordances real <button>s when interactive.
4. No failure path on any write — a failed on-air change is silent — High · MSG-01
Where: src/stores/overlay/bible.store.ts:70,84,93-97 · src/views/Events/Overlay/Bible.vue:162-198What: update(), recall(), updateLanguage(), updateVersion(), toggleBlackout() and toggleShow() all return an unawaited updateDoc promise. Every call site (toggleLowerthird, handleToggleBlackout, selectVerseFromHistory, the picker's commit(), BibleVerse.selectVerse) drops it. There is no role="alert" region, no toast, and no try/catch anywhere in the feature. Why it matters: On venue wifi a rejected write produces nothing at all — the operator sees the optimistic local state, believes the verse is on air, and only finds out from the audience. toggleLowerthird even pushes to bible.current.shown and logs an audit event before the write resolves, so the history panel shows a verse that was never broadcast. Fix: Await the writes in the handlers, catch rejections, and surface one generic human sentence in a role="alert" region on the page (not a console.error). Only append to shown and log the audit event after the write resolves.
5. Hardcoded English throughout, in a repo with CI-enforced locale parity — High · CONTENT-01
Where: src/views/Events/Overlay/Bible.vue:14,19,29,82-90 · src/components/bible/SelectVerse.vue:5,55 · src/components/bible/SelectVerseAlt.vue:5,11 · src/components/bible/BiblePreviewSettingsDialog.vue:5,12,25,39,46,49,61,72,77What: The three primary action buttons ("Select", "Short", "Blackout"), all three KbdHint labels ("select", "new verse", "extend"), both pickers' dialog title ("Select verse"), SelectVerse's "Cancel" button, and the entire preview-settings dialog ("Bible preview settings", "Language", "Version", "Preview size (px)", "Font size (px)", "Width", "Height", "Close", "Save") are literal English in the template. SelectVerseAlt correctly uses $t('common.cancel') for the same button SelectVerse hardcodes. Why it matters: pnpm check:locales only checks key parity, so hardcoded strings pass CI invisibly. A French or Norwegian operator sees a half-translated control surface, with the two most consequential buttons in the feature ("Short" puts the verse on air) in English. Fix: Route all of the above through $t, adding keys to en/fr/no.yml. Separately, note that no.yml:335-359 carries every bible.* and verseSelect.* value as English with # TODO: translate — key parity holds but Norwegian operators get English copy; worth tracking as a separate translation task.
6. Global key bindings and press timers are never cleaned up — Medium · NAV-02
Where: src/views/Events/Overlay/Bible.vue:200-205 · src/components/output/bible/BibleVerse.vue:114-119What: Mousetrap.bind(["command+f","ctrl+f"], …) is registered in onMounted with no matching onUnmounted(() => Mousetrap.unbind(...)). The VDialog Esc bind (finding 1) has the same problem. Separately, BibleVerse's long-press setTimeout and requestAnimationFrame are cleared only by pointerup/pointercancel/pointermove — not on unmount. Why it matters: After the operator navigates away from the Bible view, Ctrl+F/Cmd+F stays hijacked app-wide and the browser's native Find is unreachable on every other page for the rest of the session. If a verse element unmounts mid-press (the chapter block remounts on a book change), the pending selectVerseTo() still fires afterwards and writes a range to the live document. Fix: onUnmounted(() => Mousetrap.unbind(["command+f","ctrl+f"])) in Bible.vue; onUnmounted(cancelPress) in BibleVerse.vue.
7. Many <h1> elements on one page — Medium · A11Y-04
Where: src/components/output/bible/BibleChapterTitle.vue:2 · src/components/output/bible/BibleFullscreen.vue:45 · src/views/Events/Overlay/Bible.vue:3What: VTitle renders <h1> (packages/ui/src/components/VTitle.vue:2). The page header at Bible.vue:3 is one; BibleChapterTitle is another and is rendered inside v-for="chapter in bible.chapters" at BibleFullscreen.vue:41-52, so a book with 50 chapters produces 51 <h1>s. Both picker dialogs add a further <h1> via VTitle in their header slots. Why it matters: A screen-reader user navigating by heading gets dozens of identical top-level headings inside the preview and no usable document outline; the history panel's <h2> (Bible.vue:36) has no meaningful parent. Fix: Give VTitle an as/level prop; render the chapter title as <h2> (or a plain styled <div>, since it is decorative output chrome), and keep one <h1> per page.
8. Verse-search input has no label — Medium · FORM-01
Where: src/components/bible/SelectVerseAlt.vue:23-31What: The reference search <input> has a placeholder and no <label>, aria-label or aria-labelledby. Why it matters: The placeholder disappears on first keystroke, and a screen reader announces an unlabelled text field — the operator has no way to tell what the field accepts once typing starts. Fix: Add a visually-hidden <label for> or :aria-label="$t('verseSelect.searchPlaceholder')", and add role="combobox" + aria-expanded/aria-controls tying it to the book list, per forms/selection-input's accessibility guidance.
9. Option lists are <li>s with a suppressed focus outline and no listbox semantics — Medium · A11Y-03
Where: src/components/bible/SelectVerseAlt.vue:61-72,105-116,141-152,363,374What: Book, chapter and verse options are <li tabindex="0"> with @click and @keydown.enter only. Space does not activate them; there are no role="listbox"/role="option"/aria-selected attributes and no arrow-key navigation. The shared styles do focus:outline-none and substitute focus:bg-hover — the same treatment as hover — so keyboard focus is indistinguishable from a mouse hovering elsewhere. Why it matters: Reaching a verse by keyboard means tabbing through 66 books and then every chapter, with a focus indicator that reads as a hover highlight. Screen readers announce a plain list, not a selection control, and never announce which option is selected. Fix: Mark up the three columns as role="listbox" with role="option" + aria-selected, implement arrow/Home/End roving-tabindex navigation and Space activation, and replace focus:outline-none with a focus-visible ring distinct from the hover state.
10. Icon-only controls have no accessible name — Medium · A11Y-05
Where: src/views/Events/Overlay/Bible.vue:92-99 · src/components/bible/SelectVerse.vue:28-34What: The preview-settings trigger is a VButton whose only content is <MdiCog />, with no aria-label or title. The grid picker's back control is a bare <button> containing <MdiChevronDoubleLeft />. (TabSwitch is fine — it renders a text label alongside its icon.) Why it matters: Both are announced as "button" with no name. The back control is the only way to leave the chapter/verse level in the grid skin, so a screen- reader user is stuck at whatever level they entered. Fix: Add :aria-label="$t('bible.previewSettings')" and :aria-label="$t('common.back')" respectively.
11. "Short" is disabled with no explanation of what unblocks it — Medium · FORM-05
Where: src/views/Events/Overlay/Bible.vue:16-22 · packages/ui/src/components/VButton.vue:18What: :disabled="isNotComplete" greys out the go-to-air button whenever the reference lacks a book, chapter or verse. VButton applies disabled:pointer-events-none, so it is not hoverable, not focusable and carries no tooltip or adjacent message. Why it matters: A new operator sees a dead button and nothing telling them a verse must be picked first — and cannot even focus it to have a screen reader describe the state. Fix: Keep the button enabled and, on activation with an incomplete reference, show a message ("Select a verse first") in the page's alert region — or at minimum render persistent helper text next to the button while isNotComplete is true.
12. Clicking a legacy history entry can do nothing, silently — Medium · MSG-03
Where: src/views/Events/Overlay/Bible.vue:256-264What: selectVerseFromHistoryOld parses old string history entries with /((?:\d+\s)?[A-Za-z]+)\s(\d+):(\d+)/. [A-Za-z]+ excludes accented characters, so French/Norwegian book abbreviations (e.g. "Éph 3:16") never match; both the no-match branch and the unknown-book branch return with no feedback. Why it matters: Mid-broadcast the operator clicks a history entry to recall a verse and nothing happens at all — no error, no change, no indication whether the click registered. The natural response under time pressure is to click again. Fix: Widen the pattern to Unicode letters (\p{L} with the u flag) and match on abbr case-insensitively; on failure, surface a message in the alert region and/or disable the entry rather than rendering an inert button.
13. No loading or empty state for the preview — Low · CONTENT-04
Where: src/components/output/bible/BibleFullscreen.vue:2 · src/stores/overlay/bible.store.ts:31What: The whole preview is behind v-if="bible.current", so while the module document is loading — or if no bible version has been configured — the operator sees an unexplained blank area next to the buttons. The store already exposes currentIsLoading, and this view never reads it (its only consumer is GlobalSearch.vue). showPreviewSettings = ref(!bible.version) (Bible.vue:150) pops the settings dialog open on mount in the unconfigured case with no explanation of why it appeared. Why it matters: "Bible window empty" is exactly the symptom #427 was filed for; without a named waiting state the operator cannot distinguish "still loading", "no version configured" and "something failed". Fix: Render a skeleton/spinner keyed on bible.currentIsLoading with copy naming what is loading, and an explicit empty state ("No bible version selected — choose one in preview settings") that explains the auto-opened dialog.
14. The route sets no document title — Low · NAV-03
Where: src/router/routes.ts:94What: The bible route carries no title meta and no view in this feature calls useTitle; the only useTitle in the app is in src/components/output/LiveScreen.vue:26. Why it matters: An operator running the overlay pages in several tabs (a normal setup — Bible, Songs, Queue) cannot tell them apart from the tab titles, and browser history is a list of identical entries. Fix: Add meta: { title: "menu.bible" } and a global afterEach that sets document.title from it, applied across all routes.
Unverified
- A11Y-01 (contrast). Not assessable from code. Worth a rendered check on the faint-on-panel combinations used heavily here:
text-faintonbg-panelfor the column titles (SelectVerseAlt.vue:351), thetext-[11px] text-fainthistory hint (Bible.vue:49), thetext-[10px]kbdchip (SelectVerseAlt.vue:32), and theopacity-40blackout state (BibleFullscreen.vue:14) which multiplies against whatever it overlays. - A11Y-06 (short viewport / responsive). Both pickers hard-code
height: calc(100dvh - 20rem)(SelectVerse.vue:113,SelectVerseAlt.vue:342) with only amax-width: 480pxoverride. At ~700px viewport height that leaves roughly 380px for three scrolling columns plus the search field; needs a rendered check. The preview's fixed560×720pxcontainer (BibleFullscreen.vue:211-214, overridable via tenant settings) also needs checking against short viewports and the mobile drawer skin. - Whether
useScroll(preview)(Bible.vue:242-243) actually resolves to the scrollable element —previewis a component ref whose root is aTransitionwithv-if. If it does not, operator scroll never syncs to the live screen. Readable code did not settle this; it needs a runtime check.
Baseline additions
- A11Y-07 — State must not be conveyed by colour alone. A control whose label is identical in both states, differing only in colour, is unreadable to a colour-blind operator and invisible to a screen reader. Seen at
src/views/Events/Overlay/Bible.vue:16-22: the go-to-air button is labelled "Short" whether or not the verse is live, with only:intent="isCurrent ? 'danger' : 'success'"distinguishing them. On a live surface this is the difference between "on air" and "not on air". Rule wording: State conveyed by a control's appearance is also conveyed in its accessible name or visible text (e.g.aria-pressed, or a label that changes between "Show" and "Hide"). - A11Y-08 — A modal dialog has a resolvable accessible name.
VDialoghardcodesaria-labelledby="modal-headline"(packages/ui/src/components/VDialog.vue:23), but theidis only emitted by the default header slot (:55-61). Every call site that supplies its own#header— both verse pickers here — leavesaria-labelledbypointing at a non-existent element, so the dialog is announced with no name. Rule wording: Modal dialogs expose an accessible name viaaria-labelledbyresolving to a rendered element, oraria-label; a custom header slot must not break it. - NAV-06 — Global keyboard bindings are scoped to the component's open/mounted lifetime. Findings 1 and 6 are two instances of the same defect class and it is not specific to this feature — an app-wide
Mousetrap.bindinonMountedwith nounbindis a recurring shape in this repo. Rule wording: Any global key binding is registered no earlier than the state that makes it meaningful and removed on unmount; a binding that mutates data must be guarded on that state, not only on visibility.
Cross-project note
Findings 1, 2 and 6 are playout-specific: they arise from Mousetrap plus the Firestore live-document model, neither of which the other three projects use. Finding 1's mechanism does live in @playout/ui's VDialog, so it affects every dialog in playout, not just this feature — Songs (#8), Queue (#10) and Custom components (#11) should be checked for the same "Esc mutates data" shape.
Likely to recur elsewhere:
- A11Y-05 / FORM-01 (unnamed icon-only buttons, placeholder-as-label) — check customer-portal, members and tt-time-tracker; all three hand-roll icon buttons and search fields.
- A11Y-04 (
VTitlehardcoded to<h1>) — a shared-library defect, so it affects every playout view; members should be checked for the equivalent in@bcc-code/component-library-vue, where the fix may belong upstream. - MSG-01 (unhandled write promises with no user-visible failure path) — a plausible pattern anywhere writes are fire-and-forget; worth a targeted grep in customer-portal and tt-time-tracker.
- CONTENT-01 applies as written to playout and customer-portal only; members and tt-time-tracker carry it as a single project-level finding.
Method note
The playout queue file (ux/queue/playout.md) has no "Inventory-level observations" section; the closest is "Changes since the 11f029bb inventory", which flags Overlay/Bible.vue as recently touched but names no known non-defects for this feature. Nothing above is excused by it. One get_pattern call was made (forms/selection-input); advanced/search-results was skipped as the baseline plus the first pattern already covered the search column's behaviour, and the response body is mangled MDX with low marginal value.