Appearance
[UX] customer-portal — Emails (admin)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 8 Patterns: user-feedback/empty-states (data-display/table consulted via BASELINE only)
Summary
A read-only admin log: EmailsPage.vue renders a server-paginated @aadalen/ui DataTable of email messages with status/type Select filters and a row-click detail modal (EmailDetailModal.vue). The data flow, sandboxed HTML preview and design-token usage are clean. The real problems are at the edges: on a mobile viewport the search box and both filters disappear entirely because the page uses the desktop-only #filters slot and never passes title; a failed load is an announce-less dead end with no retry; and the whole surface is hardcoded English with no i18n despite customer-portal's en/nb requirement.
Findings
1. Search and filters are unreachable on mobile — Medium · A11Y-06-adjacent (proposed RESPONSIVE-CONTROL-PARITY)
Where: apps/web/src/pages/admin/EmailsPage.vue:105-142 (uses #filters slot, passes no :title); shared cause in packages/ui/src/components/DataTable.vue:329 and :388What: DataTable renders its search box + #filters slot only inside the desktop toolbar (v-if="!isMobile", line 388). The mobile equivalent (MobileListHeader, line 329) renders only when title || hasFilters, where hasFilters counts the declarative filters prop — not the #filters slot. EmailsPage passes neither :title nor declarative filters, so on a ≤760px viewport no header renders at all: no search, no status filter, no type filter. Only the table body and pager remain. Why it matters: This is a mobile-first PWA (see apps/web/CLAUDE.md §12). An admin on a phone can only page linearly through the entire email log — potentially thousands of rows — with no way to search a recipient or filter to FAILED. This is a code-readable fact (not a rendered-viewport judgement), so it is asserted, not left Unverified. Fix: Migrate the two Selects to DataTable's declarative filters prop (which renders as chips + picker sheets on mobile) and pass :title="'Emails'" / :mobile-header="false" so the mobile sticky header + search appear. Alternatively, fix DataTable to also surface the #filters slot on mobile.
2. Failed load is an un-announced dead end with no retry — Medium · MSG-01, MSG-04
Where: packages/ui/src/components/DataTable.vue:526-531 → packages/ui/src/components/EmptyState.vue:38-77; consumed at EmailsPage.vue:108-110What: On isError, DataTable swaps in <EmptyState tone="error">, a plain <div> with no role="alert" and no aria-live, so a screen-reader user is told nothing when the load fails (WCAG 4.1.3). The error branch also renders no recovery action — the #action/#empty-action slot is only wired on the non-error empty branch (line 538), and EmailsPage passes no retry — so a failed fetch is a permanent dead end until a manual full-page refresh. The loading skeleton likewise carries no aria-busy. Why it matters: Silent failure with no way out; assistive-tech users don't even learn it failed. The empty-states pattern's "skipping announcement strategy" and "obvious recovery action" checks both fail here. Fix: Give EmptyState role="alert" (or an aria-live="assertive" wrapper) in the error tone, add aria-busy to the loading region, and expose a retry action on the error branch that re-runs the query. Belongs in @aadalen/ui. Cross-reference the DataTable cluster in PROJECT-LEVEL.md.
3. Entire feature is hardcoded English, no i18n — Medium · CONTENT-01
Where: EmailsPage.vue:32-47 (filter labels), :104,107-116 (title, empty/error/search copy); EmailDetailModal.vue:39,48-126 (every field label + "Email Details", "No body.") What: No useI18n/t() anywhere; all copy is English string literals — page title "Emails", "No emails found.", "Failed to load emails", "Search recipient or subject", every statusOptions/ typeOptions label, and all 12 detail-modal labels. Why it matters: customer-portal ships en/nb and is held to CONTENT-01 per feature (BASELINE.md). An nb admin sees an all-English screen. Fix: Route copy through vue-i18n with en/nb keys. Note this appears to be a whole-admin-section pattern (sync/commands pages look the same) — worth confirming whether admin is intentionally English-only and, if so, recording that as a project-level decision rather than per-feature.
4. Table shows raw enum codes while the filter shows friendly labels — Low · CONTENT-02/consistency
Where: EmailsPage.vue:63-66 (Type column renders info.getValue() in font-mono, e.g. linkRequest) vs :41-47 (filter options map the same values to "Access request", "Welcome", …) What: The Type column prints the raw code (linkRequest, warrantySubmission), but the Type filter dropdown and the reader's mental model use human labels. The user filters by "Access request" and then scans a column full of linkRequest. Why it matters: Forces the admin to mentally map codes↔labels; the friendly names already exist two declarations away. Fix: Reuse the typeOptions label map (or a shared formatter) in the Type cell renderer, in both the table and EmailDetailModal.vue:58.
5. Unlabelled search + filter inputs (shared DataTable) — Low · FORM-01
Where: packages/ui/src/components/DataTable.vue:417-421 (search InputText, placeholder only) and EmailsPage.vue:125-140 (both Selects: placeholder only, no associated <label>/aria-label) What: The search field and both filter selects are labelled only by placeholder text, which disappears on input and is not a programmatic label. Why it matters: Screen-reader users get an unnamed combobox/search field. Shared-component origin, so low and cross-referenced rather than feature-specific. Fix: Add aria-label (i18n) to the search input and each Select.
Unverified
- A11Y-01 (contrast): the
text-[10px] uppercase text-text-3field labels inEmailDetailModal(:48etc.) and the muted pager text are plausible contrast risks at that size but need a contrast tool on rendered output. - A11Y-06 (responsive/short viewport): finding #1 asserts control absence from code; the remaining reflow/short-viewport behaviour of the detail modal (fixed
max-w-3xl,h-96iframe) needs a rendered viewport. - PrimeVue internals:
SelectandDialogARIA semantics (role, focus trap,aria-modal) can't be confirmed —node_modulesabsent (see PROJECT-LEVEL.md standing caveat). - Row activation: DataTable rows are
role="button"+tabindex=0with@keydown.enter(DataTable.vue:611-616) but no Space handler — arole="button"should also activate on Space. Minor A11Y-03 gap in the shared table; noted, not separately filed.
Baseline additions
- RESPONSIVE-CONTROL-PARITY (proposed): every list/search/filter control available on desktop must remain reachable on mobile — a control rendered only inside a
!isMobilebranch with no mobile equivalent is a functional regression, not merely a layout one. Distinct from A11Y-06 because it is asserted from code, not measured. (Reconcile with any existing NAV-06(e)/view-mode proposals.)
Cross-project note
- Finding #2 (error state not announced / no retry) is the customer-portal DataTable/EmptyState instance of the cross-project MSG-06 / "failed load renders as empty state" theme already confirmed in members (
TableData.vue) and tt-time-tracker (ProjectDetails,Dashboard). Same shape, shared component. - Finding #1 (desktop-only filter controls) likely recurs on every customer-portal admin/list page that uses the
#filtersslot instead of the declarativefiltersprop (sync, commands, orders) — a DataTable-level fix would clear all of them. - Finding #3 (CONTENT-01) — customer-portal admin section appears English-only across pages; members and tt-time-tracker are single-locale by design (not comparable).
- NAV-03 (one static document title app-wide) fails here too — see PROJECT-LEVEL.md, not re-filed.