Appearance
UX audit — cross-project alignment & rule reconciliation
Capstone of the 2026-07-30/31 overnight batch. 114 features audited across four Vue apps (playout 21, customer-portal 37, members 31, tt-time-tracker 25), each against
BASELINE.mdand the local UX Patterns MCP. Every audit was read-only and nothing was filed as a GitHub issue — this is documentation to review and act on.This file is the reviewer's entry point. It does three things:
- Reconciles the proposed new rule IDs (agents ran in parallel and collided on IDs).
- Completes the cross-project alignment table now that all four apps are fully audited.
- Ranks the cross-cutting issues, each with the reference implementation to copy.
Per-feature detail lives in
drafts/<project>--<feature>.md(114 files). Architectural root-cause notes live inPROJECT-LEVEL.md. This file does not repeat them; it organises and prioritises.
1. Read these first — the seven headline findings
Ranked by severity, not by project. Each links to the draft with the evidence.
Fabricated customer-facing telemetry — customer-portal LiveTracking.
LiveTrackingPage.vuepresents synthetic operating hours, battery, fuel and GPS pins (hashed from the asset id / string-matched to ~20 hardcoded cities) as "real time". Route resolves and is gated only onread CustomerAsset, so it is reachable by deep link despite the hidden nav entry. The one place in the programme a user is shown invented facts about their own equipment with no tell. ProposedDATA-SYNTHETIC. →customer-portal--customer-assets.md. Review first; decide whether the route should resolve at all before release.Offboarding does not revoke access — tt-time-tracker.
archivedis a display flag; no auth path reads it, no sessions are deleted, APIDELETEis also just archive. A removed employee keeps signing in and logging hours while invisible to every admin screen. Blocker. Fix = adopt customer-portal's per-request permission rebuild (see §4). →tt-time-tracker--users.md(pre-batch) and the offboarding thread inPROJECT-LEVEL.md.Admin-role grant via
window.prompt, no confirm/validation/OOB notice — members Superadmin. Plus org/sub-org deactivation and module removal persist on one unconfirmed click, and a failed orgs load renders as "no search match". Three High findings on one screen. →members--superadmin.md.Password set in playout Profile can never be changed or recovered on
develop. Profile lets a user attach a password; there is noupdatePassword/reset on the audited branch (those live only on unmerged PR #437). Reframes #437 as the fix for an active lockout path, not a nicety. →playout--profile.md,PROJECT-LEVEL.md§"unmerged password-reset".Cross-sub-org data exposure — members widgets (
DATA-01).DonsProgress.vuereads an undeclaredsuborgprop, so the host attribute is dropped and the widget always authenticates asb-active, able to show another sub-org's donation totals. Same undeclared-prop shape in four sibling widgets. →members--donation-progress.md(pre-batch) +PROJECT-LEVEL.md.Evening dates save one day early — members Events (
FORM-13).SelectDateTimeconcatenates a UTC date with a local time; all four Events date fields serialise the wrong wall-clock day, and downstream year-derivation compounds it. Silent data corruption. →members--events.md./devcomponent gallery ships to production, publicly reachable — tt-time-tracker (SEC-DEV-SURFACE). NorequiresAuth, no build-time strip invite.config.ts. Leaks no sensitive data (static demo content), so Medium — but it should be build-stripped, not merely unauthenticated. →tt-time-tracker--component-gallery.md.
2. Reconciled rule IDs — proposed additions to BASELINE.md
Agents proposed new rules independently and reused the next free ID, so IDs collided. Below is the canonical set: one meaning per ID, with the drafts to rewrite once these land. Nothing here is in BASELINE.md yet — treat as the review queue for extending the rubric. Grouped by family.
MSG (feedback)
- MSG-06 — failed load must be a distinct, recoverable state. A fetch/SDK failure must never render as an empty state, a permanent skeleton, a first-run "get started" screen, or a success. Must show an error and a retry. This absorbs the separately-proposed (a)/(b)/(d) MSG-06 variants and the playout "success fires before the write resolves" shape. Most-confirmed rule of the batch — all four projects, ~20 drafts.
- MSG-07 — action feedback must reflect the real outcome. (was
MSG-STATUS-STYLE,DATA-STATUS-FIDELITY.) A result banner's severity/colour must map to what actually happened, and a status indicator must map every backend enum value to a distinct treatment. Sightings: pmo sync log always styled error; commandsseverity.tsmaps a non-existentPROCESSINGand collapses QUEUED/RUNNING/RETRYING/CANCELED to one amber.
NAV (navigation) — the old "NAV-06" split into four
- NAV-06 — consumed token stripped from the URL after use (reset/invite/verify).
- NAV-07 — list/detail view state (tab, filters, period, page) reflected in the URL and restored on Back. (was
NAV-URL-STATE; customer-portal has no URL sync or KeepAlive anywhere — Back resets every list.) - NAV-08 — every user-relevant routable surface has a discoverable in-app path. (was
NAV-ENTRYPOINT; tt Organizations'organization-detailshas zero in-app links — URL-only.) - (async-guard-timeout and await-auth-before-branching are SEC/robustness, see below.)
FORM
- FORM-12 — warn before discarding entered/edited form data. (Members Events detail, members Settings, superadmin inline edits all lose work silently on nav/reload.) This is the canonical FORM-12; the other "FORM-12" proposals are re-homed: capture=/gallery → FORM-15, inert filters → FORM-16.
- FORM-13 — date/time serialisation preserves the intended wall-clock day. (members Events timezone bug.)
- FORM-14 — native input validity only fires inside a
<form>. A field saved via a click handler validates nothing; wire real validation. (wasFORM-VALIDATE-CONTEXT; customer-portal admin-configuration.) - FORM-15 — file
capture=must not suppress the gallery/file picker on desktop. (from earlier batch.) - FORM-16 — controls must not be inert (filters that never reach the API).
- FORM-17 — a resource with enabled/disabled state offers a non-destructive toggle; delete/disconnect must not be the only "stop". (was
FORM-TOGGLE-DISABLE; tt Integrations.)
A11Y — the old "A11Y-07" split; click-only rows unified
- A11Y-03 (existing, broadened) — anything that acts as a control is a real control. Rows/cards/chips/
<li>/<div @click>that open, select or activate must be focusable and key-operable (Enter/Space) with a role. This absorbs the proposed A11Y-ROW-SEMANTICS, A11Y-ROW-ACTIVATE, A11Y-08-clickable and AFFORD-01 — do not mint those. Confirmed all four projects. - A11Y-07 — state must not be conveyed by colour alone, and selection/toggle/ current state must be exposed programmatically (
aria-pressed,aria-current,aria-selected, text). (roles-permissions matrix is colour-only; release-notes active version colour-only; objectives score colour-only.) - A11Y-08 — non-text content has a text alternative. Charts need
role="img"+label or a data-table equivalent; meaningful images need alt, decorative onesalt="". (b-active-dashboard rings; tt error illustration.) - A11Y-09 — SPA route changes are announced and move focus to the new view's heading. (was
A11Y-ROUTE-ANNOUNCE; likely all four SPAs.) - A11Y-10 — a drawn/required input (signature) has a non-pointer path. (was
A11Y-SIGNATURE; customer-portal orders-logistics signature canvas.) - A11Y-11 — every page exposes one
<h1>inside a<main>landmark. Shell- level: ttLayoutMainrenders the title as<h2>with no<h1>/<main>; chrome-less error/auth routes across projects sit in no landmark. (This is the positive statement of the recurring A11Y-04 shell findings.)
SEC / robustness
- SEC-03 (existing, tightened) — a password change revokes other sessions by default, not as an opt-in the user must discover and tick. (customer-portal & tt profiles both default-off.)
- SEC-07 — stored scope id (tenant/account/org) is a hint to validate against current membership, never authoritative. Revalidate on boot and clear on sign-out. Single rule for the three-project "persisted scope" defect (§4).
- SEC-08 — internal/dev surfaces are build-stripped, not merely unauthenticated. (was
SEC-DEV-SURFACE; tt/dev.) - SEC-09 — async route guards are bounded (timeout + retry + error surface). members
while(!session.initialized)and tt'sisPending/rolewaits hang forever on a failed dependency. (Robustness sibling of the NAV split.)
DATA (data integrity) — new family, high-value
- DATA-01 — a component declares every attribute the host can set and never reads an undeclared prop. (members widgets cross-sub-org exposure.)
- DATA-02
DATA-TRUTHFUL-AGGREGATE— a displayed total/count/aggregate covers the whole set or states its coverage. No page-scoped sums under a whole-set label. Absorbs DATA-TRUTHFUL-COUNT, FILTER-SCOPE, DATA-PAGE-AGGREGATE, DATA-PAGE-SCOPE. (customer-portal dashboard/trade-in/logistics/sync; tt projects/overview/admin-dashboard.) - DATA-03
DATA-SYNTHETIC— placeholder/sampled/derived data is never presented as measured. If a surface ships before its source exists, it says so on the surface. (customer-portal LiveTracking — headline #1.) - DATA-04
DATA-SCOPE-01— the active scope is read reactively at every use site and re-read immediately before any write. Conditionally a defect: a remount saves you on read, nothing saves you on write (customer-portal service- plans Blocker vs dashboard non-defect — keep both, do not collapse). - DATA-05
DATA-ID-BOUNDARY— send the id the target layer expects. (customer-portal Contacts/Locations send DataverseexternalIdwhere the repo looks up the local uuid → every edit 404s.) - DATA-06 — a figure's scope label matches and reacts to the data shown. (was
DATA-LABEL-SCOPE/DATA-TEMPORAL-DEFAULT; b-active-dashboard year label vs pinnedgetFullYear(); b-active-memberships hard-codedAnnee:2026.)
CONTENT
- CONTENT-05 — currency precision: money is formatted at its stored precision (2 fraction digits), and a correct shared formatter is not a pass — grep
toFixed(,Math.round(,parseInt(against money too. (§4 currency.) - CONTENT-06 — render enum/status via its display label, never the raw stored key, consistently across views. (was
CONTENT-DISPLAY-LABEL; members Sponsors detail shows rawprospect/formule-1.) - CONTENT-07 — a view named for an action exposes that action in-app. (was
ACTION-SURFACE-01; members relances list candidates but has no send/select.)
Responsive / feedback polish
- RESP-01
RESPONSIVE-CONTROL-PARITY— desktop list/search/filter controls stay reachable on mobile. Asserted from code (desktop-only#filtersslot), so distinct from the render-only A11Y-06. (customer-portal Emails.) - FEEDBACK-COPY-01 — a copy-to-clipboard control confirms success and handles clipboard failure with manual-copy guidance. (tt API keys.)
LIVE — broadcast/on-air (playout only, no baseline equivalent)
Keep as its own section; MSG/A11Y don't cover an on-air state. LIVE-01 on-air output always visible + clear-vs-navigate distinguishable; LIVE-02 live state never colour-alone, transitions announced; LIVE-03 an inverse control doesn't replace its counterpart in place. Plus: global keyboard shortcuts unbound on unmount (playout Mousetrap ctrl+f bound in five views, never unbound).
3. Completed cross-project alignment table
All four apps now fully audited, so the earlier blanks are filled. ✓ = conforms, ✗ = fails, — = not applicable.
| Theme (canonical rule) | playout | customer-portal | members | tt-time-tracker |
|---|---|---|---|---|
| NAV-03 per-route document titles | ✗ none | ✗ one static | ✗ one static | ✗ one static |
| A11Y-03 click-only rows/cards/chips | ✗ | ✗ | ✗ | ✗ |
| MSG-06 failed load rendered as empty/success | ✗ | ✗ | ✗ | ✗ |
A11Y-11 <h1>/<main> on every page | ✗ (VTitle dbl-h1) | ✗ auth/onboard shells | ✗ some | ✗ LayoutMain h2 |
| SEC-01 account enumeration | ✗ | ✗ | — | ✗ |
| SEC-07 persisted scope revalidated | ✗ | ✗ | — | ✗ |
| DATA-02 page-scoped aggregate shown as whole | — | ✗ | ~ | ✗ |
| CONTENT-05 currency precision | ✓ no money path | ✗ 9 sites + bid øre | ✗ widgets + .toFixed(0) admin bypass | ~ invoices (see draft) |
| Offboarding actually revokes access | ✗ no last-admin guard | ✓ reference | ~ no client guard, server Unverified | ✗ Blocker |
| 404 catch-all that explains + links out | ✗ has route, chrome-less dead end | ✗ missing | ✗ missing | ✓ reference (NotFound.vue) |
| Forbidden/unauthorized surface | ~ dead-code route, inline 403 | ✓ | ✓ | ✗ silent redirect |
| Zero-scope dead end (no tenants/orgs) | ✗ | — | — | ✗ |
Two reference implementations emerged from the batch — copy these:
- Offboarding/revocation: customer-portal
PermissionsService.buildContextre-reads user + links + roles from Postgres on every ability-gated request and filtersstatus === "linked"; no session snapshot, so unlinking revokes on the next request. Recommend this shape for tt'sOrganizationGuard. - 404: tt-time-tracker
NotFound.vue— one<h1>, human French copy, a context-aware primary CTA plus a back link. The others should match it (not copy playout, whose error routes are chrome-less dead ends). Residual: it still sits outsideLayoutApp, so no<main>landmark — fix under A11Y-11.
4. The four cross-cutting issues worth one ticket each
These are single root causes behind many per-feature findings. Fix once.
MSG-06 — "the error components exist and aren't used." Every app has error/ retry primitives (
ListErrorState,Tableerror props,DataTable,TableData) that callers skip, so failures render as empty/success. tt is the sharpest example:ProjectListwires it,ProjectDetailsdoesn't;Dashboardsummary has retry, its entry list swallows errors. One convention + a lint that flags a query used without its error branch.A11Y-03 — click-only rows, one shared table each. Root cause is the shared list component per app (playout
@playout/ui MenuDropdown, customer-portalpackages/ui DataTable, membersTableData.vue, ttTable.vue). Fixing the four shared components clears the bulk of the a11y findings across ~60 features.SEC-07 — persisted scope treated as authoritative. playout
localStorage.tenant(a public screen URL can reassign your default), customer-portaladmin-selected-account(never cleared on sign-out, next user inherits it), tt org id (revalidation branch unreachable behind an early return). One rule, same fix shape three times.CONTENT-05 — currency precision, and a correct formatter is not a pass. members admin
useCurrency.tsis correct yetCotisations.vue:136andSponsorApp.vue:421bypass it with.toFixed(0); customer-portal has nine hand-rolled formatters and the bid input accepts øre so re-saving rewrites the amount. Audit money by greppingtoFixed/Math.round/parseInt, not by reading the shared formatter.
5. Whole-class defects (one app, but not one feature)
- customer-portal CASL subject-name mismatch.
router/index.tsandAppLayout.vuegate on bare-string subjects ("Contact") that don't match what the API grants ("CustomerContact"), so every non-admin is redirected to/forbiddenand the nav entry never appears — invisible to admins because ofmanage all. ~30abilityChecksubjects have no type relationship to the permission catalogue. Fix: a shared typed subject union so the compiler catches the next rename. →customer-portal--contacts.md. - playout
@playout/ui. VDialog opacity-hide (closed dialogs stay tab- reachable), global unguarded Escape (fires the wrong handler on Bible overlay — writes stale reference to the live document, rated Blocker), brokenaria-labelledby, VTitle double-<h1>, VButton disabled focus-loss. One issue each against the library, cross-referenced from feature drafts.
6. Verification debt (settle with one command)
node_modules is not installed in any checkout, so PrimeVue / @bcc-code / external internals could not be read; agents correctly filed those under Unverified rather than asserting. Recurring: PrimeVue Button :loading→disabled focus drop (FORM-06), Message role="alert" (MSG-01), Card/Panel title element (A11Y-11). pnpm install in each repo lets a follow-up pass settle these. A11Y-01 (contrast) and A11Y-06 (responsive/short-viewport) need a rendered page and are Unverified by design in every draft.
In-repo exceptions (asserted from source, not Unverified): playout packages/ui & packages/schemas; customer-portal packages/ui (PageLayout, MobileListHeader, DataTable).
7. Suggested review order
- §1 headline #1 (LiveTracking
DATA-SYNTHETIC) — a release-gate decision. - §1 #2–#4 (offboarding, superadmin
window.prompt, playout password lockout) — the security/lockout cluster. - §4 the four one-ticket root causes — biggest coverage per fix.
- §2 rule reconciliation — approve the canonical IDs, then the drafts can be rewritten to cite them and (optionally) filed.
- Everything else per-project via the drafts.
No issues were filed. When you're ready to file, the rule IDs in §2 should be settled first so drafts cite a stable rubric.