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

3.8 KiB

convoy brief_number depends_on recommended_model model_tier files cross_brief_commitments
scanner-desktop-layout 1
composer-2.5-fast fast
lib/use-camera-scanner.js
test/lib/use-camera-scanner.test.js
brief description
6 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:

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.