Appearance
[UX] playout — Keyboard shortcuts
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 12 Patterns: forms/form-validation
Summary
The screen is well-built for a first cut: capture is scoped to an explicit "listening" state, the window listener is registered through useEventListener so it is torn down on unmount (unlike the Mousetrap binds elsewhere in the app), duplicate keys are rejected before save, the schema enforces the same rules server-side, every action keeps at least one key, and all of this feature's copy is genuinely translated into fr and no — no # TODO: translate markers in its block. The answers to the four questions the assignment poses are: collisions within these two actions are detected and surfaced, collisions with anything else are not; reset exists and works; and the capture control itself is not keyboard- or screen-reader-operable.
The single most important defect: pressing a modifier combination during capture silently binds the bare key. A user who presses Ctrl+N intending a safe, deliberate chord ends up with plain N advancing the live output, with nothing in the UI saying the modifier was dropped.
Findings
1. A modifier combination is silently reduced to its bare key — High · FORM-04
Where: src/views/Tenant/Settings/Keybinds.vue:137-146What: The capture handler skips Shift/Control/Alt/Meta keydowns ("so holding e.g. Shift while reaching for the intended key doesn't bind Shift itself") and then binds e.key with the modifier state discarded. Press Ctrl+N and the listener sees the Control keydown (skipped), then the n keydown, and stores "n". Keybinds is z.array(z.string()) of KeyboardEvent.key names (packages/schemas/src/settings.schema.ts:29-33), so chords cannot be represented at all — but nothing tells the user that. The confirmation chip renders N, which a user in a hurry reads as their chord having been accepted. Why it matters: The resulting binding is strictly more aggressive than what the user asked for. useSongKeybinds matches on e.key document-wide (src/composables/useSongKeybinds.ts:49-56), so a bare N now advances the verse on air from anywhere outside an editable field — the exact accident the user was trying to avoid by reaching for a modifier. On a live broadcast surface that is an on-air content change nobody intended. Fix: Either capture the whole chord (record ctrlKey/metaKey/altKey/ shiftKey alongside key, extend the schema and formatKeyLabel, and match on the full combination in useSongKeybinds), or — the smaller change — reject a modified keystroke outright with a message ("Shortcuts are single keys — press a key without Ctrl, Alt or Cmd"), and say so in the section description before the user tries.
2. Starting a capture drops focus to <body> and announces nothing — High · A11Y-03
Where: src/views/Tenant/Settings/Keybinds.vue:51-67What: The "Add key" VButton and the "Press a key…" chip are v-if/v-else siblings. Activating the button destroys the element that has focus, so focus falls to <body>; the replacement is a <span>, not focusable, with no aria-live, no role="status" and no relationship to the button that was just pressed. When the key is recorded, capturing resets and the button re-renders, but nothing restores focus to it (there is no nextTick/focus() anywhere in the file). Separately, all three "Add key" buttons carry the identical accessible name "Add key" with no reference to the row's action. Why it matters: A keyboard-only user activates "Add key", is silently dropped to the top of the document, and must tab all the way back after each key. A screen-reader user gets no announcement that the app is now listening, no announcement when a key is accepted, and — because the page swallows every keydown while capturing — their own single-letter browse-mode navigation keys become the thing being bound. The pattern's accessibility guidance is explicit here: "keep focus order logical when the pattern opens, updates, or reveals additional UI" and "announce state changes … with the right politeness". Fix: Keep one persistent <button> per row that toggles capture (aria-pressed, label switching between "Add key" and "Press a key… (Esc to cancel)") instead of swapping two elements, give it :aria-label="$t('…addKeyFor', { action })", and put the listening/result text in an aria-live="polite" region tied to the row via aria-describedby.
3. A capture in progress has no cancel control, and Tab produces an error instead of leaving — Medium · A11Y-03
Where: src/views/Tenant/Settings/Keybinds.vue:137-146, :56What: While capturing, every keydown is preventDefault()ed and stopPropagation()ed. Escape cancels, but that is documented only in a code comment — the visible copy is "Press a key…" / "Listening… press a key" (src/locales/en.yml:302-303), which never mentions Esc. Tab is preventDefault()ed, ends the capture, and then falls into addKey, which rejects it as reserved — so the user's attempt to move focus on produces the error "Tab" is reserved and cannot be bound. and no focus movement. A mouse-only user who clicked "Add key" by accident has no visible control at all to get out of the state. Why it matters: This is the classic capture-control trap: the control needs the keys the user needs to escape it. The only two exits — Escape, or clicking another row's button — are both undiscoverable, and one of the natural attempts is punished with an error message. Fix: Render the capture state as a real button labelled with its escape hatch ("Press a key… Esc to cancel"), let Tab cancel the capture and pass through rather than being treated as a candidate binding, and cancel on click-outside / blur.
4. Conflict detection covers only these two actions, while the copy promises more — Medium · MSG-03
Where: src/views/Tenant/Settings/Keybinds.vue:148-157, packages/schemas/src/settings.schema.ts:25What: addKey rejects a key if it is in RESERVED_KEYS (Escape, Enter, Tab, Space) or already used by one of the two KEYBIND_ACTIONS. Nothing else is checked. There is no check against browser defaults (F5, F11, F12, / for Firefox quick-find, '), against OS-level keys, or against the app's own shortcuts — Layout.vue:97 binds ctrl+k and five views bind ctrl+f via Mousetrap. Because bound keys are preventDefault()ed on the overlay screens, a user who binds F5 or / loses that browser behaviour on those screens with no warning. Meanwhile the footer copy reads "Conflicts are flagged before saving" (settings.keybinds.conflictHint), which claims a completeness the check does not have. Why it matters: The user is told conflicts are handled, so they stop thinking about it; they discover the collision live, mid-event, which is the worst possible place. Placed at Medium rather than High because it takes a deliberately unusual key choice to hit, and the outcome is a suppressed browser shortcut rather than lost data. Fix: Keep a BROWSER_RESERVED list (function keys, /, ') and warn — not block — when one is chosen; reword conflictHint to what is actually checked ("Keys already used by another action are rejected").
5. The rules the capture enforces are only revealed by breaking them — Medium · FORM-04
Where: src/views/Tenant/Settings/Keybinds.vue:7-9, :87-89What: The description says "Customize which keys control songs during an event. Each action can be triggered by several keys." Nowhere before the first attempt does the UI say that Esc/Enter/Tab/Space are reserved, that modifiers are not supported, or that the last remaining key of an action cannot be removed (removeKey silently no-ops at :162-163 and the remove affordance is simply absent at :41-42). Every one of those constraints surfaces only as a rejection after the user has already committed a keystroke. Why it matters: The pattern names this directly — constraints discovered only on violation make the control feel punitive, and the user cannot form a correct model of what a valid shortcut is. The disappearing remove button is worse than an error: nothing at all happens, so the user assumes the UI is broken. Fix: State the constraints in the section description or per-row hint, and give the last remaining key a disabled remove control with a tooltip ("Each action needs at least one key") instead of removing the affordance.
6. A save can report success without writing anything — Medium · MSG-06 (proposed; see PROJECT-LEVEL.md collision note)
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:18-22, src/stores/tenant.store.ts:33-35What: saveSettings returns early — resolving normally — when settingsRef.value or settings.value is falsy. save() awaits it and then unconditionally clears isDirty and calls layout.showSuccess("settings"). The Keybinds view renders happily before the settings document resolves (bindings falls back to createDefaultKeybinds() at Keybinds.vue:116, by design for pre-feature tenants), so a user can edit and save inside that window and be told it worked. Why it matters: The save bar disappears, a success toast appears, and the shortcuts are unchanged. The user finds out at the next event. Fix: Have saveSettings reject (or return a result) when it cannot write, and gate showSuccess/isDirty = false on a confirmed write. This lives in the shared settings composable and affects General / Locations / Overlay / Sharing links too — worth one issue against useSettingsDirty, cross-referenced here.
7. A rejected save surfaces a raw developer string, in English — Medium · MSG-02
Where: src/composables/useValidation.ts:18-25, reached from src/stores/tenant.store.ts:36What: validate() calls layout.showRawError(error.message), where the message is built as Validation failed on "<path>": <zod message> — for this feature that renders as Validation failed on "keybinds": Key "a" is bound more than once (songNext and songPrev). The zod messages in settings.schema.ts:39-41 are hardcoded English and never pass through i18n. The same call path also means a rejected updateDoc (offline, permissions) leaves save() rejecting with nothing caught in useSettingsDirty, so the user sees no error at all — just a save bar that stays. Why it matters: A French or Norwegian operator gets an English string naming an internal field path; an offline operator gets silence. Fix: Catch in save(), map to one localised sentence, and keep the raw message to the console.
8. The unsaved-changes guard is a hardcoded English window.confirm — Medium · CONTENT-01
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:29-34What: window.confirm("You have unsaved changes. Leave without saving?") — a literal, not an i18n key, in a repo where locale parity is CI-enforced. This is distinct from the project-level "keys present but untranslated" finding: the string never enters the locale files, so pnpm check:locales cannot see it. This feature's own copy, by contrast, is fully translated in fr.yml and no.yml — no # TODO: translate markers in the keybinds block. Why it matters: The guard fires on the one path where the user is about to lose work, and it fires in the wrong language. Fix: Route it through t(); ideally replace the native dialog with the repo's ConfirmDialog.
9. The remove-key control is a 16 px target revealed only on hover — Medium · A11Y-02
Where: src/views/Tenant/Settings/Keybinds.vue:41-49What: The × button is size-4 (16×16 CSS px) with no padding, against the 24×24 floor of WCAG 2.5.8. On hover-capable devices it is opacity-0 until the key chip is hovered (it does correctly reveal on focus-visible, so it is not keyboard-invisible). Why it matters: Playout is operated from tablets in production contexts; a 16 px target at the corner of a key chip is a miss-prone control for removing a binding. The hover-gated reveal also means a mouse user may never learn that removal is possible. Fix: Give the button a 24 px hit area (padding or ::before expansion) and keep it at reduced opacity rather than fully hidden.
10. Reset to defaults discards every customisation without confirming — Low · MSG-05
Where: src/views/Tenant/Settings/Keybinds.vue:168-173What: One click replaces the whole keybinds draft with createDefaultKeybinds(). No confirmation, no undo, and no indication of what changed. It is recoverable — the change only lands on Save, and navigating away prompts — which is why this is Low rather than Medium. Fix: Confirm ("This will replace your custom shortcuts with the defaults"), or leave it unconfirmed but make the save bar say what will be written.
Project-level rules this feature also fails
- NAV-03 — no per-route document title mechanism exists at all. See PROJECT-LEVEL.md; not re-filed here.
- A11Y-04 —
VTitlehardcodes<h1>, so this section's heading is a second<h1>alongside the settings shell's. Upstream@playout/uiissue; see PROJECT-LEVEL.md. - The Mousetrap "bound on mount, never unbound /
ctrl+fbound twice" defect is the reason this feature exists and is the thing it does not fix — the keys this screen configures go throughuseSongKeybinds/onKeyStroke, which is correctly scoped, but the Mousetrap binds inLowerThird,Songs,SongSelector,BibleandQueueare untouched and remain unconfigurable. Worth noting in the PROJECT-LEVEL issue that #425 did not supersede it.
Passes worth recording
NAV-02— the capture listener usesuseEventListener, which unbinds on unmount. The one keyboard listener added by this feature is the one in the codebase that is cleaned up correctly.CONTENT-01— thesettings.keybindsblock is fully and idiomatically translated infr.ymlandno.yml.MSG-01— the error paragraph carriesrole="alert"(Keybinds.vue:71-77). Only the capture state change is unannounced (finding 2).- Client and server share one rule set:
addKey's checks mirrorKeybindsSchema.superRefine, so a hand-crafted write is rejected too. useSongKeybinds.isEditableTargetprevents bound keys firing while the user types in the song search box — the obvious footgun of single-key shortcuts is already handled.
Unverified
- A11Y-01 (contrast) —
text-accentonbg-accent-subtlefor the listening chip,text-faintfor the hints, andtext-redfor the error all need computed colour values. Not assessable from class names. - A11Y-06 (short viewport / responsive) — the row switches from column to row at
sm:, andSectionSaveBarissticky bottom-[calc(var(--tabbar-h)+1rem)]. Whether the save bar overlaps the last row on a short viewport needs a rendered page. - Whether
preventDefault()actually suppressesF5/F11during capture and on the overlay screens is browser-dependent; finding 4 does not rest on it. VButton's focus behaviour was read from source (packages/ui/src/components/VButton.vue) — that package is in-repo, so this is verified, not inferred.node_moduleswas not installed, but nothing in this draft depends on it.
Baseline additions
- KEY-01 — a shortcut-capture control must remain operable with the keyboard it is capturing. Capture must be startable and cancellable without focus loss, must state its escape key in visible copy, and must not consume the focus-traversal key as a candidate binding.
- KEY-02 — a captured shortcut must be stored as what the user pressed. Modifier state is either recorded or the keystroke is rejected; silently dropping modifiers and binding the bare key is never acceptable.
- KEY-03 — configurable shortcuts are validated against every key the app or the browser already claims, not only against the other configurable actions, and the copy describing the check must match its actual scope.
(These three are the LIVE-*-style family gap: BASELINE.md has no rules for keyboard-configuration surfaces, and none of FORM/MSG/NAV/A11Y fits finding 1 or 3 cleanly. Findings 1 and 5 are filed under FORM-04 as the closest existing rule; if KEY-02 is adopted, finding 1 should be recited against it.)
Cross-project note
Findings 1–5 are specific to playout — it is the only project in the programme with a user-configurable shortcut surface. Findings 6 and 7 are the playout instance of the cross-project MSG-06 theme (non-throwing SDK path treated as success) already confirmed in all four projects, and of MSG-02 (raw SDK/validator strings reaching users) confirmed in customer-portal and members. Finding 8 (an untranslated native window.confirm used as a navigation guard) is worth grepping for in customer-portal, which has an i18n layer and the same dirty-form pattern; members and tt-time-tracker have no i18n layer so it is not-applicable there. Finding 9 (sub-24 px icon buttons) has been reported in all four.