deckhearth/.convoys/scanner-desktop-layout/brief-1-camera-device-picker.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

90 lines
3.8 KiB
Markdown

---
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.