Give /scanner a md+ camera, live match inspector, and history strip (with device picker, batch scan, and tips) without regressing the mobile immersive checkout. Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
A11y audit — scanner-desktop-layout
Reviewer: role-a11y-auditor
Convoy: scanner-desktop-layout
Surface: local UI diff vs origin/main (+ untracked workstation components) — audit group audit-scanner-desktop-layout-local
Date: 2026-08-15
Model: cursor-grok-4.5-high (model_tier=fast)
Skill note: .cursor/skills/accessibility-audit/SKILL.md was not present in-repo; audit follows role-a11y-auditor.md, prior report shape (.convoys/scanner-mobile-checkout/audits/a11y-20260814.md), convoy UX §4 A11y constraints, and WCAG 2.2 AA.
Diff scope (UI)
| Path | Status |
|---|---|
pages/scanner.js |
modified (workstation wiring) |
components/scanner/ScannerCamera.js |
modified (variant="workstation", Auto-detect badge) |
components/scanner/ScannerResultPanel.js |
new |
components/scanner/ScannerHistoryStrip.js |
new |
components/scanner/ScannerTips.js |
new |
test/components/ScannerHistoryStrip.test.js |
new (a11y-adjacent: tab aria-labels) |
test/components/ScannerResultPanel.test.js |
new (condition name / foil switch) |
test/components/ScannerTips.test.js |
new |
test/components/ScannerCamera.test.js |
new |
Non-UI (lib/use-camera-scanner.js, batch helper, queue hooks) excluded from findings except where they gate AT-visible status.
Patterns referenced: components/ui/Modal.js (useFocusTrap, role="dialog"), components/ui/Button.js (ember focus-visible ring, visible label in name).
Executive summary
- Desktop workstation ships strong foundations: Tips via
<Modal>+ trap, Auto-detect / Foilrole="switch", striptablist/tab/tabpanelwith badge counts inaria-label, conditionaria-labelincludes card name, commit errorsrole="alert", emberfocus-visiblerings, toastrole="status", mobile checkoutinert/trapActivecarry-forward. - No severity ≥ 3 findings. Several sev-2 gaps vs promised UX §4 constraints: Camera fallback lacks
aria-describedby; Auto-detect overlay badge has noaria-live; batch progress + Cancel live only inside the Queuetabpanel(oftenhidden); empty-inspector “Identifying…” is wired to disambiguation, not identify-in-flight; queue badge white-on-ember is ~4.44:1;--color-*semantic tokens appear undefined instyles/. - Recommendation: comment-only for merge gate (no sev ≥ 3); fix the §4 constraint fails (A1–A4) in this PR or an immediate a11y follow-up.
Counts: 8 findings (sev ≥ 3: 0, sev < 3: 8). Constraint checklist: 8 pass / 3 partial / 3 fail.
Convoy constraint checklist (UX §4)
| # | Constraint | Result | Notes |
|---|---|---|---|
| 1 | 1.4.3 Contrast text on glass | Partial | Body --text-primary/secondary on solid --bg-* ≥ 4.5:1 (sampled). Queue badge #ffffff on --accent-ember ≈ 4.44:1 (fails normal text). Glass composite not instrumented. |
| 2 | 1.4.10 Reflow @ 768 / 200% | Pass* | md:flex-row → column below md; strip overflow-x-auto only (*static; no browser zoom run). |
| 3 | 1.4.11 Focus not obscured (sticky) | Pass | Inspector rail is flex column sibling — not position: sticky/fixed. Batch banner sits in strip flow. |
| 4 | 2.1.1 Keyboard all actions | Partial | Desk controls, tips, inspector, tabs, chips, Clear All, View All are <button>/<select>. Batch Cancel only mounts on Queue tab — reachable after tab switch, not after Batch Scan without navigating. |
| 5 | 2.4.3 Focus order | Pass | DOM ≈ convoy: Tips → camera → desk (Camera → Upload → Batch → Auto-detect) → inspector → strip tabs → chips → View All. |
| 6 | 2.4.7 Focus visible | Pass | Ember focus-visible:ring-2 on Button, switches, selects, tabs, chips, Clear All. |
| 7 | 2.4.11 Focus not obscured (modals) | Pass | Tips / leave / list picker use Modal + useFocusTrap; Escape closes. |
| 8 | 4.1.2 Auto-detect switch | Pass | role="switch" aria-checked={!isAutoDetectPaused} aria-labelledby → visible Auto-detect. |
| 9 | 4.1.2 Strip tabs | Pass | tablist / tab / tabpanel, aria-selected, aria-controls, badge counts in aria-label (locked by tests). |
| 10 | 4.1.2 Camera <select> + aria-describedby |
Fail | Visible <label>Camera</label> OK; fallback <p> has no id / no aria-describedby. |
| 11 | 4.1.2 Condition + Foil | Pass | Condition aria-label={Condition for ${card.name}}; Foil switch + aria-labelledby. |
| 12 | 4.1.3 Auto-detect badge live region | Fail | Overlay badge is visual only (decorative dot aria-hidden); no aria-live. Switch announces only when focused/toggled. |
| 13 | 4.1.3 Batch progress live + toast | Partial | Banner has aria-live="polite" + progressbar; toast role="status". Banner lives only inside Queue panel — silent when another tab is selected. |
| 14 | 3.3.1 Identify failed + commit alert | Pass | Chip shows Identify failed + reason; inspector role="alert" for commit errors. |
| 15 | 2.5.3 Label in Name | Pass | Upload Image / Batch Scan / Scanner Tips / vocab CTAs use visible text (not icon-only). |
| 16 | Live region discipline | Partial | One batch banner (good); camera toast + page toast both aria-live="polite"; DetectionFrame also polite (pre-existing). |
Findings
| Sev | Layer | WCAG | Surface | Issue | Fix |
|---|---|---|---|---|---|
| 2 | Robust / Name | 1.3.1, 4.1.2 | pages/scanner.js ~L415–418 |
Device fallback message is not linked to the Camera <select> (aria-describedby promised). Disabled empty/denied states are orphaned for AT. |
Give message id="scanner-camera-select-hint"; set aria-describedby={camera.devicePickerMessage ? 'scanner-camera-select-hint' : undefined} on <select>. |
| 2 | Status | 4.1.3 | ScannerCamera.js ~L350–370 |
Workstation Auto-detect ON/OFF badge has no live region (constraint 12). | Wrap badge text in role="status" aria-live="polite" (or aria-live on the GlassSurface), e.g. announce Auto-detect ON / OFF when autoDetectOn changes. Keep decorative dot aria-hidden. |
| 2 | Status / Operable | 4.1.3, 2.1.1 | ScannerHistoryStrip.js ~L277–285; pages/scanner.js handleBatchChange |
BatchProgressBanner (live region + Cancel) renders only inside Queue tabpanel. Default/other tabs leave banner hidden → progress often unannounced; Cancel not in immediate focus path after Batch Scan. |
On batch start: setStripActiveTab(TAB_QUEUE); and/or mount a single always-visible polite region + Cancel above the tablist (one live region). |
| 2 | Status | 4.1.3 | pages/scanner.js ~L520; ScannerResultPanel.js ~L83–86 |
isIdentifying={Boolean(identification.disambiguation)} never reflects gallery/webcam identify-in-flight → empty rail stays “Point your camera…” instead of “Identifying…”. |
Pass a real identifying flag from identification hook (or galleryBusy || batchBusy / in-flight promise); keep disambiguation separate. Prefer aria-live="polite" on the Identifying copy. |
| 2 | Perceivable | 1.4.3 | ScannerHistoryStrip.js ~L246–254 |
Badge uses hardcoded #ffffff on --accent-ember (~4.44:1) under 4.5:1 for text-xs. |
Use a tokenized on-accent color that clears 4.5:1, or enlarge/bold to large-text threshold, or place count in aria-label only with a higher-contrast chip chrome. |
| 2 | Perceivable | 1.4.11, 1.4.1 | ScannerResultPanel.js confidenceRingColor; error alerts |
--color-success / --color-warning / --color-error / --color-info are referenced but not defined under styles/ (or Tailwind theme). Mid/high confidence fills and error tints may not paint; low band still uses --accent-ember. Text % + captions / role="alert" mitigate. |
Define semantic tokens in the design-token surface (or map to existing --accent-* / documented status colors) so bar fill meets 3:1 vs track and error text remains distinguishable. |
| 1 | Keyboard | 2.1.1 (APG) | ScannerHistoryStrip.js tablist |
Tabs are all in Tab order; no arrow-key roving tabIndex (ARIA APG tabs pattern). |
Optional: selected tab tabIndex={0}, others -1; Left/Right moves selection. Not required for WCAG if all tabs remain operable. |
| 1 | Status | 4.1.3 | pages/scanner.js + ScannerCamera.js |
Page-level ScannerToast and in-camera toast are both polite live regions; DetectionFrame adds more. Risk of duplicate/noisy announcements. |
Prefer one page-level toast on desktop workstation; suppress camera toast when variant="workstation", or share a single live region host. |
Severity ≥ 3 detail
None.
Layer walk (5 layers, summary)
| Layer | Verdict |
|---|---|
| 1 Perceivable | Token body text contrast OK on solids; badge white/ember short; confidence mid/high fill tokens missing; confidence still has % + caption (not color-only); reflow structure OK statically. |
| 2 Operable | Focus rings consistent with Button/Modal; modals trapped; batch Cancel discoverability weak; tab APG optional. |
| 3 Understandable | Labels strong (Camera, Auto-detect, Foil, condition-with-name, vocab CTAs); leave modal copy includes count + “Keep scanning”; device fallback not programmatically associated. |
| 4 Robust | Switch/tablist/tabpanel/progressbar/alert/status mostly correct; missing aria-describedby + badge live region; Identifying flag mis-wired. |
| 5 Consistency | Correctly lifts Modal/Button/GlassSurface; matches ReviewCardItem condition/foil naming; mobile inert + trapActive={!isListPickerOpen} retained from prior a11y fix. |
Suggested diffs (priority)
- P1 — Camera
aria-describedby↔ fallback message id. - P1 — Auto-detect badge
aria-live="polite"(orrole="status"). - P1 — On batch start, select Queue tab or hoist progress+Cancel outside
hiddentabpanels (single polite region). - P1 — Wire real
isIdentifyingfor empty inspector + polite announcement. - P2 — Badge contrast; define
--color-success|warning|error|info. - P3 — Optional tab roving tabindex; dedupe desktop toasts.
Minimal patches (illustrative)
// pages/scanner.js — Camera describedby
<p id="scanner-camera-select-hint" className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{camera.devicePickerMessage}
</p>
<select
id="scanner-camera-select"
aria-describedby={camera.devicePickerMessage ? 'scanner-camera-select-hint' : undefined}
…
/>
// ScannerCamera.js — badge live region
<GlassSurface … role="status" aria-live="polite">
<span className="…" aria-hidden="true" />
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
</GlassSurface>
// pages/scanner.js — batch start
setStripActiveTab(TAB_QUEUE);
setBatchProgress({ active: true, current: 0, total: files.length, onCancel: … });
Patterns to lift
Button— visible children + emberfocus-visiblering; keep for desk/inspector CTAs.Modal+useFocusTrap— correct Tips / leave / list picker shell; do not invent a second dialog.- Strip tab
aria-labelhelpers (queueTabAriaLabel/duplicatesTabAriaLabel) — good; keep badgearia-hiddenon visual count. - Foil / Auto-detect switch markup — reusable 44×44 hit pad +
aria-labelledby. - Prior mobile fix:
inert+trapActive={!isListPickerOpen}— retain.
Automated checks
| Check | Result |
|---|---|
| axe-core / CI a11y job | Not run (static role audit) |
| Contrast math | Sampled token pairs + white/ember badge (see § checklist) |
| Manual SR | Recommend VoiceOver: toggle Auto-detect; start Batch Scan from Recent tab; deny camera permission and inspect select description; commit error alert |
Approval recommendation
- approve
- request-changes (sev ≥ 3)
- comment-only — no sev ≥ 3; fix A1–A4 (sev 2 constraint fails) before or immediately after merge
Hand-off
A11y audit complete. 8 findings (sev ≥ 3: 0, sev < 3: 8).
Report: .convoys/scanner-desktop-layout/audits/a11y-20260815.md.
Recommend fixing sev ≥ 3 before merge — none open; still land P1 constraint fixes (describedby, badge live region, batch progress visibility, Identifying wiring).