deckhearth/.convoys/scanner-desktop-layout/audits/a11y-20260815.md
varutasu 938c161a26
feat(scanner): add desktop workstation layout (#165)
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>
2026-08-15 17:21:23 -05:00

12 KiB
Raw Permalink Blame History

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 / Foil role="switch", strip tablist/tab/tabpanel with badge counts in aria-label, condition aria-label includes card name, commit errors role="alert", ember focus-visible rings, toast role="status", mobile checkout inert/trapActive carry-forward.
  • No severity ≥ 3 findings. Several sev-2 gaps vs promised UX §4 constraints: Camera fallback lacks aria-describedby; Auto-detect overlay badge has no aria-live; batch progress + Cancel live only inside the Queue tabpanel (often hidden); 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 in styles/.
  • Recommendation: comment-only for merge gate (no sev ≥ 3); fix the §4 constraint fails (A1A4) 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-ember4.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 ~L415418 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 ~L350370 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 ~L277285; 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 ~L8386 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 ~L246254 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)

  1. P1 — Camera aria-describedby ↔ fallback message id.
  2. P1 — Auto-detect badge aria-live="polite" (or role="status").
  3. P1 — On batch start, select Queue tab or hoist progress+Cancel outside hidden tabpanels (single polite region).
  4. P1 — Wire real isIdentifying for empty inspector + polite announcement.
  5. P2 — Badge contrast; define --color-success|warning|error|info.
  6. 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 + ember focus-visible ring; keep for desk/inspector CTAs.
  • Modal + useFocusTrap — correct Tips / leave / list picker shell; do not invent a second dialog.
  • Strip tab aria-label helpers (queueTabAriaLabel / duplicatesTabAriaLabel) — good; keep badge aria-hidden on 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 A1A4 (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).