# A11y audit — scanner-mobile-checkout **Reviewer:** role-a11y-auditor **Convoy:** `scanner-mobile-checkout` **Surface:** uncommitted UI diff vs `origin/main` (+ untracked) — no GitHub PR **Date:** 2026-08-14 **Model:** cursor-grok-4.5-high (`model_tier=audit`) **Skill note:** `.cursor/skills/accessibility-audit/SKILL.md` was not present in-repo; audit follows role + convoy UX § A11y constraints (14 items) + WCAG 2.2 AA (AAA target size where convoy requires). ## Diff scope (UI) | Path | Status | | --- | --- | | `components/Layout.js` | modified (`chrome="immersive"`) | | `components/scanner/ScannerCamera.js` | modified | | `components/scanner/ScannerCountPill.js` | modified | | `components/scanner/ScannerToast.js` | modified | | `components/scanner/ReviewCardItem.js` | modified | | `components/scanner/ScannerReview.js` | modified | | `components/scanner/ScannerCheckoutSheet.js` | **new** | | `components/scanner/ScannerScanPeek.js` | **new** | | `pages/scanner.js` | modified | | `test/components/ScannerCheckoutSheet.test.js` | new (a11y-adjacent) | Non-UI (session/hooks/login/tests) excluded from findings. ## Executive summary - Chrome controls (flash / swap / gallery / back / Review N), peek tap target, toast live region, checkout `role="dialog"` + Escape + `useFocusTrap`, and sitewide `prefers-reduced-motion` collapse are largely in good shape. - **One severity-3 finding:** nested List-picker `` over open checkout sheet leaves **two Escape handlers and two focus traps** active — Escape dismisses both layers (and Tab can fight). Convoy constraint 10. - Several severity-2 gaps vs promised constraints: missing sheet close control, no `aria-describedby` helper on disabled commit CTA, camera chrome not `inert`/`aria-hidden` under sheet, native checkbox hit target unreliable at 44×44. - **Recommendation:** request-changes until nested-overlay Escape/trap is fixed; ship sev-2 before merge if possible. **Counts:** 7 findings (sev ≥ 3: **1**, sev < 3: **6**). Constraint checklist: **9 pass / 3 partial / 2 fail**. ## Convoy constraint checklist | # | Constraint | Result | Notes | | --- | --- | --- | --- | | 1 | 44×44 targets (2.5.5) | **Partial** | ChromeIconButton, Review N, qty ±, overflow use `minWidth/Height: 44`. Native checkbox is `w-5 h-5` with style mins — hit box often stays ~20px. | | 2 | Contrast on glass (1.4.3) | **Pass*** | Token text colors; no instrumented contrast measure (*static review). | | 3 | Focus order open/close (2.4.3) | **Partial** | Trap focuses first focusable in panel; restore via `useFocusTrap`. No header ×; restore may miss if opener was auto-dismissed peek. | | 4 | Keyboard Escape / peek Enter (2.1.1) | **Partial** | Escape + button peek OK; nested Escape conflict (see A1). | | 5 | Dialog name/role (4.1.2) | **Pass** | Checkout: `role="dialog"` `aria-modal` `aria-labelledby` → "{N} cards scanned". List picker Modal title "Choose a List". | | 6 | Required `aria-label`s | **Pass** | Leave / gallery / flash+`aria-pressed` / swap / Review N / checkbox / qty / overflow present. | | 7 | Non-text (1.1.1) | **Pass** | Row thumbs `alt={card.name}`; peek thumb `alt=""` under named button; decorative SVGs `aria-hidden`. | | 8 | Status messages (4.1.3) | **Pass** | Toast `role="status"` `aria-live="polite"`; scan status `aria-live="polite"`. Peek is interactive, not a second live region (toast covers identify). | | 9 | Reduced motion (2.3.3) | **Pass** | Peek/sheet/toast transitions + spinners covered by `styles/globals.css` `@media (prefers-reduced-motion: reduce)`. | | 10 | Focus trap all overlays | **Fail** | Sheet traps; Modal traps; both stay active when List picker opens over checkout. | | 11 | Confidence not color-only (1.4.1) | **Partial** | Visible `{pct}% match` + ring; checkbox `aria-label` adds confidence only when <85%; header says "Some matches…" not "{N} need review". | | 12 | Gallery file input | **Pass** | `sr-only` input + labeled button; `aria-busy` while identifying. | | 13 | Disabled CTA + describedby | **Fail** | `disabled` only; no helper text / `aria-describedby` / `aria-disabled`. | | 14 | Hide camera under sheet | **Fail** | `isCheckoutOpen` only changes status copy; video + chrome not `aria-hidden`/`inert`. | ## Findings | Sev | Layer | WCAG | Surface | Issue | Fix | | --- | --- | --- | --- | --- | --- | | **3** | Keyboard / Focus | 2.1.1, 2.4.3 | `pages/scanner.js` + `ScannerCheckoutSheet.js` | Nested List picker Modal while checkout sheet open: both register document `Escape` + Tab traps. Escape closes **picker and sheet**. | Deactivate checkout trap + Escape while `isListPickerOpen`; or close sheet Escape only when picker closed (`if (e.key === 'Escape' && !isListPickerOpen)`). Prefer `useFocusTrap(active && !nestedOpen)`. | | **2** | Name / Instructions | 3.3.2, 4.1.2 | `ScannerCheckoutSheet.js` `CheckoutFooter` ~L40–51 | Zero selection: primary disabled with no visible "Select at least one card" / `aria-describedby`. | Add helper `

`; `aria-describedby={selectedCount === 0 ? 'checkout-select-hint' : undefined}`; optional `aria-disabled` mirroring `disabled`. | | **2** | Structure / AT | 1.3.2, 4.1.2 | `ScannerCamera.js` + `pages/scanner.js` | Sheet open does not set `aria-hidden`/`inert` on viewfinder chrome (constraint 14). | When `isCheckoutOpen` (mobile), set `inert` on camera root (or `aria-hidden` on `