# 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-label`s) |
| `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 (A1–A4) in this PR or an immediate a11y follow-up.

**Counts:** 8 findings (sev ≥ 3: **0**, sev &lt; 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)

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)

```jsx
// 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}
  …
/>
```

```jsx
// ScannerCamera.js — badge live region
<GlassSurface … role="status" aria-live="polite">
  <span className="…" aria-hidden="true" />
  Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
</GlassSurface>
```

```js
// 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)  
- [x] **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 &lt; 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).
