* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Ship a cart-then-commit mobile scanner so phone sessions stay on the camera. Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
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 sitewideprefers-reduced-motioncollapse are largely in good shape. - One severity-3 finding: nested List-picker
<Modal>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-describedbyhelper on disabled commit CTA, camera chrome notinert/aria-hiddenunder 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-labels |
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 <p id="checkout-select-hint">…</p>; 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 <video> + tabIndex={-1} / hide chrome from AT). |
| 2 | Target size | 2.5.5 | ReviewCardItem.js ~L72–79 |
Checkbox visual/control ~20×20 despite minWidth/Height: 44 on <input type="checkbox"> (unreliable). |
Wrap in <label className="… min-w-[44px] min-h-[44px] flex items-center justify-center"> or custom hit pad. |
| 2 | Keyboard / Operable | 2.1.1, 2.4.3 | ScannerCheckoutSheet.js ~L211–217 |
No header dismiss × (convoy: close via × / Escape / scrim). Keyboard-only users depend solely on Escape. | Add aria-label="Close cart" button in sheet header; include in focus order as first focusable. |
| 1 | Understandable | 3.3.1 / UX | pages/scanner.js Leave Modal ~L152–166 |
Copy uses "Stay" not "Keep scanning"; description omits {N} count. |
description={\${unprocessedCount} scanned cards haven't been added yet.`}`; primary label "Keep scanning". |
| 1 | Sensory | 1.4.1 | ScannerCheckoutSheet.js ~L127–130 |
Low-confidence subline lacks count; checkbox confidence in name only when <85%. | Subline: {n} need review; include confidence in checkbox aria-label whenever confidencePct != null. |
Severity ≥ 3 detail
A1 — Nested Escape / dual focus trap (sev 3)
Scenario: User opens checkout sheet → taps Add to List → List picker Modal opens. Presses Escape intending to close only the picker.
Observed behavior (static):
ScannerCheckoutSheetalways callsuseFocusTrap(true)and adds a documentkeydownEscape →onClosewhile mounted (ScannerCheckoutSheet.jsL171–188).<Modal open={isListPickerOpen}>independently traps focus and listens for Escape → itsonClose(Modal.jsL28–37;pages/scanner.jsL168–173).- Checkout stays mounted (
pages/scanner.jsL131–137) while picker is open — neither trap is suspended.
Both Escape listeners fire on one keypress → picker closes and cart sheet closes. Tab handlers from both traps remain registered → unpredictable focus cycling (constraint 10).
Suggested fix (minimal):
// ScannerCheckoutSheet — accept activeTrap prop
const sheetRef = useFocusTrap(trapActive !== false);
useEffect(() => {
if (!trapActive) return undefined;
// … Escape handler only when trapActive
}, [onClose, sheetRef, trapActive]);
// pages/scanner.js
<ScannerCheckoutSheet
trapActive={!isListPickerOpen}
…
/>
Or close List picker with Escape via Modal only, and in checkout Escape handler: if (document.querySelector('[data-list-picker-open]')) return.
Layer walk (5 layers, summary)
| Layer | Verdict |
|---|---|
| 1 Perceivable | Toast/status live regions OK; confidence has text+ring; peek/toast SVG icons OK; reduced-motion via global CSS OK. |
| 2 Operable | 44px chrome generally OK; nested Escape/trap is the blocker; missing sheet ×; checkbox target weak. |
| 3 Understandable | Labels strong on chrome; leave-modal / empty-CTA / low-confidence copy incomplete vs convoy. |
| 4 Robust | Dialog semantics present; dual aria-modal when nested; camera not inert under sheet. |
| 5 Consistency | Matches ScannerDisambiguation sheet shell pattern (good); nested Modal over sheet needs same trap suspension pattern as disambiguation-above-checkout (z-50 vs z-40) — disambiguation currently unlikely while checkout open because verify pauses, but List picker is the live path. |
Suggested diffs (priority)
- P0 — Suspend checkout Escape +
useFocusTrapwhile List picker (or any nested Modal) is open. - P1 — Disabled CTA helper +
aria-describedby. - P1 —
inert/aria-hiddenon camera when mobile checkout open. - P1 — 44×44 checkbox hit area via label wrapper.
- P2 — Sheet close button; leave-modal copy + count; "{N} need review".
Patterns to lift
ChromeIconButton— good reusable 44px +aria-label/aria-pressed/aria-busypattern (ScannerCamera.js).ScannerDisambiguation+useFocusTrap— correct shell to copy; extend withactivegated by nested overlays.ScannerToastSVG +role="status"— keep; do not add a competing live region on peek.
Automated checks
| Check | Result |
|---|---|
| axe-core / CI a11y job | Not run (static role audit) |
| Manual SR | Recommend VoiceOver pass: open cart → Add to List → Escape once |
Approval recommendation
- approve
- request-changes — sev ≥ 3 open (nested Escape/trap)
- comment-only
Fix A1 before merge. Sev-2 items should land in the same PR or an immediate follow-up brief tagged a11y.
Hand-off
A11y audit complete. 7 findings (sev ≥ 3: 1, sev < 3: 6).
Report: .convoys/scanner-mobile-checkout/audits/a11y-20260814.md.
Recommend fixing sev ≥ 3 before merge.