---
convoy: scanner-desktop-layout
brief_number: 1
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
  - lib/use-camera-scanner.js
  - test/lib/use-camera-scanner.test.js
cross_brief_commitments:
  - brief: 6
    description: |
      Exports `videoDevices`, `selectedDeviceId`, `setSelectedDeviceId`,
      `devicePickerStatus` (`loading` | `ready` | `empty` | `denied` | `error`),
      and `devicePickerMessage` for the desk Camera `<select>` in Brief 6.
      Mobile callers keep using `facingMode` / `switchFacingMode` only — do not
      mount the device picker below `md`.
---

# Brief 1: Webcam device picker hook

## Goal (1 sentence)

Extend `useCameraScanner` with `enumerateDevices` + `deviceId` stream selection for desktop webcams while preserving mobile facing-mode swap.

## Files in scope (do not edit anything else)

- `lib/use-camera-scanner.js`
- `test/lib/use-camera-scanner.test.js`

## Conventions to follow

- Tagged-template / relative imports only; no path aliases.
- No hex in JS — use CSS variables if any inline styles are needed in tests.
- `getUserMedia` constraints: when `selectedDeviceId` is set, use
  `{ video: { deviceId: { exact: selectedDeviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } }`;
  when unset, keep existing `facingMode` constraint shape.
- Call `enumerateDevices` after the first successful stream (labels require an
  active permission grant). Filter `kind === 'videoinput'`.
- Optional cheap persist: `sessionStorage` key `scanner:last-camera-device-id`
  — read on init, write on `setSelectedDeviceId`.
- Restart stream on `selectedDeviceId` change (same pattern as `activeFacingMode`
  effect). Guard with a ref to skip the initial mount double-start.
- `verificationPausedRef` contract unchanged — camera hook does not own pause logic.
- Mirror existing test style in `test/lib/scanner-card-detection.test.js` (vitest,
  mocks for `navigator.mediaDevices`).

## Implementation shape (verified against current hook)

Current hook returns `facingMode`, `switchFacingMode` only — no `deviceId`.
`startCamera` uses `facingMode: activeFacingModeRef.current` exclusively.

Add:

```js
const [videoDevices, setVideoDevices] = useState([]);
const [selectedDeviceId, setSelectedDeviceId] = useState(() => {
  try {
    return sessionStorage.getItem('scanner:last-camera-device-id') || '';
  } catch {
    return '';
  }
});
const [devicePickerStatus, setDevicePickerStatus] = useState('loading');
```

`refreshVideoDevices` async helper:

1. If `!navigator.mediaDevices?.enumerateDevices`, set status `error`.
2. Map videoinputs; if length === 0 → `empty`.
3. If `selectedDeviceId` not in list, fall back to first device or ''.
4. On `NotAllowedError` from prior getUserMedia → `denied`.

Export `setSelectedDeviceId` wrapper that persists to sessionStorage.

**Do not** remove `switchFacingMode` — mobile Brief 3 camera chrome still uses it.

## Acceptance criteria

- [ ] `enumerateDevices` populates `videoDevices` after stream start in tests (mocked)
- [ ] `selectedDeviceId` switches `getUserMedia` constraints to `deviceId: { exact }`
- [ ] `switchFacingMode` still toggles environment/user when no deviceId forced
- [ ] `devicePickerStatus` + message cover loading, empty, denied, error per Design direction table
- [ ] sessionStorage persist for last device id (read/write with try/catch)
- [ ] tests added in `test/lib/use-camera-scanner.test.js`
- [ ] no scope expansion (do not edit files outside `files:` above)

## Rationale (≤3 sentences)

Desktop workstations need a real webcam picker (C4), not a relabeled facing-mode swap. Isolating device enumeration in the hook keeps `ScannerCamera` and `pages/scanner.js` thin. Mobile behavior stays untouched because deviceId is only consumed at the page/camera wiring layer in Brief 6.
