Appearance
[UX] Playout — Tenant chooser
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 13 Patterns:forms/selection-input
Summary
The chooser itself is small and mostly well built: real <button> options with a visible focus ring, generous hit targets, an i18n'd empty state, and a sensible auto-resolve for single-tenant users. Two things are genuinely broken around it. A user with zero tenants hits a closed loop — the only control on the page pushes them to login, and the router immediately bounces them back to the same screen, with no sign-out anywhere. And the answer to "can the user tell which tenant they are in and switch away?" is yes, but only with a mouse: the sidebar TenantSwitcher correctly names the active tenant and lists the others, but its trigger is a bare <div @click> inside @playout/ui's MenuDropdown, so keyboard and screen-reader users cannot open it at all.
Findings
1. Zero-tenant users are trapped in a redirect loop — High · MSG-04
Where: src/views/TenantChooser.vue:98-103 and src/router/index.ts:53-67What: The "No tenants available" branch offers exactly one control, a common.back button that runs router.push({ name: "login" }). The global beforeEach intercepts login for an already-authenticated user: with no returnUrl and no localStorage.tenant (which a zero-tenant user will never have, since afterEach only writes it from a :tenant route) it returns { name: "choose-tenant" }. The user lands back on the page they just left. There is no sign-out, no "use a different account", and no support link. Why it matters: A user who signed in with the wrong account, or whose invite has not been processed yet, has no way out of the application short of clearing site data. The copy ("Contact your administrator") also gives them nothing actionable inside the product. This is the classic dead end MSG-04 exists to prevent. Fix: Replace the Back button with a sign-out action (auth.signOut() then router.push({ name: "login" })) — that is the real exit from this state. Keep a secondary "Contact support" link if there is an address to point at. Label the primary button for its destination rather than reusing common.back (CONTENT-02).
2. The tenant switcher is unusable by keyboard — High · A11Y-03
Where: packages/ui/src/components/MenuDropdown.vue:5-8, used by src/components/layout/TenantSwitcher.vue:6-24What: MenuDropdown's trigger wrapper is <div ref="reference" @click="toggleModel"> — no tabindex, no role="button", no aria-expanded, no aria-haspopup, no keydown handler. TenantSwitcher nests another plain <div class="… cursor-pointer"> inside it. The panel sets role="menu" but its children are plain <button> elements with no role="menuitem", and there is no Escape-to-close, no arrow-key roving focus, and no focus return to the trigger on close. Why it matters: Switching tenant is the only way back out of a tenant once one is stored (see finding 6). A keyboard-only or screen-reader user therefore cannot change tenant at all from inside the app. forms/selection-input treats Tab / arrows / Enter / Escape as the accessibility floor for any custom selection control; this clears none of it. Fix: In @playout/ui, make the MenuDropdown trigger wrapper render a real <button type="button"> (or apply tabindex="0" + role="button" + @keydown.enter/@keydown.space), add aria-haspopup="menu" and aria-expanded="model", close on Escape and restore focus to the trigger. Give the items role="menuitem" or drop role="menu" from the panel. Fixing it once upstream fixes every dropdown in the app.
3. Any :tenant route silently rewrites the saved default tenant — High · NAV-06 (proposed — see Baseline additions)
Where: src/router/index.ts:94-97What: afterEach writes localStorage.setItem("tenant", …) for every route whose params include tenant. That includes /admin/:tenant (admin-tenant, routes.ts:65) and the two unprotected screen routes /:tenant/screens/:screenId and /:tenant/screens/:productionUnit/:screenId (routes.ts:68-69). None of these is the user choosing a home tenant. Why it matters: A platform admin who opens one tenant's dashboard from /admin has their personal default silently reassigned to that tenant; next sign-in drops them there instead of the admin area. Worse, opening a public display URL for a tenant you do not belong to writes that tenant id; on the next sign-in beforeEach sends you to { name: "events", params: { tenant } }, checkAdminRole returns an empty role, and your first screen after logging in is a 403 "Forbidden" hero. The saved default is a user preference being written by navigation side effect. Fix: Only persist the tenant from a deliberate choice — write it in TenantChooser.navigateToTenant and TenantSwitcher.goToTenant (both already have the id) and delete the afterEach write. If a route-driven write is wanted, gate it on the route being under the /:tenant Layout branch and on the tenant appearing in tenants.my.
4. A failed tenant load is reported as "you have no access" — Medium · MSG-02, MSG-03
Where: src/views/TenantChooser.vue:18-45, src/stores/tenants.store.ts:16-28What: The view watches only tenants.my and tenants.myLoading. Neither the useCollection nor the useDocument binding's error is read anywhere, and myLoading is pending-only, so a permission-denied, offline or rules failure resolves to my.length === 0 and renders the "No tenants available. You don't have access to any tenants yet. Contact your administrator." branch (:87-104). Why it matters: A transient network problem is presented to the user as a permanent account-provisioning problem, and the recommended next action — contact an administrator — is wrong and wastes a support cycle. MSG-03 wants the error to say what to do next; here it confidently says the wrong thing. Fix: Surface the bindings' error refs from the store, and render a third branch: a generic "We couldn't load your workspaces" message with role="alert" and a Retry button. Reserve the no-access copy for a successful load that genuinely returned zero tenants.
5. French and Norwegian copy is untranslated English — Medium · CONTENT-01
Where: src/locales/fr.yml:34-38, src/locales/no.yml:795-799 (cf. src/locales/en.yml:788-792) What: All four tenant-chooser.* keys exist in every locale — so pnpm check:locales passes — but the fr and no values are verbatim English carrying # TODO: translate. A French user sees "Choose your tenant / Select the tenant you want to access"; the no-access copy is English too. Why it matters: This is the first screen after sign-in for a multi-tenant user, so the product's very first impression is in the wrong language. CONTENT-01 requires parity of copy, not just of keys; the CI gate only proves the latter. Fix: Translate the four keys. Note this is systemic rather than feature-specific — fr.yml carries 298 TODO: translate markers and no.yml 639, against ~968 lines each — so the durable fix is a CI check that fails on TODO: translate in a shipping locale, not four one-off strings.
6. /choose-tenant can never be reached again once a tenant is stored — Medium · NAV-05
Where: src/views/TenantChooser.vue:24-32What: If localStorage.tenant matches any tenant in tenants.my, the watcher navigates away before the chooser paints — including when the user typed /choose-tenant deliberately. No view in the app links to the route either (choose-tenant appears only in router/index.ts, routes.ts, Login.vue and Callback.vue), and nothing clears the stored value. Why it matters: The one screen that shows a user their full list of tenants side by side, with names and ids, becomes permanently unreachable after the first visit. Users who want to compare or re-pick have only the sidebar dropdown (which, per finding 2, is mouse-only), and there is no way to reset a mis-assigned default. Fix: Honour an explicit request for the chooser — e.g. skip the stored-tenant auto-navigation when the navigation was user-initiated (?switch=1, or from.name !== "login"). Add a "View all workspaces" item at the foot of the TenantSwitcher dropdown that links there.
7. The resolve is silent and drops focus — Medium · MSG-01, FORM-11
Where: src/views/TenantChooser.vue:50-55What: The <Transition> swaps AppLoader for the tenant list with no role="status", no aria-live="polite" and no focus management. The user arrives here by redirect from /, so focus is on <body>; after the swap it is still on <body>. Why it matters: A screen-reader user is told nothing when the list appears — the page simply goes quiet — and then has to tab from the top of the document to find the options (WCAG 4.1.3). AppLoader also only shows the word "Playout", naming the product rather than what is being waited on (CONTENT-04). Fix: Wrap the resolved content in a container with role="status"aria-live="polite", and move focus to the <h1> (tabindex="-1") or the first tenant button when resolved flips true. Give the loading state text along the lines of "Loading your workspaces".
8. No per-route document title — Medium · NAV-03
Where: src/router/index.ts:94-97, index.html:20What: afterEach sets no title, no route carries a meta.title, and the only useTitle in the codebase is src/components/output/LiveScreen.vue:26. Every route serves the static <title>Playout</title>. Why it matters: Screen-reader users get no page announcement on navigation, and browser history and multiple pinned tabs are indistinguishable — which matters more than usual here, since operators routinely keep several tenant/event tabs open at once. Fix: Add meta.title to routes and set document.title in afterEach, e.g. Choose workspace · Playout. Project-wide, not specific to this feature.
9. The active-tenant checkmark has no accessible name — Low · A11Y-05
Where: src/components/layout/TenantSwitcher.vue:32What: <IconCheck v-if="tenant.id === t.id" /> is the only marker of which tenant is currently active inside the dropdown list, and it is an unlabelled icon. The rows also carry no aria-current. Why it matters: A screen-reader user hears an undifferentiated list of tenant names and cannot tell which one they are already in — the exact question this control exists to answer. Fix: Add aria-current="true" to the matching row and either :aria-label="$t('…current')" on the icon or visually-hidden text.
10. The chooser page sits outside any landmark — Low · A11Y-04
Where: src/views/TenantChooser.vue:49What: The route renders outside Layout.vue, so the page root is a plain <div>; there is no <main> and no <nav>. (Layout.vue:15 does supply a <main> for tenant routes, and the <h1> count is correct here — exactly one in each branch.) Fix: Make the root <main>, or wrap the resolved content in one.
Unverified
- A11Y-01 (contrast).
text-faintis used for the subtitle (TenantChooser.vue:63), the tenant id line (:80) and the chevron (:82), andtext-accentonbg-accent-softfor the avatar tile (:75). All are token pairs whose computed values need a contrast tool in both themes; the smalltext-xsid line is the likeliest failure. Not asserted. - A11Y-06 (short viewport / mobile keyboard). The page is
min-h-dvh … justify-centerwith no scroll container; a user with many tenants on a ~700px-high viewport may have the list overflow off-screen centre. Needs a rendered viewport. - A11Y-02 (target size). The chooser's own rows are
px-5 py-4and clear the 24px floor by inspection, but theTenantSwitchertrigger (h-10) and itsp-3menu rows were not measured in a browser.
Baseline additions
- NAV-06 — Persisted navigation defaults (last tenant, last workspace, last project) are written only from a deliberate user choice, never as a side effect of route matching. Public, admin and impersonation routes must not overwrite the signed-in user's default. Cited by finding 3. This is a distinct failure mode from NAV-01: nothing is timed, but the app quietly remembers a place the user never chose and sends them there next session.
Cross-project note
- Finding 2 (mouse-only dropdown trigger) is a
@playout/uidefect and will reproduce in every Playout feature that usesMenuDropdown— it should be fixed upstream once rather than per feature. The other three projects use PrimeVue, Flowbite and@bcc-code/component-library-vue, whose menu primitives ship keyboard support, so this specific bug is likely Playout-only — but the same audit question (is the custom trigger a real button?) is worth asking of members, whose shared library is also in-house. - Finding 3 (NAV-06) is likely to recur in customer-portal and tt-time-tracker, which are both multi-tenant/multi-customer and plausibly persist a "last customer" the same way. Worth grepping both for
localStorage.setIteminside router hooks. - Finding 8 (NAV-03, no per-route titles) should be checked in all four; it is a one-line router fix everywhere.
- Finding 5 (CONTENT-01) applies to customer-portal (en/nb) in the same form — keys present, values untranslated — since key-parity CI cannot detect it.