Skip to content

[UX] playout — Custom songs ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 19 Patterns: forms/rich-text-editor

Summary ​

#415 is genuinely fixed — I traced create and edit end to end (handleSave → songs.add/songs.update → validateDoc → stripUndefined → addDoc/setDoc, with the dialog now closing only on success) and both paths are sound; there is no Blocker. MSG-05 also passes: EditSong.vue takes a post-normalisation baseline snapshot and confirms before discarding unsaved edits, so this feature does not have the unsaved-edit-loss defect found elsewhere in this programme — though the confirmation itself says nothing about what is being lost.

The real damage is inside the lyrics editor, which silently mutates the user's content in three ways: renumbering a verse onto an existing number destroys that verse's lyrics, blank lines are stripped on every round-trip, and the skipped array is never kept in step with structural edits, so after adding, deleting or dragging a verse the wrong verse is omitted from the broadcast output. All three are silent — no warning, no undo, and the song is written to Firestore on save.

Findings ​

1. Renumbering a verse onto an existing number silently destroys its lyrics — High · MSG-05 ​

Where: src/components/songs/SongEdit.vue:353-365 (with src/views/Tenant/CustomSongs.vue:115-117) What: The verse-number <input type="number"> calls updateVerseNumber → updateVerseKey(oldKey, "verse_" + n). updateVerseKey only copies the lyric entry when the target key is free:

js
if (song.value.lyrics[value] == null) song.value.lyrics[value] = { ...song.value.lyrics[oldVerseKey] };
song.value.structure = song.value.structure.map(key => key == oldVerseKey ? value : key);

If the number already exists (song has verse_1 and verse_2; the user types 1 into verse 2's number box), the copy is skipped but the remap still happens, so structure becomes ["verse_1","verse_1"] and verse 2's text is replaced on screen by verse 1's. handleSave then runs if (!song.structure.includes(key)) delete song.lyrics[key] (CustomSongs.vue:115-117) and deletes the orphaned verse_2 entry from Firestore. Why it matters: A single mistyped digit — with no confirmation, no warning and no undo — permanently deletes a verse's lyrics from a saved song. There is also no min/validation on the field, so an empty value produces the key verse_ and a negative value produces verse_-1. Fix: Reject or merge-guard a collision: if lyrics[newKey] already exists and is not the same entry, block the change and explain ("Verse 1 already exists"). Add min="1" and validate on change. If a real merge is intended, confirm it and say which verse's text will win.

2. skipped is never kept in step with the structure, so the wrong verse is dropped from the broadcast — High · proposed FORM-PARALLEL-STATE ​

Where: src/components/songs/SongEdit.vue:327-333 (drag reorder), :376-382 (addVerse), :384-389 (addInterlude), :391-395 (deleteVerse) What: song.skipped is a positional boolean[] parallel to song.structure (backfilled at EditSong.vue:70-72 and edited via SongStructure at SongEdit.vue:14-18). Every structural mutation splices or rewrites structure and leaves skipped untouched: structure.splice(verseIdx + 1, 0, newKey) on add, structure.splice(verseIdx, 1) on delete, and structure = value.map(v => v.key) in the lyricsModel setter on drag. handleSave does not reconcile the two arrays either. Why it matters: skipped is persisted on the song document and consumed by the live output (songs.store.ts:44, useSongLyrics removeSkipped). Mark chorus 2 as skipped, then insert or drag a verse above it, and a different verse is silently omitted on air — the operator sees a correct-looking editor and a wrong broadcast. The mismatch survives the save. Fix: Apply the same splice/reorder to skipped in all four handlers (or derive skip state from a key-addressed map instead of an index-addressed array, which removes the class of bug entirely). Proposed rule: State stored in an array positionally parallel to a reorderable list must be spliced and reordered with it, or be keyed rather than indexed.

3. Blank lines are silently stripped from lyrics on every round-trip — High · proposed FORM-ROUNDTRIP ​

Where: src/components/songs/SongEdit.vue:125-131, via src/composables/useSongLyrics.ts:404-406What: The lyrics textarea renders :value="verse.lines.join('\n')" and writes back content = value.split("\n"). But lines comes from toVerse, which does content.map(formatLine).filter(l => l.length > 0) — empty lines are dropped. So a blank line the user types to separate stanzas disappears from the textarea the moment the change commits, and the next edit of that verse writes the stripped version back to content, making the loss permanent. formatLine also rewrites &ndash; to – on the same path. Why it matters: The editor visibly refuses to keep what the user typed, with no message, and eventually persists the loss. For an editor whose entire job is holding the user's text verbatim this is the most confidence-destroying behaviour in the feature. Fix: Bind the textarea to the raw song.lyrics[key].content, not to the display-filtered verse.lines; keep the empty-line filtering in the output projection only. Proposed rule: An editing surface binds to the stored value, not to a display-filtered projection of it — text must round-trip unchanged.

4. Double-clicking Save creates two songs — Medium · FORM-06 ​

Where: src/components/songs/EditSong.vue:28-30, src/views/Tenant/CustomSongs.vue:114-130What: The Save button emits save on every click with no in-flight guard, no aria-busy and no visual pending state; handleSave is async and awaits songs.add(song). Two clicks before the Firestore write resolves run addDoc twice. Why it matters: Two identical custom songs in the tenant's list, which the user must then find and delete. On a slow connection the absence of any pending feedback makes the second click the natural thing to do. Fix: Per FORM-06, don't disable the button — set a saving ref, return early from handleSave while it is true, and expose it as aria-busy plus a spinner.

5. The editor is hardcoded English although translated keys already exist — Medium · CONTENT-01 ​

Where: src/components/songs/SongEdit.vue:12 ("Lyrics"), :214 ("Metadata"), :216-275 (Number, Year, Original Key, Verses, Name, Author, Composer, Arranger, Soloist, Country, Copyright), :164 ("No highlight"), :336-339 (Verse/Chorus/ Bridge/Interlude); src/views/Tenant/CustomSongs.vue:10 ("Create"); src/components/persons/SearchPerson.vue:5,19-42,96 (dialog title, four column headers, "Cancel") What: These are literal English strings, not untranslated locale keys — so pnpm check:locales cannot see them at all. src/locales/*.yml already carries the matching keys with real French translations: song.number = "Numéro", song.author = "Auteur", song.composer = "Compositeur", song.soloist = "Soliste", song.copyright, song.name, song.year, song.originalKey, song.verses, song.country (fr.yml:568-585). Why it matters: A French operator gets a fully French shell (customSongs.* and menu.custom-songs are translated) wrapped around an entirely English editor — and the translations to fix it are already written and sitting unused. Fix: Replace the literals with the existing $t('song.*') keys; add keys for "Lyrics", "Metadata", "Create", the verse-type options and the SearchPerson labels. (Separate from the project-level key-parity finding in PROJECT-LEVEL.md — this is copy that never enters the i18n layer at all.)

6. Line comments — the whole feature — are mouse-only — Medium · A11Y-03 ​

Where: src/components/songs/SongEdit.vue:87-106 (line rows), :37 (verse toolbar) What: Lyric lines are <div>s with no tabindex and no role, selected via @click.exact, @click.ctrl.exact, @click.meta.exact and @click.shift.exact. There is no keyboard equivalent, so the comment toolbar (:133) can never be opened without a pointer. Separately, the per-verse toolbar (verse type, verse number, edit-toggle, delete) is can-hover:opacity-0 can-hover:group-hover:opacity-100 with no group-focus-within variant — a keyboard user can Tab into those controls but they remain invisible while focused. Why it matters: Comments are a broadcast-operator annotation feature; a keyboard-only user cannot create, edit or remove one. And tabbing through a verse lands focus on controls that are not rendered, which is a lost-focus experience rather than a slow one. The forms/rich-text-editor pattern's accessibility section is explicit that the editor must be completable by keyboard alone and that focus order stays logical when the UI reveals additional controls. Fix: Make lines role="option"/<button> inside a listbox-like group with Arrow/Shift+Arrow/Ctrl+Space selection; add can-hover:group-focus-within:opacity-100 to the toolbar wrapper.

7. Unnamed icon-only controls and 20px colour targets — Medium · A11Y-05, A11Y-02 ​

Where: src/components/songs/SongEdit.vue:53-69 (pencil/check and delete VButtons, no accessible name), :155-166 (colour swatches: size-5 = 20×20 px, :title="c ? '' : 'No highlight'" gives the six coloured swatches an empty title and no aria-label), :168-181 (B / I buttons: no aria-pressed, no name beyond the glyph); src/components/songs/SongStructure.vue:3-17 (skip buttons render only verse.prefix, which is "" for interludes → an empty, unnamed button; skipped state is conveyed by opacity-25 alone) What/Why it matters: A screen-reader user hears "button" for the edit, delete and every highlight-colour control, and cannot tell which verses are set to be skipped because that state is opacity-only and not exposed programmatically. The 20px swatches are below the WCAG 2.5.8 24×24 minimum. Fix: aria-label on each icon button and each swatch (the colour's name), size swatches to ≥24px, aria-pressed on B/I and on the skip buttons, and give the skip buttons a real name (Verse 3, Interlude) instead of the visual prefix.

8. Both confirmations are contentless "Are you sure?" — Medium · MSG-05 ​

Where: src/views/Tenant/CustomSongs.vue:22-26 (delete song), src/components/songs/EditSong.vue:34-38 (discard edits); both render Confirm.vue with no title/subtitle, falling back to $t('common.areYouSure') and a generic $t('common.confirm') button What: Neither dialog names the song, says the delete is permanent, or says that unsaved edits will be lost. Two visually identical "Are you sure?" dialogs can appear from this one screen with opposite consequences. Separately, deleteVerse (SongEdit.vue:391-395) and clearComments (:577) destroy content with no confirmation at all. Why it matters: The dirty-check guard (which is otherwise well built) is undermined by a prompt that gives the user nothing to decide on; a user who has learned to dismiss "Are you sure?" will delete the wrong thing. Fix: Pass title/subtitle/confirmLabel — "Delete '<song name>'? This cannot be undone." and "Discard your unsaved changes to this song?" — the props already exist on Confirm.vue.

9. Raw developer error strings reach the user; failed deletes show nothing — Medium · MSG-02, MSG-03 ​

Where: src/views/Tenant/CustomSongs.vue:127 and src/composables/useValidation.ts:18-25; src/views/Tenant/CustomSongs.vue:143-146What: showRawError(error.message) puts the untranslated SDK message straight in the toast, and validate() toasts Validation failed on "number": Invalid value — a developer string naming a schema path, with no indication of which field on screen is wrong and no next step. SongSchema requires collection, collection_name, originalKey, origins and copyrights, so any legacy custom song missing one fails to save with exactly that message. Meanwhile handleDeleteConfirm calls songs.remove(...) without await or catch: a rejected delete is silently swallowed and the row simply stays. Why it matters: On the save path the user is shown text they cannot act on; on the delete path they are shown nothing at all and will assume it worked. Fix: Map validation issues back to the field and show them inline (FormKit supports per-field messages); fall back to one human sentence for everything else. await the delete, surface a failure, and clear selectedSong/toDelete after a successful one.

10. Four <h1>s on the page, all sharing one id, and invisible dialog buttons in the tab order — Medium · A11Y-04, A11Y-03 ​

Where: packages/ui/src/components/VDialog.vue:2-5, 21-39, 55-61, with packages/ui/src/components/VTitle.vue:2What: VTitle renders <h1>, and VDialog uses it for its title — so with the page title plus the delete Confirm, the discard Confirm, the edit dialog and SearchPerson, this route has four to five <h1> elements, every one of them carrying the same id="modal-headline" (so every dialog's aria-labelledby resolves to whichever comes first). Closed dialogs are never removed or inert — the closed state is only opacity-0 … pointer-events-none — so their Cancel/Confirm buttons and the person-search input stay keyboard-focusable while invisible. The always-mounted delete Confirm on CustomSongs.vue:22 means this is true even before the user opens anything. Why it matters: Tabbing across the Custom songs page drops focus into buttons that are not on screen, and a screen-reader user gets a page with several competing <h1>s and a dialog announced with the wrong name. Fix: Belongs in @playout/ui: render the dialog title as an <h2> (or an hgroup inside role="dialog"), give each instance a unique generated id, and gate the closed dialog with v-if/inert rather than opacity alone.

11. Escape closes nothing in this feature — Medium · NAV-02, A11Y-03 ​

Where: packages/ui/src/components/VDialog.vue:123-126What: onMounted(() => Mousetrap.bind("esc", …)) binds on the global Mousetrap instance and is never unbound (no Mousetrap.unbind exists anywhere in src/ or packages/ui/). Re-binding the same key replaces the previous callback, so with four VDialog instances live on this route only the last-mounted one keeps a handler — which is SearchPerson's dialog (it mounts last, inside SongEdit, which renders only after EditSong's onMounted sets copy). Pressing Escape in the song editor therefore fires close on the closed person-search dialog: a no-op. The delete and discard confirmations cannot be escaped either. Mousetrap additionally suppresses handlers while focus is in an input/textarea, so Escape is dead in every field of this form. After the editor unmounts, the stale binding remains. Why it matters: VDialog's own comment states dialogs "close via Esc"; on this route none of them do, and the page is left with a dangling handler pointing at an unmounted component. Fix: onUnmounted(() => Mousetrap.unbind("esc")) is not sufficient (it would unbind another dialog's handler) — use a plain keydown listener on the dialog element, gated on showModal, and remove it on unmount. Same root cause as the ctrl+f note in PROJECT-LEVEL.md.

12. Unlabelled fields inside the editor — Low · FORM-01 ​

Where: src/components/songs/SongEdit.vue:47-52 (verse-number input: no label, no aria-label), :38-46 (verse-type VSelect: no label), :73-79 (interlude text: placeholder is the only label), :148-153 (comment text: placeholder only) What/Why it matters: Four of the editor's controls have no programmatic name; the two placeholder-labelled ones lose their label as soon as the user types. The FormKit metadata fields on the right are correctly labelled, so this is an inconsistency inside a single form. Fix: Add aria-label (or a visually-hidden <label>) to each.

13. Opening a song wipes the search — Low · NAV-05 ​

Where: src/views/Tenant/CustomSongs.vue:103-107 (searchQuery.value = null in handleSelect) What: Selecting a song from filtered results clears the query, so cancelling out of the dialog returns the user to the unfiltered list rather than to their results. Why it matters: Correcting a typo across several songs means retyping the search after every single one. Fix: Leave searchQuery alone; the dialog covers the list anyway.

14. Project-level, not re-filed here ​

  • NAV-03 — no per-route document title mechanism exists; fails project-wide, see PROJECT-LEVEL.md.
  • CONTENT-01 key parity — menu.custom-songs and the whole song.* block are # TODO: translate in both fr.yml and no.yml; the architectural finding is already recorded project-level. (customSongs.* is genuinely translated in all three.)

Unverified ​

  • A11Y-01 (contrast) — the comment highlight colours are applied as background with a 40/aa alpha suffix (SongEdit.vue:99-101) under text-white, and the skipped-verse state uses opacity-20/opacity-25. Both are plausible contrast failures but need computed values from a rendered page. Not asserted.
  • A11Y-06 (short viewport) — EditSong.vue:81 hard-codes the editor body to calc(100dvh - 20rem) while VDialog caps the dialog at 100dvh - 10rem (88dvh on mobile). At ~700px height that leaves roughly 380px for a three-column editor, and the mobile drawer's budget does not match the desktop formula at all. Needs a rendered viewport to confirm.
  • Whether existing custom-song documents in Firestore satisfy the now-required collection / collection_name / origins / copyrights fields — I can read the schema but not the data. If any predate them, finding 9's raw validation toast is what those users will see when they try to save.

Baseline additions ​

Two proposed rules (descriptive IDs; the orchestrator should renumber — note the existing FORM-12 collision list in PROJECT-LEVEL.md):

  • FORM-PARALLEL-STATE — State stored in an array positionally parallel to a reorderable list must be spliced and reordered with that list, or be keyed rather than indexed. (Finding 2.)
  • FORM-ROUNDTRIP — An editing surface binds to the stored value, not to a display-filtered projection of it; text the user types must round-trip through the editor unchanged. (Finding 3.)

Also relevant to the existing collision list: findings 9 and 10 are the same shapes already proposed as MSG-06 (b)/(d) and A11Y-07 (d)/(e) — no new IDs needed.

Cross-project note ​

  • Finding 4 (no in-flight guard on submit) and finding 8 (contentless confirmation) are library-agnostic and worth checking in customer-portal, members and tt-time-tracker — all four projects have modal save flows.
  • Findings 10 and 11 are @playout/ui defects, so they are playout-only, but every playout feature that opens a dialog inherits them; they may deserve promotion to PROJECT-LEVEL.md rather than a per-feature fix. Note @playout/ui lives in this repo at packages/ui, so unlike PrimeVue/@bcc-code these claims are read from source, not inferred — the node_modules caveat does not apply to them.
  • Findings 1–3 are specific to this editor and have no analogue elsewhere.