Appearance
[UX] customer-portal — Shipping orders (internal)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 10 Patterns: none consulted (baseline sufficient; the queue'sforms/signature-padpattern does not apply — this feature records reception via photo + per-line received/not-received decisions, no signature capture exists)
Summary
A well-built internal goods-reception flow: a card-only list with pending/received tabs, and a detail page whose per-line decisions, partial-quantity steppers, optimistic mutations and server-polled write-back progress are unusually careful, with persistent (non-toast) error surfaces that correctly distinguish forbidden/pending/retryable command outcomes. en/nb locale parity is complete (CONTENT-01 passes). Defects are localized: a focus-dropping Confirm button (FORM-06), a sub-24px photo-delete control (A11Y-02), and an ARIA tab widget with no keyboard model or panel relationship (A11Y-03). Nothing here is a blocker.
Findings
1. Confirm button is disabled during submission — Medium · FORM-06
Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:720-728What: The primary action is bound :disabled="!allDecided || isConfirming" and :loading="isConfirming". The moment the user activates Confirm, isConfirming becomes true and the button disables itself. A disabled element cannot hold focus, so focus is dropped to <body>. Why it matters: Keyboard and screen-reader staff lose their place mid-action on the one control that commits a reception; the subsequent success/error surface is not focus-managed either, so nothing announces the outcome to where focus lands. This is the explicit || isConfirming disable, independent of PrimeVue's internal :loading→disabled behaviour, so it is assertable from source. Fix: Keep the button enabled and guard re-entry inside onConfirm (early-return while isConfirming); convey busy state with aria-busy + the spinner rather than disabled. Move focus to the resulting ReceptionProgress/error region.
2. Photo-delete control is below the 24px minimum target — Medium · A11Y-02
Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:572-580What: The remove-photo button is size-5 (20×20 CSS px), positioned overlapping the thumbnail corner (-right-1.5 -top-1.5). Why it matters: WCAG 2.5.8 requires ≥24×24px. On a warehouse phone this is a hard-to-hit control that also sits partly off the thumbnail edge, so accidental misses (and accidental hits) are both likely on the evidence photos a reception depends on. Fix: Give the control a ≥24px hit area (e.g. size-6 or a larger padded tap target while keeping the visual glyph small).
3. Tab strip declares ARIA tab roles without the tab keyboard model or a panel — Medium · A11Y-03
Where: apps/web/src/pages/internal/ShippingOrdersPage.vue:72-90What: The pending/received switch uses role="tablist" + role="tab" + aria-selected, but the tabs are plain buttons with no roving tabindex/arrow-key navigation, no aria-controls, and the DataTable they drive has no role="tabpanel" or matching id. Why it matters: A screen reader announces "tab, 1 of 2" and sets the expectation of arrow-key movement and an associated panel; neither exists, so the widget behaves unlike what it claims to be. This mislabels the control rather than merely under-describing it. Fix: Either complete the pattern (roving tabindex + Left/Right handling, aria-controls pointing at a role="tabpanel" wrapper on the list) or drop the tab roles and expose the two views as a labelled button group / segmented control.
4. Uploaded photos are deleted with no confirmation — Low · MSG-05
Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:293-300, 572-580What: Tapping the × on a thumbnail calls onRemovePhoto immediately; there is no confirm step and no undo. Why it matters: The photo is evidence attached to a reception line; an accidental tap (made more likely by finding #2) silently discards it. Fix: Confirm before deleting, or offer a brief undo. Mitigated by the receipt still being a draft (v-if="isDraft"), so the photo can be re-taken — hence Low.
5. Confirm stays disabled while the reception is incomplete — Low · FORM-05
Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:707-728What: Confirm is disabled until every line has a decision (allDecided). Why it matters: Baseline prefers submits that stay enabled with a message explaining the block, so the user can trigger the explanation. Rated Low, not Medium, because the block is already explained live and specifically: the SelectionActionBar #info slot renders "{count} product(s) still need to be checked" that updates as lines are decided, so the user is never left without a next action — this is a soft FORM-05. Fix: Optionally keep Confirm enabled and surface the same remaining-count message on activation; current mitigation is close to compliant in spirit.
6. Photo input forces the camera and suppresses the gallery — Low · FORM-12 (proposed, see Baseline additions)
Where: apps/web/src/pages/internal/ShippingOrderReceiptPage.vue:590-597What: <input type="file" accept="image/*,.heic,.heif" capture="environment">. capture forces the rear camera on mobile and removes the "choose existing photo" path. Why it matters: Staff cannot attach a photo already taken (e.g. one snapped before opening the order, or a supplier's damage photo). Likely intentional for live reception, so Low. Fix: Drop capture (keep accept) to let the OS offer both camera and gallery, or offer both affordances.
Unverified
- A11Y-01 (contrast): the pale status pills —
text-status-*-fgonbg-status-*-bg, andtext-text-3muted metadata (product counts, internId) — need a contrast tool against the ÅDALEN tokens; not determinable from class names. - A11Y-06 (short viewport / mobile keyboard): the detail page's fixed
SelectionActionBarplus sticky mobile title and the card grid need a rendered ≈700px-height viewport to confirm the last line card and the action bar do not collide. - MSG-01 on PrimeVue
Message: the load-failureMessage(:429) and the fully-received/already-confirmedMessages rely on PrimeVue's internal ARIA role;node_modulesis not installed, so whether they carryrole="alert"/statusis unverified. (The in-repoCommandErrorNoticeandReceptionProgressboth set correctrole/aria-livefrom source.)
Baseline additions
- FORM-12 (proposed, ID will collide — orchestrator to renumber): A file input must not set
captureunless capturing live media is the only valid source; otherwise it suppresses the gallery and blocks attaching an existing file. Already proposed as FORM-12(a) elsewhere in this batch (see PROJECT-LEVEL.md proposed-rule collisions) — cite, do not re-mint.
Cross-project note
- FORM-06 (disable-on-submit focus drop) is the customer-portal recurrence of a theme already confirmed in playout (
VButtondisabled-loses-focus, repo-wide). Likely present anywhere a PrimeVue/FormKit submit is bound:disabledto its own in-flight flag — worth grepping:disabled=".*isConfirming|isPending|isSubmitting"across customer-portal, tt-time-tracker (also PrimeVue) and members. - A11Y-03 mislabelled/incomplete ARIA widgets aligns with the cross-project "click-only rows/chips" A11Y-03 row in PROJECT-LEVEL.md (all four projects).
Project-level items affecting this feature (referenced, not re-filed)
- NAV-03 — one static document title app-wide. Fails here too; see PROJECT-LEVEL.md.
- List state is component-local (no URL sync / no KeepAlive) — the tab, search and page on the list reset on Back from a detail; see PROJECT-LEVEL.md.
- Shared
@aadalen/uiDataTablecluster — seedrafts/customer-portal--catalog.md. Note: the specific "error copy interpolates TanStack status string / 403 branch is dead code" defect appears fixed in this checkout'sDataTable.vue:247-276(numericstatusCode, live 403 branch,Status {n}description only when a code exists). This list passes:is-errorwithout:status, so its 403s show the generic message rather than the forbidden-specific one — minor, not re-filed. - MSG-02 (
getErrorMessageprefers raw backend string) — not exercised here: this feature maps errors viauseCommandErrortocommandError.<category>i18n keys and statict()copy; no raw SDK prose reaches the user.