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>
This commit is contained in:
varutasu 2026-08-15 17:21:23 -05:00 committed by GitHub
parent fdf8f5ece4
commit 938c161a26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 4437 additions and 108 deletions

View file

@ -116,3 +116,18 @@
{"ts": "2026-08-15T21:42:20Z", "role": "role-ux-reviewer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:42:20Z", "role": "role-architect", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "model": "composer-2.5", "model_tier": "standard"}
{"ts": "2026-08-15T21:42:20Z", "role": "role-implementer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 600, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:44:54Z", "role": "role-conductor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "classification": "feature", "duration_s": 900, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:48:07Z", "role": "role-ia-architect", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 120, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:49:34Z", "role": "role-ui-designer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 300, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:50:19Z", "role": "role-ux-reviewer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:51:58Z", "role": "role-architect", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 720, "model": "composer-2.5", "model_tier": "standard"}
{"ts": "2026-08-15T21:53:14Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 5, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:53:18Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 2, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:53:18Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 1, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:53:19Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 3, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:53:28Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 4, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:55:19Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 6, "duration_s": 900, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:56:43Z", "role": "role-reviewer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 58, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"}
{"ts": "2026-08-15T21:56:53Z", "role": "role-security-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 300, "multitask_group": "audit-scanner-desktop-layout-local", "model": "gpt-5.6-terra-medium", "model_tier": "fast"}
{"ts": "2026-08-15T21:57:11Z", "role": "role-design-system-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 165, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"}
{"ts": "2026-08-15T21:57:53Z", "role": "role-a11y-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 120, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"}

View file

@ -0,0 +1,889 @@
---
name: scanner-desktop-layout
classification: feature
success_metric: |
On md+ viewports, /scanner keeps the desktop app chrome (sidebar +
top bar), shows a framed camera workstation with a real webcam
device picker, Upload Image, Batch Scan, Auto-detect, Scanner
Tips, a live match inspector, and a bottom strip with Recent
Scans / Scan Queue / Duplicates — without regressing the mobile
immersive checkout.
skip: []
status: open
created: 2026-08-15
depends_on:
- scanner-mobile-checkout
- scanner-rebuild
model_policy:
default_session: auto
roles:
role-conductor: composer-2.5-fast
role-architect: composer-2.5
role-ia-architect: composer-2.5-fast
role-ux-reviewer: composer-2.5-fast
role-ui-designer: composer-2.5-fast
role-implementer: composer-2.5-fast
role-reviewer: cursor-grok-4.5-high
role-security-auditor: gpt-5.6-terra-medium
role-design-system-auditor: cursor-grok-4.5-high
role-a11y-auditor: cursor-grok-4.5-high
role-doc-writer: auto
escalate_to: claude-sonnet-5-thinking-medium
escalate_to_premium: claude-4.6-opus-high-thinking
never_premium:
- role-reviewer
- role-security-auditor
- role-design-system-auditor
- role-a11y-auditor
- role-ui-designer
- role-doc-writer
design_direction:
source: role-ui-designer
skill: ui-ux-pro-max
skill_version: "2.5.0"
version: 1
locked_at: 2026-08-15
product_type: desktop trading card scanner workstation
pattern: Feature-Rich Showcase (workstation variant)
style: Liquid Glass / glassmorphism
stack: nextjs
layout_reference: image-1eeebe23-9983-4a96-a3e7-4e3cdfdceb5b.png
---
# Convoy: scanner-desktop-layout
Give `/scanner` a dedicated desktop workstation layout from the
attached dark/light mock (camera + live result + history strip),
while leaving the shipped mobile immersive checkout alone.
Worktree: `tcg-vault-worktrees/scanner-desktop-layout` on
`convoy/scanner-desktop-layout` (branched from `origin/main` @
`0d52858`). Layout reference:
`image-1eeebe23-9983-4a96-a3e7-4e3cdfdceb5b.png`.
Do **not** land this on `dashboard-home-realignment` — that convoy
owns sidebar IA + top-bar chrome. Do **not** land this on
`scanner-identify-upgrade` — that epic owns detect/identify accuracy.
## Why
`scanner-mobile-checkout` shipped the phone job: full-bleed camera,
local cart, checkout sheet. Desktop (`md+`) got the leftover
composition — the same camera chrome plus a 360px cart side panel
(`ScannerReview` `variant="side-panel"`). That is not a desk
workstation.
On a laptop the user wants to see the webcam, inspect the current
match (set, rarity, number, condition, foil, confidence), decide
what to do with it, and keep a history/queue in view — without
losing the app sidebar or search bar. The mock is that layout.
Today they get a phone overlay stretched into a column.
## Scope
### In scope
- **Desktop-only composition (`md+`).** Keep Layout sidebar +
TopSearchBar. Stop treating desktop as an immersive camera page
with a bolted-on cart. Mobile (`max-md`) stays
`chrome="immersive"` + checkout sheet.
- **Framed camera viewport.** Large live feed with ember corner
brackets (already in `ScannerCamera`), Auto-detect status, and
desk controls under the frame. Camera is a panel in the page,
not a full-bleed overlay.
- **Real webcam device picker.** `enumerateDevices` + `deviceId`
in `useCameraScanner` (not facing-mode swap relabeled). Persist
the last-used device for the tab if cheap. Empty-list / denied-
permission fallback. Mobile keeps the existing facing-mode
toggle — do not replace the phone chrome with a device `<select>`.
- **Upload Image + Batch Scan.** Upload Image stays single-file
(existing gallery path). **Batch Scan** is a multi-file picker
that runs each image through `identifyFromGalleryFile`
**sequentially** (respect existing identify rate limits; no
parallel Gemini storm, no new batch API). Progress and failures
show in Scan Queue. Live multi-card on the webcam stays
Auto-detect — do not build a new pile-in-one-frame detector.
- **Scanner Tips.** Header control from the mock. Glass popover
or modal (use `<Modal>` / `GlassSurface`, no custom scrim) with
short lighting / framing / foil / auto-detect guidance. No new
route. Copy authored in this convoy (IA + UI Designer).
- **Live match inspector (right rail).** Current identify result:
thumbnail, name, set, rarity, collector number, condition,
foil, confidence, primary add, Rescan. This replaces the cart
list as the primary right-hand surface.
- **Bottom history / queue strip.** Three tabs over the existing
cart + ownership model: **Recent Scans** (session history),
**Scan Queue** (uncommitted cart), **Duplicates** (already in
My Collection via `ownershipMap`, and/or same-session
name+set repeats from `mergeScannedCardEntry`). Badge counts
on Queue and Duplicates. Clear + view-all as IA/UX refine.
- **Reuse, don't rewrite, the scan engine.** Same hooks:
`useCameraScanner`, `useScannerIdentification`, `useScannerQueue`,
`scanner-session`. Identify / OCR / Gemini stay out.
### Out of scope
- Identify / detection / Gemini (`lib/scanner-card-identify.js`,
`pages/api/scan/identify.js`, OpenCV). Sibling:
`scanner-identify-upgrade`.
- Mobile immersive checkout, scan peek, or checkout sheet — no
visual or interaction regression below `md`.
- Layout nav IA, Daily Ember, notifications, profile cluster.
Sibling: `dashboard-home-realignment`. Mock items Wishlist /
Trades / Market / Events / Binders are **not** product nav.
- Wishlist as a feature (no table / API). Mock "Save to Wishlist"
maps to **Add to List** or drops — IA decides.
- A new identify / batch-OCR API, parallel Gemini calls, or a
pile-in-one-frame detector. Batch Scan is multi-file sequential
identify on the existing path.
- Schema / new tables. Cart remains client session state.
- Changing the homepage visual-diff baseline except as a
side-effect of `/scanner` desktop chrome (scanner surfaces only).
### Product-vocab lock (from mock → Deck Hearth)
| Mock copy | Ship as |
| --- | --- |
| Add to Collection | `VOCAB.ADD_TO_MY_COLLECTION` |
| Save to Wishlist | Add to List, or omit |
| Binders (nav) | Lists — not this convoy |
| Collection (nav) | My Collection — not this convoy |
## Roles invoked
1. `role-ia-architect` — desktop flow vs mobile cart; inspector
vs queue commit; Duplicates membership (`ownershipMap` vs
session repeat); Batch Scan progress / failure; Tips content
outline; device-picker empty/denied states.
2. `role-ui-designer` — lock the md+ workstation against the
attached mock using Liquid Glass tokens (`ui-ux-pro-max` is
installed), including Tips popover, device `<select>`, Batch
Scan progress, and the three-tab strip. Do **not** skip
`ui-design`.
3. `role-ux-reviewer` — inspect-then-add vs scan-all-then-checkout
on desktop; Auto-detect off; Rescan; empty inspector; leave
with an uncommitted queue; sequential batch cancel; duplicate
tab actions (increment qty vs skip vs still add).
4. `role-architect` — briefs. Likely: (1) page composition +
Layout chrome split, (2) camera panel + `deviceId` picker,
(3) result inspector, (4) history/queue/duplicates strip,
(5) batch multi-file identify + Tips. `slice_dependencies`
must mark what can run in parallel.
5. `role-implementer` — per brief.
6. Audit fan-out: reviewer + security-auditor + design-system-auditor
+ a11y-auditor.
## Todos
- [x] IA: desktop screen inventory + inspector-add vs cart-commit;
Duplicates membership; Batch Scan + Tips content
- [x] UI Designer: lock md+ workstation (tokens, not hex) from
the attached mock; light + dark; Tips, device picker,
batch progress, three-tab strip
- [x] UX: Auto-detect off, Rescan, empty state, leave-with-queue,
keyboard on desk controls, batch cancel, duplicate actions
- [x] Architect: briefs + `slice_dependencies`; confirm Layout
is `chrome="default"` on md+ only
- [ ] Desktop composition in `pages/scanner.js` (do not hide
sidebar / top bar at md+)
- [ ] Camera as a framed panel; `deviceId` picker + Upload +
Batch Scan + Auto-detect; hide mobile overlay chrome at md+
- [ ] Live match inspector wired to the latest unprocessed
identify (condition / foil already on the cart entry)
- [ ] Bottom strip: Recent Scans + Scan Queue + Duplicates over
`useScannerQueue` / `ownershipMap` / `scanner-session`
- [ ] Scanner Tips popover/modal with convoy-authored copy
- [ ] Sequential multi-file Batch Scan through
`identifyFromGalleryFile` (cancellable, queue progress)
- [ ] Tests for desktop composition (inspector + queue +
duplicates + batch enqueue, no mobile sheet) and
no-regression on checkout sheet at `max-width: 767px`
- [ ] Visual-diff: desktop `/scanner` surface; refresh Linux
baselines if the page is in the visual suite
## What exists today (conductor survey)
`/scanner` is one route, two compositions, one engine.
| Layer | Files | Today |
| --- | --- | --- |
| Page | `pages/scanner.js` | Auth gate; `Layout chrome="immersive"` on **all** viewports; camera column + `md:` 360px `ScannerReview` cart; mobile-only `ScannerCheckoutSheet` |
| Layout | `components/Layout.js` | Immersive hides **mobile** nav + top bar (`max-md` only). Desktop sidebar + TopSearchBar already stay visible. |
| Camera chrome | `components/scanner/ScannerCamera.js` | Full-bleed video, overlay top bar (back / title / gallery), bottom bar (flash / facing / status / Review N), scan peek, disambiguation. Same chrome on desktop. |
| Cart | `lib/use-scanner-queue.js`, `lib/scanner-session.js` | Identify enqueues locally (`processed: false`). Commit via `commitSelectedToOwned` / `commitSelectedToCollection`. `sessionStorage` persist. `addSingleCardToOwned` already exists. |
| Identify | `lib/use-scanner-identification.js`, `lib/use-camera-scanner.js` | Facing-mode swap only — **no** `deviceId` / `enumerateDevices`. Auto-detect is always on unless `verificationPausedRef` (checkout / list picker / disambiguation). |
| Review | `components/scanner/ScannerReview.js` | Thin wrapper: desktop side panel titled "Cart" that mounts `ScannerCheckoutContent`. |
Mobile checkout decisions that still apply unless IA overturns them
for desktop only: stay on camera after commit (D1), skip Setup
(D3), My Collection + List only (D4), gallery in-scope (D5),
cart in `sessionStorage` (D7).
## Conductor notes (build shape)
Likely file ownership for Architect to refine:
| Area | Files |
| --- | --- |
| Viewport split | `pages/scanner.js``chrome` default on md+, immersive on mobile; desktop grid vs mobile overlay |
| Camera panel | `components/scanner/ScannerCamera.js` (desktop variant or `variant="workstation"`), `lib/use-camera-scanner.js` (`enumerateDevices` + `deviceId`) |
| Inspector | new `components/scanner/ScannerResultPanel.js` — latest cart entry + condition/foil + add/rescan |
| History strip | new `components/scanner/ScannerHistoryStrip.js` — Recent / Queue / Duplicates over `queue.scannedCards` + `ownershipMap` |
| Batch Scan | `lib/use-scanner-identification.js` (`identifyFromGalleryFile` loop), queue progress UI |
| Tips | new `components/scanner/ScannerTips.js``<Modal>` or popover, convoy copy |
| Cart reuse | `lib/use-scanner-queue.js`, `lib/scanner-session.js`, `ScannerCheckoutSheet.js` (mobile only) |
| Copy | `lib/collection-vocabulary.js` |
Do not rewrite identification. Prefer a desktop layout shell that
**hides** mobile overlay chrome at `md+` rather than forking the
camera hook.
## Decisions (post-conductor)
Locked 2026-08-15 from the parent session. IA / UX / Architect
treat these as settled.
| # | Decision |
| --- | --- |
| C1 | **Batch Scan is in.** Multi-file sequential identify via the existing gallery path. No new batch API, no parallel Gemini, no new pile detector. |
| C2 | **Duplicates tab is in.** Strip tab with a badge. Membership = already-owned (`ownershipMap`) and/or same-session name+set repeats. IA picks the exact rule and tab actions. |
| C3 | **Scanner Tips is in.** Header control → glass popover/modal. Copy in this convoy. |
| C4 | **Real webcam device picker is in.** `enumerateDevices` + `deviceId` on desktop. Mobile keeps facing-mode swap. |
| C5 | **Inspector can commit this card now** *and* the queue strip remains for multi-add (same cart, two commit surfaces). Overturn only if IA finds a conflict. |
## Open questions (IA / product)
1. **Duplicates membership + actions.** Owned-in-collection only,
session repeats only, or both? From the tab, can the user still
add (increment qty), skip, or jump the inspector to that row?
2. **Wishlist.** Out as a feature. Confirm "Save to Wishlist" →
Add to List on the inspector, or omit the third action.
3. **Auto-detect toggle.** User-facing pause of identification
(extend `verificationPausedRef`), or just a status badge?
4. **Batch Scan cancel / errors.** Mid-batch cancel: keep already-
identified rows? Per-file failure: continue the rest and flag
the row, or stop?
5. **Tips content.** Four or five short tips (lighting, frame the
card, foil glare, hold still, auto-detect). IA drafts; UI
Designer locks the surface.
## Multitask dispatch
Planning is serial: IA → UI Designer → UX → Architect.
After architect: implementer fan-out only if briefs have
`depends_on: []` and disjoint `files:`. Device picker
(`use-camera-scanner.js`) and Tips (`ScannerTips.js`) are the
best candidates to parallelize with the inspector if they do
not both own `pages/scanner.js`. Page composition likely
blocks the strip and Batch Scan wiring.
After PR draft: `/multitask` audit fan-out
`role-reviewer + role-security-auditor + role-design-system-auditor + role-a11y-auditor`
(group id: `audit-scanner-desktop-layout-<pr>`).
## IA
### Affected routes
- `/scanner`**[modified]** Single route, two viewport compositions. Desktop (`md+`) switches to `Layout chrome="default"` (sidebar + TopSearchBar visible), framed camera workstation, live match inspector (right rail), and bottom history strip. Mobile (`max-md`) stays `chrome="immersive"` with checkout sheet — no regression.
- `/login`**[impacted]** Existing `returnUrl=/scanner` auth gate unchanged; desktop users land on the workstation after sign-in.
- `/collections`, `/my-cards`**[impacted]** Post-commit navigation targets only (Add to List picker, success flows). No route or nav IA changes in this convoy.
No new routes. No API route changes.
### User flow
```mermaid
flowchart LR
A["/scanner (auth)"] --> B{"md+?"}
B -->|Yes| C["Workstation"]
B -->|No| D["Immersive mobile"]
C --> E["Scan / Upload / Batch"]
E --> F["Match inspector"]
F --> G["Add or queue"]
C --> H["Strip tabs"]
H --> F
```
Desktop path: user opens `/scanner` with app chrome → scans via webcam, single Upload Image, or Batch Scan (sequential gallery identify) → latest match appears in the right-rail inspector → commits one card via inspector **or** batches via Scan Queue strip → Duplicates tab surfaces owned + session-repeat rows for review/increment. Mobile path unchanged: full-bleed camera → checkout sheet.
### Screen inventory
| Screen | Path | New/modified | Notes |
| --- | --- | --- | --- |
| Scanner Desktop Workstation | `/scanner` | modified | `md+` grid: framed camera panel (device picker, Upload, Batch Scan, Auto-detect toggle, Tips), right-rail inspector, bottom strip. Replaces 360px cart side panel as primary right-hand surface. |
| Scanner Mobile Immersive | `/scanner` | impacted (no regression) | `max-md`: `chrome="immersive"`, overlay camera chrome, `ScannerCheckoutSheet`. D1/D3/D4/D5/D7 decisions preserved. |
| Live Match Inspector | `/scanner` | new (sub-surface) | Right rail on desktop. Shows latest unprocessed identify: thumbnail, name, set, rarity, collector #, condition, foil, confidence. Actions: `VOCAB.ADD_TO_MY_COLLECTION`, `VOCAB.ADD_TO_LIST`, Rescan. Single-card commit without opening checkout sheet. |
| History / Queue Strip | `/scanner` | new (sub-surface) | Bottom strip on desktop. Tabs: **Recent Scans** (session history), **Scan Queue** (uncommitted cart, badge = unprocessed count), **Duplicates** (badge = duplicate row count). Row click focuses card in inspector. |
| Scanner Tips | `/scanner` | new (sub-surface) | Header control → glass `<Modal>` or popover. Five convoy-authored tips (see Content deltas). No route change. |
| List Picker | `/scanner` | impacted | Existing "Choose a List" `<Modal>`. Opened from inspector `VOCAB.ADD_TO_LIST` on desktop (and unchanged on mobile). |
| Leave Scanner | `/scanner` | impacted | Existing leave-with-uncommitted-queue modal. Applies to both viewports when navigating away with queue items. |
### Content / data model deltas
**Copy (ship from `lib/collection-vocabulary.js`):**
- Primary add: `VOCAB.ADD_TO_MY_COLLECTION` ("Add to My Collection").
- Secondary add: `VOCAB.ADD_TO_LIST` ("Add to List") — **not** "Save to Wishlist" (feature omitted).
- Strip tab labels: "Recent Scans", "Scan Queue", "Duplicates".
- Camera controls: "Upload Image", "Batch Scan", "Auto-detect" (toggle + status on/off), "Scanner Tips", "Rescan".
- Device picker: "Camera" or "Webcam" `<select>` label; empty/denied fallback copy (UI Designer).
**Scanner Tips content (draft for UI Designer):**
1. Good lighting — avoid glare on foil cards.
2. Fill the frame with one card; keep corners visible.
3. Hold still until Auto-detect locks the match.
4. Use Batch Scan for a pile of photos from your gallery.
5. Switch webcam if the image is dark or mirrored.
**No schema changes.** Cart remains client `sessionStorage` via `lib/scanner-session.js`. Duplicates derive from existing `ownershipMap` (already in My Collection) and `mergeScannedCardEntry` (same-session name+set repeats) — no new tables or API fields.
**IA decisions (locked — formerly open questions):**
| Topic | Decision |
| --- | --- |
| Duplicates membership | **Both** sources: `ownershipMap` (already-owned) **and** same-session name+set repeats via `mergeScannedCardEntry`. Badge = count of rows in the Duplicates set. Row click focuses that card in the inspector. User can still add (increment qty) from inspector or queue. |
| Wishlist | **Omit.** Inspector third action is `VOCAB.ADD_TO_LIST`, not wishlist. |
| Auto-detect | **Real pause toggle** on desktop. Extends `verificationPausedRef` (user off = paused identify). Status badge reflects on/off. Mobile behavior unchanged unless UX specifies otherwise. |
| Batch cancel / errors | Mid-batch **cancel keeps** already-identified rows. Per-file failure **continues** the rest and flags that row in Scan Queue — do not stop the batch. |
| Inspector vs queue commit | **Both surfaces** (C5): inspector commits one card now; Scan Queue strip handles multi-add / bulk commit. Same cart, two commit paths. |
| Webcam picker | Desktop only: `enumerateDevices` + `deviceId`. Mobile keeps facing-mode swap (C4). |
### Open IA questions
None — all five conductor questions resolved above.
## Design direction
Locked v1 against `image-1eeebe23-9983-4a96-a3e7-4e3cdfdceb5b.png`
(dark + light side-by-side). **Incremental redesign** of an existing
Liquid Glass surface — not a new palette. Generator run:
`desktop trading card scanner workstation camera inspector queue
glassmorphism` (`ui-ux-pro-max` v2.5.0).
### Summary
On `md+`, `/scanner` is a **desk workstation** inside normal app
chrome (sidebar + TopSearchBar): a framed live-camera panel with
ember corner brackets, a right-rail **live match inspector**, and a
bottom **history / queue strip** — inspect one card, commit from the
rail, or batch from the strip. Mobile stays immersive; this direction
applies only at `md+`.
### Pattern + style
| Field | Value |
| --- | --- |
| Product type | Desktop trading card scanner workstation |
| Landing / app pattern | Feature-Rich Showcase → **workstation grid** (camera + inspector + strip; not a marketing landing) |
| UI style | **Liquid Glass** (existing Deck Hearth DS) — translucent panels, rim-light, ember accent rings |
| Stack notes | nextjs · React 18 · Tailwind + CSS variables · `<Modal>` / `<GlassSurface>` / `<Button>` primitives |
**Generator vs repo (repo wins):**
| Generator | Locked override |
| --- | --- |
| Exaggerated Minimalism (oversized type, massive whitespace) | **Rejected** — match existing app density; page title `text-2xl` / `font-semibold`, subtitle `text-sm text-secondary` |
| Palette `#1E293B` / `#2563EB` scan blue | **Rejected** — use `--accent-ember`, `--accent-flame`, `--accent-gold`, `--bg-*`, `--text-*` from `styles/globals.css` |
| Inter typography | **Rejected** — keep system stack (`-apple-system, BlinkMacSystemFont, …`) |
| Feature-Rich Showcase sections | **Adapted** — three functional zones (camera, inspector, strip) replace marketing feature cards |
### Layout authority (`md+` only)
Viewport split at `md` (768px). Below `md`, no changes (immersive +
checkout sheet).
```
┌─────────────────────────────────────────────────────────────────┐
│ [Layout sidebar] │ TopSearchBar + profile │
├──────────────────┼──────────────────────────────────────────────┤
│ │ Card Scanner [Scanner Tips] │
│ │ <subtitle>
│ ├──────────────────────────┬───────────────────┤
│ │ ┌─ Auto-detect ON ─┐ │ Scan Result 98% │
│ │ │ [live video] │ │ ┌────┐ metadata │
│ │ │ ember brackets │ │ │thumb│ set/rarity │
│ │ └──────────────────┘ │ condition ▾ foil │
│ │ [Camera ▾][Upload][Batch] │ confidence bar │
│ │ [Auto-detect]│ + Add to My Coll. │
│ │ │ Rescan | Add List │
│ ├──────────────────────────┴───────────────────┤
│ │ Recent │ Queue (N) │ Dupes (N) Clear All│
│ │ [chip][chip][chip]… │
│ │ [ View All Scans ] │
└──────────────────┴──────────────────────────────────────────────┘
```
| Zone | Width / placement | Surface recipe |
| --- | --- | --- |
| Page header | Full content width above grid | Flat on `--bg-primary`; Tips = `<Button variant="ghost">` + lightbulb icon |
| Camera column | `flex-1` / `min-w-0`; ~6065% | Viewport frame: `--glass-surface-low` + `--ember-rim-subtle` on outer frame; **ember corner brackets** on video (existing `ScannerCamera`) |
| Desk control bar | Directly under viewport | `--glass-surface-mid` bar, `rounded-xl`, horizontal flex wrap at `md` |
| Inspector rail | Fixed `w-[360px]` or `max-w-sm`; sticky top optional | `--glass-surface-low` panel, `--elevation-ambient`, `--rim-light-inner/outer` |
| Bottom strip | Full content width below main grid | `--glass-surface-mid` container; tab row + chip scroller |
**Page copy (header):**
- Title: **Card Scanner**
- Subtitle: *Identify cards with your webcam and add them to My
Collection.*
### Colors
All values via CSS variables — **no hex in `.js`**.
| Role | Token | Usage on this surface |
| --- | --- | --- |
| Page background | `--bg-primary` | Workstation canvas behind glass panels |
| Panel fill | `--glass-surface-{low,mid,high}` | Camera frame (low), control bar + strip (mid), popovers (high) |
| Primary text | `--text-primary` | Titles, card names, tab labels |
| Secondary text | `--text-secondary` | Subtitle, set/rarity, timestamps |
| CTA / primary action | `--accent-ember` | **Add to My Collection** button; active tab underline; selected chip border |
| Accent highlight | `--accent-flame`, `--accent-gold` | Confidence bar fill; match badge when ≥90% |
| Interactive rim | `--ember-rim-subtle` / `--ember-rim-pronounced` | Camera frame rest; primary button hover/focus |
| Success / match | `--accent-gold` or existing success token | Auto-detect ON dot; high-confidence pill |
| Error / batch fail | `--accent-ember` at reduced opacity or danger variant | Failed queue row indicator |
| Border | `--border` | Control bar dividers, chip separators |
Light and dark both use the same token names; theme context switches
values. Mock's cool-navy dark is approximated by existing
`--bg-primary-dark` — do not introduce a new dark base.
### Typography
| Role | Font | Notes |
| --- | --- | --- |
| Page title | System stack, `text-2xl font-semibold` | "Card Scanner" |
| Subtitle | System stack, `text-sm`, `--text-secondary` | One line under title |
| Section labels | `text-sm font-medium uppercase tracking-wide` | "Scan Result", strip tab labels |
| Card name (inspector) | `text-lg font-semibold` | Primary identify headline |
| Metadata | `text-sm`, `--text-secondary` | Set · rarity · collector # |
| Chip / queue row | `text-sm` name, `text-xs` meta | Timestamps right-aligned |
### Camera viewport + desk controls
**Inside frame (overlay on video):**
- **Auto-detect badge** — top-left pill: green status dot +
`Auto-detect ON` / `Auto-detect OFF` (reflects pause toggle).
- **Ember corner brackets** — four L-shaped corners on the card
alignment region (ship existing `ScannerCamera` bracket styling).
- Optional alignment guide (mock dotted vertical line) — **omit in
v1** unless implementer brief explicitly includes it; brackets +
badge are required.
**Desk control bar (below frame, left → right):**
| Control | Component | Notes |
| --- | --- | --- |
| Camera | `<select>` or styled native picker, label **Camera** | `enumerateDevices` video inputs; desktop only |
| Upload Image | `<Button variant="secondary">` + upload icon | Single-file; existing gallery path |
| Batch Scan | `<Button variant="secondary">` + stack icon | Multi-file picker; triggers sequential identify |
| Auto-detect | Toggle switch + label **Auto-detect** | Right-aligned on wide screens; extends `verificationPausedRef` |
**Webcam device picker — fallback copy (locked):**
| State | Message | Action hint |
| --- | --- | --- |
| Loading | Detecting cameras… | Disable `<select>` until resolved |
| Empty list | No camera found | Connect a webcam or use **Upload Image**. |
| Permission denied | Camera access blocked | Allow camera in your browser settings, or use **Upload Image**. |
| Error (enumerate failed) | Couldn't list cameras | Use **Upload Image** or reload the page. |
When empty or denied, render the message inline below the disabled
`<select>` (`text-sm`, `--text-secondary`); do not use a blocking
modal for device errors.
### Live match inspector (right rail)
Header row: **Scan Result** + confidence pill (e.g. `98% Match`).
**Empty state** (no identify yet): centered illustration area +
*"Point your camera at a card or upload an image to see a match."*
**Populated state** (latest unprocessed cart entry):
- Thumbnail + name, set, rarity icon, collector number
- **Condition**`<select>` (existing condition options)
- **Foil** — toggle switch
- Confidence — percentage + horizontal bar + caption (*Excellent match.*
/ *Good match.* / *Low confidence — verify before adding.*)
- Actions (top → bottom priority):
1. **Primary:** `VOCAB.ADD_TO_MY_COLLECTION` (`<Button variant="primary">`, `+` icon optional)
2. **Secondary row:** **Rescan** (ghost) · `VOCAB.ADD_TO_LIST` (ghost)
- **Never** ship "Add to Collection", "Save to Wishlist", or "Mark
Owned".
### Bottom history / queue strip
**Tab row:** **Recent Scans** · **Scan Queue** `(N)` · **Duplicates**
`(N)` — badge = unprocessed count / duplicate row count per IA.
**Clear All** — text link, right-aligned (`text-sm`, destructive on
hover).
**Card chips** (horizontal scroll, `overflow-x-auto`):
- Thumbnail, name, set · rarity · condition snippet
- Status: confidence % + relative time (*Just now*, *2 min ago*)
- Active / focused row: `--ember-rim-pronounced` border (matches mock
ember outline on leftmost chip)
- **No `backdrop-filter` on individual chips** — solid
`--bg-secondary` per per-card grid performance budget
(`docs/DESIGN_TOKENS.md` § Per-card grid performance budget)
**View All Scans** — full-width ghost button at strip bottom; opens
expanded queue view or scrolls strip — Architect brief decides;
visual = translucent bar button from mock.
**Row click** — focuses that card in the inspector (IA-locked).
### Batch Scan progress (Scan Queue tab)
When batch is running, **Scan Queue** tab auto-focuses (or shows an
inline banner):
- Progress line: **Scanning 3 of 12…** with determinate progress bar
(`--accent-ember` fill)
- **Cancel** — ghost button; **keeps already-identified rows** (IA)
- Per-file failure — enqueue row with error flag, label **Identify
failed**, `text-sm` reason if available; batch **continues** remaining
files
- On complete — banner dismisses; failed rows stay in queue with
visual error state (ember left border or warning icon)
### Scanner Tips (surface locked)
**`<Modal>`** — five tips exceed popover length; use
`components/ui/Modal` + modal panel recipe (`--glass-surface-low`,
`--modal-scrim`). Trigger: header **Scanner Tips** button (lightbulb
icon + label).
| # | Tip |
| --- | --- |
| 1 | **Good lighting** — avoid glare on foil cards. |
| 2 | **Fill the frame** with one card; keep corners visible. |
| 3 | **Hold still** until Auto-detect locks the match. |
| 4 | **Use Batch Scan** for a pile of photos from your gallery. |
| 5 | **Switch camera** if the image is dark or mirrored. |
Modal title: **Scanner Tips**. Dismiss via close control + Esc +
scrim click (standard `<Modal>` behavior).
### Effects + motion
- Panel transitions: **150200ms** ease on hover/focus for buttons and
chips (`docs/MOTION_SYSTEM.md` if defined; else `transition-colors
duration-200`)
- Auto-detect badge dot: subtle pulse when ON; **respect
`prefers-reduced-motion`** (static dot when reduced)
- Confidence bar: width transition 300ms on value change; no animation
when reduced
- Tab switch: instant content swap (no slide); optional 150ms fade on
chip row refresh
- Hover: `cursor-pointer` on all clickable chips, tabs, buttons;
`--ember-rim-subtle``--ember-rim-pronounced` on primary hover
### Anti-patterns (do not ship)
- Full-bleed camera overlay on desktop (mobile-only chrome)
- Hex colors in `.js` / inline styles
- Generator slate/blue palette or Inter font import
- `backdrop-filter` on per-card strip chips or thumbnail tiles
- "Add to Collection", "Save to Wishlist", "Mark Owned", "All My
Cards" in UI copy
- Custom modal scrim (must use `<Modal>` primitive)
- Popover for five tips (use Modal)
- Parallel batch identify UI implying concurrent Gemini calls
- Device `<select>` on mobile (facing-mode toggle stays)
### Pre-delivery checklist
- [ ] No emojis as icons (SVG: Lucide / Heroicons)
- [ ] `cursor-pointer` on clickable elements
- [ ] Hover/focus transitions 150300ms
- [ ] Text contrast ≥ 4.5:1 on glass-over-flat surfaces
- [ ] Keyboard focus visible (`--ember-rim-pronounced` focus ring)
- [ ] `prefers-reduced-motion` respected (badge pulse, bar animate)
- [ ] Responsive: mobile immersive unchanged; workstation at 768 /
1024 / 1440
- [ ] Light + dark verified against mock reference
- [ ] Copy from `lib/collection-vocabulary.js` for add actions
- [ ] Visual-diff: desktop `/scanner` if in suite
### Conflict rule
**Repo design tokens win** when they disagree with generator output.
This lock intentionally overrides `ui-ux-pro-max` palette and typography
only. Layout proportions follow the attached mock; nav items in the
mock that are not product routes (Wishlist, Trades, etc.) are **not**
in scope — existing Layout sidebar IA stands.
## UX
### 1. Existing components to reuse
| Component | Path | Use on desktop workstation |
| --- | --- | --- |
| `<Modal>` | `components/ui/Modal.js` | Leave-with-queue, List picker, **Scanner Tips** (five tips — no custom scrim) |
| `<Button>` | `components/ui/Button.js` | Upload Image, Batch Scan, Auto-detect toggle label area, inspector primary/ghost actions, batch Cancel, View All Scans |
| `<GlassSurface>` | `components/ui/GlassSurface.js` | Camera frame, desk control bar, inspector rail, strip container, strip chips (solid `--bg-secondary` fill inside — no per-chip blur) |
| `<Input>` | `components/ui/Input.js` | Not required v1; strip search deferred to Architect brief |
| `ScannerCamera` | `components/scanner/ScannerCamera.js` | Framed viewport, ember brackets, overlay Auto-detect badge; hide mobile overlay chrome at `md+` via variant prop |
| `ScannerDisambiguation` | `components/scanner/ScannerDisambiguation.js` | Unchanged multi-match picker; pauses identify via existing `verificationPausedRef` wiring |
| `ScannerToast` | `components/scanner/ScannerToast.js` | Post-commit confirmation, batch complete summary, identify errors |
| `ScannerCheckoutSheet` | `components/scanner/ScannerCheckoutSheet.js` | **Mobile only** (`max-md`) — do not mount at `md+` |
| `ScannerCountPill` | `components/scanner/ScannerCountPill.js` | **Mobile only** — desktop uses strip Queue badge instead |
| `ReviewCardItem` | `components/scanner/ReviewCardItem.js` | **Pattern donor** for condition `<select>`, foil toggle, confidence ring/color helpers, increment/decrement — lift constants (`CONDITION_OPTIONS`, `confidencePercent`, `confidenceRingColor`) into shared export or duplicate minimally in `ScannerResultPanel` |
| Leave modal + List picker | `pages/scanner.js` | Reuse verbatim markup and handlers; extend leave trigger to desktop back/nav paths |
**New surfaces** (`ScannerResultPanel`, `ScannerHistoryStrip`, `ScannerTips`) compose primitives only — no new modal shell, no hex in `.js`.
### 2. Design direction alignment
- **Liquid Glass tokens:** All panels use `--glass-surface-{low,mid,high}`, `--ember-rim-{subtle,pronounced}`, `--elevation-ambient` per Design direction layout authority table. Inspector rail = `GlassSurface` tint `low`; desk bar + strip = `mid`.
- **Ember accent:** Primary add, active tab underline, focused chip border, Auto-detect ON dot use `--accent-ember` / `--accent-flame` — not generator slate/blue.
- **Copy lock:** Inspector and strip actions import `VOCAB.ADD_TO_MY_COLLECTION` and `VOCAB.ADD_TO_LIST` from `lib/collection-vocabulary.js`. Wishlist omitted (IA-locked).
- **Performance budget:** Strip chips and inspector thumbnail tiles use solid `--bg-secondary`**no `backdrop-filter` on per-card elements** (Design direction + `docs/DESIGN_TOKENS.md`).
- **Tips surface:** `<Modal size="md">` with glass panel recipe — matches Design direction "Popover for five tips" rejection.
- **Motion:** 150200ms color/rim transitions; badge pulse and confidence bar width animate only when `prefers-reduced-motion: no-preference` (see §7 interaction patterns).
### 3. Existing patterns to follow
- **Viewport split:** `pages/scanner.js` already gates mobile checkout with `md:hidden` / `hidden md:flex` — extend to `Layout chrome="default"` at `md+` only; keep `chrome="immersive"` below `md`.
- **Pause identify:** `verificationPausedRef` in `pages/scanner.js` (lines 5963) — extend desktop Auto-detect OFF to set ref; keep existing pauses for list picker + disambiguation + mobile checkout.
- **Single-card commit:** `queue.addSingleCardToOwned(card)` in `lib/use-scanner-queue.js` — inspector primary action; same API path as mobile bulk commit.
- **Leave with queue:** Existing `<Modal title="Leave scanner?">` + `clearScannerCartStorage()` — trigger from desktop back, sidebar nav away, and `router.back()` when `unprocessedCount > 0`.
- **List commit:** Existing List picker `<Modal>` + `handleListPick` — inspector `VOCAB.ADD_TO_LIST` opens same modal scoped to **focused inspector card** (single selection), not bulk strip selection.
- **Error alerts:** Match `ScannerCheckoutContent` / `pages/scanner.js` list picker — `role="alert"` div with `color-mix(in srgb, var(--color-error) …)` for commit and batch failures.
- **Confidence UX:** Reuse `ReviewCardItem` thresholds: `<70%` ember warning, `≥90%` gold/high — caption text from Design direction (*Excellent match.* / *Good match.* / *Low confidence — verify before adding.*).
- **Device picker fallbacks:** Inline `text-sm` `--text-secondary` messages below disabled `<select>` per Design direction table — not a blocking modal.
- **Focus rings:** `Button` / `ChromeIconButton` pattern — `focus-visible:ring-2` + `'--tw-ring-color': 'var(--accent-ember)'` (`components/scanner/ScannerCamera.js`).
### 4. A11y constraints
Hand to `role-a11y-auditor`:
- **1.4.3 Contrast (Minimum):** All `--text-primary` / `--text-secondary` on `--glass-surface-*` over `--bg-primary` must meet **4.5:1** for body text, **3:1** for large/bold card name (`text-lg font-semibold`). Confidence bar track vs fill must meet **3:1** non-text contrast.
- **1.4.10 Reflow:** At 768px width with 200% zoom, workstation grid stacks camera above inspector; strip tabs remain horizontally scrollable without two-dimensional scroll traps.
- **1.4.11 Focus Not Obscured (Minimum):** Sticky inspector rail and batch progress banner must not fully hide focused desk controls or strip chips.
- **2.1.1 Keyboard:** Every action reachable without pointer: desk controls, strip tabs (tablist), chip rows, inspector add/rescan, batch Cancel, Tips open/close.
- **2.4.3 Focus Order:** DOM order = visual order: page header (Tips) → camera frame → desk bar (Camera → Upload → Batch → Auto-detect) → inspector → strip tabs → chip scroller → View All.
- **2.4.7 Focus Visible:** Ember focus ring on all interactive elements; native `<select>` gets `focus-visible:ring-2` wrapper if browser default ring is suppressed.
- **2.4.11 Focus Not Obscured:** Modal open (Tips, leave, list picker) uses existing `useFocusTrap` — no focus escape to camera video underneath.
- **4.1.2 Name, Role, Value:**
- Auto-detect toggle: `role="switch"`, `aria-checked={!verificationPausedRef}`, visible label **Auto-detect** associated via `htmlFor` / `aria-labelledby`.
- Strip tabs: `role="tablist"` / `role="tab"` / `role="tabpanel"` with `aria-selected`, `aria-controls`, badge counts in `aria-label` (e.g. `Scan Queue, 3 unprocessed cards`).
- Camera `<select>`: `<label>` **Camera** + `aria-describedby` pointing to empty/denied fallback text when present.
- Inspector condition `<select>`: accessible name includes card name (`Condition for {card.name}`).
- Foil toggle: `role="switch"`, `aria-checked`, label **Foil**.
- **4.1.3 Status Messages:** Auto-detect ON/OFF badge exposes `aria-live="polite"` region; batch progress (`Scanning 3 of 12…`) uses `aria-live="polite"`; commit success via `ScannerToast` with `role="status"`.
- **3.3.1 Error Identification:** Batch per-file failures show **Identify failed** + reason text on the queue row; commit errors in inspector use `role="alert"` (same pattern as checkout footer).
- **2.5.3 Label in Name:** Visible button text must appear in accessible name — e.g. **Upload Image**, not icon-only without `aria-label`.
- **Live region discipline:** Only one polite live region for batch progress; avoid duplicate announcements on tab auto-focus during batch.
### 5. Interaction patterns
#### Locked decisions (opinionated — one pattern each)
| Topic | Decision | Nielsen / rationale |
| --- | --- | --- |
| **Empty inspector** | Centered muted camera/scan illustration + single line: *"Point your camera at a card or upload an image to see a match."* No duplicate Upload/Batch CTAs in the rail (controls live in desk bar). | **H8** aesthetic minimalism; **H6** recognition — one place for actions. |
| **Leave with uncommitted queue** | Keep existing `<Modal title="Leave scanner?">`**Keep scanning** (secondary) / **Leave** (danger). Same copy and `clearScannerCartStorage()` behavior on desktop nav-away. | **H3** user control; **H5** error prevention. |
| **Inspector add success** | **Advance inspector to next unprocessed queue entry** (or empty state if queue clear). Show `ScannerToast` (*Added to My Collection*). Committed card appears in **Recent Scans** with processed styling. Do **not** linger on committed card in inspector. | **H2** real-world scan rhythm; **H1** toast + strip update confirm status without blocking next decision. |
| **Auto-detect OFF** | Real pause via `verificationPausedRef` — no identify/OCR while OFF. Badge reads **Auto-detect OFF**; dot static gray. Toggle is independent of mobile checkout pause logic. | **H3** user control; **H1** badge reflects system state. |
| **Rescan** | Clears focus card's identify metadata and re-queues same physical capture path (webcam frame or re-read gallery source if batch row) — card stays in queue unprocessed. | **H3** undo/recover from bad match. |
| **Duplicate row click** | Focuses that card in inspector (scroll chip into view, `--ember-rim-pronounced` on active chip). **Add still allowed** — increment qty via inspector primary or existing queue increment if duplicate already unprocessed. No separate "Skip" action in v1. | **H6** recognition over recall; **H4** consistency with Queue tab. |
| **Batch cancel** | Ghost **Cancel** in Scan Queue tab progress banner. Cancels remaining files; **keeps** already-identified rows (IA-locked). Banner dismisses; toast *Batch scan stopped* (info). | **H3** user control; **H9** recover from long batch. |
| **Batch per-file failure** | Row stays in Scan Queue with ember left border + **Identify failed** label; batch **continues**. Row click focuses inspector for manual Rescan or remove. | **H9** graceful recovery; **H1** visible error on row. |
| **Strip row click (all tabs)** | Sets inspector focus to that card; does not auto-commit. Recent / Queue / Duplicates share one focus model. | **H4** consistency. |
| **Clear All** | Destructive text link — confirm via existing pattern or inline `window.confirm` only if Architect brief adds it; default: immediate clear with undo **not** required v1 (queue is session-scoped). Architect to confirm in brief. | **H5** prevent accidental loss — if no confirm, disable when batch running. |
#### Required
- **Loading — identify in flight (H1):** Inspector shows skeleton/thumbnail placeholder + *Identifying…* on desk bar Upload/Batch busy states disable repeat picks.
- **Loading — commit (H1):** Inspector primary `<Button loading>` during `addSingleCardToOwned`; disable Rescan and secondary actions while processing.
- **Empty strip tabs (H9):** Recent: *No scans yet this session.* Queue: *Scan queue is empty — matches appear here before you add them.* Duplicates: *No duplicates detected.*
- **Auto-detect ON feedback (H1):** Green pulsing dot in frame badge (respect reduced motion); optional success chime already in `ScannerCamera` — desktop may keep muted default.
- **Batch running (H1):** Auto-select **Scan Queue** tab; determinate progress bar; Cancel visible throughout.
- **Focus management (H3):** Opening Tips/list/leave modals traps focus; closing returns focus to trigger button.
- **Hover/focus (H4):** Chips and tabs: `--ember-rim-subtle``--ember-rim-pronounced` on hover/focus; `cursor-pointer` on all click targets.
#### Nice-to-have
- **Optimistic UI:** Inspector advance on commit can happen after API success only (no optimistic skip) — queue hook already marks `processed` post-success.
- **Keyboard shortcut:** `A` to trigger inspector **Add to My Collection** when inspector populated and not processing — defer unless Architect brief adds; not v1 blocker.
- **View All Scans:** Expanded modal or full-width strip — Architect decides; v1 may scroll chip row only.
### 6. Anti-patterns to avoid
- **Duplicate CTAs in empty inspector** — Upload/Batch already in desk bar (**H8** minimalist design).
- **Custom modal scrim for Tips or device errors** — must use `<Modal>` or inline text only (**H4** consistency).
- **Device `<select>` on mobile** — facing-mode swap stays (**H4**).
- **Popover for five Scanner Tips** — too long; Modal only (**H8**).
- **Linger on committed card in inspector after add** — blocks scan rhythm (**H2**).
- **Stop entire batch on one file failure** — IA forbids (**H9**).
- **Discard identified rows on batch cancel** — IA forbids (**H3**).
- **"Save to Wishlist" / "Mark Owned" / "Add to Collection"** copy — CI forbidden strings (**H4**).
- **`backdrop-filter` on strip chips or inspector thumbnail** — GPU budget violation.
- **Full-bleed camera overlay on desktop** — mobile-only chrome (**H2**).
- **Parallel batch progress implying concurrent API calls** — misleading (**H1** honest status).
- **Icon-only desk controls without `aria-label`** — fails **2.5.3** / **4.1.2**.
- **Auto-commit on strip row click** — user loses inspect step (**H3** user control).
- **Slide animation on tab switch** — Design direction specifies instant swap; fade optional ≤150ms only.
### 7. Mobile / responsive notes
**Below `md` (767px) — zero regression:**
- Keep `Layout chrome="immersive"`, `ScannerCamera` overlay chrome (back, gallery, flash, facing, Review N pill), `ScannerCheckoutSheet`, `ScannerCountPill`, scan peek, disambiguation sheet behavior unchanged.
- Do not render desktop workstation grid, inspector rail, history strip, device `<select>`, Batch Scan desk control, or Tips header button on mobile unless Tips is also added to mobile overlay (out of scope — **Tips = desktop header only v1**).
- `verificationPausedRef` mobile checkout pause unchanged (`isCheckoutOpen && max-width 767px`).
**At `md+` (768px+):**
- `Layout chrome="default"` — sidebar + `TopSearchBar` visible.
- Workstation grid: camera column `flex-1 min-w-0` (~6065%) + inspector `w-[360px] max-w-sm` + full-width bottom strip.
- Hide mobile-only elements: checkout sheet, Review N pill, overlay top/bottom camera bars, facing-mode toggle (replace with Camera `<select>`).
- Desk control bar wraps at `md`; Auto-detect toggle right-aligned at `lg+`.
- Strip chip scroller: `overflow-x-auto`, `-webkit-overflow-scrolling: touch` for trackpad/touch on hybrid devices; min chip height **44px** touch target.
- **`prefers-reduced-motion`:** Disable Auto-detect badge pulse, confidence bar width transition, optional chip-row fade — use `@media (prefers-reduced-motion: reduce)` instant state swaps; keep color/rim hover transitions ≤150ms or disable per `docs/MOTION_SYSTEM.md` if defined.
- **Breakpoints to verify:** 768 / 1024 / 1440 — inspector sticky top optional; strip never collapses tabs into mystery meat menu v1.
- **Visual-diff:** Desktop `/scanner` only; mobile baseline must not change.
## Architecture
### File plan
| File | Action | Purpose |
| --- | --- | --- |
| `lib/use-camera-scanner.js` | modified | `enumerateDevices`, `deviceId` selection, picker status, sessionStorage persist |
| `test/lib/use-camera-scanner.test.js` | new | Mock `mediaDevices` — device list, constraint switch, persist |
| `components/scanner/ScannerTips.js` | new | Header trigger + five-tip `<Modal>` |
| `test/components/ScannerTips.test.js` | new | Open/close, copy lock |
| `components/scanner/ScannerResultPanel.js` | new | Right-rail live match inspector |
| `test/components/ScannerResultPanel.test.js` | new | Empty/populated, add loading, vocab |
| `components/scanner/ScannerHistoryStrip.js` | new | Recent / Queue / Duplicates strip + `getDuplicateCards` |
| `test/components/ScannerHistoryStrip.test.js` | new | Tabs, duplicates helper, batch banner |
| `lib/scanner-batch-identify.js` | new | Sequential `runSequentialGalleryIdentify` |
| `test/lib/scanner-batch-identify.test.js` | new | Progress, cancel, error continuation |
| `pages/scanner.js` | modified | md+ workstation grid, focus state, pause composite, chrome split |
| `components/scanner/ScannerCamera.js` | modified | `workstation` variant — framed viewport, hide mobile overlay at md+ |
| `lib/use-scanner-identification.js` | modified | Remove redundant `verificationPausedRef` overwrite effect |
| `lib/use-scanner-queue.js` | modified | Boolean return from `addSingleCardToOwned` / `addSingleCardToCollection` |
| `test/pages/scanner.test.js` | new | Mobile checkout regression + desktop composition smoke |
| `test/components/ScannerCamera.test.js` | new | Workstation variant hides overlay chrome |
**Not touched:** `components/Layout.js` (immersive already equals default at md+),
`lib/scanner-card-identify.js`, `pages/api/scan/identify.js`, `ScannerCheckoutSheet.js`
(mobile only), nav IA (`dashboard-home-realignment`).
### API surface
No new or modified API routes. Client cart remains `sessionStorage` via
`lib/scanner-session.js`. All commits reuse existing `scanner-route-api.js` helpers
(`addScannedCardToOwned`, `addScannedCardToCollection`).
### Schema diff
None.
### Test plan
| Area | File | What to lock |
| --- | --- | --- |
| Device picker hook | `test/lib/use-camera-scanner.test.js` | `deviceId` constraints, enumerate, persist, status messages |
| Batch helper | `test/lib/scanner-batch-identify.test.js` | Sequential order, cancel mid-batch, error continues |
| Tips modal | `test/components/ScannerTips.test.js` | Five tips, Modal primitive |
| Inspector | `test/components/ScannerResultPanel.test.js` | Empty state (no duplicate CTAs), `VOCAB` labels |
| History strip | `test/components/ScannerHistoryStrip.test.js` | `getDuplicateCards`, tab badges, batch banner |
| Camera variant | `test/components/ScannerCamera.test.js` | Overlay hidden for `workstation` at md+ |
| Page orchestration | `test/pages/scanner.test.js` | Checkout sheet at max-md; workstation surfaces at md+ |
| Mobile regression | `test/components/ScannerCheckoutSheet.test.js` | Existing suite stays green — do not weaken |
| Layout chrome | `test/components/Layout.test.js` | Existing immersive tests — no change expected |
**Visual-diff:** `tests/visual/` contains only `homepage.spec.ts` / `home.png`
no `/scanner` baseline update required for this convoy.
### Risk list
| Risk | Mitigation |
| --- | --- |
| `verificationPausedRef` race between page and `useScannerIdentification` | Brief 6 deletes identification hook's direct ref assignment; page owns composite pause including `isAutoDetectPaused` |
| `addSingleCardToOwned` lacks success signal today | Brief 6 adds boolean return in `use-scanner-queue.js` |
| `chrome="immersive"` on all viewports today | Boot finding: Layout already shows sidebar at md+; page uses explicit `default` vs `immersive` via `matchMedia` for clarity |
| Batch identify failures invisible in queue | Page enqueues `identifyFailed` rows; strip shows ember border + label |
| Rescan without stored capture | v1: re-identify via `scanImageUrl` fetch; else toast to rescan from camera |
| Desktop/mobile class split regressions | Tests mock `matchMedia`; mobile paths keep `md:hidden` gates |
| Forbidden copy in new surfaces | Import `VOCAB` from `collection-vocabulary.js`; CI `forbidden-stale-strings` |
| Brief 6 LOC >400 | Accepted — single serializer for `pages/scanner.js`; components split across Briefs 24 |
| `getDuplicateCards` over-counts | Helper uses ownershipMap + session name/set + quantity>1; tune in strip tests |
### Decomposition
| Brief # | Title | Files | Depends on | Est. PR size |
| --- | --- | --- | --- | --- |
| 1 | Camera device picker hook | `use-camera-scanner.js`, test | — | S (~180 LOC) |
| 2 | Scanner Tips modal | `ScannerTips.js`, test | — | S (~100 LOC) |
| 3 | Live match inspector | `ScannerResultPanel.js`, test | — | M (~280 LOC) |
| 4 | History / queue strip | `ScannerHistoryStrip.js`, test | — | M (~300 LOC) |
| 5 | Sequential batch helper | `scanner-batch-identify.js`, test | — | S (~120 LOC) |
| 6 | Page workstation wiring | `scanner.js`, `ScannerCamera.js`, identification + queue hooks, tests | 1, 2, 3, 4, 5 | L (~450 LOC) |
**Estimated PRs:** 6 (Briefs 15 parallelizable; Brief 6 after merge or rebase).
### Slice dependencies (multitask-ready)
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- lib/use-camera-scanner.js
- test/lib/use-camera-scanner.test.js
- brief: 2
depends_on: []
files:
- components/scanner/ScannerTips.js
- test/components/ScannerTips.test.js
- brief: 3
depends_on: []
files:
- components/scanner/ScannerResultPanel.js
- test/components/ScannerResultPanel.test.js
- brief: 4
depends_on: []
files:
- components/scanner/ScannerHistoryStrip.js
- test/components/ScannerHistoryStrip.test.js
- brief: 5
depends_on: []
files:
- lib/scanner-batch-identify.js
- test/lib/scanner-batch-identify.test.js
- brief: 6
depends_on: [1, 2, 3, 4, 5]
files:
- pages/scanner.js
- components/scanner/ScannerCamera.js
- lib/use-scanner-identification.js
- lib/use-scanner-queue.js
- test/pages/scanner.test.js
- test/components/ScannerCamera.test.js
```
### Boot-the-brief findings (fixed in briefs)
1. **Layout chrome:** `chrome="immersive"` already renders sidebar + TopSearchBar at
`md+` (`Layout.js` uses `max-md:hidden` only). Brief 6 uses `matchMedia` to pass
`chrome="default"` on desktop for semantic clarity — no `Layout.js` edit.
2. **`verificationPausedRef` overwrite:** `useScannerIdentification` lines 6266 set
the ref to `Boolean(disambiguation)` only, racing page composite pause — Brief 6
removes that effect.
3. **`addSingleCardToOwned` return:** No success boolean today — Brief 6 extends
`use-scanner-queue.js` to return `true`/`false`.
4. **Visual suite:** `/scanner` not in `tests/visual/` — no Linux baseline refresh.
5. **No new packages** — dep-set check N/A; all primitives exist (`Modal`, `GlassSurface`, `Button`).
6. **Gallery batch path verified:** `identifyFromGalleryFile` exists in
`use-scanner-identification.js` (line 326) — Brief 5 wraps it sequentially.

View file

@ -0,0 +1,147 @@
# 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 (A1A4) 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` ~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)
```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 A1A4 (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).

View file

@ -0,0 +1,153 @@
# Design-System Audit — scanner-desktop-layout
**Role:** `role-design-system-auditor`
**Surface:** `convoy/scanner-desktop-layout` workstation UI (`git diff origin/main` + untracked scanner components)
**Convoy:** `.convoys/scanner-desktop-layout.md`
**Multitask group:** `audit-scanner-desktop-layout-local`
**Date:** 2026-08-15
**Scope:** UI / DS only — read-only; no code changes
**Inputs reviewed**
| Source | Notes |
| --- | --- |
| Diff UI (vs `origin/main`) | `pages/scanner.js`, `components/scanner/ScannerCamera.js` (+ unrelated dashboard/Layout noise in same branch tip — **out of audit scope**) |
| Untracked (convoy) | `ScannerTips.js`, `ScannerResultPanel.js`, `ScannerHistoryStrip.js` + component tests |
| Direction lock | `design_direction` v1 + `## Design direction` + `## UX` |
| Tokens | `docs/DESIGN_TOKENS.md`, `styles/globals.css` |
| Primitives | `components/ui/{GlassSurface,Button,Modal,Input}.js` |
| Skill | `.cursor/skills/design-systems/SKILL.md` **not installed**; report follows prior Liquid Glass audit shape + role file |
---
## Audit summary
| Check | Status | Notes |
| --- | --- | --- |
| Direction lock (md+ workstation grid) | ✅ Strong | Framed camera + desk bar + inspector rail + history strip + Tips Modal; mobile immersive left alone |
| Hex in JSX (new convoy files) | ❌ | `#ffffff` on Queue/Duplicates badge in `ScannerHistoryStrip.js` |
| VOCAB / stale strings | ✅ | `VOCAB.ADD_TO_*` / `MY_COLLECTION`; no Wishlist / Mark Owned / All My Cards |
| `backdrop-filter` on strip chips | ✅ | Chips solid `--bg-secondary`; container is `GlassSurface tint="mid"` |
| GlassSurface / Button / Modal reuse | ✅ High | Tips=`Modal`; inspector/strip/desk/camera frame=`GlassSurface`; primary CTAs=`Button` |
| Custom modal scrim | ✅ | Tips + leave + list picker use `<Modal>` only |
| Sev ≥ 3 findings | **1** | Hex literal on tab badges |
---
## Maturity scoring (repo DS + this surface)
Scores 04. Evidence cites paths/counts.
| Axis | Score | Evidence |
| --- | --- | --- |
| **Tokens (T)** | **3** | Workstation surfaces consume `--glass-surface-*` via `GlassSurface`, `--text-*`, `--accent-ember` / `--accent-flame`, `--ember-rim-*`, `--border`. Residual: on-accent text still `#ffffff` in new strip badges (and pre-existing `Button.js` primary/danger). No `--text-on-accent` alias in `globals.css`. |
| **Components (C)** | **3** | Kit: GlassSurface, Modal, Button, Input, SearchBar, StatCard. New surfaces compose GlassSurface + Button + Modal. Gaps: no Switch / TabList primitive — Auto-detect + Foil switches and strip tabs are raw `<button>` (acceptable; pattern repeated ×3). |
| **Patterns (P)** | **3** | Locked workstation pattern largely shipped: `Layout chrome="default"` at md+, camera `variant="workstation"` with ember brackets + reduced-motion scan-line (`ScannerCamera.js` `DetectionFrame`), desk `GlassSurface mid`, inspector `tint="low"`, strip three tabs + batch banner, Tips `<Modal size="md">`. Minor drift: confidence fill uses `--color-warning` / `--color-success` vs directions flame/gold highlight row (aligned with UX “reuse ReviewCardItem” instead). |
| **Governance (G)** | **3** | Convoy `design_direction` locked 2026-08-15; `ui-and-theming.mdc` + vocab CI; `forbidden-hex-in-jsx` documented in `DESIGN_TOKENS.md`. Design-systems skill package still absent in-repo. |
| **Adoption (A)** | **3** | Desktop surface counts: `<Button>` ×12 across `scanner.js` + ResultPanel + HistoryStrip + Tips; `<GlassSurface>` on camera frame, desk bar, inspector, strip container, Auto-detect badge; `<Modal>` ×1 Tips + leave/list on page. Chips correctly **avoid** GlassSurface blur. Raw `<button>` ×7 mostly switches/tabs/Clear All (direction allows Clear All as text link). |
**Composite:** T3 / C3 / P3 / G3 / A3
**Top leverage:** **Tokens (T)** — introduce a shared on-accent text token (or route badge text through `<Button>` / a tiny Badge primitive) and purge `#ffffff` from convoy JSX so the hex gate and Liquid Glass lock stay green.
---
## Direction lock
Locked fields enforced: Liquid Glass; md+ workstation (camera + inspector + strip); ember accents; VOCAB CTAs; no hex; no per-chip `backdrop-filter`; Tips via `<Modal>`; no custom scrim; mobile immersive unchanged.
| Locked requirement | Diff status |
| --- | --- |
| md+ `chrome="default"`; mobile immersive | ✅ `pages/scanner.js` |
| Page title / subtitle + Scanner Tips | ✅ Header + `ScannerTips` |
| Camera framed panel + ember brackets | ✅ `variant="workstation"` + `DetectionFrame` L-brackets + scan-line + `prefers-reduced-motion` |
| Desk bar: Camera / Upload / Batch / Auto-detect | ✅ `GlassSurface tint="mid"` + native `<select>` + `Button`×2 + switch |
| Device picker fallbacks inline (not modal) | ✅ `camera.devicePickerMessage` under select |
| Inspector rail `w-[360px]` + GlassSurface low | ✅ `ScannerResultPanel` |
| Empty / confidence / VOCAB primary + Rescan / Add to List | ✅ |
| Strip: Recent / Queue / Dupes + Clear All + View All | ✅ `ScannerHistoryStrip` |
| Chips solid `--bg-secondary`; focused ember rim | ✅; no chip `backdrop-filter` |
| Batch progress + Cancel | ✅ `BatchProgressBanner` |
| Tips = `<Modal>` five tips (not popover) | ✅ `ScannerTips.js` |
| No hex / no Wishlist / Mark Owned | ❌ one `#ffffff`; vocab clean |
| Checkout sheet mobile-only | ✅ `md:hidden` mount |
---
## Token audit (3-tier)
| Tier | Expectation | Diff notes |
| --- | --- | --- |
| Primitives | Hex only in CSS token definitions | No new CSS hex from convoy. **JSX** `#ffffff` in strip badge violates consumer rule. |
| Aliases | `--glass-surface-*`, blur, rim, elevation, text, accent | Correctly used via GlassSurface props + `var(--*)` styles. Missing alias for on-accent / inverse text (primitives still hardcode `#ffffff`). |
| Components | GlassSurface / Button / Modal compose aliases | Desk, inspector, strip, Tips comply. Tab badge invents parallel on-accent styling instead of composing Button/Badge. |
---
## Component / adoption audit (scoped desktop surface)
| Element | Expected | Actual | Rate |
| --- | --- | --- | --- |
| Camera frame | `GlassSurface` low + ember rim | Yes (`ScannerCamera` workstation branch) | High |
| Desk control bar | `GlassSurface` mid | Yes (`pages/scanner.js`) | High |
| Inspector | `GlassSurface` low | Yes (`ScannerResultPanel`) | High |
| History strip shell | `GlassSurface` mid | Yes | High |
| Strip chips | Solid `--bg-secondary` (no blur) | Yes | High |
| Tips | `<Modal>` | Yes | High |
| Primary / secondary CTAs | `<Button>` | Yes (Upload, Batch, Add, Rescan, Cancel, View All) | High |
| Auto-detect / Foil | Switch pattern | Raw `<button role="switch">` ×2 | Medium — no Switch primitive |
| Strip tabs | tablist | Raw `<button role="tab">` | Medium — correct a11y; not Button |
| Clear All | Text link | Raw `<button>` ember text | Medium — matches direction “text link” |
| Leave / List | `<Modal>` | Yes (page) | High |
**Missing primitive (optional follow-up):** `Switch` (and optionally `Badge`) would remove duplicated toggle/`#ffffff` badge styling across desk + inspector + strip.
---
## Findings
Severity 04. Cap prioritized; sev ≥ 3 blocks merge recommendation for DS gate.
| ID | Sev | Finding | Evidence | Fix |
| --- | --- | --- | --- | --- |
| DS-1 | **3** | Hardcoded hex in convoy JSX | `ScannerHistoryStrip.js` tab badge `color: '#ffffff'` (≈L250) | Replace with theme token (add `--text-on-accent` / reuse Buttons on-accent once tokenized) so `forbidden-hex-in-jsx` stays green |
| DS-2 | **2** | Toggle knobs use Tailwind `bg-white` | `pages/scanner.js` Auto-detect knob; `ScannerResultPanel.js` Foil knob | Prefer `backgroundColor: 'var(--bg-primary)'` or a documented knob token — avoid raw white utility on themed surfaces |
| DS-3 | **1** | Confidence bar accent drift vs Design direction color table | `ScannerResultPanel` `confidenceRingColor`: mid `--color-warning`, high `--color-success` | Optional align to `--accent-flame` / `--accent-gold` for ≥90% match badge; or document UX override (ReviewCardItem reuse) as accepted |
| DS-4 | **1** | Clear All lacks destructive hover | Always `--accent-ember` | Direction: destructive on hover — add hover token mix if shipping polish pass |
| DS-5 | **1** | Systemic on-accent hex in primitive (context) | `components/ui/Button.js` primary/danger still `#ffffff` / `#dc2626` | Out of convoy scope but explains copy-paste; tokenize in a follow-up DS convoy |
### Passes (not findings)
- No `"Add to Collection"`, `"Save to Wishlist"`, `"Mark Owned"`, `"All My Cards"` in scoped new UI.
- Inspector + strip CTAs import `VOCAB.ADD_TO_MY_COLLECTION` / `VOCAB.ADD_TO_LIST`.
- Tips uses `<Modal size="md">` with convoy-authored five tips; no custom scrim.
- Strip chips: solid `--bg-secondary`, focused `--ember-rim-pronounced`; no per-chip blur.
- Camera detection chrome: ember/flame L-brackets + scan-line with `prefers-reduced-motion` static mid-line.
- Auto-detect badge: `motion-safe:animate-pulse` when ON.
- Confidence bar width transition gated with `@media (prefers-reduced-motion: reduce)`.
- Icons are SVG (lightbulb / empty-state camera), not emoji.
- Mobile checkout sheet remains `md:hidden`; workstation controls `hidden md:flex`.
---
## Governance notes
- Contribution path: `docs/DESIGN_TOKENS.md` + `.cursor/rules/ui-and-theming.mdc`.
- CI: `forbidden-hex-in-jsx` + stale-vocab gates — **DS-1 will fail the hex gate once these files are in CIs path**.
- Design-systems skill/templates not under `.cursor/skills/design-systems/` — G capped partly by missing shared audit tooling.
---
## Recommendation
**Treat DS as not merge-clean until DS-1 is fixed** (one-line / token swap on the strip badge). DS-2 is the next polish item for light/dark knob contrast. DS-3DS-5 are non-blocking documentation / follow-up token work.
**Child tasks (sev ≥ 3):**
1. Remove `#ffffff` from `ScannerHistoryStrip.js` badge text — use a CSS variable (prefer adding `--text-on-accent: #fff` in `globals.css` and referencing it from Button + badge).
---
## Hand-off
DS audit complete. Maturity: **T3/C3/P3/G3/A3**. Top leverage: invest in **Tokens** (on-accent alias + purge convoy hex). Sev ≥ 3 findings: **1**. Report: `.convoys/scanner-desktop-layout/audits/design-system-20260815.md`.

View file

@ -0,0 +1,21 @@
## Reviewer Report
| Check | Status | Notes |
| --- | --- | --- |
| Scope match | ✅ | Working-tree JS/tests match the union of briefs 16 `files:` (camera hook, Tips/Result/History components, batch helper, page + Camera + queue/identification hooks, matching tests). Convoy docs under `.convoys/scanner-desktop-layout/` expected. No out-of-list app code. **Note:** branch tip is behind `origin/main` (merge-base `0d52858`); a naive `git diff origin/main` includes unrelated dashboard-home-realignment reversals — rebase before PR. |
| Conventions | ✅ | `VOCAB` used for add actions; `<Modal>` / `GlassSurface` / `Button`; CSS variables (no hex / forbidden stale strings in scanner surfaces); relative imports; no new API routes or schema. |
| Security | ✅ | No new auth/API surfaces; client-only. Device id in `sessionStorage` only. Depth deferred to `role-security-auditor` in this fan-out. |
| Regression risk | medium | Touches `/scanner` composition, `useCameraScanner` stream constraints, and `verificationPausedRef` ownership (identification overwrite removed). Mobile chrome gated via `variant` + `isDesktop`, but pause/batch heuristics are easy to break. |
| Test coverage | ✅ | 45 tests green across briefs 16 artifacts (`use-camera-scanner`, batch helper, Tips/Result/History/Camera, page viewport split). Gaps: checkout sheet not exercised open on mobile; Identifying… never wired to real in-flight identify. |
| Documentation | ✅ | Convoy + briefs present; no AGENTS.md change required for this UI composition. |
### Findings
- 🟡 **Suggestion:** `isIdentifying={Boolean(identification.disambiguation)}` in `pages/scanner.js` does not match Brief 3s “Identifying…” intent — disambiguation is post-match choice, and the hook has no in-flight identify flag. Empty rail will not show Identifying… during real scans; it may flash the wrong copy if disambiguation opens with no focused card. Wire a real verifying signal or leave `false` until one exists.
- 🟡 **Suggestion:** Batch Scan does not switch the strip to **Scan Queue** when `batchProgress.active` (Design direction / strip UX). Progress banner only appears if the user is already on that tab.
- 🟡 **Suggestion:** Device picker messages use short status strings (`No camera found`, etc.) but Design directions locked table includes action hints (e.g. “Connect a webcam or use Upload Image”). Align copy with the Design table.
- 🟡 **Suggestion:** `test/pages/scanner.test.js` asserts the checkout sheet is absent when closed on mobile, but Brief 6 asks for no-regression coverage of the sheet at narrow width — open `isCheckoutOpen` (or equivalent) and assert the sheet mounts under `max-md`.
- 🟢 **Nice to have:** Rebase/merge `origin/main` before opening the PR so the diff does not look like a dashboard revert; implementation itself stays in-scope once compared to the brief file union.
### Approval recommendation
- approve

View file

@ -0,0 +1,30 @@
## Security Audit
| Check | Status | Notes |
| --- | --- | --- |
| Auth boundary | ✅ | No new API route or auth flow. `/scanner` keeps its `useAuth` redirect, and the unchanged scan endpoint verifies the Bearer token before its per-user scan rate limit. |
| Authorization (IDOR) | ✅ | The desktop list picker uses the existing collection-card API; its server-side POST path confirms authenticated owner/editor permission before mutation. Client-held `collectionId` is not trusted as authorization. |
| Input / injection | ⚠️ | File names and server error text are rendered as React text (no HTML sink), but new batch intake has no client-side file-size or count bound before decoding images into a canvas. |
| Secrets exposure | ✅ | Device IDs are stored only in tab-scoped `sessionStorage`; no new secret, `NEXT_PUBLIC_*` value, token, or credential was found in the scanner additions. |
| Dependencies | ⚠️ | No dependency manifest change is in the supplied diff, but `npm audit --omit=dev --json` reports five pre-existing production high-severity advisories (including `next`, `jws`, `nanoid`, `postcss`, and `sharp`). |
### Findings
1. **[Severity 4 — scope expansion]** `git diff origin/main -- . ':!.convoys/.metrics.jsonl'` contains 15 tracked paths outside Briefs 16, including dashboard/nav work, `components/Layout.js`, `pages/dashboard.js`, `pages/my-cards.js`, and `pages/api/user-cards.js`; it also deletes the separate `dashboard-home-realignment` convoy artifacts. This violates every brief's explicit file scope and prevents a trustworthy scanner-only review. **Fix:** rebase/cherry-pick the scanner implementation onto `origin/main` (or split the unrelated dashboard/API changes into their own convoy) and re-run the audit. Note that the supplied `git diff` does not show untracked scanner files, so this audit additionally inspected their working-tree contents.
2. **[Severity 1 — file intake hardening]** `pages/scanner.js:269-300` accepts an arbitrary number of arbitrary-sized `image/*` files, and `lib/use-scanner-identification.js:20-42` decodes each selected image at full natural dimensions into a canvas before the existing server-side 6 MB request cap can apply. A logged-in user can select a very large or decompression-bomb image set, freezing their tab and creating repeated scan attempts. `accept="image/*"` is only a picker hint, not validation. **Fix:** before `runSequentialGalleryIdentify`, enforce a maximum file count, MIME allowlist, and conservative byte limit compatible with the 6 MB base64 server limit; reject failures per file before calling `readFileToImageData`.
3. **[Severity 2 — inherited production dependency advisories]** `npm audit --omit=dev --json` reports five high-severity production vulnerabilities. The most material is the direct `next` range `<16.2.11`; the audit also identifies transitive `jws`, `nanoid`, `postcss`, and `sharp`. No package manifest changed in this convoy, so this is not introduced by the scanner work, but it remains a release risk. **Fix:** open or link the dependency-upgrade/accepted-risk work before release, and verify the deployed Next.js version against the advisories.
### Layer notes
- **Layer 1:** Existing scanner endpoints retain authentication and the scan rate-limit gate; this convoy adds no server action or API route.
- **Layer 2:** The list picker is UI convenience only. `pages/api/collections/[identifier]/cards.js:90-101` independently requires an authenticated owner/editor for POST, preventing a forged picker ID from becoming an IDOR.
- **Layer 3:** React escapes the batch-derived file basename (`pages/scanner.js:226-235`) and failure strings (`components/scanner/ScannerHistoryStrip.js:91-94`); no `dangerouslySetInnerHTML`, `eval`, or shell construction was found. The file-bound finding above remains.
- **Layer 4:** `lib/use-camera-scanner.js:37-90` stores only opaque camera device IDs in `sessionStorage`, using a fixed key and guarded reads/writes. No scanner secret is exposed to the client.
- **Layer 5:** See dependency finding 3. The full audit reports 16 issues including dev dependencies; the production-only audit reports five high findings.
- **Layer 6:** No middleware, Next config, CORS, cookie, or header change was included.
### Recommendation
**Do not merge as currently scoped.** Resolve the severity-4 scope expansion first. After isolating the scanner-only diff, add the file size/count/type guard (or document an accepted risk) and re-audit; the existing dependency findings should be tracked or risk-accepted separately because they were not introduced here.

View file

@ -0,0 +1,90 @@
---
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.

View file

@ -0,0 +1,79 @@
---
convoy: scanner-desktop-layout
brief_number: 2
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- components/scanner/ScannerTips.js
- test/components/ScannerTips.test.js
cross_brief_commitments:
- brief: 6
description: |
Brief 6 mounts `<ScannerTips />` in the desktop page header row (md+ only)
beside "Card Scanner" title. This brief ships the self-contained modal +
trigger button; no page wiring here.
---
# Brief 2: Scanner Tips modal
## Goal (1 sentence)
Ship a desktop Scanner Tips control that opens a glass `<Modal>` with five convoy-authored tips.
## Files in scope (do not edit anything else)
- `components/scanner/ScannerTips.js`
- `test/components/ScannerTips.test.js`
## Conventions to follow
- Use `Modal` + `Button` from `components/ui` — no custom scrim (`ui-and-theming.mdc`).
- Glass panel recipe: `--glass-surface-low` via `GlassSurface` inside modal body if needed.
- Copy locked (Design direction + IA):
| # | Tip |
| --- | --- |
| 1 | **Good lighting** — avoid glare on foil cards. |
| 2 | **Fill the frame** with one card; keep corners visible. |
| 3 | **Hold still** until Auto-detect locks the match. |
| 4 | **Use Batch Scan** for a pile of photos from your gallery. |
| 5 | **Switch camera** if the image is dark or mirrored. |
- Modal title: **Scanner Tips**. Trigger: ghost `Button` with lightbulb SVG + label **Scanner Tips** (visible text for 2.5.3 Label in Name).
- Dismiss: close control, Esc, scrim click — standard `<Modal>` behavior.
- No hex in `.js`; tokens only.
- Test pattern: `test/components/ScannerCheckoutSheet.test.js` (vitest + jsdom + RTL).
## Implementation shape
```js
export default function ScannerTips({ className = '' }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="ghost" onClick={() => setOpen(true)} /* lightbulb + Scanner Tips */ />
<Modal open={open} onClose={() => setOpen(false)} title="Scanner Tips" size="md">
<ol className="space-y-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
{/* five <li> with <strong> lead + em dash body */}
</ol>
</Modal>
</>
);
}
```
Component is self-contained — no props required v1.
## Acceptance criteria
- [ ] Trigger shows lightbulb icon + **Scanner Tips** visible label
- [ ] Modal lists exactly five tips with locked copy
- [ ] Uses `<Modal>` primitive (no `fixed inset-0 bg-black` shell)
- [ ] Opening/closing traps focus per existing Modal behavior
- [ ] tests: trigger opens modal, all five tips visible, close dismisses
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
Tips are a standalone surface (C3) with no queue or camera dependencies. Shipping the modal in isolation lets Brief 6 wire it into the header without blocking other parallel work. Five tips exceed popover length — Modal is Design-locked.

View file

@ -0,0 +1,78 @@
---
convoy: scanner-desktop-layout
brief_number: 3
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- components/scanner/ScannerResultPanel.js
- test/components/ScannerResultPanel.test.js
cross_brief_commitments:
- brief: 6
description: |
Brief 6 passes `focusedCard`, `queue`, `isIdentifying`, handlers
(`onAddToOwned`, `onAddToList`, `onRescan`), and `commitError`. On
successful add, Brief 6 advances `focusedCardId` to the next unprocessed
entry and fires `ScannerToast` — this component calls `onAddToOwned` only.
---
# Brief 3: Live match inspector rail
## Goal (1 sentence)
Build the right-rail `ScannerResultPanel` that inspects the focused cart entry and commits one card via `VOCAB.ADD_TO_MY_COLLECTION`.
## Files in scope (do not edit anything else)
- `components/scanner/ScannerResultPanel.js`
- `test/components/ScannerResultPanel.test.js`
## Conventions to follow
- Import `VOCAB` from `lib/collection-vocabulary.js` — never ship forbidden strings
(`forbidden-stale-strings` CI).
- Primitives: `GlassSurface`, `Button` from `components/ui`.
- Duplicate minimally from `ReviewCardItem.js`: `CONDITION_OPTIONS`,
`confidencePercent`, `confidenceRingColor` (do not edit `ReviewCardItem` in this brief).
- Confidence captions (Design direction): ≥90% *Excellent match.* · ≥70% *Good match.*
· &lt;70% *Low confidence — verify before adding.*
- Inspector thumbnail + tiles: solid `--bg-secondary`**no `backdrop-filter`** on per-card elements.
- Empty state: centered muted scan/camera SVG illustration + single line:
*Point your camera at a card or upload an image to see a match.***no** Upload/Batch CTAs in rail.
- Populated: thumbnail, name (`text-lg font-semibold`), set · rarity · collector #,
condition `<select>` (`aria-label` includes card name), foil `role="switch"`,
confidence % + horizontal bar (300ms width transition; `@media (prefers-reduced-motion: reduce)` → instant).
- Actions: primary `VOCAB.ADD_TO_MY_COLLECTION` · ghost **Rescan** · ghost `VOCAB.ADD_TO_LIST`.
- Primary `Button` uses `loading` when `isAdding` (from `queue.addingCardIds.has(card.id)`).
- Commit errors: `role="alert"` div with `color-mix(in srgb, var(--color-error) …)` pattern from `pages/scanner.js`.
- `isIdentifying` state: show *Identifying…* placeholder when true and no focused card yet.
## Props shape (for Brief 6 wiring)
```js
export default function ScannerResultPanel({
focusedCard = null,
queue,
isIdentifying = false,
commitError = null,
onAddToOwned,
onAddToList,
onRescan,
}) {
```
`onUpdateMetadata` via `queue.updateCardMetadata(focusedCard.id, patch)` inline.
## Acceptance criteria
- [ ] Empty state: illustration + one line only (no duplicate Upload/Batch)
- [ ] Populated state shows metadata, condition select, foil switch, confidence bar
- [ ] Primary button label is `VOCAB.ADD_TO_MY_COLLECTION`
- [ ] Secondary row: Rescan + `VOCAB.ADD_TO_LIST` (no wishlist)
- [ ] `prefers-reduced-motion` disables confidence bar width animation
- [ ] tests: empty state, populated card, add button disabled while adding
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
The inspector replaces the 360px cart side panel as the primary right-hand surface on desktop. Keeping it a dumb view lets Brief 6 own focus/advance/toast orchestration. Confidence and condition UX matches checkout patterns users already know from `ReviewCardItem`.

View file

@ -0,0 +1,105 @@
---
convoy: scanner-desktop-layout
brief_number: 4
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- components/scanner/ScannerHistoryStrip.js
- test/components/ScannerHistoryStrip.test.js
cross_brief_commitments:
- brief: 6
description: |
Brief 6 supplies `focusedCardId`, `onFocusCard`, `batchProgress`
(`{ active, current, total, onCancel }`), bulk commit handlers, and
`onClearAll`. Row click calls `onFocusCard(card.id)` — no auto-commit.
---
# Brief 4: History / queue / duplicates strip
## Goal (1 sentence)
Build the bottom `ScannerHistoryStrip` with Recent Scans, Scan Queue, and Duplicates tabs over the existing cart model.
## Files in scope (do not edit anything else)
- `components/scanner/ScannerHistoryStrip.js`
- `test/components/ScannerHistoryStrip.test.js`
## Conventions to follow
- `GlassSurface` tint `mid` for strip container; chips use solid `--bg-secondary` fill — **no per-chip `backdrop-filter`**.
- Tab labels: **Recent Scans**, **Scan Queue**, **Duplicates** with badge counts on Queue and Duplicates.
- `role="tablist"` / `role="tab"` / `role="tabpanel"`; badges in `aria-label`
(e.g. `Scan Queue, 3 unprocessed cards`).
- Empty tab copy (UX §5): Recent → *No scans yet this session.* · Queue →
*Scan queue is empty — matches appear here before you add them.* · Duplicates →
*No duplicates detected.*
- **Clear All** text link right-aligned; disabled while `batchProgress?.active`.
- **View All Scans** ghost button at strip bottom scrolls chip row to end (v1 — no expanded modal).
- Chip row: horizontal `overflow-x-auto`, min height 44px, ember rim on focused chip
(`focusedCardId === card.id` → `--ember-rim-pronounced`).
- Row click → `onFocusCard(card.id)` only — never auto-commit.
- Batch progress UI (when `batchProgress.active`): inline banner in Scan Queue tabpanel —
*Scanning {current} of {total}…* determinate bar, ghost **Cancel** calling `batchProgress.onCancel`.
- Failed identify rows: `identifyFailed` flag on card (Brief 6 sets) → ember left border +
**Identify failed** label + optional reason `text-sm`.
## Duplicate membership helper (export from this file)
```js
export function getDuplicateCards(scannedCards, ownershipMap) {
const sessionRepeatIds = new Set();
const seen = new Map(); // key: name+set → first id
for (const card of scannedCards) {
const key = `${card.name}::${card.set}`;
if (seen.has(key)) sessionRepeatIds.add(card.id);
else seen.set(key, card.id);
if ((card.quantity || 1) > 1) sessionRepeatIds.add(card.id);
}
return scannedCards.filter(
(card) =>
sessionRepeatIds.has(card.id) ||
(card.databaseId && ownershipMap[card.databaseId])
);
}
```
**Recent Scans** tab: all `scannedCards` session history (processed + unprocessed), newest first.
**Scan Queue** tab: `!card.processed` rows; badge = unprocessed count.
**Duplicates** tab: `getDuplicateCards(...)`; badge = duplicate set length.
## Props shape
```js
export default function ScannerHistoryStrip({
scannedCards,
ownershipMap,
focusedCardId,
onFocusCard,
activeTab,
onTabChange,
batchProgress = null,
onClearAll,
onCommitSelectedToOwned,
onOpenListPicker,
isProcessing = false,
}) {
```
Bulk commit buttons in Queue tab footer: `VOCAB.ADD_TO_MY_COLLECTION` + `VOCAB.ADD_TO_LIST` calling parent handlers (Brief 6 wires `queue.commitSelectedToOwned`).
## Acceptance criteria
- [ ] Three tabs with correct labels and badge counts
- [ ] `getDuplicateCards` covers ownershipMap + session name/set repeats + quantity>1
- [ ] Row click invokes `onFocusCard` without commit
- [ ] Batch progress banner with Cancel when `batchProgress.active`
- [ ] Clear All disabled during active batch
- [ ] Chip focus ring matches `focusedCardId`
- [ ] tests: tab switching, duplicate helper, empty states, batch banner
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
The strip is the second commit surface (C5) alongside the inspector. Centralizing duplicate logic in one exported helper keeps `pages/scanner.js` thin. Tab + chip UX is independent of camera device picker, so this brief parallelizes cleanly.

View file

@ -0,0 +1,92 @@
---
convoy: scanner-desktop-layout
brief_number: 5
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- lib/scanner-batch-identify.js
- test/lib/scanner-batch-identify.test.js
cross_brief_commitments:
- brief: 6
description: |
Brief 6 calls `runSequentialGalleryIdentify(files, identifyFn, options)`
from the Batch Scan multi-file picker. On per-file failure Brief 6 enqueues
a queue row with `identifyFailed: true` and `identifyError` message; cancel
via `cancelRef.current = true` keeps completed rows (IA-locked).
---
# Brief 5: Sequential batch identify helper
## Goal (1 sentence)
Add a small library helper that runs `identifyFromGalleryFile` sequentially over multiple files with progress, cancel, and per-file failure continuation.
## Files in scope (do not edit anything else)
- `lib/scanner-batch-identify.js`
- `test/lib/scanner-batch-identify.test.js`
## Conventions to follow
- **No new API routes** — caller passes `identifyFn` (Brief 6 binds
`identification.identifyFromGalleryFile`).
- **Sequential only** — one `await identifyFn(file)` at a time; no `Promise.all`.
- Respect identify rate limits implicitly (sequential pacing).
- Pure JS module — no React.
## Implementation shape
```js
/**
* @typedef {{ current: number, total: number, file: File }} BatchProgress
*/
export async function runSequentialGalleryIdentify(files, identifyFn, options = {}) {
const {
onProgress,
onFileSuccess,
onFileError,
cancelRef = { current: false },
} = options;
const list = Array.from(files || []);
const total = list.length;
const results = [];
for (let i = 0; i < list.length; i++) {
if (cancelRef.current) break;
const file = list[i];
onProgress?.({ current: i + 1, total, file });
try {
await identifyFn(file);
results.push({ file, ok: true });
onFileSuccess?.({ file, index: i });
} catch (error) {
results.push({ file, ok: false, error });
onFileError?.({ file, index: i, error });
// continue — IA forbids stopping the batch
}
}
return { results, cancelled: cancelRef.current };
}
```
`identifyFn` may not throw today — helper still catches for forward-compat.
If `identifyFromGalleryFile` only reports via console, Brief 6 may wrap it to
throw on hard failures.
## Acceptance criteria
- [ ] Processes files one-at-a-time in order
- [ ] `onProgress` fires before each file with `{ current, total, file }`
- [ ] `cancelRef.current = true` stops remaining files but returns partial `results`
- [ ] Per-file errors invoke `onFileError` and continue loop
- [ ] tests cover success path, mid-batch cancel, and error continuation
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
Batch Scan (C1) needs orchestration without touching `use-scanner-identification.js` identify logic. A 60-line pure helper is testable and keeps the page brief focused on UI wiring. Sequential execution avoids Gemini rate-limit storms explicitly forbidden in scope.

View file

@ -0,0 +1,171 @@
---
convoy: scanner-desktop-layout
brief_number: 6
depends_on: [1, 2, 3, 4, 5]
recommended_model: composer-2.5-fast
model_tier: fast
files:
- pages/scanner.js
- components/scanner/ScannerCamera.js
- lib/use-scanner-identification.js
- lib/use-scanner-queue.js
- test/pages/scanner.test.js
- test/components/ScannerCamera.test.js
cross_brief_commitments:
- brief: 1
description: |
Wires `camera.videoDevices`, `selectedDeviceId`, `setSelectedDeviceId`,
and `devicePickerStatus` into the desk Camera `<select>` (md+ only).
- brief: 2
description: |
Mounts `<ScannerTips />` in desktop page header (`hidden md:flex` row).
- brief: 3
description: |
Mounts `<ScannerResultPanel />` with focus/advance/toast orchestration
after `addSingleCardToOwned`.
- brief: 4
description: |
Mounts `<ScannerHistoryStrip />` with `focusedCardId`, batch progress,
and bulk commit handlers.
- brief: 5
description: |
Batch Scan multi-file input calls `runSequentialGalleryIdentify` with
`identification.identifyFromGalleryFile`.
---
# Brief 6: Desktop workstation page wiring
## Goal (1 sentence)
Wire `pages/scanner.js` into the md+ workstation grid (inspector + strip + desk controls) without regressing mobile immersive checkout.
## Files in scope (do not edit anything else)
- `pages/scanner.js`
- `components/scanner/ScannerCamera.js`
- `lib/use-scanner-identification.js`
- `lib/use-scanner-queue.js`
- `test/pages/scanner.test.js`
- `test/components/ScannerCamera.test.js`
## Conventions to follow
- **Layout chrome (boot finding):** `chrome="immersive"` already shows sidebar +
TopSearchBar at `md+` per `Layout.js` (`max-md:hidden` only on mobile nav).
Use explicit viewport split for semantics:
```js
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)');
const sync = () => setIsDesktop(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
// <Layout chrome={isDesktop ? 'default' : 'immersive'} />
```
Smallest change — no `Layout.js` edits.
- **Mobile zero regression:** keep `ScannerCheckoutSheet`, overlay camera chrome,
`ScannerCountPill`, scan peek — all `md:hidden` or `max-md:` as today.
- Remove desktop `ScannerReview` side panel (`hidden md:flex` block) — replaced by
inspector + strip.
- **Pause coordination (boot finding):** `useScannerIdentification` currently has:
```js
useEffect(() => {
if (verificationPausedRef) {
verificationPausedRef.current = Boolean(disambiguation);
}
}, [disambiguation, verificationPausedRef]);
```
**Delete this effect** — page owns composite pause:
```js
verificationPausedRef.current =
mobileCheckoutPauses ||
isListPickerOpen ||
isAutoDetectPaused ||
Boolean(identification.disambiguation);
```
Desktop Auto-detect toggle sets `isAutoDetectPaused` (OFF = paused).
- **Focused card state:** `focusedCardId` + derived `focusedCard`. On new scan,
auto-focus latest unprocessed (`scannedCards.find(c => !c.processed)`).
- **Inspector add success:** wrap `addSingleCardToOwned` — on success show
`ScannerToast` (*Added to My Collection*), advance to next unprocessed or empty.
Boot finding: current `addSingleCardToOwned` does not return boolean — check
`markCardAsProcessed` by awaiting and verifying card.processed or catch errors;
optionally extend queue method to `return true/false` in a follow-up if needed
inside this brief only if `use-scanner-queue.js` is NOT in scope — use try/catch
around API and inspect state after await.
**Note:** To return success reliably, add minimal change to
`lib/use-scanner-queue.js` `addSingleCardToOwned``return true` on success,
`return false` on error — **only if needed**; prefer not to expand files list.
Alternative: duplicate add call in page using `addScannedCardToOwned` from
`scanner-route-api.js`**rejected**; instead add `return true/false` to
`use-scanner-queue.js` is out of files — use `addingCardIds` clearing + no error
log as success heuristic OR add `use-scanner-queue.js` to this brief's files.
**Architect lock:** add `lib/use-scanner-queue.js` to this brief for
`addSingleCardToOwned` returning `boolean`.
- **List picker desktop:** `handleListPick` uses **focused card id** single selection
(`queue.addSingleCardToCollection(focusedCard, collectionId)`), not bulk selected set.
- **Leave modal:** keep existing; trigger on back + unprocessed queue.
- **Batch Scan:** hidden `<input type="file" multiple accept="image/*" />` on desk bar;
`runSequentialGalleryIdentify` with `cancelRef`; on `onFileError` enqueue synthetic
failed row via `queue.handleCardScanned` with `identifyFailed: true` OR dedicated
`enqueueFailedIdentify(file, error)` inline in page.
- **Rescan:** clear identify fields on focused card (`updateCardMetadata`), set
`processed: false`; if `scanImageUrl` present fetch blob and
`identifyFromGalleryFile`; else toast *Rescan from camera*.
- **ScannerCamera workstation:** add `variant="default" | "workstation"` (or `layoutMode`).
At `md+` with `variant="workstation"`:
- Container: framed panel not `min-h-[100dvh]` full-bleed — use `md:rounded-xl`,
`md:aspect-video`, `md:min-h-0`, outer `GlassSurface` + ember rim.
- Hide overlay top bar + bottom bar (`max-md:` keep current).
- Hide `ScannerScanPeek`, mobile LIVE badge, `ScannerCountPill` at `md+`.
- Show **Auto-detect badge** top-left in frame (ON/OFF from `!verificationPausedRef`
composite for auto-detect slice only — pass `autoDetectOn` prop).
- Badge dot pulse when ON; `prefers-reduced-motion: reduce` → static dot.
- Accept optional `deskControls` slot rendered **below** frame by page OR
`deskControlBar` prop — page renders Upload/Batch/Auto-detect/Camera select below.
- **Desk control bar (page, md+):** Camera `<select>` + Upload Image + Batch Scan +
Auto-detect switch per Design direction. Upload uses existing single-file input path.
- **Tests:** `test/pages/scanner.test.js` — mock hooks; assert mobile checkout sheet
present at narrow width; workstation elements (`ScannerResultPanel`, strip) at `md+`
(use `window.matchMedia` mock). `test/components/ScannerCamera.test.js` — overlay
chrome hidden at workstation variant.
- **Visual-diff:** `/scanner` is **not** in `tests/visual/` (only `home.png`) — no
baseline update required.
## `use-scanner-queue.js` changes
- `addSingleCardToOwned` returns `true` on success, `false` on error (after `try/catch`).
- `addSingleCardToCollection` same boolean return for desktop list picker.
## Acceptance criteria
- [ ] `md+` workstation grid: header + camera column + inspector + bottom strip
- [ ] `max-md` immersive checkout sheet unchanged (regression test)
- [ ] `chrome` default on desktop, immersive on mobile
- [ ] Device picker + Batch Scan + Auto-detect on desk bar only at `md+`
- [ ] `ScannerTips` in desktop header
- [ ] Inspector advance + toast on successful add
- [ ] Batch sequential with cancel + per-file failure flags
- [ ] `verificationPausedRef` composite pause without identification hook overwrite
- [ ] No forbidden copy strings
- [ ] tests added; no visual-diff baseline change
- [ ] no scope expansion beyond listed files
## Rationale (≤3 sentences)
Only one brief can own `pages/scanner.js` composition. Camera workstation variant and pause fix are tightly coupled to page wiring. Briefs 15 ship mountable units; this brief integrates them without blocking parallel implementers.

View file

@ -129,7 +129,10 @@ export default function ScannerCamera({
cartCount,
isCheckoutOpen = false,
verificationPausedRef,
variant = 'default',
autoDetectOn = true,
}) {
const isWorkstation = variant === 'workstation';
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
const [galleryBusy, setGalleryBusy] = useState(false);
const toastTimerRef = useRef(null);
@ -230,7 +233,21 @@ export default function ScannerCamera({
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
return (
<div className="relative w-full min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh] overflow-hidden">
<div
className={`relative w-full overflow-hidden ${
isWorkstation
? 'md:rounded-xl md:aspect-video md:min-h-0 md:max-h-[min(56vh,640px)]'
: 'min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh]'
}`}
>
{isWorkstation ? (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
className="absolute inset-0 md:rounded-xl overflow-hidden"
style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }}
>
<div
className="absolute inset-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
@ -251,36 +268,45 @@ export default function ScannerCamera({
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
))}
{isStreaming && (
<div className="absolute top-3 right-3 z-10 md:hidden">
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error)' }}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
LIVE
</GlassSurface>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
</div>
</GlassSurface>
) : (
<div
className="absolute inset-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover"
style={{ display: isStreaming ? 'block' : 'none' }}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
))}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
@ -300,9 +326,68 @@ export default function ScannerCamera({
</div>
)}
</div>
)}
{isStreaming && (
<div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error)' }}
aria-hidden="true"
/>
LIVE
</GlassSurface>
</div>
)}
{isWorkstation && isStreaming && (
<div className="absolute top-3 left-3 z-10 hidden md:block">
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
role="status"
aria-live="polite"
>
<span
className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
style={{
backgroundColor: autoDetectOn
? 'var(--color-success)'
: 'var(--text-secondary)',
}}
aria-hidden="true"
/>
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
</GlassSurface>
</div>
)}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
<div className={isWorkstation ? 'md:hidden' : undefined}>
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
</div>
<div
className="absolute top-0 left-0 right-0 z-30 px-3 pb-2"
className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`}
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}
>
<GlassSurface
@ -378,7 +463,7 @@ export default function ScannerCamera({
{isStreaming && (
<div
className="absolute bottom-0 left-0 right-0 z-30 px-3 pt-2"
className={`absolute bottom-0 left-0 right-0 z-30 px-3 pt-2 ${isWorkstation ? 'md:hidden' : ''}`}
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))' }}
>
<GlassSurface

View file

@ -0,0 +1,343 @@
/* eslint-disable @next/next/no-img-element -- External card thumbnails; next/image migration is out of scope. */
import { useRef } from 'react';
import { VOCAB } from '../../lib/collection-vocabulary.js';
import Button from '../ui/Button.js';
import GlassSurface from '../ui/GlassSurface.js';
export const TAB_RECENT = 'recent';
export const TAB_QUEUE = 'queue';
export const TAB_DUPLICATES = 'duplicates';
const EMPTY_COPY = {
[TAB_RECENT]: 'No scans yet this session.',
[TAB_QUEUE]: 'Scan queue is empty — matches appear here before you add them.',
[TAB_DUPLICATES]: 'No duplicates detected.',
};
export function getDuplicateCards(scannedCards, ownershipMap) {
const sessionRepeatIds = new Set();
const seen = new Map();
for (const card of scannedCards) {
const key = `${card.name}::${card.set}`;
if (seen.has(key)) sessionRepeatIds.add(card.id);
else seen.set(key, card.id);
if ((card.quantity || 1) > 1) sessionRepeatIds.add(card.id);
}
return scannedCards.filter(
(card) =>
sessionRepeatIds.has(card.id) ||
(card.databaseId && ownershipMap[card.databaseId])
);
}
function queueTabAriaLabel(count) {
if (count === 0) return 'Scan Queue';
if (count === 1) return 'Scan Queue, 1 unprocessed card';
return `Scan Queue, ${count} unprocessed cards`;
}
function duplicatesTabAriaLabel(count) {
if (count === 0) return 'Duplicates';
if (count === 1) return 'Duplicates, 1 duplicate card';
return `Duplicates, ${count} duplicate cards`;
}
function HistoryChip({ card, isFocused, onFocus }) {
const isFailed = Boolean(card.identifyFailed);
return (
<button
type="button"
onClick={() => onFocus(card.id)}
className="flex-shrink-0 flex items-center gap-2 rounded-xl px-3 py-2 min-h-[44px] cursor-pointer transition-shadow duration-150 hover:shadow-[var(--rim-light-inner),var(--ember-rim-subtle)] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderLeft: isFailed ? '3px solid var(--accent-ember)' : undefined,
opacity: card.processed ? 0.65 : 1,
boxShadow: isFocused
? 'var(--rim-light-inner), var(--ember-rim-pronounced)'
: undefined,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-current={isFocused ? 'true' : undefined}
>
{card.image_url ? (
<img
src={card.image_url}
alt=""
className="h-10 w-7 rounded object-cover flex-shrink-0"
/>
) : (
<div
className="h-10 w-7 rounded flex-shrink-0"
style={{ backgroundColor: 'var(--bg-primary)' }}
aria-hidden="true"
/>
)}
<div className="min-w-0 text-left">
<div
className="text-sm font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</div>
{isFailed && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-ember)' }}>
Identify failed
</div>
)}
{isFailed && card.identifyFailureReason && (
<div className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
{card.identifyFailureReason}
</div>
)}
{!isFailed && card.set && (
<div className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{card.set}
</div>
)}
</div>
{(card.quantity || 1) > 1 && (
<span
className="text-xs font-semibold rounded-full px-2 py-0.5 flex-shrink-0"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)',
}}
>
×{card.quantity}
</span>
)}
</button>
);
}
function BatchProgressBanner({ batchProgress }) {
if (!batchProgress?.active) return null;
const { current, total, onCancel } = batchProgress;
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div
className="mb-3 rounded-xl px-4 py-3"
style={{ backgroundColor: 'var(--bg-secondary)' }}
aria-live="polite"
>
<div className="flex items-center justify-between gap-3 mb-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Scanning {current} of {total}
</span>
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-primary)' }}
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={total}
aria-label={`Scanning ${current} of ${total}`}
>
<div
className="h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none"
style={{
width: `${percent}%`,
background:
'linear-gradient(90deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
}}
/>
</div>
</div>
);
}
export default function ScannerHistoryStrip({
scannedCards,
ownershipMap,
focusedCardId,
onFocusCard,
activeTab,
onTabChange,
batchProgress = null,
onClearAll,
onCommitSelectedToOwned,
onOpenListPicker,
isProcessing = false,
}) {
const chipScrollerRef = useRef(null);
const unprocessedCards = scannedCards.filter((card) => !card.processed);
const duplicateCards = getDuplicateCards(scannedCards, ownershipMap || {});
const recentCards = [...scannedCards].reverse();
const tabCards = {
[TAB_RECENT]: recentCards,
[TAB_QUEUE]: unprocessedCards,
[TAB_DUPLICATES]: duplicateCards,
};
const visibleCards = tabCards[activeTab] ?? [];
const handleViewAllScans = () => {
const scroller = chipScrollerRef.current;
if (scroller) {
scroller.scrollLeft = scroller.scrollWidth;
}
};
const tabs = [
{
id: TAB_RECENT,
label: 'Recent Scans',
badge: null,
ariaLabel: 'Recent Scans',
},
{
id: TAB_QUEUE,
label: 'Scan Queue',
badge: unprocessedCards.length,
ariaLabel: queueTabAriaLabel(unprocessedCards.length),
},
{
id: TAB_DUPLICATES,
label: 'Duplicates',
badge: duplicateCards.length,
ariaLabel: duplicatesTabAriaLabel(duplicateCards.length),
},
];
return (
<GlassSurface tint="mid" className="rounded-2xl p-4 w-full">
<div className="flex items-center justify-between gap-4 mb-3">
<div role="tablist" aria-label="Scan history" className="flex gap-1 flex-wrap">
{tabs.map((tab) => {
const isSelected = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={`panel-${tab.id}`}
aria-label={tab.ariaLabel}
onClick={() => onTabChange(tab.id)}
className="relative px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
color: isSelected ? 'var(--accent-ember)' : 'var(--text-secondary)',
backgroundColor: isSelected
? 'color-mix(in srgb, var(--accent-ember) 12%, transparent)'
: 'transparent',
boxShadow: isSelected
? 'inset 0 -2px 0 var(--accent-ember)'
: undefined,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span aria-hidden="true">
{tab.label}
{tab.badge != null && tab.badge > 0 && (
<span
className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold"
style={{
backgroundColor:
'color-mix(in srgb, var(--accent-ember) 18%, var(--bg-secondary))',
color: 'var(--accent-ember)',
}}
>
{tab.badge}
</span>
)}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={onClearAll}
disabled={batchProgress?.active}
className="text-sm font-medium whitespace-nowrap transition-opacity duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
Clear All
</button>
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
>
{tab.id === TAB_QUEUE && <BatchProgressBanner batchProgress={batchProgress} />}
{visibleCards.length === 0 && activeTab === tab.id ? (
<p
className="text-sm py-4 text-center"
style={{ color: 'var(--text-secondary)' }}
>
{EMPTY_COPY[tab.id]}
</p>
) : activeTab === tab.id ? (
<div
ref={chipScrollerRef}
className="flex gap-2 overflow-x-auto pb-1 min-h-[44px]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{visibleCards.map((card) => (
<HistoryChip
key={card.id}
card={card}
isFocused={focusedCardId === card.id}
onFocus={onFocusCard}
/>
))}
</div>
) : null}
{tab.id === TAB_QUEUE && activeTab === TAB_QUEUE && unprocessedCards.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
<Button
variant="primary"
size="sm"
loading={isProcessing}
disabled={isProcessing}
onClick={onCommitSelectedToOwned}
>
{VOCAB.ADD_TO_MY_COLLECTION}
</Button>
<Button
variant="secondary"
size="sm"
disabled={isProcessing}
onClick={onOpenListPicker}
>
{VOCAB.ADD_TO_LIST}
</Button>
</div>
)}
</div>
))}
<div className="mt-3 flex justify-center">
<Button variant="ghost" size="sm" onClick={handleViewAllScans}>
View All Scans
</Button>
</div>
</GlassSurface>
);
}

View file

@ -0,0 +1,323 @@
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { VOCAB } from '../../lib/collection-vocabulary.js';
import Button from '../ui/Button.js';
import GlassSurface from '../ui/GlassSurface.js';
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
function confidencePercent(confidence) {
if (confidence == null || Number.isNaN(confidence)) return null;
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
}
function confidenceRingColor(pct) {
if (pct == null) return 'var(--border)';
if (pct < 70) return 'var(--accent-ember)';
if (pct < 85) return 'var(--color-warning)';
return 'var(--color-success)';
}
function confidenceCaption(pct) {
if (pct == null) return null;
if (pct >= 90) return 'Excellent match.';
if (pct >= 70) return 'Good match.';
return 'Low confidence — verify before adding.';
}
function formatMetadataLine(card) {
return [card.set, card.rarity, card.cardNumber].filter(Boolean).join(' · ');
}
function EmptyStateIllustration() {
return (
<svg
className="w-16 h-16 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
style={{ color: 'var(--text-secondary)', opacity: 0.6 }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
export default function ScannerResultPanel({
focusedCard = null,
queue,
isIdentifying = false,
commitError = null,
onAddToOwned,
onAddToList,
onRescan,
}) {
if (!focusedCard) {
return (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
elevation="ambient"
className="rounded-2xl p-6 h-full flex flex-col"
>
<h2
className="text-sm font-medium uppercase tracking-wide mb-6"
style={{ color: 'var(--text-secondary)' }}
>
Scan Result
</h2>
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
{isIdentifying ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Identifying
</p>
) : (
<>
<EmptyStateIllustration />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Point your camera at a card or upload an image to see a match.
</p>
</>
)}
</div>
</GlassSurface>
);
}
const card = focusedCard;
const confidencePct = confidencePercent(card.confidence);
const isAdding = queue?.addingCardIds?.has(card.id) ?? false;
const metadataLine = formatMetadataLine(card);
const handleUpdateMetadata = (patch) => {
queue?.updateCardMetadata?.(card.id, patch);
};
return (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
elevation="ambient"
className="rounded-2xl p-6 h-full flex flex-col"
>
<style>{`
.scanner-result-confidence-bar-fill {
transition: width 300ms ease;
}
@media (prefers-reduced-motion: reduce) {
.scanner-result-confidence-bar-fill {
transition: none;
}
}
`}</style>
<div className="flex items-center justify-between gap-3 mb-6">
<h2
className="text-sm font-medium uppercase tracking-wide"
style={{ color: 'var(--text-secondary)' }}
>
Scan Result
</h2>
{confidencePct != null && (
<span
className="text-xs font-medium px-2.5 py-1 rounded-full"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: `1px solid ${confidenceRingColor(confidencePct)}`,
}}
>
{confidencePct}% Match
</span>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div
className="w-full max-w-[200px] mx-auto mb-4 rounded-xl overflow-hidden"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
aspectRatio: '5 / 7',
}}
>
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
>
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<h3
className="text-lg font-semibold text-center mb-1"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h3>
{metadataLine && (
<p className="text-sm text-center mb-5" style={{ color: 'var(--text-secondary)' }}>
{metadataLine}
</p>
)}
<div className="space-y-4 mb-6">
<div>
<label
htmlFor={`scanner-result-condition-${card.id}`}
className="text-xs font-medium block mb-1.5"
style={{ color: 'var(--text-secondary)' }}
>
Condition
</label>
<select
id={`scanner-result-condition-${card.id}`}
value={card.condition || 'NM'}
onChange={(e) => handleUpdateMetadata({ condition: e.target.value })}
className="w-full px-3 py-2 rounded-xl text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="flex items-center justify-between gap-3">
<span
id={`scanner-result-foil-label-${card.id}`}
className="text-xs font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Foil
</span>
<button
type="button"
role="switch"
aria-checked={Boolean(card.isFoil)}
aria-labelledby={`scanner-result-foil-label-${card.id}`}
onClick={() => handleUpdateMetadata({ isFoil: !card.isFoil })}
className="relative inline-flex h-7 w-12 flex-shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: card.isFoil ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
border: '1px solid var(--border)',
minWidth: 44,
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span
className="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200"
style={{
transform: card.isFoil ? 'translateX(1.35rem)' : 'translateX(0.15rem)',
marginTop: '0.35rem',
}}
aria-hidden="true"
/>
</button>
</div>
{confidencePct != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Confidence
</span>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{confidencePct}%
</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
role="presentation"
>
<div
className="scanner-result-confidence-bar-fill h-full rounded-full"
style={{
width: `${confidencePct}%`,
backgroundColor: confidenceRingColor(confidencePct),
}}
/>
</div>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)' }}>
{confidenceCaption(confidencePct)}
</p>
</div>
)}
</div>
</div>
<div className="mt-auto pt-4 space-y-3">
{commitError && (
<div
className="px-3 py-2 rounded-lg text-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
color: 'var(--color-error)',
}}
role="alert"
>
{commitError}
</div>
)}
<Button
variant="primary"
className="w-full"
loading={isAdding}
disabled={isAdding}
onClick={() => onAddToOwned?.(card)}
>
{VOCAB.ADD_TO_MY_COLLECTION}
</Button>
<div className="flex gap-2">
<Button variant="ghost" className="flex-1" onClick={() => onRescan?.(card)}>
Rescan
</Button>
<Button variant="ghost" className="flex-1" onClick={() => onAddToList?.(card)}>
{VOCAB.ADD_TO_LIST}
</Button>
</div>
</div>
</GlassSurface>
);
}

View file

@ -0,0 +1,64 @@
import { useState } from 'react';
import Button from '../ui/Button';
import Modal from '../ui/Modal';
const SCANNER_TIPS = [
{ lead: 'Good lighting', body: 'avoid glare on foil cards.' },
{ lead: 'Fill the frame', body: 'with one card; keep corners visible.' },
{ lead: 'Hold still', body: 'until Auto-detect locks the match.' },
{ lead: 'Use Batch Scan', body: 'for a pile of photos from your gallery.' },
{ lead: 'Switch camera', body: 'if the image is dark or mirrored.' },
];
function LightbulbIcon() {
return (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
);
}
export default function ScannerTips({ className = '' }) {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="ghost"
size="sm"
className={className}
leadingIcon={<LightbulbIcon />}
onClick={() => setOpen(true)}
>
Scanner Tips
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Scanner Tips"
size="md"
>
<ol className="space-y-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
{SCANNER_TIPS.map((tip) => (
<li key={tip.lead}>
<strong style={{ color: 'var(--text-primary)' }}>{tip.lead}</strong>
{' — '}
{tip.body}
</li>
))}
</ol>
</Modal>
</>
);
}

View file

@ -0,0 +1,46 @@
/**
* @typedef {{ current: number, total: number, file: File }} BatchProgress
*/
/**
* Run gallery identify sequentially over multiple files with progress, cancel, and per-file failure continuation.
*
* @param {File[] | FileList | Iterable<File>} files
* @param {(file: File) => Promise<unknown>} identifyFn
* @param {{
* onProgress?: (progress: BatchProgress) => void,
* onFileSuccess?: (payload: { file: File, index: number }) => void,
* onFileError?: (payload: { file: File, index: number, error: unknown }) => void,
* cancelRef?: { current: boolean },
* }} [options]
* @returns {Promise<{ results: Array<{ file: File, ok: true } | { file: File, ok: false, error: unknown }>, cancelled: boolean }>}
*/
export async function runSequentialGalleryIdentify(files, identifyFn, options = {}) {
const {
onProgress,
onFileSuccess,
onFileError,
cancelRef = { current: false },
} = options;
const list = Array.from(files || []);
const total = list.length;
const results = [];
for (let i = 0; i < list.length; i++) {
if (cancelRef.current) break;
const file = list[i];
onProgress?.({ current: i + 1, total, file });
try {
await identifyFn(file);
results.push({ file, ok: true });
onFileSuccess?.({ file, index: i });
} catch (error) {
results.push({ file, ok: false, error });
onFileError?.({ file, index: i, error });
}
}
return { results, cancelled: cancelRef.current };
}

View file

@ -8,6 +8,16 @@ import {
VERIFICATION_INTERVAL_MS,
} from './scanner-card-detection.js';
const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id';
const DEVICE_PICKER_MESSAGES = {
loading: 'Detecting cameras…',
ready: '',
empty: 'No camera found. Connect a webcam or use Upload Image.',
denied: 'Camera access blocked. Allow camera permission or use Upload Image.',
error: "Couldn't list cameras. Try again or use Upload Image.",
};
/**
* Camera stream + card shape detection loop for the card scanner.
* Identification callbacks stay in the parent component.
@ -23,6 +33,18 @@ export function useCameraScanner({
const [trackedCards, setTrackedCards] = useState([]);
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
const [videoDevices, setVideoDevices] = useState([]);
const [selectedDeviceId, setSelectedDeviceIdState] = useState(() => {
try {
return sessionStorage.getItem(SESSION_DEVICE_KEY) || '';
} catch {
return '';
}
});
const [devicePickerStatus, setDevicePickerStatus] = useState('loading');
const [devicePickerMessage, setDevicePickerMessage] = useState(
DEVICE_PICKER_MESSAGES.loading
);
const videoRef = useRef(null);
const canvasRef = useRef(null);
@ -35,6 +57,8 @@ export function useCameraScanner({
const isStreamingRef = useRef(false);
const activeFacingModeRef = useRef(activeFacingMode);
const facingModeInitializedRef = useRef(false);
const selectedDeviceIdRef = useRef(selectedDeviceId);
const selectedDeviceIdInitializedRef = useRef(false);
useEffect(() => {
isStreamingRef.current = isStreaming;
@ -44,6 +68,63 @@ export function useCameraScanner({
activeFacingModeRef.current = activeFacingMode;
}, [activeFacingMode]);
useEffect(() => {
selectedDeviceIdRef.current = selectedDeviceId;
}, [selectedDeviceId]);
const persistSelectedDeviceId = useCallback((deviceId) => {
try {
if (deviceId) {
sessionStorage.setItem(SESSION_DEVICE_KEY, deviceId);
} else {
sessionStorage.removeItem(SESSION_DEVICE_KEY);
}
} catch {
// sessionStorage may be unavailable in private mode or SSR
}
}, []);
const setSelectedDeviceId = useCallback((deviceId) => {
setSelectedDeviceIdState(deviceId);
persistSelectedDeviceId(deviceId);
}, [persistSelectedDeviceId]);
const refreshVideoDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) {
setDevicePickerStatus('error');
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error);
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((device) => device.kind === 'videoinput');
setVideoDevices(videoInputs);
if (videoInputs.length === 0) {
setDevicePickerStatus('empty');
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.empty);
return;
}
const availableIds = videoInputs.map((device) => device.deviceId);
const currentId = selectedDeviceIdRef.current;
if (currentId && !availableIds.includes(currentId)) {
const fallbackId = videoInputs[0]?.deviceId || '';
selectedDeviceIdRef.current = fallbackId;
setSelectedDeviceIdState(fallbackId);
persistSelectedDeviceId(fallbackId);
}
setDevicePickerStatus('ready');
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.ready);
} catch (err) {
console.error('enumerateDevices failed:', err);
setDevicePickerStatus('error');
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error);
}
}, [persistSelectedDeviceId]);
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.getContext('2d', { willReadFrequently: true });
@ -124,16 +205,29 @@ export function useCameraScanner({
}
};
const startCamera = async () => {
try {
console.log('🎥 Starting camera...');
const stream = await navigator.mediaDevices.getUserMedia({
video: {
const buildVideoConstraints = () => {
const deviceId = selectedDeviceIdRef.current;
if (deviceId) {
return {
deviceId: { exact: deviceId },
width: { ideal: 1280 },
height: { ideal: 720 },
};
}
return {
facingMode: activeFacingModeRef.current,
width: { ideal: 1280 },
height: { ideal: 720 },
aspectRatio: { ideal: 16 / 9 },
},
};
};
const startCamera = async () => {
try {
console.log('🎥 Starting camera...');
const stream = await navigator.mediaDevices.getUserMedia({
video: buildVideoConstraints(),
});
console.log('📹 Camera stream obtained:', stream);
@ -153,6 +247,7 @@ export function useCameraScanner({
console.log('▶️ Video playback started successfully');
setIsStreaming(true);
isStreamingRef.current = true;
refreshVideoDevices();
}).catch((err) => {
console.error('❌ Video playback failed:', err);
onError?.(`Video playback failed: ${err.message}`);
@ -171,11 +266,16 @@ export function useCameraScanner({
console.log('▶️ Fallback video playback started');
setIsStreaming(true);
isStreamingRef.current = true;
refreshVideoDevices();
}).catch(console.error);
}
}, 2000);
} catch (err) {
console.error('❌ Camera access error:', err);
if (err?.name === 'NotAllowedError') {
setDevicePickerStatus('denied');
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.denied);
}
onError?.(`Unable to access camera: ${err.message}`);
}
};
@ -236,6 +336,19 @@ export function useCameraScanner({
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles
}, [activeFacingMode]);
useEffect(() => {
if (!selectedDeviceIdInitializedRef.current) {
selectedDeviceIdInitializedRef.current = true;
return;
}
if (!isStreamingRef.current) return;
stopCamera();
startCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when selected device changes
}, [selectedDeviceId]);
return {
videoRef,
canvasRef,
@ -249,5 +362,10 @@ export function useCameraScanner({
streamRef,
facingMode: activeFacingMode,
switchFacingMode,
videoDevices,
selectedDeviceId,
setSelectedDeviceId,
devicePickerStatus,
devicePickerMessage,
};
}

View file

@ -51,6 +51,7 @@ export function useScannerIdentification({
verificationPausedRef,
}) {
const [disambiguation, setDisambiguation] = useState(null);
const [isIdentifying, setIsIdentifying] = useState(false);
const [scanNotice, setScanNotice] = useState(null);
const [submittingReview, setSubmittingReview] = useState(false);
@ -59,12 +60,6 @@ export function useScannerIdentification({
const lastErrorAtRef = useRef(0);
const disambiguationRefineRef = useRef(null);
useEffect(() => {
if (verificationPausedRef) {
verificationPausedRef.current = Boolean(disambiguation);
}
}, [disambiguation, verificationPausedRef]);
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
cardTracker.status = 'scanned';
@ -280,6 +275,7 @@ export function useScannerIdentification({
activeVerificationRef.current += 1;
cardTracker.status = 'verifying';
setIsIdentifying(true);
try {
cardTracker.scanAttempts++;
@ -314,6 +310,7 @@ export function useScannerIdentification({
reportScannerError(error.message || 'Scan failed');
} finally {
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
setIsIdentifying(activeVerificationRef.current > 0);
}
};
@ -326,6 +323,7 @@ export function useScannerIdentification({
const identifyFromGalleryFile = async (file) => {
if (!file || verificationPausedRef?.current) return;
setIsIdentifying(true);
try {
const imageData = await readFileToImageData(file);
const authHeaders = getScanAuthHeaders();
@ -368,11 +366,14 @@ export function useScannerIdentification({
reportScannerError('Could not identify card from gallery image');
} catch (error) {
reportScannerError(error.message || 'Gallery identify failed');
} finally {
setIsIdentifying(activeVerificationRef.current > 0);
}
};
return {
disambiguation,
isIdentifying,
scanNotice,
submittingReview,
handleDisambiguationPick,

View file

@ -203,24 +203,28 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
};
const addSingleCardToOwned = async (card) => {
if (!tryBeginAdding(card.id)) return;
if (!tryBeginAdding(card.id)) return false;
try {
await addScannedCardToOwned(card);
markCardAsProcessed(card.id, 'owned');
return true;
} catch (error) {
console.error('Error adding card to owned:', error);
return false;
} finally {
endAdding(card.id);
}
};
const addSingleCardToCollection = async (card, collectionId) => {
if (!tryBeginAdding(card.id)) return;
if (!tryBeginAdding(card.id)) return false;
try {
await addScannedCardToCollection(card, collectionId);
markCardAsProcessed(card.id, 'collection');
return true;
} catch (error) {
console.error('Error adding card to collection:', error);
return false;
} finally {
endAdding(card.id);
}

View file

@ -1,26 +1,53 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import ScannerCamera from '../components/scanner/ScannerCamera';
import ScannerReview from '../components/scanner/ScannerReview';
import ScannerCheckoutSheet from '../components/scanner/ScannerCheckoutSheet';
import ScannerResultPanel from '../components/scanner/ScannerResultPanel';
import ScannerHistoryStrip, {
TAB_QUEUE,
TAB_RECENT,
} from '../components/scanner/ScannerHistoryStrip';
import ScannerTips from '../components/scanner/ScannerTips';
import ScannerToast from '../components/scanner/ScannerToast';
import { Modal, Button } from '../components/ui';
import GlassSurface from '../components/ui/GlassSurface.js';
import { useAuth } from '../lib/use-auth';
import { useScannerSession } from '../lib/use-scanner-session.js';
import { useScannerQueue } from '../lib/use-scanner-queue.js';
import { useCameraScanner } from '../lib/use-camera-scanner.js';
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
import { runSequentialGalleryIdentify } from '../lib/scanner-batch-identify.js';
import { clearScannerCartStorage } from '../lib/scanner-session.js';
import { collectionDisplayName } from '../lib/collection-vocabulary.js';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
const TOAST_DURATION_MS = 2500;
export default function Scanner() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [isDesktop, setIsDesktop] = useState(false);
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [isListPickerOpen, setIsListPickerOpen] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [listCommitError, setListCommitError] = useState(null);
const [inspectorCommitError, setInspectorCommitError] = useState(null);
const [focusedCardId, setFocusedCardId] = useState(null);
const [stripActiveTab, setStripActiveTab] = useState(TAB_RECENT);
const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(false);
const [pageToast, setPageToast] = useState({ message: '', visible: false, type: 'success' });
const [galleryBusy, setGalleryBusy] = useState(false);
const [batchBusy, setBatchBusy] = useState(false);
const [batchProgress, setBatchProgress] = useState(null);
const verificationPausedRef = useRef(false);
const prevCardCountRef = useRef(0);
const scannedCardsRef = useRef([]);
const toastTimerRef = useRef(null);
const batchCancelRef = useRef(false);
const deskGalleryInputRef = useRef(null);
const batchInputRef = useRef(null);
const disambiguationActiveRef = useRef(false);
const { scanDefaults } = useScannerSession();
const queue = useScannerQueue({ user, scanDefaults, deckMode: null });
@ -43,6 +70,20 @@ export default function Scanner() {
});
const unprocessedCount = queue.unprocessedCount;
const focusedCard =
queue.scannedCards.find((card) => card.id === focusedCardId) ?? null;
useEffect(() => {
scannedCardsRef.current = queue.scannedCards;
}, [queue.scannedCards]);
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)');
const sync = () => setIsDesktop(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
useEffect(() => {
if (!authLoading && !user) {
@ -50,6 +91,10 @@ export default function Scanner() {
}
}, [authLoading, user, router]);
useEffect(() => {
disambiguationActiveRef.current = Boolean(identification.disambiguation);
}, [identification.disambiguation]);
useEffect(() => {
const mobileCheckoutPauses =
typeof window !== 'undefined' &&
@ -59,8 +104,38 @@ export default function Scanner() {
verificationPausedRef.current =
mobileCheckoutPauses ||
isListPickerOpen ||
isAutoDetectPaused ||
Boolean(identification.disambiguation);
}, [isCheckoutOpen, isListPickerOpen, identification.disambiguation]);
}, [
isCheckoutOpen,
isListPickerOpen,
isAutoDetectPaused,
identification.disambiguation,
]);
useEffect(() => {
if (queue.scannedCards.length > prevCardCountRef.current) {
const latestUnprocessed = queue.scannedCards.find((card) => !card.processed);
if (latestUnprocessed) {
// Auto-focus the newest unprocessed scan when the queue grows.
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional focus sync on enqueue
setFocusedCardId(latestUnprocessed.id);
}
}
prevCardCountRef.current = queue.scannedCards.length;
}, [queue.scannedCards]);
useEffect(() => {
return () => clearTimeout(toastTimerRef.current);
}, []);
const showPageToast = useCallback((message, type = 'success') => {
clearTimeout(toastTimerRef.current);
setPageToast({ message, visible: true, type });
toastTimerRef.current = setTimeout(() => {
setPageToast((current) => ({ ...current, visible: false }));
}, TOAST_DURATION_MS);
}, []);
const handleLeaveConfirm = () => {
clearScannerCartStorage();
@ -71,12 +146,31 @@ export default function Scanner() {
const handleListPick = async (collectionId) => {
setListCommitError(null);
const selectedIds = queue.scannedCards
.filter((card) => queue.selectedCards.has(card.id))
.map((card) => card.id);
const card = isDesktop ? focusedCard : null;
const selectedIds = isDesktop
? card
? [card.id]
: []
: queue.scannedCards
.filter((entry) => queue.selectedCards.has(entry.id))
.map((entry) => entry.id);
if (selectedIds.length === 0) return;
if (isDesktop && card) {
const success = await queue.addSingleCardToCollection(card, collectionId);
if (!success) {
setListCommitError('Could not add cards to the list. Try again.');
return;
}
setIsListPickerOpen(false);
const remaining = queue.scannedCards.filter(
(entry) => !entry.processed && entry.id !== card.id
);
setFocusedCardId(remaining[0]?.id ?? null);
return;
}
const successfulIds = await queue.commitSelectedToCollection(collectionId);
if (!successfulIds?.length) {
setListCommitError('Could not add cards to the list. Try again.');
@ -86,6 +180,142 @@ export default function Scanner() {
setIsListPickerOpen(false);
};
const handleInspectorAddToOwned = async (card) => {
setInspectorCommitError(null);
const success = await queue.addSingleCardToOwned(card);
if (!success) {
setInspectorCommitError('Could not add card. Try again.');
return;
}
showPageToast(`Added to ${VOCAB.MY_COLLECTION}`);
const remaining = queue.scannedCards.filter(
(entry) => !entry.processed && entry.id !== card.id
);
setFocusedCardId(remaining[0]?.id ?? null);
};
const handleInspectorAddToList = () => {
if (!focusedCard) return;
setListCommitError(null);
setIsListPickerOpen(true);
};
const handleRescan = async (card) => {
queue.updateCardMetadata(card.id, {
processed: false,
identifyFailed: false,
identifyFailureReason: undefined,
confidence: undefined,
});
if (card.scanImageUrl) {
try {
const response = await fetch(card.scanImageUrl);
const blob = await response.blob();
const file = new File([blob], 'rescan.jpg', { type: blob.type || 'image/jpeg' });
await identification.identifyFromGalleryFile(file);
} catch (error) {
showPageToast(error.message || 'Rescan failed', 'error');
}
return;
}
showPageToast('Rescan from camera', 'info');
};
const enqueueFailedIdentify = useCallback(
(file, error) => {
const baseName = file?.name?.replace(/\.[^.]+$/, '') || 'Unknown image';
queue.handleCardScanned({
name: baseName,
set: 'Batch scan',
identifyFailed: true,
identifyFailureReason:
error?.message || 'Could not identify card from gallery image',
});
},
[queue]
);
const identifyGalleryFileOrThrow = useCallback(
async (file) => {
const countBefore = scannedCardsRef.current.length;
await identification.identifyFromGalleryFile(file);
await new Promise((resolve) => setTimeout(resolve, 50));
if (scannedCardsRef.current.length > countBefore) return;
if (disambiguationActiveRef.current) return;
throw new Error('Could not identify card from gallery image');
},
[identification]
);
const handleDeskGalleryChange = async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setGalleryBusy(true);
try {
await identification.identifyFromGalleryFile(file);
} finally {
setGalleryBusy(false);
}
};
const handleBatchChange = async (event) => {
const files = event.target.files;
event.target.value = '';
if (!files?.length) return;
batchCancelRef.current = false;
setStripActiveTab(TAB_QUEUE);
setBatchBusy(true);
setBatchProgress({ active: true, current: 0, total: files.length });
try {
await runSequentialGalleryIdentify(files, identifyGalleryFileOrThrow, {
cancelRef: batchCancelRef,
onProgress: ({ current, total }) => {
setBatchProgress({
active: true,
current,
total,
onCancel: () => {
batchCancelRef.current = true;
},
});
},
onFileError: ({ file, error }) => {
enqueueFailedIdentify(file, error);
},
});
} finally {
setBatchBusy(false);
setBatchProgress(null);
batchCancelRef.current = false;
}
};
const handleClearAll = () => {
if (batchProgress?.active) return;
clearScannerCartStorage();
queue.clearScannedCards();
setFocusedCardId(null);
};
const handleBack = () => {
if (unprocessedCount > 0) {
setShowLeaveModal(true);
return;
}
router.back();
};
if (authLoading) {
return (
<Layout user={null}>
@ -103,11 +333,33 @@ export default function Scanner() {
return null;
}
const listPickerDescription = isDesktop
? `Add ${focusedCard?.name ?? 'this card'} to one of your lists.`
: 'Add the selected cards to one of your lists.';
return (
<Layout user={user} chrome="immersive">
<div className="relative flex flex-col md:flex-row h-full min-h-0 max-md:fixed max-md:inset-0">
<Layout user={user} chrome={isDesktop ? 'default' : 'immersive'}>
<div className="flex flex-col h-full min-h-0 gap-4">
{isDesktop && (
<div className="hidden md:flex items-start justify-between gap-4">
<div>
<h1
className="text-2xl font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Card Scanner
</h1>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
Identify cards with your webcam and add them to {VOCAB.MY_COLLECTION}.
</p>
</div>
<ScannerTips />
</div>
)}
<div className="relative flex flex-col md:flex-row flex-1 min-h-0 max-md:fixed max-md:inset-0">
<div
className="flex-1 min-h-0 min-w-0"
className="flex-1 min-h-0 min-w-0 flex flex-col gap-3"
inert={isCheckoutOpen ? true : undefined}
aria-hidden={isCheckoutOpen || undefined}
>
@ -115,9 +367,9 @@ export default function Scanner() {
queue={queue}
camera={camera}
identification={identification}
onBack={() =>
unprocessedCount > 0 ? setShowLeaveModal(true) : router.back()
}
variant={isDesktop ? 'workstation' : 'default'}
autoDetectOn={!isAutoDetectPaused}
onBack={handleBack}
onOpenCheckout={() => setIsCheckoutOpen(true)}
onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)}
latestPeekCard={queue.scannedCards[0] ?? null}
@ -125,6 +377,137 @@ export default function Scanner() {
isCheckoutOpen={isCheckoutOpen}
verificationPausedRef={verificationPausedRef}
/>
{isDesktop && (
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="hidden md:flex flex-wrap items-center gap-3 rounded-xl px-4 py-3"
>
<div className="flex flex-col gap-1 min-w-[180px] flex-1">
<label
htmlFor="scanner-camera-select"
className="text-xs font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Camera
</label>
<select
id="scanner-camera-select"
value={camera.selectedDeviceId}
onChange={(event) => camera.setSelectedDeviceId(event.target.value)}
disabled={camera.devicePickerStatus !== 'ready'}
aria-describedby={
camera.devicePickerMessage
? 'scanner-camera-select-hint'
: undefined
}
className="w-full px-3 py-2 rounded-xl text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
{camera.videoDevices.map((device) => (
<option key={device.deviceId || device.label} value={device.deviceId}>
{device.label || 'Camera'}
</option>
))}
</select>
{camera.devicePickerMessage && (
<p
id="scanner-camera-select-hint"
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{camera.devicePickerMessage}
</p>
)}
</div>
<Button
variant="secondary"
size="sm"
loading={galleryBusy}
disabled={galleryBusy || batchBusy}
onClick={() => deskGalleryInputRef.current?.click()}
>
Upload Image
</Button>
<Button
variant="secondary"
size="sm"
loading={batchBusy}
disabled={galleryBusy || batchBusy}
onClick={() => batchInputRef.current?.click()}
>
Batch Scan
</Button>
<div className="flex items-center gap-2">
<span
id="scanner-auto-detect-label"
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Auto-detect
</span>
<button
type="button"
role="switch"
aria-checked={!isAutoDetectPaused}
aria-labelledby="scanner-auto-detect-label"
onClick={() => setIsAutoDetectPaused((paused) => !paused)}
className="relative inline-flex h-7 w-12 flex-shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: !isAutoDetectPaused
? 'var(--accent-ember)'
: 'var(--bg-tertiary)',
border: '1px solid var(--border)',
minWidth: 44,
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span
className="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200"
style={{
transform: !isAutoDetectPaused
? 'translateX(1.35rem)'
: 'translateX(0.15rem)',
marginTop: '0.35rem',
}}
aria-hidden="true"
/>
</button>
</div>
<input
ref={deskGalleryInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
onChange={handleDeskGalleryChange}
/>
<input
ref={batchInputRef}
type="file"
accept="image/*"
multiple
className="sr-only"
tabIndex={-1}
onChange={handleBatchChange}
/>
</GlassSurface>
)}
</div>
<div className="md:hidden">
@ -138,17 +521,48 @@ export default function Scanner() {
)}
</div>
{isDesktop && (
<div
className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:border-l md:min-h-0"
style={{ borderColor: 'var(--border)' }}
className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:min-h-0 md:pl-4"
>
<ScannerReview
<ScannerResultPanel
focusedCard={focusedCard}
queue={queue}
collections={queue.collections}
variant="side-panel"
onOpenListPicker={() => setIsListPickerOpen(true)}
isIdentifying={
identification.isIdentifying || galleryBusy || batchBusy
}
commitError={inspectorCommitError}
onAddToOwned={handleInspectorAddToOwned}
onAddToList={handleInspectorAddToList}
onRescan={handleRescan}
/>
</div>
)}
</div>
{isDesktop && (
<div className="hidden md:block">
<ScannerHistoryStrip
scannedCards={queue.scannedCards}
ownershipMap={queue.ownershipMap}
focusedCardId={focusedCardId}
onFocusCard={setFocusedCardId}
activeTab={stripActiveTab}
onTabChange={setStripActiveTab}
batchProgress={batchProgress}
onClearAll={handleClearAll}
onCommitSelectedToOwned={() => queue.commitSelectedToOwned()}
onOpenListPicker={() => setIsListPickerOpen(true)}
isProcessing={queue.isProcessing}
/>
</div>
)}
<ScannerToast
message={pageToast.message}
visible={pageToast.visible}
type={pageToast.type}
/>
<Modal
open={showLeaveModal}
@ -173,7 +587,7 @@ export default function Scanner() {
setListCommitError(null);
}}
title="Choose a List"
description="Add the selected cards to one of your lists."
description={listPickerDescription}
>
{listCommitError && (
<div

View file

@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
vi.mock('../../lib/use-scanner-sound.js', () => ({
useScannerSound: () => ({ playSuccess: vi.fn() }),
}));
vi.mock('../../lib/use-scanner-flash.js', () => ({
useScannerFlash: () => ({
flashSupported: false,
flashOn: false,
toggleFlash: vi.fn(),
}),
}));
vi.mock('../../components/scanner/ScannerScanPeek.js', () => ({
default: () => <div data-testid="scanner-scan-peek" />,
}));
vi.mock('../../components/scanner/ScannerCountPill.js', () => ({
default: () => <div data-testid="scanner-count-pill" />,
}));
vi.mock('../../components/scanner/ScannerDisambiguation.js', () => ({
default: () => null,
}));
import ScannerCamera from '../../components/scanner/ScannerCamera.js';
function createCameraFixture() {
return {
videoRef: { current: document.createElement('video') },
canvasRef: { current: document.createElement('canvas') },
detectionCanvasRef: { current: document.createElement('canvas') },
isStreaming: true,
trackedCards: [],
videoMetrics: { width: 1280, height: 720 },
startCamera: vi.fn(),
streamRef: { current: null },
facingMode: 'environment',
switchFacingMode: vi.fn(),
};
}
function renderCamera(overrides = {}) {
const props = {
queue: { scannedCards: [], addingCardIds: new Set() },
camera: createCameraFixture(),
identification: { disambiguation: null },
onBack: vi.fn(),
onOpenCheckout: vi.fn(),
latestPeekCard: null,
cartCount: 0,
...overrides,
};
return render(<ScannerCamera {...props} />);
}
describe('ScannerCamera workstation variant', () => {
afterEach(() => cleanup());
it('keeps mobile overlay chrome visible in the default variant', () => {
const { container } = renderCamera({ variant: 'default' });
expect(screen.getByRole('button', { name: 'Leave scanner' })).toBeTruthy();
expect(screen.getByTestId('scanner-scan-peek')).toBeTruthy();
expect(screen.getByTestId('scanner-count-pill')).toBeTruthy();
expect(screen.queryByText('Auto-detect ON')).toBeNull();
expect(container.querySelector('.md\\:hidden')).toBeNull();
});
it('marks overlay chrome for md+ hiding and shows the Auto-detect badge in workstation mode', () => {
const { container } = renderCamera({
variant: 'workstation',
autoDetectOn: true,
});
expect(container.querySelector('.md\\:hidden')).toBeTruthy();
expect(screen.getByText('Auto-detect ON')).toBeTruthy();
expect(screen.getByRole('status').textContent).toContain('Auto-detect ON');
expect(screen.getByText('LIVE')).toBeTruthy();
});
it('shows Auto-detect OFF when autoDetectOn is false', () => {
renderCamera({
variant: 'workstation',
autoDetectOn: false,
});
expect(screen.getByText('Auto-detect OFF')).toBeTruthy();
});
});

View file

@ -0,0 +1,206 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import ScannerHistoryStrip, {
TAB_DUPLICATES,
TAB_QUEUE,
TAB_RECENT,
getDuplicateCards,
} from '../../components/scanner/ScannerHistoryStrip.js';
const CARD_A = {
id: 'card-a',
name: 'Lightning Bolt',
set: 'Alpha',
databaseId: 101,
quantity: 1,
processed: false,
};
const CARD_B = {
id: 'card-b',
name: 'Lightning Bolt',
set: 'Alpha',
databaseId: 102,
quantity: 1,
processed: false,
};
const CARD_C = {
id: 'card-c',
name: 'Counterspell',
set: 'Beta',
databaseId: 201,
quantity: 2,
processed: true,
};
function renderStrip(overrides = {}) {
const props = {
scannedCards: [],
ownershipMap: {},
focusedCardId: null,
onFocusCard: vi.fn(),
activeTab: TAB_RECENT,
onTabChange: vi.fn(),
batchProgress: null,
onClearAll: vi.fn(),
onCommitSelectedToOwned: vi.fn(),
onOpenListPicker: vi.fn(),
isProcessing: false,
...overrides,
};
return {
...render(<ScannerHistoryStrip {...props} />),
props,
};
}
describe('getDuplicateCards', () => {
it('flags session name/set repeats', () => {
const result = getDuplicateCards([CARD_A, CARD_B], {});
expect(result.map((card) => card.id)).toEqual(['card-b']);
});
it('flags quantity greater than one', () => {
const solo = { ...CARD_C, quantity: 2 };
const result = getDuplicateCards([solo], {});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('card-c');
});
it('flags cards already in ownershipMap by databaseId', () => {
const owned = { ...CARD_A, databaseId: 999 };
const result = getDuplicateCards([owned], { 999: { quantity: 1 } });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('card-a');
});
});
describe('ScannerHistoryStrip', () => {
afterEach(() => cleanup());
it('renders three tabs with badge counts in aria-label', () => {
renderStrip({
scannedCards: [CARD_A, CARD_B, CARD_C],
activeTab: TAB_RECENT,
});
expect(screen.getByRole('tab', { name: 'Recent Scans' })).toBeTruthy();
expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' })).toBeTruthy();
expect(screen.getByRole('tab', { name: 'Duplicates, 2 duplicate cards' })).toBeTruthy();
expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' }).innerHTML).not.toContain('#ffffff');
});
it('calls onTabChange when a tab is clicked', () => {
const { props } = renderStrip({ scannedCards: [CARD_A] });
fireEvent.click(screen.getByRole('tab', { name: /scan queue/i }));
expect(props.onTabChange).toHaveBeenCalledWith(TAB_QUEUE);
});
it('shows empty states for each tab', () => {
const { rerender, props } = renderStrip({ activeTab: TAB_RECENT });
expect(screen.getByText('No scans yet this session.')).toBeTruthy();
rerender(<ScannerHistoryStrip {...props} activeTab={TAB_QUEUE} />);
expect(
screen.getByText('Scan queue is empty — matches appear here before you add them.')
).toBeTruthy();
rerender(<ScannerHistoryStrip {...props} activeTab={TAB_DUPLICATES} />);
expect(screen.getByText('No duplicates detected.')).toBeTruthy();
});
it('row click invokes onFocusCard without commit handlers', () => {
const onFocusCard = vi.fn();
const onCommitSelectedToOwned = vi.fn();
const onOpenListPicker = vi.fn();
renderStrip({
scannedCards: [CARD_A],
activeTab: TAB_RECENT,
onFocusCard,
onCommitSelectedToOwned,
onOpenListPicker,
});
fireEvent.click(screen.getByRole('button', { name: /lightning bolt/i }));
expect(onFocusCard).toHaveBeenCalledWith('card-a');
expect(onCommitSelectedToOwned).not.toHaveBeenCalled();
expect(onOpenListPicker).not.toHaveBeenCalled();
});
it('applies focused styling when focusedCardId matches a chip', () => {
renderStrip({
scannedCards: [CARD_A],
activeTab: TAB_RECENT,
focusedCardId: 'card-a',
});
const chip = screen.getByRole('button', { name: /lightning bolt/i });
expect(chip.getAttribute('aria-current')).toBe('true');
});
it('shows batch progress banner with cancel in Scan Queue tab', () => {
const onCancel = vi.fn();
renderStrip({
scannedCards: [CARD_A],
activeTab: TAB_QUEUE,
batchProgress: { active: true, current: 2, total: 5, onCancel },
});
expect(screen.getByText('Scanning 2 of 5…')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables Clear All while batch progress is active', () => {
renderStrip({
scannedCards: [CARD_A],
batchProgress: { active: true, current: 1, total: 3, onCancel: vi.fn() },
});
expect(screen.getByRole('button', { name: /clear all/i }).disabled).toBe(true);
});
it('shows identify failed label on failed queue rows', () => {
const failedCard = {
...CARD_A,
identifyFailed: true,
identifyFailureReason: 'No match found',
};
renderStrip({
scannedCards: [failedCard],
activeTab: TAB_QUEUE,
});
expect(screen.getByText('Identify failed')).toBeTruthy();
expect(screen.getByText('No match found')).toBeTruthy();
});
it('lists recent scans newest first', () => {
const older = { ...CARD_A, id: 'older', name: 'Older Card' };
const newer = { ...CARD_B, id: 'newer', name: 'Newer Card', set: 'New Set' };
renderStrip({
scannedCards: [older, newer],
activeTab: TAB_RECENT,
});
const panel = screen.getByRole('tabpanel', { name: /recent scans/i });
const buttons = within(panel).getAllByRole('button');
expect(buttons[0].textContent).toContain('Newer Card');
expect(buttons[1].textContent).toContain('Older Card');
});
it('exposes tablist semantics', () => {
renderStrip({ scannedCards: [CARD_A] });
expect(screen.getByRole('tablist', { name: 'Scan history' })).toBeTruthy();
expect(screen.getAllByRole('tab')).toHaveLength(3);
expect(screen.getAllByRole('tabpanel', { hidden: true })).toHaveLength(3);
});
});

View file

@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import ScannerResultPanel from '../../components/scanner/ScannerResultPanel.js';
import { VOCAB } from '../../lib/collection-vocabulary.js';
const FOCUSED_CARD = {
id: 'card-1',
name: 'Lightning Bolt',
set: 'Alpha',
rarity: 'Common',
cardNumber: '161',
image_url: 'https://example.com/bolt.jpg',
condition: 'NM',
isFoil: false,
confidence: 0.92,
};
function createQueueFixture({ addingCardIds = new Set() } = {}) {
return {
addingCardIds,
updateCardMetadata: vi.fn(),
};
}
function renderPanel(overrides = {}) {
const props = {
focusedCard: null,
queue: createQueueFixture(),
isIdentifying: false,
commitError: null,
onAddToOwned: vi.fn(),
onAddToList: vi.fn(),
onRescan: vi.fn(),
...overrides,
};
return {
...render(<ScannerResultPanel {...props} />),
props,
};
}
describe('ScannerResultPanel', () => {
afterEach(() => cleanup());
it('renders empty state illustration and instructional copy', () => {
renderPanel();
expect(
screen.getByText('Point your camera at a card or upload an image to see a match.')
).toBeTruthy();
expect(screen.getByRole('heading', { name: 'Scan Result' })).toBeTruthy();
expect(screen.queryByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION })).toBeNull();
expect(screen.queryByRole('button', { name: /upload/i })).toBeNull();
expect(screen.queryByRole('button', { name: /batch/i })).toBeNull();
});
it('shows Identifying… placeholder when identifying without a focused card', () => {
renderPanel({ isIdentifying: true });
expect(screen.getByText('Identifying…')).toBeTruthy();
expect(
screen.queryByText('Point your camera at a card or upload an image to see a match.')
).toBeNull();
});
it('renders populated card metadata, controls, and vocab action labels', () => {
const queue = createQueueFixture();
renderPanel({ focusedCard: FOCUSED_CARD, queue });
expect(screen.getByRole('heading', { name: FOCUSED_CARD.name, level: 3 })).toBeTruthy();
expect(screen.getByText('Alpha · Common · 161')).toBeTruthy();
expect(screen.getByRole('combobox', { name: `Condition for ${FOCUSED_CARD.name}` })).toBeTruthy();
expect(screen.getByRole('switch', { name: 'Foil' })).toBeTruthy();
expect(screen.getByText('92%')).toBeTruthy();
expect(screen.getByText('Excellent match.')).toBeTruthy();
expect(screen.getByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Rescan' })).toBeTruthy();
expect(screen.getByRole('button', { name: VOCAB.ADD_TO_LIST })).toBeTruthy();
expect(screen.queryByText(/wishlist/i)).toBeNull();
});
it('disables the primary add button while the card is adding', () => {
const queue = createQueueFixture({ addingCardIds: new Set(['card-1']) });
renderPanel({ focusedCard: FOCUSED_CARD, queue });
const primaryButton = screen.getByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION });
expect(primaryButton.disabled).toBe(true);
expect(primaryButton.getAttribute('aria-busy')).toBe('true');
});
it('calls queue.updateCardMetadata when condition changes', () => {
const queue = createQueueFixture();
renderPanel({ focusedCard: FOCUSED_CARD, queue });
fireEvent.change(screen.getByRole('combobox', { name: `Condition for ${FOCUSED_CARD.name}` }), {
target: { value: 'LP' },
});
expect(queue.updateCardMetadata).toHaveBeenCalledWith('card-1', { condition: 'LP' });
});
it('calls queue.updateCardMetadata when foil switch is toggled', () => {
const queue = createQueueFixture();
renderPanel({ focusedCard: FOCUSED_CARD, queue });
fireEvent.click(screen.getByRole('switch', { name: 'Foil' }));
expect(queue.updateCardMetadata).toHaveBeenCalledWith('card-1', { isFoil: true });
});
it('shows commit errors with role=alert', () => {
renderPanel({
focusedCard: FOCUSED_CARD,
commitError: 'Could not add card. Try again.',
});
const alert = screen.getByRole('alert');
expect(alert.textContent).toContain('Could not add card. Try again.');
});
it('includes reduced-motion CSS for the confidence bar', () => {
const { container } = renderPanel({ focusedCard: FOCUSED_CARD });
expect(container.innerHTML).toContain('scanner-result-confidence-bar-fill');
expect(container.innerHTML).toContain('prefers-reduced-motion: reduce');
expect(container.innerHTML).toContain('transition: none');
});
});

View file

@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import ScannerTips from '../../components/scanner/ScannerTips';
const TIP_LEADS = [
'Good lighting',
'Fill the frame',
'Hold still',
'Use Batch Scan',
'Switch camera',
];
const TIP_BODIES = [
'avoid glare on foil cards.',
'with one card; keep corners visible.',
'until Auto-detect locks the match.',
'for a pile of photos from your gallery.',
'if the image is dark or mirrored.',
];
describe('ScannerTips', () => {
afterEach(() => cleanup());
it('opens the modal when the trigger is clicked', () => {
render(<ScannerTips />);
expect(screen.queryByRole('dialog')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' }));
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByRole('heading', { name: 'Scanner Tips' })).toBeTruthy();
});
it('lists all five locked tips when open', () => {
render(<ScannerTips />);
fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' }));
for (const lead of TIP_LEADS) {
expect(screen.getByText(lead, { selector: 'strong' })).toBeTruthy();
}
for (const body of TIP_BODIES) {
expect(screen.getByText(body, { exact: false })).toBeTruthy();
}
expect(screen.getAllByRole('listitem')).toHaveLength(5);
});
it('dismisses the modal when the close control is clicked', () => {
render(<ScannerTips />);
fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' }));
expect(screen.getByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
});

View file

@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { runSequentialGalleryIdentify } from '../../lib/scanner-batch-identify.js';
function makeFile(name) {
return new File(['x'], name, { type: 'image/jpeg' });
}
describe('runSequentialGalleryIdentify', () => {
it('processes files one-at-a-time in order on success', async () => {
const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')];
const callOrder = [];
const progressEvents = [];
const successEvents = [];
const identifyFn = vi.fn(async (file) => {
callOrder.push(file.name);
});
const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, {
onProgress: (p) => progressEvents.push({ current: p.current, total: p.total, name: p.file.name }),
onFileSuccess: ({ file, index }) => successEvents.push({ name: file.name, index }),
});
expect(cancelled).toBe(false);
expect(callOrder).toEqual(['a.jpg', 'b.jpg', 'c.jpg']);
expect(identifyFn).toHaveBeenCalledTimes(3);
expect(progressEvents).toEqual([
{ current: 1, total: 3, name: 'a.jpg' },
{ current: 2, total: 3, name: 'b.jpg' },
{ current: 3, total: 3, name: 'c.jpg' },
]);
expect(successEvents).toEqual([
{ name: 'a.jpg', index: 0 },
{ name: 'b.jpg', index: 1 },
{ name: 'c.jpg', index: 2 },
]);
expect(results).toHaveLength(3);
expect(results.every((r) => r.ok === true)).toBe(true);
expect(results.map((r) => r.file.name)).toEqual(['a.jpg', 'b.jpg', 'c.jpg']);
});
it('stops remaining files when cancelRef is set mid-batch', async () => {
const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')];
const cancelRef = { current: false };
const identifyFn = vi.fn(async (file) => {
if (file.name === 'a.jpg') {
cancelRef.current = true;
}
});
const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, {
cancelRef,
});
expect(cancelled).toBe(true);
expect(identifyFn).toHaveBeenCalledTimes(1);
expect(results).toEqual([{ file: files[0], ok: true }]);
});
it('invokes onFileError and continues after a per-file failure', async () => {
const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')];
const err = new Error('identify failed');
const errorEvents = [];
const callOrder = [];
const identifyFn = vi.fn(async (file) => {
callOrder.push(file.name);
if (file.name === 'b.jpg') {
throw err;
}
});
const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, {
onFileError: ({ file, index, error }) => {
errorEvents.push({ name: file.name, index, error });
},
});
expect(cancelled).toBe(false);
expect(callOrder).toEqual(['a.jpg', 'b.jpg', 'c.jpg']);
expect(identifyFn).toHaveBeenCalledTimes(3);
expect(errorEvents).toEqual([{ name: 'b.jpg', index: 1, error: err }]);
expect(results).toHaveLength(3);
expect(results[0]).toMatchObject({ file: files[0], ok: true });
expect(results[1]).toMatchObject({ file: files[1], ok: false, error: err });
expect(results[2]).toMatchObject({ file: files[2], ok: true });
});
it('accepts FileList-like iterables via Array.from', async () => {
const files = [makeFile('solo.jpg')];
const fileList = {
length: files.length,
0: files[0],
[Symbol.iterator]() {
let i = 0;
return {
next: () => {
if (i < files.length) {
return { value: files[i++], done: false };
}
return { done: true };
},
};
},
};
const identifyFn = vi.fn(async () => {});
const { results } = await runSequentialGalleryIdentify(fileList, identifyFn);
expect(identifyFn).toHaveBeenCalledTimes(1);
expect(results).toEqual([{ file: files[0], ok: true }]);
});
it('returns empty results for null/undefined files', async () => {
const identifyFn = vi.fn(async () => {});
const { results, cancelled } = await runSequentialGalleryIdentify(null, identifyFn);
expect(cancelled).toBe(false);
expect(results).toEqual([]);
expect(identifyFn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
vi.mock('../../lib/scanner-card-detection.js', () => ({
detectCardShapesFromFrame: vi.fn(() => []),
DETECTION_START_DELAY_MS: 10_000,
mergeDetectedShapesIntoTrackedCards: vi.fn(() => ({ cards: [], nextCardId: 1 })),
selectCardsReadyForVerification: vi.fn(() => []),
SHAPE_DETECTION_INTERVAL_MS: 10_000,
VERIFICATION_INTERVAL_MS: 10_000,
}));
import { useCameraScanner } from '../../lib/use-camera-scanner.js';
const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id';
function createMockStream() {
const track = { stop: vi.fn() };
return {
getTracks: () => [track],
};
}
function attachMockVideo(hookResult) {
const video = document.createElement('video');
video.play = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(video, 'readyState', { value: 2, configurable: true });
hookResult.videoRef.current = video;
return video;
}
async function startCameraWithMetadata(hookResult) {
const video = attachMockVideo(hookResult);
await act(async () => {
await hookResult.startCamera();
});
await act(async () => {
video.onloadedmetadata?.();
await Promise.resolve();
});
}
describe('useCameraScanner — device picker', () => {
let getUserMedia;
let enumerateDevices;
beforeEach(() => {
sessionStorage.clear();
getUserMedia = vi.fn().mockResolvedValue(createMockStream());
enumerateDevices = vi.fn().mockResolvedValue([
{
deviceId: 'cam-a',
kind: 'videoinput',
label: 'Desk Webcam',
},
{
deviceId: 'cam-b',
kind: 'videoinput',
label: 'USB Camera',
},
{
deviceId: 'mic-1',
kind: 'audioinput',
label: 'Built-in Mic',
},
]);
Object.defineProperty(global.navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia,
enumerateDevices,
},
});
});
afterEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
});
it('starts with loading picker status and message', () => {
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
expect(result.current.devicePickerStatus).toBe('loading');
expect(result.current.devicePickerMessage).toBe('Detecting cameras…');
});
it('enumerates video inputs after stream start', async () => {
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await startCameraWithMetadata(result.current);
expect(enumerateDevices).toHaveBeenCalled();
expect(result.current.videoDevices).toEqual([
{
deviceId: 'cam-a',
kind: 'videoinput',
label: 'Desk Webcam',
},
{
deviceId: 'cam-b',
kind: 'videoinput',
label: 'USB Camera',
},
]);
expect(result.current.devicePickerStatus).toBe('ready');
expect(result.current.devicePickerMessage).toBe('');
});
it('uses deviceId exact constraints when selectedDeviceId is set', async () => {
sessionStorage.setItem(SESSION_DEVICE_KEY, 'cam-b');
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
expect(result.current.selectedDeviceId).toBe('cam-b');
await startCameraWithMetadata(result.current);
expect(getUserMedia).toHaveBeenCalledWith({
video: {
deviceId: { exact: 'cam-b' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
});
it('uses facingMode constraints when no selectedDeviceId is set', async () => {
const { result } = renderHook(() =>
useCameraScanner({ onError: vi.fn(), facingMode: 'user' })
);
await startCameraWithMetadata(result.current);
expect(getUserMedia).toHaveBeenCalledWith({
video: {
facingMode: 'user',
width: { ideal: 1280 },
height: { ideal: 720 },
aspectRatio: { ideal: 16 / 9 },
},
});
});
it('persists selectedDeviceId to sessionStorage', async () => {
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await act(async () => {
result.current.setSelectedDeviceId('cam-a');
});
expect(result.current.selectedDeviceId).toBe('cam-a');
expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a');
});
it('restarts the stream when selectedDeviceId changes while streaming', async () => {
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await startCameraWithMetadata(result.current);
getUserMedia.mockClear();
await act(async () => {
result.current.setSelectedDeviceId('cam-b');
});
expect(getUserMedia).toHaveBeenCalledWith({
video: {
deviceId: { exact: 'cam-b' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
});
it('switchFacingMode still toggles facingMode when no deviceId is forced', async () => {
const { result } = renderHook(() =>
useCameraScanner({ onError: vi.fn(), facingMode: 'environment' })
);
await startCameraWithMetadata(result.current);
getUserMedia.mockClear();
await act(async () => {
result.current.switchFacingMode();
});
expect(result.current.facingMode).toBe('user');
expect(getUserMedia).toHaveBeenCalledWith({
video: {
facingMode: 'user',
width: { ideal: 1280 },
height: { ideal: 720 },
aspectRatio: { ideal: 16 / 9 },
},
});
});
it('reports empty picker status when no video inputs are found', async () => {
enumerateDevices.mockResolvedValue([
{ deviceId: 'mic-1', kind: 'audioinput', label: 'Mic' },
]);
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await startCameraWithMetadata(result.current);
expect(result.current.devicePickerStatus).toBe('empty');
expect(result.current.devicePickerMessage).toBe(
'No camera found. Connect a webcam or use Upload Image.'
);
expect(result.current.videoDevices).toEqual([]);
});
it('reports denied picker status when getUserMedia throws NotAllowedError', async () => {
const deniedError = new Error('Permission denied');
deniedError.name = 'NotAllowedError';
getUserMedia.mockRejectedValueOnce(deniedError);
const onError = vi.fn();
const { result } = renderHook(() => useCameraScanner({ onError }));
await act(async () => {
attachMockVideo(result.current);
await result.current.startCamera();
});
expect(result.current.devicePickerStatus).toBe('denied');
expect(result.current.devicePickerMessage).toBe(
'Camera access blocked. Allow camera permission or use Upload Image.'
);
expect(onError).toHaveBeenCalled();
});
it('reports error picker status when enumerateDevices is unavailable', async () => {
Object.defineProperty(global.navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia,
},
});
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await startCameraWithMetadata(result.current);
expect(result.current.devicePickerStatus).toBe('error');
expect(result.current.devicePickerMessage).toBe(
"Couldn't list cameras. Try again or use Upload Image."
);
});
it('falls back to the first device when persisted id is missing', async () => {
sessionStorage.setItem(SESSION_DEVICE_KEY, 'missing-device');
const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() }));
await startCameraWithMetadata(result.current);
expect(result.current.selectedDeviceId).toBe('cam-a');
expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a');
});
});

184
test/pages/scanner.test.js Normal file
View file

@ -0,0 +1,184 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
const mockRouterPush = vi.fn();
const mockRouterBack = vi.fn();
vi.mock('next/router', () => ({
useRouter: () => ({
push: mockRouterPush,
back: mockRouterBack,
pathname: '/scanner',
asPath: '/scanner',
query: {},
prefetch: vi.fn().mockResolvedValue(undefined),
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}),
}));
vi.mock('next/link', () => ({
__esModule: true,
default: ({ href, children, ...rest }) => (
<a href={typeof href === 'string' ? href : ''} {...rest}>
{children}
</a>
),
}));
vi.mock('../../lib/theme-context', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
}));
const layoutPropsRef = { current: null };
vi.mock('../../components/Layout', () => ({
default: ({ children, chrome, user }) => {
layoutPropsRef.current = { chrome, user };
return (
<div data-testid="layout" data-chrome={chrome}>
{children}
</div>
);
},
}));
vi.mock('../../components/scanner/ScannerCamera', () => ({
default: ({ variant }) => (
<div data-testid="scanner-camera" data-variant={variant} />
),
}));
vi.mock('../../components/scanner/ScannerCheckoutSheet', () => ({
default: () => <div data-testid="scanner-checkout-sheet" />,
}));
vi.mock('../../components/scanner/ScannerResultPanel', () => ({
default: () => <div data-testid="scanner-result-panel" />,
}));
vi.mock('../../components/scanner/ScannerHistoryStrip', () => ({
default: () => <div data-testid="scanner-history-strip" />,
TAB_RECENT: 'recent',
TAB_QUEUE: 'queue',
}));
vi.mock('../../components/scanner/ScannerTips', () => ({
default: () => <button type="button">Scanner Tips</button>,
}));
vi.mock('../../components/scanner/ScannerToast', () => ({
default: () => null,
}));
const queueFixture = {
scannedCards: [],
collections: [],
selectedCards: new Set(),
isProcessing: false,
addingCardIds: new Set(),
ownershipMap: {},
unprocessedCount: 0,
handleCardScanned: vi.fn(),
updateCardMetadata: vi.fn(),
addSingleCardToOwned: vi.fn(async () => true),
addSingleCardToCollection: vi.fn(async () => true),
commitSelectedToCollection: vi.fn(async () => []),
commitSelectedToOwned: vi.fn(async () => []),
clearScannedCards: vi.fn(),
};
const cameraFixture = {
videoRef: { current: null },
canvasRef: { current: null },
videoDevices: [{ deviceId: 'cam-1', label: 'FaceTime HD Camera' }],
selectedDeviceId: 'cam-1',
setSelectedDeviceId: vi.fn(),
devicePickerStatus: 'ready',
devicePickerMessage: '',
};
const identificationFixture = {
disambiguation: null,
isIdentifying: false,
identifyFromGalleryFile: vi.fn(async () => {}),
};
vi.mock('../../lib/use-auth', () => ({
useAuth: () => ({
user: { email: 'scanner@test.com', role: 'user' },
loading: false,
}),
}));
vi.mock('../../lib/use-scanner-session.js', () => ({
useScannerSession: () => ({
scanDefaults: { condition: 'NM', isFoil: false },
}),
}));
vi.mock('../../lib/use-scanner-queue.js', () => ({
useScannerQueue: () => queueFixture,
}));
vi.mock('../../lib/use-camera-scanner.js', () => ({
useCameraScanner: () => cameraFixture,
}));
vi.mock('../../lib/use-scanner-identification.js', () => ({
useScannerIdentification: () => identificationFixture,
}));
vi.mock('../../lib/scanner-session.js', () => ({
clearScannerCartStorage: vi.fn(),
}));
import Scanner from '../../pages/scanner.js';
function setMatchMedia(matches) {
window.matchMedia = vi.fn((query) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
}
describe('Scanner page viewport composition', () => {
beforeEach(() => {
layoutPropsRef.current = null;
vi.clearAllMocks();
});
afterEach(() => cleanup());
it('uses immersive Layout chrome and hides workstation surfaces on mobile', () => {
setMatchMedia(false);
render(<Scanner />);
expect(layoutPropsRef.current?.chrome).toBe('immersive');
expect(screen.getByTestId('scanner-camera').getAttribute('data-variant')).toBe('default');
expect(screen.queryByTestId('scanner-result-panel')).toBeNull();
expect(screen.queryByTestId('scanner-history-strip')).toBeNull();
expect(screen.queryByTestId('scanner-checkout-sheet')).toBeNull();
expect(screen.queryByRole('button', { name: 'Scanner Tips' })).toBeNull();
});
it('uses default Layout chrome and mounts workstation surfaces at md+', () => {
setMatchMedia(true);
render(<Scanner />);
expect(layoutPropsRef.current?.chrome).toBe('default');
expect(screen.getByTestId('scanner-camera').getAttribute('data-variant')).toBe('workstation');
expect(screen.getByTestId('scanner-result-panel')).toBeTruthy();
expect(screen.getByTestId('scanner-history-strip')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Scanner Tips' })).toBeTruthy();
expect(screen.getByLabelText('Camera')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Upload Image' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Batch Scan' })).toBeTruthy();
expect(screen.getByRole('switch', { name: 'Auto-detect' })).toBeTruthy();
expect(screen.queryByTestId('scanner-checkout-sheet')).toBeNull();
});
});