Skip to content

[UX] playout — Error and empty states ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 13 Patterns: user-feedback/empty-states

Summary ​

playout is one of only two projects in the programme with a catch-all 404, so this feature is the intended reference implementation — but measured against MSG-03/MSG-04 it is a template without an action. ErrorHero.vue renders a code, a title and a sentence, and contains zero interactive elements; both standalone error routes (not-found, unauthorized) also sit outside Layout, so there is no sidebar or navbar either. A user who mistypes a URL, follows a stale deep link, or hits a disabled feature flag lands on a chrome-less page whose only exit is the browser's own back button.

Separately, the dedicated unauthorized route is unreachable dead code — no code path navigates to it. Real permission denials are handled by layout.forbidden, which renders a different, hardcoded-English 403 inline in Layout.vue, so the translated error.unauthorized* keys that exist in all three locales are never shown to anyone.

Findings ​

1. No error state offers a route out — High · MSG-04, MSG-03 ​

Where: src/components/layout/ErrorHero.vue:1-22; src/views/NotFound.vue:2-7; src/views/Unauthorized.vue:2-6; src/router/routes.ts:53,100

What: ErrorHero is the single component behind all three error surfaces and has no link, no button, no slot — its whole template is a <p> (code), an <h1> (title) and a <p> (description). Neither not-found nor unauthorized is a child of the Layout route, so neither page renders DesktopSidebar, MobileSidebar, Navbar or MobileTabBar. NotFound.vue:3 additionally sets min-h-dvh, making it a full-viewport page with nothing on it but text.

The user-feedback/empty-states anatomy is explicit that the pattern is state message → supporting detail → primary action → secondary recovery path, and its reference implementation ends in <button type="button">. playout ships the first two elements and neither of the last two. The copy also fails MSG-03: "Sorry, we could not find the page you're looking for." says what went wrong and nothing about what to do next.

Only one of the three states escapes this — the layout.forbidden 403 renders inside Layout (Layout.vue:22-27), so it keeps the sidebar and is genuinely recoverable. The two dedicated error routes, which are the ones users actually reach from a bad URL, are the ones with no way out.

Why it matters: A signed-in user who follows a stale bookmark or a shared link to a renamed event is dropped out of the application entirely. Nothing on screen indicates they still have a session, which tenant they were in, or how to get back; recovery depends on browser chrome the page itself never suggests. On a mobile web view launched from a chat app, browser back may not be present at all.

Fix: Give ErrorHero an actions slot (or an actions prop) and render a primary RouterLink on every usage. For not-found, target the user's last tenant — localStorage.getItem("tenant") is already maintained by router/index.ts:94-97 — falling back to choose-tenant; for unauthorized, target login. Add a secondary "Contact support" link to help. Drop min-h-dvh in favour of nesting the authenticated 404 under Layout so signed-in users keep their navigation.


2. The Help form's send has no failure path — High · MSG-02 ​

Where: src/views/Help.vue:82-88

What:

js
const handleSave = async () => {
    await sendMessage(newMessage.value)
        .then(() => {
            router.push({ name: "events" });
            showSuccess("message");
        });
};

There is no .catch and no try. sendMessage returns batch.commit() (Help.vue:104), a two-document Firestore write (tickets + mails) that rejects on any permission, network or quota failure. When it rejects, the .then never runs, so there is no navigation, no toast, and no error — the rejection becomes an unhandled promise rejection in the console. The page simply does not respond to the button. layout.store.ts exposes showError/showRawError (lines 17-18) and alert.error.default exists in en.yml; neither is used here.

Why it matters: This is the support contact form — the surface a user reaches because something else has already failed. Silently dropping the message is the worst possible failure mode for it: the user believes support has been contacted and waits for a reply that will never come. Rated High rather than Blocker because the happy path does work; the defect is confined to the failure branch.

Fix: Wrap the commit and call showError("default") (or a dedicated alert.error.support key) on rejection, keeping the user on the page with their typed message intact.


3. The unauthorized route is unreachable; the real denial surface is a different, untranslated page — Medium · MSG-03, CONTENT-02 ​

Where: src/router/routes.ts:53; src/views/Unauthorized.vue; src/router/index.ts:86-89; src/views/Layout.vue:22-27

What: Grepping the whole of src/ for unauthorized returns only the route definition, the view itself and the three locale files — nothing navigates to it. The router's actual denial path is checkAdminRole returning false (index.ts:88), which sets layout.forbidden = true; Layout.vue:22-27 then renders a second, inline ErrorHero with :code="403", title="Forbidden" and description="Sorry, you don't have access to this page." as English literals.

So there are two permission-denied designs in the codebase and the wrong one is live:

dedicated routewhat users actually see
Code401403
Title$t('error.unauthorized')"Forbidden" (literal)
Body$t('error.unauthorized_description')literal
Reachableneveron every role/admin denial

The status semantics are also inverted relative to their copy: the dead route is labelled 401 (unauthenticated) but its description talks about permission (en.yml:782), which is a 403 concern.

Note that checkAdminRole returns false for two quite different situations — the user has no role in this tenant at all (session.role == "", index.ts:27) and the user has a role but the route requires admin (index.ts:29). Both produce the same "Forbidden" text, so a user who has landed on the wrong tenant entirely gets the same message as one who is merely not an admin, and no hint that switching tenant would fix it.

Why it matters: French and Norwegian users see English on the one page most likely to make them feel locked out, while three correctly-keyed translations sit unused. The wrong-tenant case in particular is silently unrecoverable when combined with finding 1.

Fix: Delete Unauthorized.vue and its route, or wire the guard to it. Either way, point the live 403 at $t('error.unauthorized') / $t('error.unauthorized_description') (renaming the keys to forbidden* to match the code), and distinguish the no-role-in-tenant case with copy that links to choose-tenant.


4. Feature-level i18n gaps that CI cannot catch — Medium · CONTENT-01 ​

Where: src/views/NotFound.vue:5-6; src/views/Layout.vue:25-26; src/views/Help.vue:13,16,25,30,35,40,52

What: Two distinct problems, both feature-specific and both invisible to pnpm check:locales:

  1. Keys exist and are ignored. error.not_found and error.not_found_description are present in en.yml:783-784, fr.yml:29-30 and no.yml:790-791, yet NotFound.vue:5-6 passes English string literals instead. Unauthorized.vue — the unreachable view — is the only one that uses $t. The literal even diverges from the key it should be using ("could not find" vs "couldn't find"). Same shape at Layout.vue:25-26.
  2. Keys do not exist at all. On the Help page only VTitle's $t('menu.help') is localised. "Need help?" (:13), the subtitle (:16), the four field labels Name / Email / Phone / Message (:25,30,35,40) and the Send button (:52) are literals with no corresponding key in any locale file. Key-parity CI is structurally unable to flag these, because there is no key to compare.

This is separate from the project-level content-parity finding in PROJECT-LEVEL.md (# TODO: translate values). These strings never enter the i18n layer at all.

Why it matters: A Norwegian user asking for help fills in an entirely English form; a French user who mistypes a URL gets an English 404 even though the translation was already written and shipped.

Fix: Swap NotFound.vue and Layout.vue:22-27 onto the existing keys — a two-line change each. Add a help.* block to en.yml and mirror it into fr/no.


5. The Help form has no validation, no required fields and no in-flight guard — Medium · FORM-04, FORM-05, FORM-06, CONTENT-04 ​

Where: src/views/Help.vue:20-54,82-88; packages/schemas/src/support.schema.ts:5-6

What: None of the four FormKit inputs declares a validation prop, and the group is not wrapped in a FormKit type="form" — the submit is a plain VButton @click="handleSave" (:48-53). Consequences that follow directly:

  • SupportRequestSchema types name and message as required (support.schema.ts:5-6), but the UI enforces nothing. Pressing Send on an untouched form writes an empty ticket and dispatches an email to support@playout.studio (Help.vue:94-103). No constraint is stated before or after typing (FORM-04), and there is no message explaining any block (FORM-05).
  • handleSave has no in-flight flag. Two clicks produce two batch.commit() calls, i.e. two tickets and two support emails from one message. FORM-06 explicitly requires the guard to live in the handler; there is none, and no aria-busy either.
  • Nothing names the wait. The Firestore round-trip is unindicated: the button neither changes nor announces, so on a slow connection the page appears inert for the whole commit — which is also what makes finding 2 indistinguishable from "the click didn't register" (CONTENT-04).

Because the submit is a click handler on a button outside any <form>, pressing Enter in Name / Email / Phone does nothing — a keyboard user must tab past the textarea to reach Send. See Baseline additions.

Why it matters: Support receives blank and duplicated tickets, and the user gets no signal about what a valid message needs or whether their click landed.

Fix: Wrap the inputs in FormKit type="form" with @submit="handleSave", add validation="required" to name and message (and validation="email" to email), and set an isSending ref that handleSave returns early on and binds to aria-busy on the button.


6. A disabled feature flag is reported as "Not Found" — Medium · MSG-03 ​

Where: src/router/index.ts:81-85

What: When checkFeature resolves false the guard returns { name: "not-found" }, so a route that exists but is not enabled for the tenant renders the 404 hero: "Not Found — Sorry, we could not find the page you're looking for." The nav does hide flagged apps (Menu.vue:160,172), so this is mainly reached via bookmarks, shared links and links pasted between colleagues — all routine in a product whose screen and streaming URLs are meant to be shared. It is also the state a user lands in if the flags fetch has not resolved.

Why it matters: The message actively misleads. A user told the page does not exist will retype the URL or conclude the link is broken, when the correct action is to ask an admin to enable the feature for the tenant. Combined with finding 1 there is not even a link back into the tenant.

Fix: Route to a distinct "not available for this tenant" state (reuse ErrorHero with its own code/copy) that names the feature and links to help. This is the same shape as the MSG-06(c) proposal already logged in PROJECT-LEVEL.md — a router-enforced gate must expose the escape hatch that clears it — and should be reconciled with it rather than filed as a new rule.


7. Body copy marked up as a heading — Low · A11Y-04 ​

Where: src/views/Help.vue:15-17

What: "Send us a message! We'll reach out to help you as soon as possible" is wrapped in <h2 class="text-base text-faint">, directly after a real <h2> ("Need help?", :12-14). It is styled as body text and reads as body text; only the element is a heading.

Why it matters: A screen-reader user navigating by heading gets a spurious entry, and the page's heading outline claims two sibling sections where there is one section and its subtitle.

Fix: Change line 15 to <p class="text-base text-faint">.


8. Success confirmation does not say what happens next — Low · CONTENT-03 ​

Where: src/views/Help.vue:85-86; src/locales/en.yml (alert.success.message)

What: On success the user is pushed straight to events and shown the toast "Your message has been sent!". Nothing states who will reply, to which address, or in what timeframe — and the message they wrote is gone from the screen with no copy retained.

Why it matters: CONTENT-03 requires confirmations to cover the follow-up path. A user who typed the wrong email at Help.vue:29-33 (the field is prefilled from the session but freely editable) has no way to notice, and no record of what they sent.

Fix: Extend the success copy to name the reply address and an expected response window, and consider a confirmation state on the page itself rather than an immediate redirect away.


Project-level, not re-filed here ​

  • NAV-03 — no per-route document title mechanism exists at all; not-found, unauthorized and help all inherit the static title. See PROJECT-LEVEL.md.
  • CONTENT-01 (content parity) — the # TODO: translate situation is the project-level finding. Finding 4 above is deliberately scoped to strings that bypass i18n entirely, which that finding does not cover.
  • A11Y-03 / @playout/ui — VTitle hardcoding <h1> and VButton losing focus when disabled both apply to Help.vue (VTitle:3-7, VButton:48-53). Fix belongs upstream; see PROJECT-LEVEL.md.

Unverified ​

  • A11Y-01 (contrast). ErrorHero uses text-faint for the description at text-lg/sm:text-xl on bg-bg, and Help.vue:15 uses text-faint at text-base. A "faint" token on body-size text is where 4.5:1 failures typically live, but the token resolves through the Tailwind theme and cannot be settled by reading markup. Measure with a contrast tool.
  • A11Y-06 (short viewport). NotFound.vue:3 applies min-h-dvh on top of ErrorHero's py-24 sm:py-32, while Unauthorized.vue applies no height class at all — so the two sibling error pages are laid out differently by construction. Whether either overflows a ~700px viewport, and how the 401 reads as a short band on an otherwise empty page, needs a rendered viewport.
  • FormKit grid behaviour. Help.vue:19 sets grid grid-cols-1 sm:grid-cols-3 on a div whose only child is a FormKit type="group", and outer-class="col-span-full" on the message field (:42) assumes that field is a direct grid child. Whether the group renders a wrapper element (collapsing all four fields into one of three columns and no-op'ing the col-span-full) depends on FormKit internals. node_modules is not installed in this checkout — see the standing caveat in PROJECT-LEVEL.md. Worth one render check; if it reproduces it is a visible layout defect.

Baseline additions ​

  • FORM-SUBMIT-SEMANTICS (proposed; orchestrator to renumber) — A form's primary action submits a real <form> (or the framework's form component), so Enter submits from any field and validation gates the write. A bare @click handler on a button is not a submit: it bypasses field validation and strands keyboard users who expect Enter to work. (Instance: Help.vue:20-53.)
  • Finding 6 is not a new rule. "A router gate must state its own cause rather than reuse the 404" is already proposed as MSG-06(c) in PROJECT-LEVEL.md. Please reconcile there rather than adding a duplicate ID.

Cross-project note ​

  • MSG-04 / dead-end error pages. The alignment table in PROJECT-LEVEL.md records tt-time-tracker as the only other project with a 404 catch-all — it should be checked for the same defect (does its 404 link anywhere?). customer-portal and members have no catch-all at all, so the cross-project recommendation should be "add a 404 that links out", not "add a 404"; pointing those two at playout's current implementation as the reference would propagate the missing-action defect into two more codebases. Fix playout's ErrorHero before the alignment pass cites it.
  • CONTENT-01 / strings that bypass i18n. Untranslated literals in a repo with CI-enforced key parity is a playout-specific failure mode (the same pattern was flagged on Callback.vue in the Sign in row) and applies equally to customer-portal (en/nb). Not applicable to members or tt-time-tracker, which have no i18n layer.
  • MSG-02 / promise chain with no catch. The await x().then(...) shape at Help.vue:83 is worth grepping for across all four projects; it is the playout instance of the cross-project MSG-06 theme (failure never surfaced to the user).