* 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>
46 KiB
| 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 |
|
|
|
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
torchis 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_pricewith 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
role-ia-architect— scan → peek → cart → commit flow; where destination choice lives now that Setup is gone.role-ui-designer— lock the mobile overlay + checkout sheet against the last mock, using Liquid Glass tokens (ui-ux-pro-max is installed).role-ux-reviewer— cart selection, low-confidence handling, back with unsaved cart, pause-vs-kill camera under the sheet.role-architect— briefs. Likely: (1) Layout immersive + camera chrome, (2) stop auto-route + cart model, (3) checkout sheet + destinations.slice_dependenciesmust mark what can run in parallel.role-implementer— per brief.- 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 stayprocessed: falseuntil 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/scannervisits still redirect here (unchanged gate inpages/scanner.js). Post-login return target is TBD (see open questions).[impacted]/dashboard, Layout sidebar, command palette — entry links to/scannerunchanged; 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.js—ADD_TO_MY_COLLECTION,ADD_TO_LIST/ADD_TO_LISTS; bottom-bar "Review N" (N = cart count); flash and camera-swaparia-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 stayprocessed: falseuntil 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), existingGET /api/collections+GET /api/decksfor destination lists. OptionalGETbatch-ownership during cart review. No schema / table changes. - Immersive Layout: new chrome mode (likely
chrome="immersive"onLayout) affects how/scannercomposes global nav — content delta only on this page, not a new route.
Open IA questions
- Post-checkout exit: Product intent says "then exit" — does the user return to the prior page (
router.back()), land on/my-cardsor/collections, or stay on/scannerwith an empty cart and live camera? - Post-login deep link: If an unauthenticated user hits
/scanner, should login return them to/scanner(camera) or a safer default (/dashboard)? - Desktop Setup: Is Setup skipped on
md+as well, or only on mobile viewports? - 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).
- Gallery import: Convoy mentions "optional gallery" in the top overlay — is this in-scope as a file-picker overlay on
/scanner, or deferred? - 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 70–84%, 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 | 150–300ms 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-filteron cart thumbnail grid items (card grid perf budget)- Hardcoded hex in JSX;
--accent-blue/--accent-purplelegacy 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-pointeron clickable elements- Hover/focus transitions 150–300ms
- Text contrast ≥ 4.5:1 on all glass copy (use inner opaque panel if over card art)
- Keyboard focus visible (
--accent-emberring) prefers-reduced-motionrespected (scan line, peek, sheet)- Responsive: 375 (immersive mobile), 768, 1024 (side panel), 1440
- Flash hidden when
torchunsupported; camera-swaparia-labels - Checkout copy from
collection-vocabulary.jsonly
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. Usetint="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",loadingduring commit) and secondary (variant="ghost") CTAs; back-guard actions.<Input>(components/ui/Input.js) — optional filter field inside List picker whencollections.length > 8.<ScannerDisambiguation>(components/scanner/ScannerDisambiguation.js) — canonical bottom-sheet shell for checkout cart on mobile: samefixed inset-0scrim,glass-panel-strongpanel, 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 (sameonReviewcontract,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; keepverificationPausedRefplumbing.<Layout>(components/Layout.js) —chrome="immersive"on/scannerfor<mdonly; 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 70–84%, 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 stack —
ScannerDisambiguation.js(scrim click dismisses only when safe; focus trap;aria-labelledby). Checkout sheet sits atz-40; disambiguation stays atz-50above it. - Cart row editing —
ReviewCardItem.jsqty stepper + condition select pattern; overflow ⋮ menu mirrors row remove button placement. - Bulk selection semantics —
useScannerQueueselectedCardsSet +toggleCardSelection/handleBulkAction; do not fork a parallel selection model. - Pause identification —
CameraScanner.js+pages/scanner.jsverificationPausedRefpattern:useCameraScannerearly-returns whenverificationPausedRef.current === true;useScannerIdentificationalready sets it during disambiguation — extend to checkout sheet + List picker open. - Flash gating —
useScannerFlash.jsflashSupportedprobe; conditionally render flash button (already inScannerCamera.jsL221–242). Flash is rear camera only — hide whenfacingMode === 'user'. - Immersive page shell — follow
components/ProtectedRoute.js/pages/scanner.jsauth gate; post-login return to/scanner(D2) viarouter.query.returnUrlor 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.mdduration tokens: peek/toastquick(150ms), sheetslow(400ms); sitewideprefers-reduced-motioncollapse 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:
- 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.
- WCAG 1.4.3 Contrast (AA): all text on glass ≥ 4.5:1; if card-art bleeds through a row thumb, use
--bg-secondaryinner panel behind text (design-direction row spec). - 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. - WCAG 2.1.1 Keyboard: sheet dismiss via Escape; peek opens sheet on Enter/Space when focused; back button triggers confirm modal before navigation.
- 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"). aria-labelrequired: 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}.- WCAG 1.1.1 Non-text Content: card thumbs
alt={card.name}; decorative bracket cornersaria-hidden="true". - WCAG 4.1.3 Status Messages: identify success toast + post-commit success toast use
role="status"aria-live="polite"; do not steal focus. - WCAG 2.3.3 Animation from Interactions: scan-line loop, peek slide, sheet slide respect
prefers-reduced-motion: reduce(instant show/hide, no sweep). - Focus trap: checkout sheet, disambiguation sheet, back-guard confirm, List picker — all trap Tab; disambiguation above checkout maintains trap when both mounted.
- Confidence communicated without color alone: ring color + text label on row (
aria-labelincludes "confidence 62 percent") and header subline "{N} need review" when any row < 70%. - Gallery file input: visually hidden
<input type="file" accept="image/*">triggered by labeled button;aria-labelon button, not bare input. - Disabled primary CTA: when zero rows selected,
Add to My Collectionisdisabled+aria-disabled="true"with visible helper "Select at least one card" (linked viaaria-describedby). - Screen reader pause announcement: when sheet opens,
aria-hidden="true"on camera video element ORinerton 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
verificationPausedRefonly (H1). - Auto-routing to DB on identify — rebuild D3 reversed; enqueue only (H3).
- "binder" / "Save to binder" / "Mark Owned" button copy — use
VOCABconstants; CI forbidden-strings (H4). - Hardcoded hex /
rgba(0,0,0,0.55)in new chrome — use glass tokens; migrate existingScannerCameraliterals in same brief (H4). - Emoji toast glyphs — SVG only per design-direction checklist (H4).
backdrop-filteron 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 existingSCANNER_SESSION_STORAGE_KEYlocalStorage for cart rows (H6).
Mobile / responsive notes
- <768px (mobile):
Layoutchrome="immersive"— hide sidebar,TopSearchBar,MobileNavigation. Camera100dvhfull-bleed (min-h-0flex child). Top bar respectsenv(safe-area-inset-top); bottom barenv(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 L148–168 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.