deckhearth/.convoys/scanner-mobile-checkout.md
varutasu 73424aae59
Mobile scanner checkout: scan first, commit later (#157)
* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ship a cart-then-commit mobile scanner so phone sessions stay on the camera.

Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 20:20:43 -05:00

644 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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: open
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`.