Appearance
[UX] customer-portal — Terms
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 6 Patterns: none consulted (feature is a plain sanitized-HTML render; the queue'scontent-management/accordionandexpandable-textare not implemented — the page renders the whole admin-authored document at once — and the baseline covers it fully)
Summary
Terms is a thin read-only page: useTermsQuery fetches published, locale-resolved HTML and TermsPage.vue renders it through PageLayout after DOMPurify.sanitize. The sanitisation is correct and is the right call (SEC/XSS handled). The defects are all in the non-happy states: a fetch error is silently shown as "no terms exist", two user-facing strings bypass i18n, and admin-authored headings collide with the page's own <h1>.
Findings
1. A fetch error is rendered as "No terms are currently available" — Medium · MSG-06 (proposed)
Where: apps/web/src/pages/TermsPage.vue:23-28, apps/web/src/domains/terms/terms.queries.ts:16-33What: The query calls termsView({ throwOnError: true }), so a 500/network failure throws and the query enters its error state. The component never reads an isError/error value — the composable does not even expose one. On failure isLoading is false and terms is undefined, so isAvailable is false and the template falls into the v-else-if="!isAvailable" branch, printing "No terms are currently available." Why it matters: A transient backend error is indistinguishable from the legitimate "nothing published yet" state. The user is told, authoritatively, that their terms do not exist, with no error indication and no retry — and the repo's own apps/web/CLAUDE.md §7 says "No silent failures." This is the feature-level instance of the project-wide MSG-06 theme (failed load rendered as an empty/normal state — see PROJECT-LEVEL.md, customer-portal DataTable cluster); here it is a hand-rolled page, not DataTable, so it is a separate site. Fix: Expose isError/error from useTermsQuery and add a distinct error branch with a retry (role="alert"), so the empty state means only "genuinely not published."
2. Admin-authored <h1> in the body collides with the page <h1> — Medium · A11Y-04
Where: apps/web/src/pages/TermsPage.vue:30-34 and :42; packages/ui/src/components/PageLayout.vue:103What: PageLayout renders the page title (menuLabel) as the page's <h1> (PageLayout.vue:103). The Terms body is then injected via v-html and the scoped CSS explicitly styles .terms-content :deep(h1) (TermsPage.vue:42) — i.e. the component anticipates that the Quill-authored content contains its own <h1>/<h2>. A Terms document conventionally opens with a top-level heading ("Terms & Conditions"), so the page ships two <h1>s and a heading outline that does not nest under the page title. Why it matters: Screen-reader users navigating by heading get two competing document titles and a broken outline (WCAG 1.3.1). The defect is latent in the code and triggered entirely by admin content, so it will not show in a quick visual check. Fix: Demote body headings on render (e.g. shift h1→h2, h2→h3 when injecting, or style/label the page title as an eyebrow and let the body own the <h1>), and document the expected authored heading level for the admin editor (#36 admin.terms).
3. Loading and empty strings bypass i18n — Medium · CONTENT-01
Where: apps/web/src/pages/TermsPage.vue:21 ("Loading…"), :27 ("No terms are currently available.") What: The component imports no useI18n; both status strings are hardcoded English literals. customer-portal is held to CONTENT-01 per feature (en/nb). Why it matters: An nb user sees English for the loading and empty states while the rest of the page (the menuLabel and body) is localised — an inconsistent, untranslated seam. (The body itself is correctly locale-resolved server-side.) Fix: Route both strings through t(...) with nb translations.
4. Loading and empty state swaps are not announced — Low · MSG-01
Where: apps/web/src/pages/TermsPage.vue:17-34What: The v-if/v-else-if/v-else swap from "Loading…" to the rendered body (or to the empty state) is a silent DOM replacement — no aria-live/role="status". Why it matters: A screen-reader user who lands during the fetch gets no announcement when the terms appear or when the empty state resolves (WCAG 4.1.3). Low because the page is a one-shot read with no interaction to block. Fix: Wrap the state region in aria-live="polite" (or role="status" on the loading/empty nodes).
5. "Loading…" does not name what is loading — Low · CONTENT-04
Where: apps/web/src/pages/TermsPage.vue:21What: Generic "Loading…" rather than naming the terms. Why it matters: Minor; a named waiting state ("Loading terms…") reads better and pairs with the i18n fix above. Fix: "Loading terms…" via t(...).
Unverified
- A11Y-01 (contrast): body link colour is
var(--p-primary-color, #2563eb)onbg-paper(TermsPage.vue:49), and the mutedtext-text-2on the status strings; both need a rendered contrast check. Cannot be settled from code. - A11Y-06 (responsive): body width is capped at
max-w-3xl; long admin content, wide tables or<blockquote>on a 320px viewport need a rendered check.PageLayoutprovides the mobile shell, but the injected HTML is unconstrained. - PrimeVue/
@aadalen/uiinternals:PageLayoutis in-repo and asserted from source (real<h1>, per PROJECT-LEVEL.md); no PrimeVue internals are relied on here.
Baseline additions
- MSG-06 (proposed, colliding — see PROJECT-LEVEL.md reconciliation): a failed fetch must not be rendered as an empty/"no data" state; the error branch must be surfaced with a recovery action. This feature is a fresh instance in a non-DataTable page.
- Otherwise none. NAV-03 (one static document title) fails project-wide — see PROJECT-LEVEL.md; not re-filed here.
Cross-project note
- MSG-06 (error rendered as empty/normal state): confirmed in all four projects (PROJECT-LEVEL.md alignment table). This Terms instance shows it also appears in simple hand-rolled read pages, not just shared table components.
- Double-
<h1>from injected rich text: any project that renders admin/CMS rich text under a shell that already owns the page<h1>is exposed — check members widgets and playout whereverv-htmlbodies are shown. - Hardcoded status strings in an otherwise-i18n'd page: worth grepping customer-portal for
Loading…/literal empty-state copy; playout is CI-guarded, members/tt-time-tracker are single-locale by design.