deckhearth/.convoys/scanner-mobile-checkout.md

645 lines
46 KiB
Markdown
Raw Normal View History

---
name: scanner-mobile-checkout
classification: feature
success_metric: |
On a phone, opening /scanner goes straight into a full-bleed camera;
each successful identify lands in a local cart (not the database);
the user can open a checkout sheet, select cards, and commit them
to My Collection or a List without leaving the session.
skip: []
status: shipped
created: 2026-08-14
depends_on:
- scanner-rebuild
- redesign-scanner-flow
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-14
product_type: mobile camera scanner checkout cart trading cards glassmorphism
pattern: Immersive camera overlay + bottom-sheet checkout
style: Liquid Glass (repo canonical)
stack: nextjs
layout_reference: image-93781104-69e3-4f0b-b25f-df7c750ef046.png
---
# Convoy: scanner-mobile-checkout
Turn the scanner into a mobile checkout: scan everything first, then
commit the cart. Layout reference is the last attached mock (full-bleed
camera, glass overlays, review as a bottom sheet).
## Why
The overnight `scanner-rebuild` got us a three-phase machine (Setup →
Scanning → Review), but the phone experience is still chrome-heavy:
Layout's sidebar, top search bar, and bottom nav compete with the
viewfinder; a destination form blocks the camera; and `handleCardScanned`
writes each match to the database immediately (rebuild D3). That fights
the actual table-scan job — hold the phone, sweep cards, then check out
once.
Users want supermarket-checkout semantics: open scanner → scan all the
things → open the cart → add selected cards to My Collection or a List
→ leave. No per-card "Add" tap. No setup screen in the way.
## Scope
### In scope
- **Immersive scan chrome (mobile).** Hide Layout sidebar, TopSearchBar,
and MobileNavigation while the camera is live. Camera is full-bleed.
Overlay only: back, title, optional gallery, detection brackets +
status around the tracked card, bottom glass bar (flash, scan status,
Review N).
- **Cart, not auto-route.** Successful identifies enqueue locally.
Nothing POSTs to `user_cards` / collections / decks until checkout.
Reverse rebuild D3 for this flow.
- **Scan peek.** After a match, a compact non-interactive preview slides
up briefly (name + thumbnail + confidence) and auto-dismisses. Tapping
it opens the cart. No condition / foil / Add buttons on the peek.
- **Checkout sheet.** Bottom sheet over the still-live (but paused)
camera. List of cart items with selection, qty, confidence, overflow
menu (remove / condition / foil). Primary: add selected to My
Collection. Secondary: add selected to a List (picker). Copy from
`lib/collection-vocabulary.js` — never "binder" as a button label
(mock says "Save to binder"; product vocab is List).
- **Camera swap.** Front / rear toggle in the bottom bar. Flash stays
rear-only and hides when `torch` is unsupported (iOS Safari).
- **Desktop.** Same cart semantics. At `md+`, cart can be a persistent
side panel instead of a sheet; do not hide the desktop sidebar.
### Out of scope
- Identify / OCR / Gemini pipeline (`lib/scanner-card-identify.js`,
`pages/api/scan/identify.js`, OpenCV detection). Layer UI on the
existing hooks.
- Schema / new tables. Cart is client-side session state.
- Deck Mode, game pre-filter, scan-history list on the setup screen
(rebuild Setup). Revisit as a later convoy if needed.
- Estimated total value row from the mock, unless identify payloads
already include `market_price` with no extra fetch.
- Deleting leftover `components/CameraScanner.js` /
`ScannerPageView.js` (pre-rebuild). Separate cleanup.
- Changing desktop visual-diff homepage baseline except as a
side-effect of Layout's new immersive prop.
## Roles invoked
1. `role-ia-architect` — scan → peek → cart → commit flow; where
destination choice lives now that Setup is gone.
2. `role-ui-designer` — lock the mobile overlay + checkout sheet against
the last mock, using Liquid Glass tokens (ui-ux-pro-max is installed).
3. `role-ux-reviewer` — cart selection, low-confidence handling, back
with unsaved cart, pause-vs-kill camera under the sheet.
4. `role-architect` — briefs. Likely: (1) Layout immersive + camera
chrome, (2) stop auto-route + cart model, (3) checkout sheet +
destinations. `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: route/flow/screen inventory for immersive scan + checkout sheet
- [x] UI Designer: lock overlay recipe (tokens, not hex) from last mock
- [x] UX: cart selection, low-confidence, back-guard, camera pause
- [x] Architect: briefs + whether Layout gets `chrome="immersive"`
- [ ] Stop auto-route in `use-scanner-queue.js`; scans stay `processed: false` until checkout
- [ ] Full-bleed camera; hide app chrome on small viewports
- [ ] Bottom bar: flash, status, Review N; add facingMode swap
- [ ] Non-interactive scan peek; checkout sheet with bulk + per-card commit
- [ ] Pause identification while the sheet is open (`verificationPausedRef`)
- [ ] Tests for cart enqueue (no network) and checkout commit paths
- [ ] Visual-diff: scanner surfaces + Layout immersive; refresh baselines if chrome changes
## Predecessor
`scanner-rebuild` (2026-06-13, overnight) shipped the phase machine,
flash hook, toasts, count pill, and review list. This convoy keeps those
hooks and **replaces the UX contract**: skip Setup, immersive camera,
cart-then-commit instead of auto-route.
## Conductor notes (build shape)
Likely file ownership for Architect to refine:
| Area | Files |
| --- | --- |
| Immersive shell | `pages/scanner.js`, `components/Layout.js`, `components/MobileNavigation.js` |
| Camera chrome | `components/scanner/ScannerCamera.js`, `lib/use-camera-scanner.js`, `lib/use-scanner-flash.js` |
| Cart model | `lib/use-scanner-queue.js`, `lib/scanner-session.js` |
| Peek + sheet | new `ScannerScanPeek.js`, rewrite `ScannerReview.js` as a sheet; reuse `ScannerDisambiguation.js` |
| Copy | `lib/collection-vocabulary.js` (`ADD_TO_MY_COLLECTION`, `ADD_TO_LIST`) |
Do not rewrite identification. `handleCardScanned` should enqueue only.
## Multitask dispatch
Planning is serial: IA → UI Designer → UX → Architect.
After architect: implementer fan-out only if briefs have `depends_on: []`
and disjoint `files:`. Cart-model (stop auto-route) likely blocks the
checkout sheet; immersive Layout may be parallel with cart-model if
they do not both own `pages/scanner.js`.
After PR draft: `/multitask` audit fan-out
`role-reviewer + role-security-auditor + role-design-system-auditor + role-a11y-auditor`
(group id: `audit-scanner-mobile-checkout-<pr>`).
## IA
### Affected routes
- `[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.
- **API calls (commit-time only, unchanged endpoints):** `POST /api/user-cards` (My Collection), `POST /api/collections/:id/cards` (List), existing `GET /api/collections` + `GET /api/decks` for destination lists. Optional `GET` batch-ownership during cart review. No schema / table changes.
- **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
(`image-93781104-69e3-4f0b-b25f-df7c750ef046.png`). Generator query:
*"mobile camera scanner checkout cart trading cards glassmorphism"*.
### Summary
Deck Hearth's mobile scanner should feel like a **supermarket checkout lane
over a live viewfinder**: full-bleed camera, ember-bracketed detection,
glass overlays that float above card art, a brief non-interactive peek after
each match, and a bottom-sheet cart for bulk commit. Warm ember/flame accents
cast light onto glass — never fill entire panels with brand orange.
### Pattern + style
| Field | Value |
| --- | --- |
| Product type | Mobile TCG collection scanner with session cart + checkout |
| App pattern | **Immersive camera overlay + bottom-sheet checkout** (generator's "Bento Grid Showcase" rejected — marketing grid, not camera UX) |
| UI style | **Liquid Glass** — repo canonical (`docs/DESIGN_TOKENS.md`). Generator's "Exaggerated Minimalism" / oversized typography rejected. |
| Stack notes | Next.js Pages router, React 18, Tailwind + CSS variables. Next.js stack query returned 0 rows — follow existing scanner components + `components/ui/` primitives. |
| 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. |
| **Success toast** | Glass pill: check + "{Card name} added" + dismiss × | `--glass-surface-high` + rim; green check via success token (not emoji). Auto-dismiss ~2s. `role="status"`. |
| **Scan peek** | Compact slide-up after identify (name, thumb, confidence ring) | `--glass-surface-mid`; **non-interactive** except tap-to-open-sheet. No Add / condition / foil controls. Auto-dismiss ~3s. |
| **Bottom bar** | Three zones: Flash \| status \| **Review N** pill | Bar: `--glass-surface-mid` + blur mid + `--elevation-ambient`. Flash + camera-swap icons (rear-only torch; hide flash when unsupported). **Review N**: gradient pill `background: var(--gradient-primary-*)` + `--ember-rim-pronounced`; label `Review {N}` with chevron. Min 44px height. |
Camera overlays **may** use `backdrop-filter` (exception to per-card grid
rule — overlays sit above the viewfinder, not on grid items).
### Screen 2 — Checkout sheet (mobile)
Bottom sheet over **paused** (not unmounted) camera. Scrim + sheet follow
Modal primitive recipes.
| Zone | Composition | Tokens / notes |
| --- | --- | --- |
| **Scrim** | Dim live camera | `--modal-scrim` + `blur(--glass-blur-high)` |
| **Sheet panel** | Rounded top (~16px), grab handle, scrollable list, sticky footer | Panel: `--glass-surface-low` inside scrim + blur mid + `--elevation-pronounced`. Handle: 36×4px `--border` pill, centered. |
| **Header** | "{N} cards scanned" + low-confidence subline (orange dot) + Edit | Subline only when any row &lt; threshold. Edit toggles bulk-select mode (UX brief). |
| **Cart rows** | Checkbox, thumb, name/set/rarity, confidence ring, qty stepper ( N +), overflow ⋮ | Row surface: `--glass-surface-high` or solid `--bg-secondary` inner panel over sheet (legibility over busy thumbs). Confidence ring: green ≥85%, amber 7084%, orange &lt;70% using `--accent-ember` / success tokens — not hardcoded hex. |
| **Row overflow** | Remove, condition, foil | Popover: `--glass-surface-high` recipe. |
| **Footer CTAs** | Primary gradient + secondary text button | Primary: `VOCAB.ADD_TO_MY_COLLECTION` — full-width gradient button (`var(--gradient-primary-*)`, `--ember-rim-pronounced`). Secondary: `VOCAB.ADD_TO_LIST` — ghost / link style with list icon; opens picker. **Never** "Save to binder" / "Add all to collection" without vocab import. |
| **Out of scope row** | "Total value (est.)" from mock | Omit unless identify payload already includes `market_price` (convoy scope). |
Partial commit (D6): committed rows leave the list; sheet stays open until
cart empty or user dismisses. Empty cart → close sheet, resume scanning (D1).
### Colors
**Repo tokens win.** Generator palette (slate `#1E293B`, scan-blue `#2563EB`)
is **not adopted**.
| Role | Token | Usage in this flow |
| --- | --- | --- |
| Primary accent | `--accent-ember` | Brackets, focus rings, low-confidence warnings, primary CTA gradient stop |
| Secondary accent | `--accent-flame` | Scan line, gradient stops, Review N pill |
| Gold (rare) | `--accent-gold` | Rarity badges only |
| Glass fills | `--glass-surface-{low,mid,high}` | Sheet panel / top+bottom bars / toast / peek |
| Blur | `--glass-blur-{low,mid,high}`, `--glass-saturate` | All glass overlays |
| Rims | `--rim-light-{inner,outer}`, `--ember-rim-{subtle,pronounced}` | Interactive pills and primary buttons get pronounced ember rim |
| Scrim | `--modal-scrim` | Sheet backdrop |
| Text | `--text-primary`, `--text-secondary` | All copy on glass |
| Background | `--bg-primary`, `--bg-secondary` | Row inner panels when glass-over-art fails contrast |
No hex in `.js` files. Use `var(--token)` or existing utility classes
(`.glass-panel`, gradient vars in `globals.css`).
### Typography
| Role | Font | Notes |
| --- | --- | --- |
| Display / title | System / Inter (existing) | Top bar "Scan Cards" — `font-semibold`, ~17px. No generator "clamp(3rem…)" oversized type. |
| Body | System / Inter | Row names `font-medium`; set/rarity `text-sm` `--text-secondary` |
| CTA | System / Inter | Primary button `font-semibold`; secondary `font-medium` |
### Effects + motion
| Effect | Spec |
| --- | --- |
| Transitions | 150300ms ease on toast, peek slide, sheet open/close |
| Scan line | Looping horizontal sweep over brackets; disable loop when `prefers-reduced-motion: reduce` |
| Sheet | Slide up from bottom; scrim fade in. Focus trap while open. |
| Hover / focus | Primary controls: `--ember-rim-pronounced` on focus-visible; `cursor-pointer` on all clickables |
| Bracket pulse | Subtle ember glow on active track — `--ember-rim-subtle`, not full-panel fill |
See `docs/MOTION_SYSTEM.md` for shared motion taxonomy when implementing.
### Copy (mandatory)
Import from `lib/collection-vocabulary.js`:
| UI surface | Constant |
| --- | --- |
| Primary checkout | `VOCAB.ADD_TO_MY_COLLECTION` |
| Secondary checkout | `VOCAB.ADD_TO_LIST` |
| Bottom bar | `Review {N}` (N = cart count) |
| Toast | `{Card name} added` (success) |
Retired: "Save to binder", "Mark Owned", "Add all to collection" (use vocab
or "Add selected to My Collection" when subset selected).
### Anti-patterns (do not ship)
- Generator's light slate background + blue CTA palette on scanner chrome
- Opaque drawer replacing bottom sheet (see deprecated mock
`image-be4e787b-…` — too many actions on peek)
- Per-card Add / condition / foil on scan peek
- `backdrop-filter` on cart **thumbnail grid items** (card grid perf budget)
- Hardcoded hex in JSX; `--accent-blue` / `--accent-purple` legacy aliases
- Emoji icons (toast check must be SVG or existing glyph pattern)
- Filling overlay panels with solid ember orange (brand is rim/glow/gradient only)
- Desktop dual-theme mock layout (`image-6654f196-…`) as mobile reference
### 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 all glass copy (use inner opaque panel if over card art)
- [ ] Keyboard focus visible (`--accent-ember` ring)
- [ ] `prefers-reduced-motion` respected (scan line, peek, sheet)
- [ ] Responsive: 375 (immersive mobile), 768, 1024 (side panel), 1440
- [ ] Flash hidden when `torch` unsupported; camera-swap `aria-label`s
- [ ] Checkout copy from `collection-vocabulary.js` only
### Token overrides vs generator
| Generator output | Locked override |
| --- | --- |
| Pattern: Bento Grid Showcase | Immersive camera + bottom-sheet checkout |
| Style: Exaggerated Minimalism | Liquid Glass (shipped) |
| Primary `#1E293B`, accent `#2563EB` | `--accent-ember`, `--accent-flame`, `--gradient-primary-*` |
| Background `#F8FAFC` | Full-bleed camera; glass overlays use `--glass-surface-*` |
| Typography: massive display type | Existing body scale; scanner chrome stays compact |
| Key effects: 10vw headings | Scan-line animation + sheet slide only |
### Conflict rule
**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).
- `<ScannerCamera>` (`components/scanner/ScannerCamera.js`) — viewfinder, bracket overlays, flash hook wiring, disambiguation mount point. Refactor chrome into top/bottom glass bars; keep `verificationPausedRef` plumbing.
- `<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:
| Design-direction surface | Token / primitive |
| --- | --- |
| Top + bottom chrome bars | `--glass-surface-mid`, `--glass-blur-mid`, `--rim-light-*`, safe-area insets |
| Ember brackets + scan line | `--accent-flame`, `--accent-ember`, `--gradient-primary-*`; scan-line loop disabled under `prefers-reduced-motion` |
| Review N pill | `var(--gradient-primary-*)` + `--ember-rim-pronounced` on `<Button>` or styled `<GlassSurface as="button">` |
| Checkout sheet scrim | `--modal-scrim` + `blur(--glass-blur-high)` — same recipe as `<Modal>` backdrop |
| Confidence rings | green ≥85%, amber 7084%, orange &lt;70% via `--color-success` / `--accent-ember` tokens (not hex literals) |
| Footer CTAs | `VOCAB.ADD_TO_MY_COLLECTION`, `VOCAB.ADD_TO_LIST` from `lib/collection-vocabulary.js` |
| Peek + toast | `--glass-surface-mid` / `--glass-surface-high`; SVG icons only |
**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.
- **Cart row editing** — `ReviewCardItem.js` qty stepper + condition select pattern; overflow ⋮ menu mirrors row remove button placement.
- **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` L221242). 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.
- **Motion** — `docs/MOTION_SYSTEM.md` duration tokens: peek/toast `quick` (150ms), sheet `slow` (400ms); sitewide `prefers-reduced-motion` collapse applies.
- **Rules** — `.cursor/rules/ui-and-theming.mdc` (Modal primitive mandate, 44px targets, focus rings via `--accent-ember`).
### A11y constraints
Hand to `role-a11y-auditor`:
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}`.
7. **WCAG 1.1.1 Non-text Content:** card thumbs `alt={card.name}`; decorative bracket corners `aria-hidden="true"`.
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 &lt; 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 &lt;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 |
### Anti-patterns to avoid
- **Separate "Edit" mode hiding checkboxes** — violates supermarket-checkout mental model (H6).
- **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
- **&lt;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.
### File plan
| File | Action | Purpose |
| --- | --- | --- |
| `components/Layout.js` | modified | `chrome="immersive"` prop — hide `MobileNavigation` + `TopSearchBar` below `md` |
| `test/components/Layout.test.js` | modified | Regression-lock immersive chrome |
| `lib/use-scanner-queue.js` | modified | Enqueue-only `handleCardScanned`; checkout commit helpers; remove auto-route |
| `lib/scanner-session.js` | modified | `SCANNER_CART_STORAGE_KEY` + load/save/clear cart in `sessionStorage` (D7) |
| `test/lib/scanner-session.test.js` | modified | Cart persistence helpers |
| `test/lib/use-scanner-queue.test.js` | new | No-network enqueue + bulk commit mocks |
| `lib/use-camera-scanner.js` | modified | `facingMode` state + `switchFacingMode` (restart stream) |
| `lib/use-scanner-identification.js` | modified | `identifyFromGalleryFile` adapter only (calls existing `tryLayer1TextIdentify`) |
| `components/scanner/ScannerCamera.js` | modified | Full-bleed glass top/bottom bars; flash/swap/gallery; peek mount point |
| `components/scanner/ScannerCountPill.js` | modified | Gradient Review {N} pill |
| `components/scanner/ScannerScanPeek.js` | new | Non-interactive 3s peek; tap opens checkout |
| `components/scanner/ScannerToast.js` | modified | SVG icons (no Unicode glyphs) |
| `components/scanner/ScannerCheckoutSheet.js` | new | Mobile bottom-sheet cart (shell from `ScannerDisambiguation`) |
| `components/scanner/ScannerReview.js` | modified | Desktop `md+` persistent side panel variant |
| `components/scanner/ReviewCardItem.js` | modified | Checkbox + confidence ring; drop row destination picker |
| `pages/scanner.js` | modified | Scanning-first orchestration; sheet state; pause ref; immersive Layout |
| `pages/login.js` | modified | Honor `returnUrl` query after login (D2) |
| `test/components/ScannerCheckoutSheet.test.js` | new | Sheet a11y + vocab + selection-disabled CTA |
**Not touched:** `pages/api/**`, `migrations/**`, `lib/scanner-card-identify.js` (identify pipeline), `components/scanner/ScannerDisambiguation.js` (reuse pattern only), `components/scanner/ScannerSetup.js` (dead path, not deleted).
### API surface
No new or modified API routes. Commit-time calls (unchanged):
| Method | Path | Request | Response | Auth | Rate limit |
| --- | --- | --- | --- | --- | --- |
| POST | `/api/user-cards` | `buildScannerCardPayload(card)` | 2xx / error JSON | Bearer JWT | none (existing) |
| POST | `/api/collections/:id/cards` | `buildScannerCardPayload(card)` | 2xx / error JSON | Bearer JWT | none |
| GET | `/api/collections` | — | collection[] | Bearer JWT | none |
| POST | `/api/cards/batch-ownership` | `{ cardIds: number[] }` | `{ ownership }` | Bearer JWT | none |
Helpers live in `lib/scanner-route-api.js` (`addScannedCardToOwned`, `addScannedCardToCollection`, `fetchScannerCollections`, `fetchBatchOwnership`).
### Schema diff
None. Cart is client-side `sessionStorage` only (`SCANNER_CART_STORAGE_KEY`).
### Test plan
| Area | File | What to assert |
| --- | --- | --- |
| Cart merge defaults | `test/lib/scanner-session.test.js` (existing) | `processed: false` on new entries — keep green |
| Cart persistence | `test/lib/scanner-session.test.js` | `saveScannerCart` / `loadScannerCart` round-trip |
| No auto-route | `test/lib/use-scanner-queue.test.js` (new) | `handleCardScanned` never calls `routeScannedCardToDestination`; mock `fetch` |
| Bulk commit | `test/lib/use-scanner-queue.test.js` | `handleBulkAction('owned')` invokes `addScannedCardToOwned` per selected id |
| Layout immersive | `test/components/Layout.test.js` | Mobile nav hidden when `chrome="immersive"` |
| Checkout sheet | `test/components/ScannerCheckoutSheet.test.js` | Dialog labels, `VOCAB` CTAs, disabled primary when selection empty |
| 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 |
| `Set` not JSON-serializable | Brief 2 persists `selectedCardIds: number[]` |
| Login lacks `returnUrl` today | Brief 4 adds query param + login handler; validate path starts with `/` |
| `handleBulkAction` currently marks `processed: true` | Brief 2 removes committed rows from queue instead |
| Layout visual-diff sensitivity | Narrow prop; immersive mobile-only; no sidebar restyle |
| `verificationPausedRef` shared by disambiguation + sheet | Brief 4 `useEffect` ORs sheet/list-picker/disambiguation on mobile |
| Deck code paths remain in queue hook | UI omits deck (D4); dead `deck` branch in `handleBulkAction` harmless until cleanup convoy |
| iOS Safari no `torch` | Existing `flashSupported` probe; hide flash button |
### Decomposition
| Brief # | Title | Files | Depends on | Est. PR size |
| --- | --- | --- | --- | --- |
| 1 | Layout immersive chrome | `Layout.js`, `test/components/Layout.test.js` | — | ~80 LOC |
| 2 | Cart model + sessionStorage | `use-scanner-queue.js`, `scanner-session.js`, tests | — | ~200 LOC |
| 3 | Camera chrome + facing + peek | `ScannerCamera.js`, `use-camera-scanner.js`, `ScannerCountPill.js`, `ScannerScanPeek.js`, `ScannerToast.js`, `use-scanner-identification.js` | 2 | ~350 LOC |
| 4 | Page orchestration + checkout sheet | `pages/scanner.js`, `pages/login.js`, `ScannerCheckoutSheet.js`, `ScannerReview.js`, `ReviewCardItem.js`, test | 1, 2, 3 | ~380 LOC |
### Slice dependencies (multitask-ready)
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- components/Layout.js
- test/components/Layout.test.js
- brief: 2
depends_on: []
files:
- lib/use-scanner-queue.js
- lib/scanner-session.js
- test/lib/scanner-session.test.js
- test/lib/use-scanner-queue.test.js
- brief: 3
depends_on: [2]
files:
- components/scanner/ScannerCamera.js
- components/scanner/ScannerCountPill.js
- components/scanner/ScannerScanPeek.js
- components/scanner/ScannerToast.js
- lib/use-camera-scanner.js
- lib/use-scanner-identification.js
- brief: 4
depends_on: [1, 2, 3]
files:
- pages/scanner.js
- pages/login.js
- components/scanner/ScannerCheckoutSheet.js
- components/scanner/ScannerReview.js
- components/scanner/ReviewCardItem.js
- test/components/ScannerCheckoutSheet.test.js
```
**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` L148168 calls `routeScannedCardToDestination` | Brief 2 deletes block |
| No `chrome` on Layout | `Layout({ children, user, showSearch })` only | Brief 1 adds prop |
| `facingMode` hardcoded | `getUserMedia` uses `'environment'` only | Brief 3 adds toggle + restart |
| No gallery entry point | Grep found zero file-picker wiring | Brief 3 `identifyFromGalleryFile` via `tryLayer1TextIdentify` |
| Login no `returnUrl` | `login.js` always pushes `/dashboard` | Brief 4 adds safe return path |
| Cart persistence | IA said in-memory; D7 overrides to `sessionStorage` | Brief 2 separate key from `localStorage` session prefs |
| `ScannerToast` Unicode | `ICON_MAP` uses `✓` / `✗` | Brief 3 SVG replacement |
| Cross-brief props | `ScannerCamera` needs sheet orchestration | Declared in Brief 3 ↔ 4 `cross_brief_commitments` |
No new npm packages. No peer-dep conflicts. `tryLayer1TextIdentify` and `resolveIdentifyOutcome` confirmed exported from `lib/scanner-card-identify.js`.