Appearance
[UX] Playout — Tenant settings
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/form-validation
Summary
The three tenant-settings pages (settings-general, settings-locations, settings-overlay) all sit on one shared composable, useSettingsDirty, and that composable is where the serious defects are: it fires the green "saved" toast unconditionally after calling a store method that silently returns without writing, and it has no error path at all, so a rejected write produces no message of any kind. The only error a user can ever see from this feature is a raw Zod developer string. Below that, the pages are a mix of good work (the API-key regenerate flow is careful and correct; EditServicePassword states its password rules up front) and routine baseline failures — unlabelled icon-only buttons, placeholder-as-label, disabled submits, and VTitle rendering an <h1> for every section heading.
Project-level items that also fail here and are not re-filed: NAV-03 (no per-route title mechanism), CONTENT-01 (key-parity-only CI — 86 of the ~120 lines under settings: in no.yml are English marked # TODO: translate, and no is the default tenant language), and A11Y-03 (click-only rows — the production-unit <li> at ProductionUnits.vue:38 is another instance). See PROJECT-LEVEL.md.
Findings
1. Save shows a success toast for a write that never happened — High · MSG-06 (proposed) / MSG-01
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:18-22, with src/stores/tenant.store.ts:34What: save() is
ts
const save = async () => {
await tenant.saveSettings(draft.value);
isDirty.value = false;
layout.showSuccess("settings");
};and saveSettings opens with if (!settingsRef.value || !settings.value) return; — a guard clause that resolves normally without writing. save() cannot tell that apart from a real write, so it clears the dirty flag and shows "saved". Locations.vue and Overlay.vue have no loading gate (unlike General.vue, which skeletons on !tenant.settings at line 3), so both render an interactive form while the Firestore settings document is still in flight. In that window a user can type, hit Save, get a green confirmation, and have written nothing. Why it matters: silent data loss with positive confirmation — the worst possible failure shape. The user has no reason to re-check. Fix: make saveSettings throw (or return a discriminated result) instead of returning undefined on the not-loaded guard; wrap save() in try/catch and only call showSuccess on the resolved success path. Give Locations.vue and Overlay.vue the same !tenant.settings skeleton General.vue already has. Severity call: High, not Blocker — it needs the pre-load window rather than failing for every user on every save.
2. A failed save produces no message at all — High · MSG-02 / MSG-03
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:19What: there is no try/catch. If updateDoc rejects (permission denied, document missing, backend error) the rejection escapes as an unhandled promise rejection. isDirty stays true and showSuccess never runs, so the only user-visible result is that the sticky save bar does not go away. No toast, no field error, no explanation, no retry affordance. Why it matters: the user clicks Save and the app does nothing. There is nothing to act on and no indication whether the data is stored. Fix: catch, map to layout.showError("settings"), keep isDirty true so the draft survives, and add a localised "couldn't save — try again" string.
3. Numeric overlay fields have no validation; the schema rejection surfaces as a raw developer string — High · FORM-04 / MSG-02
Where: src/views/Tenant/Settings/Overlay.vue:34-60; src/composables/useValidation.ts:22-24What: the five number inputs pass :min/:max as plain attributes with no FormKit validation prop, and the Save button lives outside any <form>, so neither FormKit nor native constraint validation ever runs. An out-of-range value therefore reaches validate(GeneralSettingsSchema, merged) in the store (tenant.store.ts:36), which calls layout.showRawError(error.message) — the message being Validation failed on "minLyricsSize": Too small: expected number to be >=20. It is an internal field path, in English regardless of locale, in a toast, with no highlight on the offending field. Why it matters: the entire settings page becomes unsaveable — every subsequent Save re-validates the merged object — and the user must guess which of seven numeric inputs the message refers to. minLyricsSize is labelled "Min font size (fullscreen)"; the string names neither. Fix: add validation="min:20|max:200" (etc.) with localised validation-messages on each numeric field so the block is shown inline before Save, per forms/form-validation ("keep labels, helper text, and validation messages tightly grouped"). Also add the missing cross-field rule — nothing today prevents minLyricsSize > maxLyricsSize, which both schema and UI accept.
4. Icon-only controls with no accessible name — High · A11Y-05
Where: src/views/Tenant/Settings/Locations.vue:23-29 and :55-62; src/components/tenant/settings/ProductionUnits.vue:18-24, :54-63, :184-189What: these VButtons contain only an SVG icon component and pass no aria-label, title or text. VButton (packages/ui/src/components/VButton.vue — readable in-repo, not a node_modules guess) is a single-root <button><slot/></button>, so nothing supplies a name. Screen-reader output is "button" for the add control and "button" for every delete row. The worst instance is ProductionUnits.vue:184-189: the confirm button of the destructive "remove production unit" dialog is a bare <MdiDelete> with no label at all — the dialog's two buttons read as "Close" and "button". Why it matters: a non-sighted admin cannot tell the destructive action from the cancel action in a confirmation dialog. That is an unrecoverable dead end. Fix: add aria-label. settings.locations.add ("Add location") already exists in src/locales/en.yml and is currently unused — wire it up. Give the production-unit confirm button real text ($t('common.delete')).
5. Every section heading renders as <h1> — Medium · A11Y-04
Where: packages/ui/src/components/VTitle.vue:2, used at Settings/Index.vue:3-7, General.vue:17, :122, :162, Overlay.vue:9, :81, Locations.vue:4, ProductionUnits.vue:3What: VTitle hardcodes <h1> and varies only the font size via the size prop. Settings → General therefore ships four <h1> elements ("Settings", "Tenant", "General", "Features"); Overlay ships three; Locations ships three. There is no <h2> anywhere in the feature. Why it matters: heading navigation — the primary way screen-reader users skim a settings page — reports every section as the page title, so the structure conveys nothing. Fix: belongs upstream in @playout/ui: add a level/as prop (defaulting to h2, with the page shell opting into h1) and set it at each call site. Visual size stays decoupled from the level, which is the point of the existing size prop.
6. Submit and add buttons are disabled while invalid and while submitting — Medium · FORM-05 / FORM-06
Where: src/components/common/FormDialog.vue:22 (:disabled="submitDisabled || loading"); consumed by EditServicePassword.vue:6 (:submit-disabled="!isValid"); Locations.vue:24; ProductionUnits.vue:19What: the service-account-password dialog's submit is disabled until the whole form validates, so a user who mistypes the confirmation gets a dead button and no message saying why. The same button is then disabled again by :loading during the request — disabling a just-activated button drops focus to <body>. The two "add" buttons in Locations and Production units are disabled on empty input with no accompanying hint. Why it matters: a disabled control gives the user nothing to act on and nothing to read; the mid-submission disable additionally loses keyboard place. Fix: keep submits enabled, guard inside the handler (if (!isValid) return) and surface the blocking validation message; use aria-busy="true" plus a label swap for the in-flight state instead of disabled.
7. Placeholder used as the only label — Medium · FORM-01
Where: src/views/Tenant/Settings/Locations.vue:16-22; src/components/tenant/settings/ProductionUnits.vue:9-14 and the unlabelled ColorPicker at :15-17What: both "add" inputs pass :placeholder and no :label. The placeholder disappears on first keystroke and is not a programmatic label; the colour picker next to the production-unit id has no name of any kind. Why it matters: once typing starts there is nothing on screen or in the accessibility tree saying what the field is. Fix: pass :label="$t('settings.locations.namePlaceholder')" (or add proper label keys) — FormKit will associate it — keeping the placeholder as an example only.
8. Live snapshot overwrites edits in progress — Medium · proposed FORM-DRAFT-OVERWRITE
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:16What: watch(() => tenant.settings, syncFromStore, { immediate: true }) replaces draft.value wholesale on every snapshot. tenant.settings is a VueFire useDocument ref whose value object is replaced on each server update, so any change made by another admin — or simply the initial document arriving after the user has started typing on Locations/Overlay, which render before it loads — discards the local draft with no notice and no diff. Why it matters: typed input vanishes mid-edit and the user cannot tell what was lost or why. Fix: skip syncFromStore while isDirty is true and surface a "these settings changed elsewhere — reload / keep mine" affordance instead. Related, unverified: with @input="isDirty = true" bound to the FormKit group (General.vue:119, Overlay.vue:6), the programmatic draft replacement is likely to mark the form dirty by itself, showing "You have unsaved changes" on a page the user has not touched. FormKit's emit-on-initialise behaviour cannot be confirmed from source here (node_modules not installed) — worth checking in the browser.
9. Deleting a location: no confirmation, no warning that events reference it — Medium · MSG-05
Where: src/views/Tenant/Settings/Locations.vue:55-63 and :111-117What: the per-row delete button removes the location immediately on a single click. Events store their location as a bare id string (packages/schemas/src/event.schema.ts:19), so a location in use by existing events can be removed with no lookup, no count and no confirmation. The same file does silently null defaultLocation when the deleted location was the default, which is also unannounced. ProductionUnits.vue — on the same page — does confirm before removing, so the two halves of the Locations screen behave differently. Why it matters: one mis-click orphans event references, and the recovery path (navigate away without saving) is not signposted anywhere. Fix: reuse the existing Confirm component, state how many events reference the location, and mention that the default assignment will be cleared.
10. Copy-to-clipboard gives no confirmation — Medium · proposed MSG-COPY-CONFIRM
Where: src/views/Tenant/Settings/General.vue:284-286 and :223-229; src/components/tenant/settings/ProductionUnits.vue:265-268What: copyToClipboard calls copy(value) and returns. Nothing changes on screen — no toast, no icon swap, no live-region text. The most important instance is the show-once API-key dialog (General.vue:223), where the key can never be retrieved again after the dialog closes. Why it matters: the user cannot tell whether the copy succeeded, so they either close a never-to-be-shown-again secret on a failed copy, or paste blind. Fix: flip the icon to a check for ~2s and announce via role="status"; useClipboard already exposes copied and isSupported (the latter is used in ProductionUnits but not in General).
11. Toasts are not announced — Medium · MSG-01
Where: src/components/common/Success.vue:11-14; src/components/common/Error.vue:11-14What: both toast containers are plain <div v-if> with no role="alert", role="status" or aria-live. Everything this feature says to the user after an action — "settings saved", the raw validation error, the service-password result — goes through them, so a screen-reader user gets silence. Both also auto-dismiss after 5s via a setTimeout that is cleared on value change but not on unmount (Success.vue:50, Error.vue:51) — NAV-02. Why it matters: WCAG 4.1.3. The only feedback channel in the feature is inaudible. Fix: role="status" aria-live="polite" on the success container, role="alert" on the error container, on the persistent wrapper rather than the conditionally rendered child so the insertion is announced; clear the timeout in onUnmounted. Shared components, app-wide — candidate for promotion to PROJECT-LEVEL rather than a per-feature fix.
12. Icon-only credential controls are below the minimum target size — Medium · A11Y-02
Where: src/views/Tenant/Settings/General.vue:44-51, :52-58, :64-70, :79-86, :87-93, :94-100What: these are bare <button class="text-faint hover:text-muted transition-colors"> with no padding and a single unplugin-icons SVG as content. The plugin's default scale is 1.2em (vite.config.ts:142-144 sets no override), so the hit box is roughly 19×19 px — under the 24×24 floor — and six of them sit in a row with only gap-x-2 between. Why it matters: reveal / copy / regenerate / edit-password all sit in one dense cluster on a touch screen; mis-taps here regenerate an API key or open the password dialog. Fix: p-1.5 (or min-w-6 min-h-6 with centring) on each button.
13. Reveal toggles do not expose their state — Low · FORM-03
Where: src/views/Tenant/Settings/General.vue:44-51 and :79-86What: the show/hide toggles for the legacy API key and the service-account password swap MdiEye/MdiEyeOff and swap the title between "Reveal" and "Hide", but carry no aria-pressed and no type="button". The accessible name changes rather than the state, which reads as two different controls appearing and disappearing. Why it matters: minor, but it is the documented shape for this control and the repo already has SecretField doing something similar. Fix: type="button" plus :aria-pressed="showPassword" with a stable accessible name.
14. Unsaved changes survive in-app navigation only, behind an untranslated native dialog — Low · CONTENT-01, proposed FORM-UNLOAD-GUARD
Where: src/views/Tenant/Settings/composables/useSettingsDirty.ts:29-34What: onBeforeRouteLeave fires window.confirm("You have unsaved changes. Leave without saving?") — a hardcoded English literal in a repo with CI-enforced locale parity, and a two-way choice with no "save and leave". There is no beforeunload handler anywhere in src/, so a reload or tab close drops the draft with no prompt at all. Why it matters: French and Norwegian admins get an English browser dialog; everyone loses the draft on Cmd-R. Fix: route the copy through $t, and add a beforeunload listener while isDirty is true.
15. Mobile section picker desyncs when the unsaved-changes guard cancels the move — Low · NAV-05
Where: src/views/Tenant/Settings/Sidebar.vue:8-21 and :65-70What: the small-screen <select> binds v-model="currentName" and pushes on @change; currentName is only re-synced by watch(() => route.name, …). When the leave guard from finding 14 cancels the navigation, the route does not change, the watcher never fires, and the dropdown is left displaying a section the user is not on. Why it matters: the only navigation affordance on mobile now lies about where you are, and re-selecting the same value fires no change event, so the user cannot get back to the intended page without picking a third option first. Fix: reset currentName from route.name when router.push resolves false.
16. Stream key: loading, empty and failed all render as "—" — Low · MSG-06 (proposed) / CONTENT-04
Where: src/components/tenant/settings/ProductionUnits.vue:131-133, with :243-259What: loadCredentials sets loadingCredentials and swallows the error (catch { streamKey.value = null }), and loadingCredentials is never read in the template. The stream-key row therefore shows — whether the request is in flight, the unit genuinely has no key, or the call failed. Why it matters: an admin wiring up OBS cannot tell "not provisioned yet" (action: press Sync) from "we couldn't fetch it" (action: retry). Fix: render a spinner while loadingCredentials, and an error row with a retry when the call throws.
17. Adding a production unit that already exists overwrites it silently — Low · MSG-05
Where: src/stores/productionUnits.store.ts:13-17, called from ProductionUnits.vue:21What: add does setDoc(doc(ref.value, unit.id), unit) — setDoc replaces the whole document. Typing an existing unit's id into the add field and pressing + overwrites that unit's stored fields (colour, hasStream) with the new blank object. The id is not trimmed either, so " cam1" creates a distinct whitespace-prefixed unit. The call site also does not await the promise or report success or failure. Why it matters: an existing, stream-configured production unit is destroyed by what looks like an "add" action, with no confirmation and no toast. Fix: trim the id, reject duplicates in the UI with an inline message, and await the write to report success or failure.
Unverified
- A11Y-01 (contrast) — the feature leans heavily on
text-faintfor labels, help text, hint copy and all the icon buttons inGeneral.vue. Needs a contrast tool against the rendered OKLCH tokens; not assertable from class names. - A11Y-06 (short viewport / mobile keyboard) —
SectionSaveBarissticky bottom-[calc(var(--tabbar-h)+1rem)]. Whether it stays reachable above an open mobile keyboard, and whether the two-columngrid-cols-2inGeneral.vue:130and:170(no responsive prefix, unlikeOverlay.vue:17) is usable at 320px, needs a rendered viewport. - FormKit dirty-on-initialise (finding 8) — depends on whether the group emits
inputon mount and on programmatic value replacement.node_modulesis not installed in this checkout. - Whether the server sends an out-of-band notification when the service-account password changes (
SEC-04) — the call isapi.updateServiceAccountPassword; the function implementation was not read.
Baseline additions
Descriptive IDs with definitions; the orchestrator should renumber and reconcile against the existing MSG-06 / FORM-12 proposals listed in PROJECT-LEVEL.md.
MSG-WRITE-CONFIRMED— Success feedback is emitted only after the write is confirmed. A guard clause that skips the write must not fall through to the success path; a store method that can no-op must signal that to its caller. (This is the same rule as the existing MSG-06 (a)/(d) proposals — merge.)FORM-DRAFT-OVERWRITE— A live data subscription must not overwrite form fields the user is currently editing. Either suspend the sync while the draft is dirty or surface an explicit reconcile choice.FORM-UNLOAD-GUARD— Unsaved form data is protected against tab close and reload, not only in-app navigation, and the prompt is localised. (Same as the existing FORM-12 (d) proposal — merge.)MSG-COPY-CONFIRM— A copy-to-clipboard action confirms visibly and programmatically that the copy happened, especially for a value that cannot be displayed again.A11Y-04clarification — worth stating explicitly in the rule that a design-system title component which hardcodes one heading element fails A11Y-04 at every call site, and that the fix belongs in the component.
Cross-project note
- Findings 1 and 2 (success before/independent of the write; no error branch) are the playout instance of the MSG-06 theme PROJECT-LEVEL.md already records as failing in all four projects. This is the cleanest reproduction so far: the guard clause and the success call are four lines apart in one shared composable.
- Finding 6 (disabled submit + disabled-while-loading) will recur in customer-portal and tt-time-tracker, where PrimeVue's
Button :loadingsetsdisabled— already flagged there as unverified pendingpnpm install. - Findings 4 and 7 (icon-only buttons, placeholder-as-label) are generic and worth grepping for in members and tt-time-tracker.
- Finding 5 (
VTitlealways<h1>) mirrors the customer-portal PROJECT-LEVEL note that PrimeVueCardtitles render asdiv— the same class of defect (heading semantics decided by the component library, not the page) in opposite directions.