| 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. |
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. |
-`/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.
| 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).
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. |
| `<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 |
| `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:** 150–200ms 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 59–63) — 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.
- **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.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.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.
- **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**).
- 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`).
**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 2–4 |