-`[modified]``/scanner` — sole scanner surface. Entry skips Setup and lands on full-bleed camera; cart and checkout live as in-page overlays/sheets on this route (no new URLs). Phase machine collapses from setup → scanning → review into scanning-first with optional cart sheet open/closed.
-`[impacted]``/login` — unauthenticated `/scanner` visits still redirect here (unchanged gate in `pages/scanner.js`). Post-login return target is TBD (see open questions).
-`[impacted]``/dashboard`, Layout sidebar, command palette — entry links to `/scanner` unchanged; users now arrive directly in camera instead of Setup.
-`[impacted]``/my-cards`, `/collections` — not part of the scan flow, but natural post-checkout destinations if the user navigates away after commit. No route or nav changes required.
No `[new]` routes. Destination choice (My Collection vs List) moves from pre-scan Setup into the checkout sheet on `/scanner` only — **no route changes**.
### User flow
```mermaid
flowchart LR
A["Open /scanner"] --> B["Live camera"]
B --> C["Card identified"]
C --> D["Scan peek"]
D --> B
B --> E["Review N"]
E --> F["Checkout sheet"]
F --> G["Add to My Collection / List"]
G --> H["Exit scanner"]
```
### Screen inventory
| Screen | Path | New/modified | Notes |
| --- | --- | --- | --- |
| Scanner — live camera | `/scanner` | modified | Default view on open (mobile: immersive, no Layout chrome). Bottom glass bar: flash, status, camera swap, Review N. Detection brackets + status around tracked card. |
| Scan peek | `/scanner` (overlay) | new | Brief non-interactive slide-up after identify (name, thumbnail, confidence). Auto-dismiss; tap opens checkout sheet. No per-card Add / condition / foil on peek. |
| Checkout cart sheet | `/scanner` (overlay) | modified | Replaces full-page `ScannerReview` as primary cart UI on mobile. Camera stays mounted but paused under sheet. Selection, qty, confidence, overflow (remove / condition / foil). Primary: Add to My Collection; secondary: Add to List (picker). |
| Disambiguation sheet | `/scanner` (overlay) | impacted | Existing `ScannerDisambiguation` stays in overlay stack above camera; blocks identify until resolved, then card enqueues to cart. |
| Scanner Setup | `/scanner` (phase) | impacted | Removed from default entry path (out of scope to delete component). Game filter, deck mode, pre-scan destination, and scan history no longer gate camera start. |
| Auth loading | `/scanner` | impacted | Spinner while `useAuth` resolves; immersive chrome applies once authenticated (see open question on loading shell). |
Desktop (`md+`): same `/scanner` route; cart may render as persistent side panel instead of sheet; Layout sidebar remains visible.
### Content / data model deltas
- **Copy (write / wire):** checkout CTAs from `lib/collection-vocabulary.js` — `ADD_TO_MY_COLLECTION`, `ADD_TO_LIST` / `ADD_TO_LISTS`; bottom-bar "Review N" (N = cart count); flash and camera-swap `aria-label`s; empty-cart and commit-success toasts. Never use "binder" or "Save to binder" as a button label.
- **Copy (retire from entry path):** Setup destination picker, game pre-filter, deck-mode selector, and scan-history list as pre-scan gate copy (component may remain for later convoy).
- **Client session state:** cart is in-memory queue on `/scanner` — cards stay `processed: false` until checkout commit. Reverses rebuild D3 auto-route; no persistence across refresh or navigation away.
- **Immersive Layout:** new chrome mode (likely `chrome="immersive"` on `Layout`) affects how `/scanner` composes global nav — content delta only on this page, not a new route.
### Open IA questions
1.**Post-checkout exit:** Product intent says "then exit" — does the user return to the prior page (`router.back()`), land on `/my-cards` or `/collections`, or stay on `/scanner` with an empty cart and live camera?
2.**Post-login deep link:** If an unauthenticated user hits `/scanner`, should login return them to `/scanner` (camera) or a safer default (`/dashboard`)?
3.**Desktop Setup:** Is Setup skipped on `md+` as well, or only on mobile viewports?
4.**Deck destination:** Deck mode and add-to-deck are out of scope — confirm deck is fully absent from checkout destinations (not just hidden behind Setup).
5.**Gallery import:** Convoy mentions "optional gallery" in the top overlay — is this in-scope as a file-picker overlay on `/scanner`, or deferred?
6.**Partial commit:** After adding a subset to My Collection / a List, does the user remain in the sheet with remaining items, or does any successful commit close the session?
## Decisions (post-IA)
Locked 2026-08-14 from product intent (parent session). UX / UI / Architect treat these as settled.
| # | Decision |
| --- | --- |
| D1 | **Stay on camera after commit.** Successful checkout removes committed rows from the cart. If the cart is empty, close the sheet and resume scanning. Back (header) is the only exit from `/scanner`. |
| D2 | **Login returns to `/scanner`.** Keep the existing auth gate; after login, send the user back to the camera, not `/dashboard`. |
| D3 | **Skip Setup on all viewports.** Desktop uses the same scanning-first entry; cart is a side panel at `md+`, not a return of the Setup form. |
| D4 | **No deck destination in checkout.** My Collection and List only. Deck Mode stays out of this convoy. |
| D5 | **Gallery is in-scope.** Top-right control opens a file picker; chosen image goes through the existing identify path and lands in the cart like a live scan. |
| D6 | **Partial commit stays in the sheet.** Uncommitted rows remain. Sheet closes only when the cart is empty or the user dismisses it. |
| D7 | **Persist cart in `sessionStorage`.** Survive refresh within the tab; do not persist across browser sessions. |
## Design direction
Locked v1 — 2026-08-14. Layout authority: last mock
| Desktop (`md+`) | Same cart semantics; cart as persistent side panel; Layout sidebar stays visible. Immersive chrome is mobile-only. |
### Screen 1 — Live camera (mobile default)
Full-bleed `<video>` under all chrome. Layout sidebar, TopSearchBar, and
MobileNavigation hidden via `chrome="immersive"` on `/scanner`.
| Zone | Composition | Tokens / notes |
| --- | --- | --- |
| **Top bar** | Back (←), title "Scan Cards", gallery (file picker, D5) | `--glass-surface-mid` + `blur(--glass-blur-mid)` + rim stack. Safe-area inset top. 44×44 tap targets. |
| **Viewfinder brackets** | Four L-shaped corner brackets framing tracked card | Stroke `--accent-flame` / `--accent-ember`; no filled box. Animated scan line: horizontal ember gradient sweep (`--gradient-primary-*`), respect `prefers-reduced-motion`. |
| **Scan status** | Centered label below brackets ("Scanning… Hold steady") | `--text-primary` on semi-opaque pill or direct over darkened letterbox — ensure 4.5:1. |
**Repo design tokens win** when they disagree with generator output. This
lock intentionally overrides ui-ux-pro-max palette/pattern for every
scanner surface. File a convoy note + bump `design_direction.version` only
if product approves a token change.
## UX
### Existing components to reuse
-`<GlassSurface>` (`components/ui/GlassSurface.js`) — top bar, bottom bar, scan peek, checkout sheet panel, row inner panels. Use `tint="mid"` for chrome bars, `tint="low"` for sheet body, `rim="ember-pronounced"` on Review N pill and primary CTA.
-`<Modal>` (`components/ui/Modal.js`) — back-guard confirm ("Leave scanner?"), List picker (`size="md"`), nested above checkout sheet z-index stack. Do **not** roll a custom scrim for these dialogs.
-`<Button>` (`components/ui/Button.js`) — footer primary (`variant="primary"`, `loading` during commit) and secondary (`variant="ghost"`) CTAs; back-guard actions.
-`<Input>` (`components/ui/Input.js`) — optional filter field inside List picker when `collections.length > 8`.
-`<ScannerDisambiguation>` (`components/scanner/ScannerDisambiguation.js`) — **canonical bottom-sheet shell** for checkout cart on mobile: same `fixed inset-0` scrim, `glass-panel-strong` panel, drag handle, `useFocusTrap`, Escape-to-dismiss. Checkout sheet copies this shell; do not invent a second sheet primitive.
-`<ScannerToast>` (`components/scanner/ScannerToast.js`) — success feedback after identify (`role="status"`, `aria-live="polite"`). Replace emoji glyphs with SVG check per design-direction checklist; message shape: `{Card name} added`.
-`<ScannerCountPill>` (`components/scanner/ScannerCountPill.js`) — evolve into bottom-bar **Review N** gradient pill (same `onReview` contract, `aria-label={`Review ${count} scanned cards`}`); retire the separate count row above the viewport.
-`<ReviewCardItem>` (`components/scanner/ReviewCardItem.js`) — cart row body: thumbnail, name/set, qty stepper, condition/foil overflow. Extend with leading checkbox + confidence ring; drop inline destination picker (destinations move to footer CTAs only).
-`<Layout>` (`components/Layout.js`) — `chrome="immersive"` on `/scanner` for `<md` only; desktop keeps sidebar.
-`useFocusTrap` / `useFocusTrapContainer` (`lib/use-focus-trap.js`) — every overlay sheet and confirm dialog; restore focus to Review N pill or back button on close.
**8 primitives** (+ 2 hooks, 1 page shell). New file `ScannerScanPeek.js` is the only net-new overlay component; it composes `<GlassSurface>` only.
### Design direction alignment
Locked `design_direction` maps directly to repo tokens — no conflicts deferred:
**UX override of design-direction "Edit toggles bulk-select mode":** reject Edit mode. Checkboxes are **always visible** with all rows selected by default — supermarket checkout does not hide selection behind a mode switch (Nielsen H6).
### Existing patterns to follow
- **Bottom-sheet overlay stack** — `ScannerDisambiguation.js` (scrim click dismisses only when safe; focus trap; `aria-labelledby`). Checkout sheet sits at `z-40`; disambiguation stays at `z-50` above it.
- **Bulk selection semantics** — `useScannerQueue``selectedCards` Set + `toggleCardSelection` / `handleBulkAction`; do not fork a parallel selection model.
- **Pause identification** — `CameraScanner.js` + `pages/scanner.js``verificationPausedRef` pattern: `useCameraScanner` early-returns when `verificationPausedRef.current === true`; `useScannerIdentification` already sets it during disambiguation — extend to checkout sheet + List picker open.
- **Flash gating** — `useScannerFlash.js``flashSupported` probe; conditionally render flash button (already in `ScannerCamera.js` L221–242). Flash is **rear camera only** — hide when `facingMode === 'user'`.
- **Immersive page shell** — follow `components/ProtectedRoute.js` / `pages/scanner.js` auth gate; post-login return to `/scanner` (D2) via `router.query.returnUrl` or equivalent login redirect — do not invent a new auth wrapper.
- **List display names** — `collectionDisplayName()` for picker rows; never render `'All My Cards'` literal.
1.**WCAG 2.5.5 Target Size (AAA goal):** every tappable chrome control (back, gallery, flash, camera swap, Review N, peek tap target, row checkbox, qty ±, overflow ⋮, footer CTAs) ≥ 44×44 CSS px — use invisible padding if icon is smaller.
2.**WCAG 1.4.3 Contrast (AA):** all text on glass ≥ 4.5:1; if card-art bleeds through a row thumb, use `--bg-secondary` inner panel behind text (design-direction row spec).
3.**WCAG 2.4.3 Focus Order:** checkout sheet opens → focus moves to sheet panel (first focusable: header close/dismiss or first row checkbox via `useFocusTrap`); on close → restore to Review N pill.
4.**WCAG 2.1.1 Keyboard:** sheet dismiss via Escape; peek opens sheet on Enter/Space when focused; back button triggers confirm modal before navigation.
5.**WCAG 4.1.2 Name, Role, Value:** checkout sheet `role="dialog"``aria-modal="true"``aria-labelledby` → "{N} cards scanned"; List picker nested dialog gets its own label ("Choose a List").
6.**`aria-label` required:** back → "Leave scanner"; gallery → "Choose image from gallery"; flash → "Turn on flash" / "Turn off flash" + `aria-pressed`; camera swap → "Switch to front camera" / "Switch to rear camera"; Review N → `Review ${N} scanned cards`; row checkbox → `Select ${card.name}`; qty buttons → `Decrease quantity for ${card.name}` / `Increase…`; overflow → `More actions for ${card.name}`.
8.**WCAG 4.1.3 Status Messages:** identify success toast + post-commit success toast use `role="status"``aria-live="polite"`; do not steal focus.
9.**WCAG 2.3.3 Animation from Interactions:** scan-line loop, peek slide, sheet slide respect `prefers-reduced-motion: reduce` (instant show/hide, no sweep).
10.**Focus trap:** checkout sheet, disambiguation sheet, back-guard confirm, List picker — all trap Tab; disambiguation above checkout maintains trap when both mounted.
11.**Confidence communicated without color alone:** ring color + text label on row (`aria-label` includes "confidence 62 percent") and header subline "{N} need review" when any row < 70%.
12.**Gallery file input:** visually hidden `<input type="file" accept="image/*">` triggered by labeled button; `aria-label` on button, not bare input.
13.**Disabled primary CTA:** when zero rows selected, `Add to My Collection` is `disabled` + `aria-disabled="true"` with visible helper "Select at least one card" (linked via `aria-describedby`).
14.**Screen reader pause announcement:** when sheet opens, `aria-hidden="true"` on camera video element OR `inert` on viewfinder chrome beneath scrim so SR does not read live camera controls under the sheet.
### Interaction patterns
| Pattern | Spec | Nielsen |
| --- | --- | --- |
| **Cart selection — default all selected***(required)* | On first sheet open, `selectedCards` = all unprocessed row IDs. New card enqueued while sheet open → auto-add its ID to selection. Deselecting is per-row checkbox. Primary CTA label stays `VOCAB.ADD_TO_MY_COLLECTION`; when subset selected, append count: "Add to My Collection (3)". No "Edit mode" toggle. | H6 Recognition rather than recall |
| **Low-confidence vs disambiguation***(required)* | **Pipeline `disambiguation` outcome → always block** via existing `ScannerDisambiguation` sheet; never enqueue until user picks. **Single-match `emit` outcome → enqueue silently** to cart regardless of confidence; surface confidence visually (ring + header subline for any row <70%). Do not re-prompt at scan time for low-confidence singles — checkout is the verification gate. | H5 Error prevention; H3 User control |
| **Scan peek***(required)* | Slide-up 3s auto-dismiss. Container `pointer-events-none`; single child button wrapper `pointer-events-auto` with `aria-label="Open cart to review {card name}"`. No condition/foil/Add on peek. Tap opens checkout sheet. | H4 Consistency; H8 Minimalist design |
| **Checkout sheet open/close***(required)* | Open via Review N pill or peek tap. Close via scrim tap, swipe-down on handle (nice-to-have), or header ×. On close with items remaining: resume camera (`verificationPausedRef = false`). Empty cart after commit → auto-close sheet (D1). | H1 Visibility of system status |
| **Pause identification under sheet***(required)* | `verificationPausedRef.current = true` when checkout sheet **or** List picker **or** disambiguation is open. Camera stream stays mounted; detection/verify loop does not run. Resume on sheet close. | H1 Visibility of system status |
| **Back with unsaved cart***(required)* | Header back with any unprocessed cart rows → `<Modal>` confirm: title "Leave scanner?", body "{N} scanned cards haven't been added yet.", primary "Keep scanning" (dismiss), secondary "Leave" (`router.back()` or `/dashboard`). No confirm when cart empty. | H3 User control and freedom |
| **Partial commit***(required)* | Commit removes only selected rows from cart; sheet stays open with remainder (D6). Success toast `role="status"`. Primary button shows loading via `<Button loading>`. | H9 Error recovery; H1 Feedback |
| **sessionStorage cart persist***(required)* | Serialize `scannedCards` + `selectedCards` to `sessionStorage` on change (D7); hydrate on `/scanner` mount. Tab refresh preserves cart; new tab starts empty. Clear on explicit "Leave" confirm. | H6 Recognition rather than recall |
| **Flash + camera swap***(required)* | Flash button rendered only when `flashSupported && facingMode === 'environment'`. Camera-swap toggles `facingMode`; turning to front auto-disables torch. Swap button always visible when streaming. | H2 Match between system and real world |
| **Gallery import***(required)* | Top-right gallery button → hidden file input; selected image runs existing identify path; on success same peek + cart enqueue as live scan. Show spinner on gallery button while identifying (`aria-busy`). | H4 Consistency |
| **Empty states***(required)* | Cart sheet with 0 rows: should not open (Review N hidden at N=0). If last item removed in sheet → auto-close + resume camera. | H9 Error recovery |
| **Loading states***(required)* | Camera starting: existing spinner + "Starting camera…". Commit in flight: footer CTA `loading`, row checkboxes disabled. List picker: skeleton or spinner while `collections` fetch. | H1 Visibility of system status |
| **Error states***(required)* | Commit API failure: inline banner in sheet footer (not alert()), rows stay selected, retry enabled. Identify error: existing toast/notice path; do not enqueue. | H9 Error recovery |
| **Desktop side panel***(required)* | `md+`: cart is persistent right panel (not bottom sheet); no immersive Layout chrome; same selection + CTA semantics; no `verificationPausedRef` pause required (user can see camera + panel simultaneously) — **optional:** still pause on mobile only. | H4 Consistency |
| **Optimistic commit***(nice-to-have)* | Defer — show loading on CTA until API confirms; no optimistic removal (batch failures are painful). | H9 |
- **Silent enqueue on `disambiguation` / `needsUserSelection`** — must block on picker (H5).
- **Per-card Add / condition / foil on scan peek** — design-direction anti-pattern; peek is read-only + tap-to-cart (H8).
- **Unmounting camera when sheet opens** — causes slow re-start and flash re-permission; pause via `verificationPausedRef` only (H1).
- **Auto-routing to DB on identify** — rebuild D3 reversed; enqueue only (H3).
- **"binder" / "Save to binder" / "Mark Owned" button copy** — use `VOCAB` constants; CI forbidden-strings (H4).
- **Hardcoded hex / `rgba(0,0,0,0.55)` in new chrome** — use glass tokens; migrate existing `ScannerCamera` literals in same brief (H4).
- **Emoji toast glyphs** — SVG only per design-direction checklist (H4).
- **`backdrop-filter` on cart thumbnail elements** — GPU budget; glass on row container only (H8).
- **Deck destination in checkout** — D4; no deck picker (H4).
- **Navigating away on commit** — D1 stay on camera (H3).
- **Confirm dialog via `window.confirm`** — use `<Modal>` for back-guard; matches focus-trap contract (H7 Flexibility).
- **Interactive peek controls stealing taps from viewfinder** — peek must not block scanning except its single open-cart hit target (H8).
- **localStorage for cart** — D7 specifies `sessionStorage`; do not use existing `SCANNER_SESSION_STORAGE_KEY` localStorage for cart rows (H6).
### Mobile / responsive notes
- **<768px (mobile):** `Layout``chrome="immersive"` — hide sidebar, `TopSearchBar`, `MobileNavigation`. Camera `100dvh` full-bleed (`min-h-0` flex child). Top bar respects `env(safe-area-inset-top)`; bottom bar `env(safe-area-inset-bottom)`. Checkout = bottom sheet (max-height ~85dvh, scrollable list, sticky footer). Peek sits above bottom bar, below brackets.
- **≥768px (tablet/desktop):** Layout sidebar visible. Camera in main column; cart as **persistent right panel** (~360px) not sheet. Review N pill still opens/focuses panel if collapsed. No immersive chrome.
- **375px floor:** verify footer CTAs stack (primary full-width, secondary below) without clipping qty steppers; row thumb stays 40×56.
- **Landscape phone:** sheet max-height may need `max-h-[70dvh]` so viewfinder remains partially visible — architect to decide in brief; UX prefers partial camera peek above sheet scrim (H1).
- **Touch:** swipe-down on sheet handle to dismiss (nice-to-have); scrim tap dismisses (required). 8px minimum gap between adjacent 44px targets in bottom bar.
## Architecture
Locked 2026-08-14. Layout gets `chrome="immersive"` (Brief 1). Four implementer briefs; no new API routes or schema changes.
| Auth/Layout regressions | `test/lib/permission-middleware.test.js`, `test/components/Layout.test.js` | Do not weaken existing assertions |
Smoke (manual / post-merge): authenticated `/scanner` on 375px viewport — camera full-bleed, Review N opens sheet, commit to My Collection. Visual-diff may need baseline refresh if Layout chrome changes bleed to homepage (unlikely — scanner-only prop).
### Risk list
| Risk | Mitigation |
| --- | --- |
| `pages/scanner.js` was shared by all three conductor guesses | Serialized: only Brief 4 touches `scanner.js` |
| Gallery has no existing file-picker hook (boot finding) | Brief 3 adds `identifyFromGalleryFile` calling `tryLayer1TextIdentify` — no API change |
| `facingMode` swap requires stream restart | Brief 3 `stopCamera` + `startCamera` on toggle; flash forced off on front |
**Parallelism:** Brief **1** and **2** can run in parallel (`depends_on: []`, disjoint files). Brief **3** starts after **2**. Brief **4** is serial after **1 + 2 + 3**. Do not dispatch Brief 3 until Brief 2 merges (cart API contract). Maximum fan-out: 2 implementers (Brief 1 ∥ Brief 2), then 1, then 1.
### Boot-the-brief check (2026-08-14)
| Check | Finding | Brief fix |
| --- | --- | --- |
| Auto-route still live | `handleCardScanned` L148–168 calls `routeScannedCardToDestination` | Brief 2 deletes block |
| No `chrome` on Layout | `Layout({ children, user, showSearch })` only | Brief 1 adds prop |
No new npm packages. No peer-dep conflicts. `tryLayer1TextIdentify` and `resolveIdentifyOutcome` confirmed exported from `lib/scanner-card-identify.js`.