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

46 KiB
Raw Blame History

name classification success_metric skip status created depends_on model_policy design_direction
scanner-mobile-checkout feature 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.
open 2026-08-14
scanner-rebuild
redesign-scanner-flow
default_session roles escalate_to escalate_to_premium never_premium
auto
role-conductor role-architect role-ia-architect role-ux-reviewer role-ui-designer role-implementer role-reviewer role-security-auditor role-design-system-auditor role-a11y-auditor role-doc-writer
composer-2.5-fast composer-2.5 composer-2.5-fast composer-2.5-fast composer-2.5-fast composer-2.5-fast cursor-grok-4.5-high gpt-5.6-terra-medium cursor-grok-4.5-high cursor-grok-4.5-high auto
claude-sonnet-5-thinking-medium claude-4.6-opus-high-thinking
role-reviewer
role-security-auditor
role-design-system-auditor
role-a11y-auditor
role-ui-designer
role-doc-writer
source skill skill_version version locked_at product_type pattern style stack layout_reference
role-ui-designer ui-ux-pro-max 2.5.0 1 2026-08-14 mobile camera scanner checkout cart trading cards glassmorphism Immersive camera overlay + bottom-sheet checkout Liquid Glass (repo canonical) nextjs 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

  • IA: route/flow/screen inventory for immersive scan + checkout sheet
  • UI Designer: lock overlay recipe (tokens, not hex) from last mock
  • UX: cart selection, low-confidence, back-guard, camera pause
  • 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

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.jsADD_TO_MY_COLLECTION, ADD_TO_LIST / ADD_TO_LISTS; bottom-bar "Review N" (N = cart count); flash and camera-swap aria-labels; 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 < 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 <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-labels
  • 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 <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 stackScannerDisambiguation.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 editingReviewCardItem.js qty stepper + condition select pattern; overflow ⋮ menu mirrors row remove button placement.
  • Bulk selection semanticsuseScannerQueue selectedCards Set + toggleCardSelection / handleBulkAction; do not fork a parallel selection model.
  • Pause identificationCameraScanner.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 gatinguseScannerFlash.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 namescollectionDisplayName() for picker rows; never render 'All My Cards' literal.
  • Motiondocs/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 < 70%.
  12. Gallery file input: visually hidden <input type="file" accept="image/*"> triggered by labeled button; aria-label on button, not bare input.
  13. Disabled primary CTA: when zero rows selected, Add to My Collection is disabled + aria-disabled="true" with visible helper "Select at least one card" (linked via aria-describedby).
  14. Screen reader pause announcement: when sheet opens, aria-hidden="true" on camera video element OR inert on viewfinder chrome beneath scrim so SR does not read live camera controls under the sheet.

Interaction patterns

Pattern Spec Nielsen
Cart selection — default all selected (required) On first sheet open, selectedCards = all unprocessed row IDs. New card enqueued while sheet open → auto-add its ID to selection. Deselecting is per-row checkbox. Primary CTA label stays VOCAB.ADD_TO_MY_COLLECTION; when subset selected, append count: "Add to My Collection (3)". No "Edit mode" toggle. H6 Recognition rather than recall
Low-confidence vs disambiguation (required) Pipeline disambiguation outcome → always block via existing ScannerDisambiguation sheet; never enqueue until user picks. Single-match emit outcome → enqueue silently to cart regardless of confidence; surface confidence visually (ring + header subline for any row <70%). Do not re-prompt at scan time for low-confidence singles — checkout is the verification gate. H5 Error prevention; H3 User control
Scan peek (required) Slide-up 3s auto-dismiss. Container pointer-events-none; single child button wrapper pointer-events-auto with aria-label="Open cart to review {card name}". No condition/foil/Add on peek. Tap opens checkout sheet. H4 Consistency; H8 Minimalist design
Checkout sheet open/close (required) Open via Review N pill or peek tap. Close via scrim tap, swipe-down on handle (nice-to-have), or header ×. On close with items remaining: resume camera (verificationPausedRef = false). Empty cart after commit → auto-close sheet (D1). H1 Visibility of system status
Pause identification under sheet (required) verificationPausedRef.current = true when checkout sheet or List picker or disambiguation is open. Camera stream stays mounted; detection/verify loop does not run. Resume on sheet close. H1 Visibility of system status
Back with unsaved cart (required) Header back with any unprocessed cart rows → <Modal> confirm: title "Leave scanner?", body "{N} scanned cards haven't been added yet.", primary "Keep scanning" (dismiss), secondary "Leave" (router.back() or /dashboard). No confirm when cart empty. H3 User control and freedom
Partial commit (required) Commit removes only selected rows from cart; sheet stays open with remainder (D6). Success toast role="status". Primary button shows loading via <Button loading>. H9 Error recovery; H1 Feedback
sessionStorage cart persist (required) Serialize scannedCards + selectedCards to sessionStorage on change (D7); hydrate on /scanner mount. Tab refresh preserves cart; new tab starts empty. Clear on explicit "Leave" confirm. H6 Recognition rather than recall
Flash + camera swap (required) Flash button rendered only when flashSupported && facingMode === 'environment'. Camera-swap toggles facingMode; turning to front auto-disables torch. Swap button always visible when streaming. H2 Match between system and real world
Gallery import (required) Top-right gallery button → hidden file input; selected image runs existing identify path; on success same peek + cart enqueue as live scan. Show spinner on gallery button while identifying (aria-busy). H4 Consistency
Empty states (required) Cart sheet with 0 rows: should not open (Review N hidden at N=0). If last item removed in sheet → auto-close + resume camera. H9 Error recovery
Loading states (required) Camera starting: existing spinner + "Starting camera…". Commit in flight: footer CTA loading, row checkboxes disabled. List picker: skeleton or spinner while collections fetch. H1 Visibility of system status
Error states (required) Commit API failure: inline banner in sheet footer (not alert()), rows stay selected, retry enabled. Identify error: existing toast/notice path; do not enqueue. H9 Error recovery
Desktop side panel (required) md+: cart is persistent right panel (not bottom sheet); no immersive Layout chrome; same selection + CTA semantics; no verificationPausedRef pause required (user can see camera + panel simultaneously) — optional: still pause on mobile only. H4 Consistency
Optimistic commit (nice-to-have) Defer — show loading on CTA until API confirms; no optimistic removal (batch failures are painful). H9

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

  • <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)

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.