feat(scanner): add debug instrumentation for vision pipeline timing #155

Merged
rstillwell merged 31 commits from feat/scanner-debug-mode into main 2026-09-01 18:11:03 -04:00
28 changed files with 3420 additions and 572 deletions
Showing only changes of commit 73424aae59 - Show all commits

View file

@ -75,3 +75,17 @@
{"ts": "2026-06-13T06:20:00Z", "role": "role-architect", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 180, "outcome": "architecture-only"}
{"ts": "2026-06-13T06:20:00Z", "role": "role-implementer", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 600, "outcome": "pr-open"}
{"ts": "2026-06-14T13:06:36Z", "role": "role-implementer", "convoy": "multi-game-bulk-sync", "repo": "tcg-vault", "skip_flags": [], "brief": 2, "duration_s": 600}
{"ts": "2026-08-14T23:57:39Z", "role": "role-conductor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "classification": "feature", "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:14:27Z", "role": "role-ia-architect", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 95, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:15:31Z", "role": "role-ui-designer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:16:36Z", "role": "role-ux-reviewer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:18:40Z", "role": "role-architect", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 420, "model": "composer-2.5", "model_tier": "standard"}
{"ts": "2026-08-15T00:19:56Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 1, "duration_s": 240, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:20:23Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 2, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:22:15Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 3, "duration_s": 600, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:24:15Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 4, "duration_s": 600, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T00:25:33Z", "role": "role-security-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 480, "model": "gpt-5.6-terra-medium", "model_tier": "security"}
{"ts": "2026-08-15T00:25:54Z", "role": "role-reviewer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 210, "multitask_group": "audit-scanner-mobile-checkout-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
{"ts": "2026-08-15T00:26:05Z", "role": "role-a11y-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 78, "model": "cursor-grok-4.5-high", "model_tier": "audit"}
{"ts": "2026-08-15T00:26:08Z", "role": "role-design-system-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 82, "multitask_group": "audit-scanner-mobile-checkout-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
{"ts": "2026-08-15T00:56:39Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 4, "duration_s": 240, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}

View file

@ -0,0 +1,644 @@
---
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`.

View file

@ -0,0 +1,146 @@
# A11y audit — scanner-mobile-checkout
**Reviewer:** role-a11y-auditor
**Convoy:** `scanner-mobile-checkout`
**Surface:** uncommitted UI diff vs `origin/main` (+ untracked) — no GitHub PR
**Date:** 2026-08-14
**Model:** cursor-grok-4.5-high (`model_tier=audit`)
**Skill note:** `.cursor/skills/accessibility-audit/SKILL.md` was not present in-repo; audit follows role + convoy UX § A11y constraints (14 items) + WCAG 2.2 AA (AAA target size where convoy requires).
## Diff scope (UI)
| Path | Status |
| --- | --- |
| `components/Layout.js` | modified (`chrome="immersive"`) |
| `components/scanner/ScannerCamera.js` | modified |
| `components/scanner/ScannerCountPill.js` | modified |
| `components/scanner/ScannerToast.js` | modified |
| `components/scanner/ReviewCardItem.js` | modified |
| `components/scanner/ScannerReview.js` | modified |
| `components/scanner/ScannerCheckoutSheet.js` | **new** |
| `components/scanner/ScannerScanPeek.js` | **new** |
| `pages/scanner.js` | modified |
| `test/components/ScannerCheckoutSheet.test.js` | new (a11y-adjacent) |
Non-UI (session/hooks/login/tests) excluded from findings.
## Executive summary
- Chrome controls (flash / swap / gallery / back / Review N), peek tap target, toast live region, checkout `role="dialog"` + Escape + `useFocusTrap`, and sitewide `prefers-reduced-motion` collapse are largely in good shape.
- **One severity-3 finding:** nested List-picker `<Modal>` over open checkout sheet leaves **two Escape handlers and two focus traps** active — Escape dismisses both layers (and Tab can fight). Convoy constraint 10.
- Several severity-2 gaps vs promised constraints: missing sheet close control, no `aria-describedby` helper on disabled commit CTA, camera chrome not `inert`/`aria-hidden` under sheet, native checkbox hit target unreliable at 44×44.
- **Recommendation:** request-changes until nested-overlay Escape/trap is fixed; ship sev-2 before merge if possible.
**Counts:** 7 findings (sev ≥ 3: **1**, sev &lt; 3: **6**). Constraint checklist: **9 pass / 3 partial / 2 fail**.
## Convoy constraint checklist
| # | Constraint | Result | Notes |
| --- | --- | --- | --- |
| 1 | 44×44 targets (2.5.5) | **Partial** | ChromeIconButton, Review N, qty ±, overflow use `minWidth/Height: 44`. Native checkbox is `w-5 h-5` with style mins — hit box often stays ~20px. |
| 2 | Contrast on glass (1.4.3) | **Pass*** | Token text colors; no instrumented contrast measure (*static review). |
| 3 | Focus order open/close (2.4.3) | **Partial** | Trap focuses first focusable in panel; restore via `useFocusTrap`. No header ×; restore may miss if opener was auto-dismissed peek. |
| 4 | Keyboard Escape / peek Enter (2.1.1) | **Partial** | Escape + button peek OK; nested Escape conflict (see A1). |
| 5 | Dialog name/role (4.1.2) | **Pass** | Checkout: `role="dialog"` `aria-modal` `aria-labelledby` → "{N} cards scanned". List picker Modal title "Choose a List". |
| 6 | Required `aria-label`s | **Pass** | Leave / gallery / flash+`aria-pressed` / swap / Review N / checkbox / qty / overflow present. |
| 7 | Non-text (1.1.1) | **Pass** | Row thumbs `alt={card.name}`; peek thumb `alt=""` under named button; decorative SVGs `aria-hidden`. |
| 8 | Status messages (4.1.3) | **Pass** | Toast `role="status"` `aria-live="polite"`; scan status `aria-live="polite"`. Peek is interactive, not a second live region (toast covers identify). |
| 9 | Reduced motion (2.3.3) | **Pass** | Peek/sheet/toast transitions + spinners covered by `styles/globals.css` `@media (prefers-reduced-motion: reduce)`. |
| 10 | Focus trap all overlays | **Fail** | Sheet traps; Modal traps; both stay active when List picker opens over checkout. |
| 11 | Confidence not color-only (1.4.1) | **Partial** | Visible `{pct}% match` + ring; checkbox `aria-label` adds confidence only when &lt;85%; header says "Some matches…" not "{N} need review". |
| 12 | Gallery file input | **Pass** | `sr-only` input + labeled button; `aria-busy` while identifying. |
| 13 | Disabled CTA + describedby | **Fail** | `disabled` only; no helper text / `aria-describedby` / `aria-disabled`. |
| 14 | Hide camera under sheet | **Fail** | `isCheckoutOpen` only changes status copy; video + chrome not `aria-hidden`/`inert`. |
## Findings
| Sev | Layer | WCAG | Surface | Issue | Fix |
| --- | --- | --- | --- | --- | --- |
| **3** | Keyboard / Focus | 2.1.1, 2.4.3 | `pages/scanner.js` + `ScannerCheckoutSheet.js` | Nested List picker Modal while checkout sheet open: both register document `Escape` + Tab traps. Escape closes **picker and sheet**. | Deactivate checkout trap + Escape while `isListPickerOpen`; or close sheet Escape only when picker closed (`if (e.key === 'Escape' && !isListPickerOpen)`). Prefer `useFocusTrap(active && !nestedOpen)`. |
| **2** | Name / Instructions | 3.3.2, 4.1.2 | `ScannerCheckoutSheet.js` `CheckoutFooter` ~L4051 | Zero selection: primary disabled with no visible "Select at least one card" / `aria-describedby`. | Add helper `<p id="checkout-select-hint">…</p>`; `aria-describedby={selectedCount === 0 ? 'checkout-select-hint' : undefined}`; optional `aria-disabled` mirroring `disabled`. |
| **2** | Structure / AT | 1.3.2, 4.1.2 | `ScannerCamera.js` + `pages/scanner.js` | Sheet open does not set `aria-hidden`/`inert` on viewfinder chrome (constraint 14). | When `isCheckoutOpen` (mobile), set `inert` on camera root (or `aria-hidden` on `<video>` + `tabIndex={-1}` / hide chrome from AT). |
| **2** | Target size | 2.5.5 | `ReviewCardItem.js` ~L7279 | Checkbox visual/control ~20×20 despite `minWidth/Height: 44` on `<input type="checkbox">` (unreliable). | Wrap in `<label className="… min-w-[44px] min-h-[44px] flex items-center justify-center">` or custom hit pad. |
| **2** | Keyboard / Operable | 2.1.1, 2.4.3 | `ScannerCheckoutSheet.js` ~L211217 | No header dismiss × (convoy: close via × / Escape / scrim). Keyboard-only users depend solely on Escape. | Add `aria-label="Close cart"` button in sheet header; include in focus order as first focusable. |
| **1** | Understandable | 3.3.1 / UX | `pages/scanner.js` Leave Modal ~L152166 | Copy uses "Stay" not "Keep scanning"; description omits `{N}` count. | `description={\`${unprocessedCount} scanned cards haven't been added yet.\`}`; primary label "Keep scanning". |
| **1** | Sensory | 1.4.1 | `ScannerCheckoutSheet.js` ~L127130 | Low-confidence subline lacks count; checkbox confidence in name only when &lt;85%. | Subline: `{n} need review`; include confidence in checkbox `aria-label` whenever `confidencePct != null`. |
### Severity ≥ 3 detail
#### A1 — Nested Escape / dual focus trap (sev 3)
**Scenario:** User opens checkout sheet → taps **Add to List** → List picker Modal opens. Presses **Escape** intending to close only the picker.
**Observed behavior (static):**
1. `ScannerCheckoutSheet` always calls `useFocusTrap(true)` and adds a document `keydown` Escape → `onClose` while mounted (`ScannerCheckoutSheet.js` L171188).
2. `<Modal open={isListPickerOpen}>` independently traps focus and listens for Escape → its `onClose` (`Modal.js` L2837; `pages/scanner.js` L168173).
3. Checkout stays mounted (`pages/scanner.js` L131137) while picker is open — neither trap is suspended.
Both Escape listeners fire on one keypress → picker closes **and** cart sheet closes. Tab handlers from both traps remain registered → unpredictable focus cycling (constraint 10).
**Suggested fix (minimal):**
```js
// ScannerCheckoutSheet — accept activeTrap prop
const sheetRef = useFocusTrap(trapActive !== false);
useEffect(() => {
if (!trapActive) return undefined;
// … Escape handler only when trapActive
}, [onClose, sheetRef, trapActive]);
```
```js
// pages/scanner.js
<ScannerCheckoutSheet
trapActive={!isListPickerOpen}
/>
```
Or close List picker with Escape via Modal only, and in checkout Escape handler: `if (document.querySelector('[data-list-picker-open]')) return`.
## Layer walk (5 layers, summary)
| Layer | Verdict |
| --- | --- |
| **1 Perceivable** | Toast/status live regions OK; confidence has text+ring; peek/toast SVG icons OK; reduced-motion via global CSS OK. |
| **2 Operable** | 44px chrome generally OK; nested Escape/trap is the blocker; missing sheet ×; checkbox target weak. |
| **3 Understandable** | Labels strong on chrome; leave-modal / empty-CTA / low-confidence copy incomplete vs convoy. |
| **4 Robust** | Dialog semantics present; dual `aria-modal` when nested; camera not inert under sheet. |
| **5 Consistency** | Matches `ScannerDisambiguation` sheet shell pattern (good); nested Modal over sheet needs same trap suspension pattern as disambiguation-above-checkout (z-50 vs z-40) — disambiguation currently unlikely while checkout open because verify pauses, but List picker is the live path. |
## Suggested diffs (priority)
1. **P0** — Suspend checkout Escape + `useFocusTrap` while List picker (or any nested Modal) is open.
2. **P1** — Disabled CTA helper + `aria-describedby`.
3. **P1**`inert` / `aria-hidden` on camera when mobile checkout open.
4. **P1** — 44×44 checkbox hit area via label wrapper.
5. **P2** — Sheet close button; leave-modal copy + count; "{N} need review".
## Patterns to lift
- `ChromeIconButton` — good reusable 44px + `aria-label` / `aria-pressed` / `aria-busy` pattern (`ScannerCamera.js`).
- `ScannerDisambiguation` + `useFocusTrap` — correct shell to copy; extend with **`active` gated by nested overlays**.
- `ScannerToast` SVG + `role="status"` — keep; do not add a competing live region on peek.
## Automated checks
| Check | Result |
| --- | --- |
| axe-core / CI a11y job | Not run (static role audit) |
| Manual SR | Recommend VoiceOver pass: open cart → Add to List → Escape once |
## Approval recommendation
- [ ] approve
- [x] **request-changes** — sev ≥ 3 open (nested Escape/trap)
- [ ] comment-only
Fix A1 before merge. Sev-2 items should land in the same PR or an immediate follow-up brief tagged a11y.
## Hand-off
A11y audit complete. **7 findings** (sev ≥ 3: **1**, sev &lt; 3: **6**).
Report: `.convoys/scanner-mobile-checkout/audits/a11y-20260814.md`.
Recommend fixing sev ≥ 3 before merge.

View file

@ -0,0 +1,153 @@
# Design-System Audit — scanner-mobile-checkout
**Role:** `role-design-system-auditor`
**Surface:** uncommitted `feat/scanner-mobile-checkout` UI (`git diff origin/main` + untracked scanner UI)
**Convoy:** `.convoys/scanner-mobile-checkout.md`
**Date:** 2026-08-14
**Scope:** UI / DS only — read-only; no code changes
**Inputs reviewed**
| Source | Notes |
| --- | --- |
| Diff UI | `Layout.js`, `ScannerCamera.js`, `ScannerCountPill.js`, `ScannerToast.js`, `ReviewCardItem.js`, `ScannerReview.js`, `pages/scanner.js` |
| Untracked | `ScannerCheckoutSheet.js`, `ScannerScanPeek.js` |
| Direction lock | `design_direction` v1 + `## Design direction` in convoy |
| Tokens | `docs/DESIGN_TOKENS.md`, `styles/globals.css` |
| Primitives | `components/ui/{GlassSurface,Button,Modal,Input}.js` |
| Skill | `.cursor/skills/design-systems/SKILL.md` **not installed** in this repo; audit follows role file + prior Liquid Glass audit shape + `docs/DESIGN_TOKENS.md` |
---
## Audit summary
| Check | Status | Notes |
| --- | --- | --- |
| Direction lock (Liquid Glass + immersive camera + sheet) | ⚠️ Partial | Chrome bars + VOCAB CTAs + Modal reuse land; **L-brackets / scan-line missing**; sheet tint recipe drifts |
| Hex in JSX | ❌ | 1 literal `#ffffff` in `ReviewCardItem.js` |
| Binder / stale vocab | ✅ | No `binder` / `Mark Owned` / `Save to binder` in scoped UI |
| `backdrop-filter` on cart **thumbnails** | ✅ | Thumbs are solid image/`--bg-tertiary`; no filter on `<img>` |
| GlassSurface / Button / Modal reuse | ⚠️ Mixed | Camera chrome + peek: GlassSurface; checkout sheet/toast/rows: `glass-panel*` / hand-rolled |
| Sev ≥ 3 findings | **2** | Hex literal; missing bracket + scan-line direction lock |
---
## Maturity scoring (repo DS + this surface)
Scores 04. Evidence cites paths/counts.
| Axis | Score | Evidence |
| --- | --- | --- |
| **Tokens (T)** | **3** | Documented 3-tier glass surface / blur / rim / elevation / scrim in `docs/DESIGN_TOKENS.md` + `styles/globals.css`. Diff mostly consumes `var(--*)`. Residual: `.glass-panel` still hardcodes `blur(12px) saturate(180%)` (not `--glass-blur-*` / `--glass-saturate`); toast uses literal `blur(12px)`. |
| **Components (C)** | **3** | Kit under `components/ui/`: GlassSurface, Modal, Button, Input, SearchBar, StatCard (+ chrome). Scanner adopts GlassSurface on camera bars/icons/peek (9 call sites in `ScannerCamera.js`); Modal×2 + Button in `pages/scanner.js` / checkout footer. Gaps: toast + sheet panel + cart rows skip GlassSurface. |
| **Patterns (P)** | **2** | Locked pattern “immersive camera + bottom-sheet checkout” partially shipped (immersive Layout, sheet shell mirroring Disambiguation, peek non-interactive). **Detection chrome still filled rectangles** (info/success), not ember L-brackets + scan-line. Confidence mid-band maps to gold, not directions amber/ember table. |
| **Governance (G)** | **3** | Convoy `design_direction` locked 2026-08-14; `ui-and-theming.mdc` + vocab CI; `forbidden-hex-in-jsx` / forbidden stale strings. No design-systems skill package in-repo (template path missing). |
| **Adoption (A)** | **2** | This surface: GlassSurface on live chrome/peek ✅; checkout sheet + toast + rows use utility classes / ad-hoc styles ❌; Review N pill is raw `<button>` with gradient (OK shape) but skips ember-rim + dead GlassSurface import. Vocab CTAs ✅. |
**Composite:** T3 / C3 / P2 / G3 / A2
**Top leverage:** **Patterns (P)** — implement Screen 1 bracket + scan-line recipe and align checkout sheet to `GlassSurface tint="low"` + toast to GlassSurface/`--glass-surface-high` so the locked Liquid Glass pattern reads as one system, not chrome-good / body-utility.
---
## Direction lock
Locked fields enforced: Liquid Glass; immersive overlay + bottom sheet; ember/flame accents as rim/glow/gradient only; VOCAB CTAs; no binder; no hex; no per-card Add on peek; no `backdrop-filter` on cart thumbs; reuse GlassSurface / Modal / Button.
| Locked requirement | Diff status |
| --- | --- |
| Immersive `chrome="immersive"` mobile | ✅ `Layout.js` + `pages/scanner.js` |
| Top/bottom glass bars (`--glass-surface-mid`) | ✅ `GlassSurface tint="mid"` in `ScannerCamera.js` |
| L-brackets `--accent-flame` / `--accent-ember` + scan-line | ❌ Filled `border` boxes + `--color-info` / `--color-success` overlays; **no** scan-line / `prefers-reduced-motion` gate |
| Scan peek glass, tap-to-cart only | ✅ `ScannerScanPeek.js` |
| Toast glass-high + SVG check | ⚠️ SVG ✅; surface is `rgba(--bg-secondary-rgb)` + literal blur, not GlassSurface / `--glass-surface-high` |
| Review N gradient + `--ember-rim-pronounced` | ⚠️ Gradient ✅; **no** ember rim; unused `GlassSurface` import |
| Sheet scrim `--modal-scrim` + blur-high | ✅ `ScannerCheckoutSheet.js` styled-jsx |
| Sheet panel `--glass-surface-low` via GlassSurface | ❌ `glass-panel-strong` → surface-**high** recipe |
| Footer VOCAB + Button primary/ghost | ⚠️ VOCAB ✅; secondary uses `variant="secondary"` not `ghost` |
| Confidence rings tokenized (green / amber / orange) | ⚠️ Tokens used; mid=`--accent-gold`, low=`--color-warning` vs locks ember/amber table |
| No hex / no binder | ❌ one `#ffffff`; binder clear |
| Modal for leave + list picker | ✅ |
| No backdrop on thumbnails | ✅ |
---
## Token audit (3-tier)
| Tier | Expectation | Diff notes |
| --- | --- | --- |
| Primitives | Palette / RGB triples in CSS only | No new hex in CSS from this diff. JSX `#ffffff` violates consumer rule. |
| Aliases | `--glass-surface-*`, `--glass-blur-*`, rims, scrim | Camera/scrim/peek mostly correct. Toast blur literal `12px` should be `var(--glass-blur-low)`. `.glass-panel` utility (row list) still non-token blur/saturate (pre-existing; amplified by N cart rows). |
| Components | GlassSurface / Button / Modal compose aliases | Sheet + toast + rows compose utilities/hand styles instead of primitives. |
---
## Component / adoption audit (scoped surface)
| Element | Expected | Actual | Rate |
| --- | --- | --- | --- |
| Glass chrome bars | `<GlassSurface>` | Yes (`ScannerCamera`) | High |
| Scan peek | `<GlassSurface>` | Yes | High |
| Toast | `<GlassSurface tint="high">` or equivalent | Hand-rolled | Low |
| Checkout sheet panel | `<GlassSurface tint="low">` | `glass-panel-strong` | Low |
| Cart rows | GlassSurface high **or** solid `--bg-secondary` | `.glass-panel` (blur per row) | Medium — OK container glass; GPU stack risk |
| Footer CTAs | `<Button>` | Yes | High |
| Leave / List dialogs | `<Modal>` | Yes | High |
| Review N | Button or GlassSurface + ember rim | Raw `<button>` + gradient | Medium |
**Missing primitive usage (not missing primitives):** toast + sheet should compose existing GlassSurface rather than inventing parallel glass.
---
## Findings
Severity 04. Cap prioritized; sev ≥ 3 blocks merge recommendation for DS gate.
| ID | Sev | Finding | Evidence | Fix |
| --- | --- | --- | --- | --- |
| DS-1 | **3** | Hardcoded hex in JSX | `ReviewCardItem.js` qty `+` button `color: '#ffffff'` | Use theme-safe token (e.g. dark-theme `--text-primary` on ember fill, or shared on-accent pattern used by `<Button variant="primary">`) |
| DS-2 | **3** | Direction lock: detection chrome ≠ L-brackets + scan-line | `ScannerCamera.js` filled rect overlays via `overlayBorderColor` (`--color-info` / `--color-success`); no bracket SVG/corners; no ember scan-line / reduced-motion | Ship Screen 1 bracket recipe (`--accent-ember` / `--accent-flame`) + gradient scan-line; disable loop under `prefers-reduced-motion` |
| DS-3 | **2** | Toast bypasses glass recipe | `ScannerToast.js`: `rgba(var(--bg-secondary-rgb), 0.9)` + `backdropFilter: 'blur(12px)'` | Wrap with `<GlassSurface tint="high" blur="low|mid" rim="subtle">`; drop literal blur px |
| DS-4 | **2** | Checkout sheet panel wrong surface tier + no GlassSurface | `ScannerCheckoutSheet.js` `glass-panel-strong` (high) for panel + sticky footer | Panel: GlassSurface `tint="low"` inside scrim; footer sticky on same recipe or solid inner; match Modal panel docs |
| DS-5 | **2** | Review N missing ember-pronounced rim; dead import | `ScannerCountPill.js` imports GlassSurface unused; no `--ember-rim-pronounced`; `text-white` utility | Apply rim via GlassSurface `as="button"` `rim="ember-pronounced"` or box-shadow token; remove dead import; avoid raw white |
| DS-6 | **2** | Confidence band colors drift from lock | `confidenceRingColor`: &lt;70 `--color-warning`, 7084 `--accent-gold`, else success | Align to lock: &lt;70 `--accent-ember`, 7084 amber/warning token, ≥85 `--color-success` |
| DS-7 | **1** | Secondary CTA variant | Checkout footer `Button variant="secondary"` | Prefer `variant="ghost"` per design direction |
| DS-8 | **1** | Leave-modal dismiss copy | `pages/scanner.js` label `"Stay"` | Direction: primary dismiss **"Keep scanning"** |
| DS-9 | **1** | Hardcoded vocab fallback | `ReviewCardItem.js` `'My Collection'` string | `VOCAB.MY_COLLECTION` |
| DS-10 | **1** | Stacked row `backdrop-filter` via `.glass-panel` | Each cart row blurs (`blur(12px)` in utility) | Prefer solid `--bg-secondary` (or GlassSurface without stacking N blurs) for row inners over thumbs — thumbs themselves are clean |
### Passes (not findings)
- No `"binder"` / `"Save to binder"` / `"Mark Owned"` in scoped UI.
- Checkout CTAs import `VOCAB.ADD_TO_MY_COLLECTION` / `VOCAB.ADD_TO_LIST`.
- List picker uses `collectionDisplayName`.
- Peek has no Add / condition / foil; single open-cart control.
- Toast icons are SVG (no emoji glyphs).
- Scrim uses `--modal-scrim` + `var(--glass-blur-high)`.
- Cart thumbnails: no `backdrop-filter`.
- Leave + List picker use `<Modal>`; commit CTAs use `<Button>`.
---
## Governance notes
- Contribution path for glass: `docs/DESIGN_TOKENS.md` + `ui-and-theming.mdc` (“reach for `components/ui/` first”).
- CI: hex-in-JSX + stale vocab gates apply — **DS-1 will fail / should fail `forbidden-hex-in-jsx`**.
- Design-systems skill/templates not present under `.cursor/skills/design-systems/` — maturity G capped partly by missing shared audit tooling in-repo.
---
## Recommendation
**Do not treat DS as merge-clean until DS-1 and DS-2 are fixed.** DS-3DS-6 are the follow-up pass that makes checkout/toast/pill match the same Liquid Glass composition as the camera chrome.
**Child tasks (sev ≥ 3):**
1. Replace `#ffffff` in `ReviewCardItem.js` (and any sibling `text-white` on detection badges if hex-gate/token policy treats them as on-accent debt).
2. Implement ember/flame L-brackets + reduced-motion-aware scan-line on the viewfinder per locked Screen 1.
---
## Hand-off
DS audit complete. Maturity: **T3/C3/P2/G3/A2**. Top leverage: invest in **Patterns** (brackets/scan-line + sheet/toast GlassSurface alignment). Sev ≥ 3 findings: **2**. Report: `.convoys/scanner-mobile-checkout/audits/design-system-20260814.md`.

View file

@ -0,0 +1,39 @@
## Reviewer Report
| Check | Status | Notes |
| --- | --- | --- |
| Scope match | ✅ | All app changes map to briefs 14 `files:`; no unauthorized expansion. Convoy docs/metrics only extras. |
| Conventions | ✅ | Vocab via `VOCAB`; CSS tokens / GlassSurface; Modal for leave + list picker; login `returnUrl` hardened (`/` only, blocks `//` and `://`). |
| Security | ✅ | Shallow L1L2: no new API routes; client cart in `sessionStorage`; returnUrl open-redirect guarded. Defer depth to security-auditor when PR exists. |
| Regression risk | medium | Scanner page rewritten (phase machine removed); auto-route deleted (intentional); Layout immersive gated by prop but TopSearchBar/MobileNavigation paths change for `/scanner`. |
| Test coverage | ⚠️ | Brief-required suites green (27/27). Commit success/error detection untested and currently wrong (see Critical). |
| Documentation | ✅ | No AGENTS.md / schema updates required for this convoy. |
### Findings
- 🔴 **Critical** (must fix before merge): Stale React state used to detect commit failure in `ScannerCheckoutContent.handleCommitOwned` and `pages/scanner.js` `handleListPick`. After `await queue.commitSelectedToOwned()` / `commitSelectedToCollection()`, `queue.scannedCards` is still the pre-commit render snapshot, so a successful commit looks like “all remaining” → false error banner; list picker never closes on success (`setIsListPickerOpen(false)` skipped). Fix: have commit helpers return `successfulIds` (or a boolean), and branch on that — do not re-read hook state immediately after `await`.
- 🟡 **Suggestion** (consider): `ReviewCardItem` still mounts the per-row destination `<select>` when `!showCheckbox`. Checkout path is fine (`showCheckbox`), but Brief 4 asked to remove inline destination pickers; dead path adds confusion and retains pre-existing `#ffffff` on that control.
- 🟡 **Suggestion** (consider): Add a focused test that commit success returns/propagates success without relying on immediate `queue.scannedCards` re-read (locks the Critical fix). Optional: login `returnUrl` honor + reject open redirect.
- 🟢 **Nice to have** (optional): Convoy markdown todos still unchecked despite implemented work — conductor/doc-writer hygiene, not a merge blocker.
### Approval recommendation
- request-changes
### AC evidence (briefs 14)
| Brief | AC | Evidence |
| --- | --- | --- |
| 1 | Immersive hides mobile nav / TopSearchBar below md | `Layout.js` `!isImmersive` MobileNav; TopSearchBar `max-md:hidden`; tests added |
| 1 | Default chrome unchanged | `chrome = 'default'`; regression tests |
| 2 | No fetch in `handleCardScanned` | Auto-route block removed; queue test asserts no route helpers |
| 2 | sessionStorage cart | `SCANNER_CART_STORAGE_KEY` + load/save/clear + tests |
| 2 | Commit removes rows | `successfulIds` filter in `handleBulkAction` |
| 3 | Facing swap + flash rear-only | `switchFacingMode`; `showFlash` env-only; torch off on `user` |
| 3 | Peek 3s / toast SVG / gallery adapter | `ScannerScanPeek`, SVG `ToastIcon`, `identifyFromGalleryFile` |
| 4 | No Setup; returnUrl; sheet + pause; leave Modal | `pages/scanner.js` + hardened `login.js` |
| 4 | Desktop side panel | `ScannerReview` `variant="side-panel"` |
| 4 | Checkout sheet tests | header count, disabled primary, vocab labels |
### Test run
`npm run test:run -- test/components/Layout.test.js test/lib/scanner-session.test.js test/lib/use-scanner-queue.test.js test/components/ScannerCheckoutSheet.test.js`**27/27 passed**.

View file

@ -0,0 +1,69 @@
---
convoy: scanner-mobile-checkout
brief_number: 1
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- components/Layout.js
- test/components/Layout.test.js
cross_brief_commitments:
- brief: 4
description: |
Brief 4 passes `chrome="immersive"` on `<Layout>` from `pages/scanner.js`
for authenticated scanner sessions. This brief only adds the prop and
mobile-only hiding behavior; Brief 4 owns when it is set.
---
# Brief 1: Layout immersive chrome
## Goal (1 sentence)
Add a narrow `chrome="immersive"` prop to `Layout` that hides global app chrome on viewports `< md` without restyling the desktop sidebar.
## Files in scope (do not edit anything else)
- `components/Layout.js`
- `test/components/Layout.test.js`
## Conventions to follow
- Extend the existing `Layout` signature: `export default function Layout({ children, user = null, showSearch = false })` → add `chrome = 'default'`.
- Immersive is **mobile-only** (`md:hidden` / `max-md:` patterns). At `md+`, immersive behaves like `default` (sidebar + TopSearchBar unchanged per D3 desktop note).
- Hide when `chrome === 'immersive'` on `< md`:
- `<MobileNavigation />` (line ~674)
- `<TopSearchBar />` (line ~986)
- Desktop `<aside>` sidebar is already `hidden md:flex` — no change needed at `md+`; on mobile the drawer + bottom nav are the chrome to suppress.
- Main column: remove `pb-16` bottom padding when immersive (no bottom nav). Use `max-md:pb-0` on the main wrapper (`flex-1 flex flex-col pb-16 md:pb-0`).
- Outer shell: optional `max-md:p-0` when immersive so camera can be true full-bleed.
- Do **not** change sidebar styles, nav items, or command palette behavior.
- Use CSS tokens only — no new hex in JSX.
## Implementation shape (verified against `Layout.js`)
```js
export default function Layout({
children,
user = null,
showSearch = false,
chrome = 'default',
}) {
const isImmersive = chrome === 'immersive';
// ...
// MobileNavigation: {!isImmersive && <MobileNavigation ... />}
// TopSearchBar: {user && !isImmersive && <TopSearchBar ... />}
// Main wrapper className: include isImmersive && 'max-md:pb-0'
}
```
## Acceptance criteria
- [ ] `chrome="immersive"` hides `MobileNavigation` and `TopSearchBar` below `md` breakpoint
- [ ] `chrome="immersive"` at `md+` renders identical chrome to `chrome="default"` (sidebar + TopSearchBar visible)
- [ ] `chrome="default"` (or omitted) is unchanged from current behavior
- [ ] `test/components/Layout.test.js` adds regression tests: immersive hides mobile nav landmark; default still renders Sign-in CTA when `user={null}` (existing 5 assertions preserved)
- [ ] No scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
Immersive chrome is isolated to `Layout.js` so visual-diff-sensitive sidebar code stays untouched. Mobile-only gating matches design-direction desktop exception. Brief 4 wires the prop from `/scanner` without further Layout changes.

View file

@ -0,0 +1,99 @@
---
convoy: scanner-mobile-checkout
brief_number: 2
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- lib/use-scanner-queue.js
- lib/scanner-session.js
- test/lib/scanner-session.test.js
- test/lib/use-scanner-queue.test.js
cross_brief_commitments:
- brief: 4
description: |
Export `clearScannerCartStorage()` from `scanner-session.js`. Brief 4
calls it when the user confirms "Leave" on the back-guard modal (D1/D7).
Queue hook exposes `hydrateFromStorage` on mount — Brief 4 does not
reimplement hydration.
---
# Brief 2: Cart model — stop auto-route + sessionStorage
## Goal (1 sentence)
Reverse rebuild D3 auto-route: identifies enqueue locally only, auto-select new rows, and persist cart + selection in `sessionStorage` (D7).
## Files in scope (do not edit anything else)
- `lib/use-scanner-queue.js`
- `lib/scanner-session.js`
- `test/lib/scanner-session.test.js`
- `test/lib/use-scanner-queue.test.js` (new)
## Conventions to follow
- **No new API routes.** Commit paths stay `addScannedCardToOwned` / `addScannedCardToCollection` from `lib/scanner-route-api.js` (already verified).
- **Do not use** `SCANNER_SESSION_STORAGE_KEY` / `localStorage` for cart rows (UX anti-pattern). Add a separate key in `scanner-session.js`:
```js
export const SCANNER_CART_STORAGE_KEY = 'deckhearth:scanner-cart';
```
- Serialize `selectedCards` as `number[]` (Set is not JSON-safe).
- Keep `mergeScannedCardEntry` unchanged — cart entry shape already includes `processed: false` and `confidence` from `buildScannedCardPayload`.
- `sessionDestination` param may remain on the hook signature for backward compat but **must not** trigger network I/O in `handleCardScanned`.
## Implementation shape (verified against `use-scanner-queue.js`)
**Remove auto-route** — delete the block after `setScannedCards(nextQueue)`:
```js
// DELETE lines ~148-168 (sessionDestination guard + routeScannedCardToDestination)
```
**Replace `handleCardScanned` with enqueue-only:**
```js
const handleCardScanned = (cardData) => {
setAutoRouteError(null);
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
scannedCards,
cardData,
scanDefaults
);
setScannedCards(nextQueue);
setSelectedCards((prev) => new Set([...prev, cardEntry.id]));
persistCart(nextQueue, new Set([...selectedCards, cardEntry.id]));
};
```
Add `loadScannerCart()` / `saveScannerCart({ scannedCards, selectedCardIds })` / `clearScannerCartStorage()` in `scanner-session.js`.
Hydrate on mount in `useScannerQueue` via `useState` initializer + `useEffect` debounced save on `[scannedCards, selectedCards]` changes.
**Checkout helpers** (used by Brief 4 footer CTAs):
```js
const commitSelectedToOwned = async () => handleBulkAction('owned');
const commitSelectedToCollection = async (collectionId) =>
handleBulkAction('collection', collectionId);
```
Export these plus `unprocessedCount` derived helper: `scannedCards.filter(c => !c.processed).length`.
After successful `handleBulkAction`, remove committed rows from `scannedCards` entirely (not `processed: true`) per D1/D6 — cart holds only uncommitted items.
## Acceptance criteria
- [ ] `handleCardScanned` performs zero `fetch` calls (no `routeScannedCardToDestination`)
- [ ] New enqueue auto-adds card `id` to `selectedCards`
- [ ] Cart + selection survive `sessionStorage` round-trip within a tab (`test/lib/scanner-session.test.js`)
- [ ] `test/lib/use-scanner-queue.test.js`: mock `scanner-route-api` and assert `handleCardScanned` never calls route helpers; assert `handleBulkAction('owned')` calls `addScannedCardToOwned` once per selected card
- [ ] `clearScannerCartStorage()` clears persisted state
- [ ] Existing `test/lib/scanner-session.test.js` cases still pass
- [ ] No scope expansion
## Rationale (≤3 sentences)
Cart semantics are pure client state and can land before any UI work. Separating sessionStorage from the existing `localStorage` destination prefs avoids D7/localStorage anti-pattern. Removing committed rows instead of flipping `processed` simplifies the checkout sheet list.

View file

@ -0,0 +1,124 @@
---
convoy: scanner-mobile-checkout
brief_number: 3
depends_on: [2]
recommended_model: composer-2.5-fast
model_tier: fast
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
cross_brief_commitments:
- brief: 4
description: |
`ScannerCamera` accepts orchestration props wired by Brief 4:
`onBack`, `onOpenCheckout`, `onGalleryIdentify`, `latestPeekCard`,
`cartCount`, `isCheckoutOpen`, `verificationPausedRef`. Defaults/no-ops
keep the component renderable before Brief 4 lands. Brief 4 owns sheet
open state and back-guard modal.
- brief: 4
description: |
`useScannerIdentification` exports `identifyFromGalleryFile(file)`
thin adapter only; does not modify `pages/api/scan/identify.js` or
`lib/scanner-card-identify.js` server contract.
---
# Brief 3: Camera chrome, facing mode, scan peek, gallery adapter
## Goal (1 sentence)
Refactor `ScannerCamera` into full-bleed immersive glass chrome (top/bottom bars, flash, camera swap, Review N pill, scan peek, gallery) and add rear/front `facingMode` support in `useCameraScanner`.
## Files in scope (do not edit anything else)
- `components/scanner/ScannerCamera.js`
- `components/scanner/ScannerCountPill.js`
- `components/scanner/ScannerScanPeek.js` (new)
- `components/scanner/ScannerToast.js`
- `lib/use-camera-scanner.js`
- `lib/use-scanner-identification.js` (gallery adapter + pause contract only)
## Conventions to follow
- Glass surfaces: `<GlassSurface tint="mid">` per UX brief; migrate hardcoded `rgba(0,0,0,0.55)` in `ScannerCamera` to tokens.
- `ScannerToast`: replace `ICON_MAP` Unicode glyphs with inline SVG check/error (design-direction checklist).
- `ScannerCountPill`: evolve to gradient **Review {N}** pill; keep `aria-label={`Review ${count} scanned cards`}`.
- Flash: `flash.flashSupported && facingMode === 'environment'` (UX spec). Hide on front camera; turning to front must call `toggleFlash` off if torch was on.
- Peek: new `ScannerScanPeek.js` — 3s auto-dismiss, `pointer-events-none` wrapper, single child button `pointer-events-auto`, tap calls `onOpenCheckout`.
- Copy: toast message `"{card.name} added"` with `role="status"`.
- Do **not** mount checkout sheet here — call `onOpenCheckout` only.
- Identification pipeline files (`scanner-card-identify.js`, API routes) are out of scope.
## `useCameraScanner` shape (verified)
Current `startCamera` hardcodes `facingMode: 'environment'` (line ~119). Extend:
```js
export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef, facingMode = 'environment' }) {
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
// startCamera uses activeFacingMode in getUserMedia constraints
const switchFacingMode = useCallback(() => {
setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
}, []);
// useEffect: when activeFacingMode changes and stream exists, stopCamera + startCamera
return { /* existing */, facingMode: activeFacingMode, switchFacingMode };
}
```
`verificationPausedRef` early-return already exists at line ~91 in the tracking interval — no change needed.
## Gallery adapter (verified — no existing file-picker path)
Add to `useScannerIdentification` return object:
```js
const identifyFromGalleryFile = async (file) => {
if (!file || verificationPausedRef?.current) return;
const imageData = await readFileToImageData(file); // canvas draw; local helper in same file
const authHeaders = getScanAuthHeaders();
const result = await tryLayer1TextIdentify(imageData, authHeaders);
const outcome = resolveIdentifyOutcome(result);
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
await applyIdentifyOutcome(syntheticTracker, imageData, outcome);
};
```
Import `tryLayer1TextIdentify`, `resolveIdentifyOutcome` from `./scanner-card-identify.js` (already exported — verified).
## `ScannerCamera` props contract (for Brief 4)
```js
export default function ScannerCamera({
queue,
camera,
identification,
onBack,
onOpenCheckout,
onGalleryIdentify, // (file) => identification.identifyFromGalleryFile(file)
latestPeekCard, // most recent unprocessed queue entry or null
cartCount,
isCheckoutOpen,
verificationPausedRef,
}) { /* ... */ }
```
Remove: `deckMode`, `sessionDestination`, `onStopSession`, phase-based stop button. Back button calls `onBack` (Brief 4 shows leave modal).
Layout: viewport `min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh]`; top bar with back / "Scan Cards" / gallery file input; bottom bar flash | status | Review N.
## Acceptance criteria
- [ ] `switchFacingMode` restarts stream with toggled `facingMode`; flash hidden on `user`
- [ ] `ScannerScanPeek` auto-dismisses ~3s; tap invokes `onOpenCheckout`
- [ ] `ScannerToast` uses SVG icons, not Unicode glyphs
- [ ] `identifyFromGalleryFile` calls existing `tryLayer1TextIdentify` — no API route changes
- [ ] `ScannerCountPill` hidden when `count <= 0`; shows gradient Review pill when `count > 0`
- [ ] No hex literals added in edited `.js` files
- [ ] No scope expansion
## Rationale (≤3 sentences)
Camera chrome files are disjoint from `pages/scanner.js`, so this brief can land after the cart model without file conflicts. Gallery needs a thin hook adapter because no file-picker path exists today (boot-the-brief finding). Brief 4 wires props and pause ref when the sheet opens.

View file

@ -0,0 +1,159 @@
---
convoy: scanner-mobile-checkout
brief_number: 4
depends_on: [1, 2, 3]
recommended_model: composer-2.5-fast
model_tier: fast
files:
- pages/scanner.js
- pages/login.js
- components/scanner/ScannerCheckoutSheet.js
- components/scanner/ScannerReview.js
- components/scanner/ReviewCardItem.js
- test/components/ScannerCheckoutSheet.test.js
cross_brief_commitments:
- brief: 1
description: |
Passes `chrome="immersive"` to `<Layout>` on authenticated `/scanner`
(mobile immersive; desktop uses default chrome per D3).
- brief: 2
description: |
Calls `clearScannerCartStorage()` on confirmed leave. Uses
`commitSelectedToOwned` / `commitSelectedToCollection` from queue hook.
Does not reimplement sessionStorage persistence.
- brief: 3
description: |
Wires `ScannerCamera` props (`onBack`, `onOpenCheckout`, `latestPeekCard`,
`onGalleryIdentify`, `cartCount`, `isCheckoutOpen`, `verificationPausedRef`).
Sets `verificationPausedRef.current = true` when checkout sheet or List
picker modal is open (mobile); desktop side panel does not pause (UX spec).
deletes: []
---
# Brief 4: Page orchestration + checkout sheet
## Goal (1 sentence)
Collapse `/scanner` to scanning-first immersive flow, wire checkout sheet (mobile) / side panel (desktop), auth return URL (D2), and commit CTAs using vocab constants.
## Files in scope (do not edit anything else)
- `pages/scanner.js`
- `pages/login.js`
- `components/scanner/ScannerCheckoutSheet.js` (new)
- `components/scanner/ScannerReview.js` (desktop `md+` side panel only)
- `components/scanner/ReviewCardItem.js`
- `test/components/ScannerCheckoutSheet.test.js` (new)
## Conventions to follow
- **Vocab:** `import { VOCAB } from '../lib/collection-vocabulary.js'` — primary `VOCAB.ADD_TO_MY_COLLECTION`, secondary `VOCAB.ADD_TO_LIST`. Never "Save to binder" / "Mark Owned".
- **Sheet shell:** copy `ScannerDisambiguation.js` pattern (`fixed inset-0`, `glass-panel-strong`, `useFocusTrap`, Escape dismiss, `z-40` — disambiguation stays `z-50`).
- **Modal primitive:** back-guard and List picker use `<Modal>` from `components/ui/Modal.js` (not `window.confirm`).
- **Selection:** checkboxes always visible; all unprocessed IDs selected on first sheet open; new enqueue while open auto-selects (Brief 2 + local effect).
- **Pause:** `verificationPausedRef.current = isCheckoutOpen || isListPickerOpen || identification.disambiguation` on mobile only.
- **Auth:** `getUserFromRequest` not needed (page is client-only). Login redirect:
```js
// pages/scanner.js — replace router.push('/login')
router.push(`/login?returnUrl=${encodeURIComponent('/scanner')}`);
// pages/login.js — after successful login, before role redirect:
const returnUrl = typeof router.query.returnUrl === 'string' ? router.query.returnUrl : null;
if (returnUrl && returnUrl.startsWith('/')) {
router.push(returnUrl);
return;
}
```
- **D1:** after commit, stay on camera; empty cart closes sheet and resumes scanning.
- **D4:** no deck destination in checkout UI.
- **D6:** partial commit keeps sheet open with remaining rows.
## `pages/scanner.js` shape (verified — replace phase machine)
Remove `phase` state (`setup` / `scanning` / `review`). Default: camera always mounted when authenticated.
```js
export default function Scanner() {
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [isListPickerOpen, setIsListPickerOpen] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const verificationPausedRef = useRef(false);
// useScannerSession: keep scanDefaults only; drop Setup UI (D3)
const queue = useScannerQueue({ user, scanDefaults, deckMode: null });
// ...
return (
<Layout user={user} chrome="immersive">
<div className="relative flex flex-col md:flex-row h-full min-h-0 max-md:fixed max-md:inset-0">
<ScannerCamera
queue={queue}
camera={camera}
identification={identification}
onBack={() => unprocessedCount > 0 ? setShowLeaveModal(true) : router.back()}
onOpenCheckout={() => setIsCheckoutOpen(true)}
onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)}
latestPeekCard={queue.scannedCards[0] ?? null}
cartCount={unprocessedCount}
isCheckoutOpen={isCheckoutOpen}
verificationPausedRef={verificationPausedRef}
/>
{/* Mobile sheet */}
<div className="md:hidden">
{isCheckoutOpen && (
<ScannerCheckoutSheet
queue={queue}
collections={queue.collections}
onClose={() => setIsCheckoutOpen(false)}
onOpenListPicker={() => setIsListPickerOpen(true)}
/>
)}
</div>
{/* Desktop side panel */}
<div className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:border-l" style={{ borderColor: 'var(--border)' }}>
<ScannerReview queue={queue} collections={queue.collections} variant="side-panel" />
</div>
<Modal open={showLeaveModal} /* ... */ />
<Modal open={isListPickerOpen} title="Choose a List" /* ... */ />
</div>
</Layout>
);
}
```
Sync `verificationPausedRef` in `useEffect` when `isCheckoutOpen || isListPickerOpen || identification.disambiguation` (mobile: checkout open; desktop: only list picker + disambiguation per UX).
## `ScannerCheckoutSheet` responsibilities
- Header: `{N} cards scanned` + low-confidence subline when any row `confidence < 70`
- Rows: `ReviewCardItem` with checkbox, confidence ring, qty stepper, overflow menu (remove / condition / foil)
- Footer: primary `VOCAB.ADD_TO_MY_COLLECTION` with `(N)` when subset selected; secondary `VOCAB.ADD_TO_LIST`
- Commit error: inline banner in footer, rows stay selected
- `role="dialog"` `aria-modal="true"` `aria-labelledby`
- On last item removed → `onClose()` + resume camera
## `ReviewCardItem` changes
- Add optional `selected`, `onToggleSelect`, `showCheckbox`, `confidence` ring props
- Remove inline destination picker from row (destinations in sheet footer only)
- `aria-label` on checkbox: `Select ${card.name}`; confidence in `aria-label` when `< 85`
## `ScannerReview` changes
- Support `variant="side-panel"` for desktop persistent panel (same row/footer semantics as sheet, no scrim)
- Remove full-page empty state / "New Session" / deck progress from default path
## Acceptance criteria
- [ ] Opening `/scanner` authenticated shows camera immediately — no Setup phase (D3)
- [ ] Unauthenticated redirect includes `returnUrl=/scanner`; login honors it (D2)
- [ ] Mobile checkout sheet opens from Review N and peek; pauses identification under sheet
- [ ] `commitSelectedToOwned` / List commit work; committed rows leave cart; camera resumes when empty (D1/D6)
- [ ] Back with unprocessed cart shows `<Modal>` leave confirm; confirm calls `clearScannerCartStorage()` + `router.back()`
- [ ] Desktop `md+` shows side panel with same CTA semantics; Layout sidebar visible
- [ ] `test/components/ScannerCheckoutSheet.test.js`: renders header count, disabled primary when nothing selected, vocab labels present
- [ ] No scope expansion
## Rationale (≤3 sentences)
Integration brief owns the sole `pages/scanner.js` writer after Layout and camera contracts exist. Login `returnUrl` is a two-line change required by D2 and verified absent today. Checkout sheet and desktop panel share row components but split shells to stay under 400 LOC.

View file

@ -597,9 +597,15 @@ function NavigationContent({ user, router, onItemClick }) {
);
}
export default function Layout({ children, user = null, showSearch = false }) {
export default function Layout({
children,
user = null,
showSearch = false,
chrome = 'default',
}) {
const router = useRouter();
const { theme, toggleTheme } = useTheme();
const isImmersive = chrome === 'immersive';
const [searchQuery, setSearchQuery] = useState('');
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Command palette (redesign-v2 sub-convoy #3, 2026-06-04). Opens
@ -669,11 +675,15 @@ export default function Layout({ children, user = null, showSearch = false }) {
// like one large page" is achieved by the hearth gradient
// showing through the gap. Mobile (< 768px) keeps the current
// flush layout because the sidebar is `hidden md:flex` anyway.
<div className="flex h-screen md:p-4 md:gap-4">
<div
className={`flex h-screen md:p-4 md:gap-4${isImmersive ? ' max-md:p-0' : ''}`}
>
{/* Mobile Navigation - Bottom bar for mobile */}
{!isImmersive && (
<MobileNavigation
onMenuOpen={() => setIsMobileMenuOpen(true)}
/>
)}
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
{isMobileMenuOpen && (
@ -971,7 +981,9 @@ export default function Layout({ children, user = null, showSearch = false }) {
content). `min-w-0` prevents flex children from blowing past
the column width when long card titles or table cells refuse
to wrap. */}
<div className="flex-1 flex flex-col pb-16 md:pb-0 md:gap-4 min-w-0">
<div
className={`flex-1 flex flex-col pb-16 md:pb-0 md:gap-4 min-w-0${isImmersive ? ' max-md:pb-0' : ''}`}
>
{/* Global top bar redesign-v2 sub-convoy #3 (2026-06-04).
Rendered for ALL authenticated pages so the search +
notifications + user-menu chrome is consistent everywhere
@ -983,11 +995,13 @@ export default function Layout({ children, user = null, showSearch = false }) {
be removed in a cleanup convoy along with the dead
`searchQuery` state. */}
{user && (
<div className={isImmersive ? 'max-md:hidden' : undefined}>
<TopSearchBar
user={user}
notificationCount={user?.unreadNotifications ?? 0}
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
/>
</div>
)}
{/* Page Content */}

View file

@ -1,8 +1,21 @@
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { formatProcessedDestination } from '../../lib/collection-vocabulary.js';
import { useEffect, useRef, useState } from 'react';
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
function confidencePercent(confidence) {
if (confidence == null || Number.isNaN(confidence)) return null;
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
}
function confidenceRingColor(pct) {
if (pct == null) return 'var(--border)';
if (pct < 70) return 'var(--accent-ember)';
if (pct < 85) return 'var(--color-warning)';
return 'var(--color-success)';
}
export default function ReviewCardItem({
card,
collections,
@ -14,21 +27,70 @@ export default function ReviewCardItem({
onUndo,
isAdding = false,
sessionDestination,
selected = false,
onToggleSelect,
showCheckbox = false,
confidence,
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
const resolvedConfidence = confidence ?? card.confidence;
const confidencePct = confidencePercent(resolvedConfidence);
const destinationLabel = card.processedAction
? formatProcessedDestination(card.processedAction)
: sessionDestination?.label ?? 'My Collection';
: sessionDestination?.label ?? VOCAB.MY_COLLECTION;
useEffect(() => {
if (!menuOpen) return undefined;
const handlePointerDown = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [menuOpen]);
const checkboxAriaLabel =
confidencePct != null
? `Select ${card.name}, ${confidencePct}% confidence`
: `Select ${card.name}`;
return (
<div
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
style={{
borderLeft: !card.processed ? '3px solid var(--accent-ember)' : '3px solid transparent',
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : '3px solid transparent',
}}
>
<div className="flex gap-3">
{/* Thumbnail */}
<div className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden">
{showCheckbox && !card.processed && (
<label
className="flex-shrink-0 flex items-center justify-center"
style={{ minWidth: 44, minHeight: 44 }}
>
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
className="w-5 h-5 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
aria-label={checkboxAriaLabel}
/>
</label>
)}
<div
className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden"
style={{
boxShadow: showCheckbox
? `0 0 0 2px ${confidenceRingColor(confidencePct)}`
: undefined,
}}
>
{card.image_url ? (
<img
src={card.image_url}
@ -48,22 +110,21 @@ export default function ReviewCardItem({
)}
</div>
{/* Card info + controls */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{card.processed ? (
{!showCheckbox && card.processed ? (
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
) : !showCheckbox && !card.processed ? (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: 'var(--accent-flame)' }}
aria-hidden="true"
/>
)}
) : null}
<h3
className="font-semibold text-sm truncate"
style={{ color: 'var(--text-primary)' }}
@ -74,10 +135,80 @@ export default function ReviewCardItem({
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
</p>
{showCheckbox && confidencePct != null && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{confidencePct}% match
</p>
)}
</div>
{/* Remove button (unprocessed only) */}
{!card.processed && (
{showCheckbox && !card.processed ? (
<div className="relative flex-shrink-0" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
style={{ color: 'var(--text-secondary)', minWidth: 44, minHeight: 44 }}
aria-label={`More actions for ${card.name}`}
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01" />
</svg>
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1 glass-panel-strong shadow-lg"
style={{ border: '1px solid var(--border)' }}
>
<button
type="button"
role="menuitem"
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
style={{ color: 'var(--text-primary)', minHeight: 44 }}
onClick={() => {
onRemove();
setMenuOpen(false);
}}
>
Remove
</button>
<div className="px-3 py-2" role="none">
<label className="text-xs block mb-1" style={{ color: 'var(--text-secondary)' }}>
Condition
</label>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="w-full px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: 44,
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
style={{ color: 'var(--text-primary)', minHeight: 44 }}
onClick={() => onUpdateMetadata({ isFoil: !card.isFoil })}
>
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
</button>
</div>
)}
</div>
) : !card.processed ? (
<button
type="button"
onClick={onRemove}
@ -89,7 +220,7 @@ export default function ReviewCardItem({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
) : null}
</div>
{card.processed ? (
@ -111,14 +242,14 @@ export default function ReviewCardItem({
</div>
) : (
<>
{/* Metadata summary line */}
{!showCheckbox && (
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
</p>
)}
{/* Inline edit controls */}
{!showCheckbox && (
<div className="flex flex-col sm:flex-row gap-2 mt-2">
{/* Condition */}
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="sr-only">Condition for {card.name}</span>
<select
@ -140,7 +271,6 @@ export default function ReviewCardItem({
</select>
</label>
{/* Foil toggle */}
<label
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
@ -154,9 +284,10 @@ export default function ReviewCardItem({
/>
<span>Foil</span>
</label>
</div>
)}
{/* Quantity ± */}
<div className="inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 mt-2">
<button
type="button"
onClick={onDecrement}
@ -188,7 +319,7 @@ export default function ReviewCardItem({
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--accent-ember)',
color: '#ffffff',
color: 'var(--text-primary-dark)',
minWidth: '44px',
minHeight: '44px',
}}
@ -197,10 +328,8 @@ export default function ReviewCardItem({
+
</button>
</div>
</div>
{/* Destination override */}
{(collections.length > 0 || decks.length > 0) && (
{!showCheckbox && collections?.length > 0 && (
<div className="mt-2">
<select
value={card.destinationOverride || ''}
@ -229,7 +358,7 @@ export default function ReviewCardItem({
{c.name}
</option>
))}
{decks.map((d) => (
{decks?.map((d) => (
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
{d.name}
</option>

View file

@ -1,71 +1,167 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import GlassSurface from '../ui/GlassSurface.js';
import ScannerToast from './ScannerToast.js';
import ScannerCountPill from './ScannerCountPill.js';
import DeckModeIndicator from './DeckModeIndicator.js';
import ScannerScanPeek from './ScannerScanPeek.js';
import ScannerDisambiguation from './ScannerDisambiguation.js';
import { useScannerSound } from '../../lib/use-scanner-sound.js';
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
const TOAST_DURATION_MS = 2500;
function overlayBorderColor(status) {
if (status === 'scanned') return 'var(--color-info, #3B82F6)';
return 'var(--color-success, #10B981)';
function DetectionFrame({ card, videoMetrics }) {
return (
<div
className="absolute pointer-events-none scan-frame"
style={{
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
}}
>
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
<span className="scan-bracket scan-bracket-bl" aria-hidden="true" />
<span className="scan-bracket scan-bracket-br" aria-hidden="true" />
<span className="scan-line" aria-hidden="true" />
<style jsx>{`
.scan-bracket {
position: absolute;
width: 22px;
height: 22px;
border-color: var(--accent-ember);
border-style: solid;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent-flame) 55%, transparent));
}
.scan-bracket-tl { top: 0; left: 0; border-width: 3px 0 0 3px; }
.scan-bracket-tr { top: 0; right: 0; border-width: 3px 3px 0 0; }
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 3px 3px; }
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 3px 3px 0; }
.scan-line {
position: absolute;
left: 8%;
right: 8%;
height: 2px;
top: 0;
background: linear-gradient(
90deg,
transparent,
var(--accent-ember),
var(--accent-flame),
var(--accent-ember),
transparent
);
box-shadow: 0 0 10px color-mix(in srgb, var(--accent-flame) 70%, transparent);
animation: scan-sweep 1.6s var(--motion-ease-linear, linear) infinite;
}
@keyframes scan-sweep {
0% { top: 8%; opacity: 0.35; }
50% { top: 88%; opacity: 1; }
100% { top: 8%; opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.scan-line {
animation: none;
top: 50%;
opacity: 0.85;
}
}
`}</style>
</div>
);
}
function overlayGlowColor(status) {
if (status === 'scanned') return 'rgba(59, 130, 246, 0.31)';
return 'rgba(16, 185, 129, 0.31)';
function ChromeIconButton({
onClick,
label,
pressed,
busy,
children,
className = '',
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${className}`}
style={{
minWidth: 44,
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={label}
aria-pressed={pressed}
aria-busy={busy || undefined}
disabled={busy}
>
<GlassSurface
as="span"
tint="mid"
rim="subtle"
blur="mid"
className="w-full h-full rounded-full flex items-center justify-center"
style={{ color: 'var(--text-primary)' }}
>
{children}
</GlassSurface>
</button>
);
}
export default function ScannerCamera({
queue,
camera,
identification,
deckMode,
onReview,
onStopSession,
sessionDestination,
onBack = () => {},
onOpenCheckout = () => {},
onGalleryIdentify,
latestPeekCard = null,
cartCount,
isCheckoutOpen = false,
verificationPausedRef,
}) {
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
const [galleryBusy, setGalleryBusy] = useState(false);
const toastTimerRef = useRef(null);
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
const galleryInputRef = useRef(null);
const {
videoRef,
canvasRef,
detectionCanvasRef,
isStreaming,
isDetecting,
trackedCards,
videoMetrics,
startCamera,
stopCamera,
streamRef,
facingMode,
switchFacingMode,
} = camera;
const sound = useScannerSound();
// Requires useCameraScanner to expose streamRef — gracefully degrades to flash-unsupported if absent
const flash = useScannerFlash(streamRef);
// Auto-start camera on mount
const resolvedCartCount = cartCount ?? queue.scannedCards?.length ?? 0;
useEffect(() => {
startCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
}, []);
// React to newly scanned cards
const currentCount = queue.scannedCards?.length ?? 0;
useEffect(() => {
if (currentCount > prevCountRef.current) {
const newest = queue.scannedCards[0];
const cardName = newest?.name || newest?.card?.name || 'Card scanned';
const cardName = newest?.name || newest?.card?.name || 'Card';
navigator.vibrate?.(50);
sound.playSuccess();
clearTimeout(toastTimerRef.current);
setToast({ message: cardName, visible: true, type: 'success' });
setToast({ message: `${cardName} added`, visible: true, type: 'success' });
toastTimerRef.current = setTimeout(() => {
setToast((t) => ({ ...t, visible: false }));
}, TOAST_DURATION_MS);
@ -77,29 +173,60 @@ export default function ScannerCamera({
return () => clearTimeout(toastTimerRef.current);
}, []);
const handleStop = useCallback(() => {
stopCamera();
onStopSession?.();
}, [stopCamera, onStopSession]);
useEffect(() => {
if (facingMode === 'user' && flash.flashOn) {
flash.toggleFlash();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to facing mode changes
}, [facingMode]);
const handleGalleryClick = useCallback(() => {
galleryInputRef.current?.click();
}, []);
const handleGalleryChange = useCallback(
async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const identify =
onGalleryIdentify ||
identification.identifyFromGalleryFile?.bind(identification);
if (!identify) return;
setGalleryBusy(true);
try {
await identify(file);
} finally {
setGalleryBusy(false);
}
},
[identification, onGalleryIdentify]
);
const foundCards = trackedCards.filter(
(c) => c.status === 'confirmed' || c.status === 'scanned'
);
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
const showFlash = flash.flashSupported && facingMode === 'environment';
const scanStatus = isCheckoutOpen
? 'Checkout open'
: isStreaming
? 'Scanning…'
: 'Starting camera…';
const switchCameraLabel =
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
return (
<div className="flex flex-col w-full h-full gap-3">
{/* Camera viewport */}
<div className="relative w-full min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh] overflow-hidden">
<div
className="relative w-full overflow-hidden rounded-2xl flex-1"
style={{
minHeight: '60vh',
aspectRatio: '4 / 3',
backgroundColor: 'var(--bg-tertiary)',
}}
className="absolute inset-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
{/* Video */}
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover"
@ -110,140 +237,43 @@ export default function ScannerCamera({
aria-label="Card scanner camera feed"
/>
{/* Bounding box overlays */}
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
<div
key={card.id}
className="absolute rounded-lg transition-all duration-200 pointer-events-none"
style={{
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
borderWidth: 3,
borderStyle: 'solid',
borderColor: overlayBorderColor(card.status),
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
}}
>
<div
className="absolute -top-7 left-0 px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-md whitespace-nowrap"
style={{ backgroundColor: overlayBorderColor(card.status) }}
>
{card.status === 'scanned' ? 'Scanned' : 'Found'}
</div>
</div>
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
))}
{/* LIVE indicator */}
{isStreaming && (
<div className="absolute top-3 right-3 z-10">
<div
<div className="absolute top-3 right-3 z-10 md:hidden">
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
color: '#fff',
}}
style={{ color: 'var(--text-primary)' }}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error, #EF4444)' }}
style={{ backgroundColor: 'var(--color-error)' }}
aria-hidden="true"
/>
LIVE
</div>
</GlassSurface>
</div>
)}
{/* Toast */}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
{/* Bottom controls */}
{isStreaming && (
<div className="absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-4 px-4">
{/* Sound toggle */}
<button
type="button"
onClick={() => sound.setEnabled((v) => !v)}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={sound.enabled ? 'Mute scan sounds' : 'Unmute scan sounds'}
aria-pressed={sound.enabled}
>
{sound.enabled ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
)}
</button>
{/* Stop button */}
<button
type="button"
onClick={handleStop}
className="w-16 h-16 rounded-full flex items-center justify-center shadow-2xl transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(239, 68, 68, 0.9)',
border: '3px solid rgba(255, 255, 255, 0.3)',
}}
aria-label="Stop scanning"
>
<div
className="w-6 h-6 rounded-sm"
style={{ backgroundColor: '#fff' }}
aria-hidden="true"
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
</button>
{/* Flash toggle */}
{flash.flashSupported && (
<button
type="button"
onClick={flash.toggleFlash}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: flash.flashOn
? 'rgba(255, 171, 64, 0.85)'
: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
aria-pressed={flash.flashOn}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</button>
)}
</div>
)}
{/* Loading state (before stream starts) */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
@ -263,23 +293,168 @@ export default function ScannerCamera({
)}
</div>
{/* Hidden canvases for detection */}
<div
className="absolute top-0 left-0 right-0 z-30 px-3 pb-2"
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}
>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
>
<ChromeIconButton onClick={onBack} label="Leave scanner">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
</ChromeIconButton>
<h1
className="text-base font-semibold truncate"
style={{ color: 'var(--text-primary)' }}
>
Scan Cards
</h1>
<ChromeIconButton
onClick={handleGalleryClick}
label="Choose image from gallery"
busy={galleryBusy}
>
{galleryBusy ? (
<div
className="w-4 h-4 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
</ChromeIconButton>
</GlassSurface>
</div>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
onChange={handleGalleryChange}
/>
{isStreaming && (
<div
className="absolute bottom-0 left-0 right-0 z-30 px-3 pt-2"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))' }}
>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
>
<div className="flex items-center gap-2">
{showFlash ? (
<ChromeIconButton
onClick={flash.toggleFlash}
label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
pressed={flash.flashOn}
className={flash.flashOn ? 'ring-2' : ''}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</ChromeIconButton>
) : (
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
<path d="M8 12h8" />
<path d="m16 9 3 3-3 3" />
<path d="m8 15-3-3 3-3" />
</svg>
</ChromeIconButton>
)}
{showFlash && (
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
<path d="M8 12h8" />
<path d="m16 9 3 3-3 3" />
<path d="m8 15-3-3 3-3" />
</svg>
</ChromeIconButton>
)}
</div>
<p
className="text-xs font-medium truncate text-center flex-1 px-1"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
>
{scanStatus}
</p>
<ScannerCountPill
count={resolvedCartCount}
onOpenCheckout={onOpenCheckout}
/>
</GlassSurface>
</div>
)}
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
{/* Count pill */}
<ScannerCountPill count={currentCount} onReview={onReview} />
{/* Deck mode progress */}
{deckMode && (
<DeckModeIndicator
current={currentCount}
target={deckMode.targetSize}
game={deckMode.game}
/>
)}
{/* Disambiguation overlay */}
{identification.disambiguation && (
<ScannerDisambiguation
disambiguation={identification.disambiguation}

View file

@ -0,0 +1,274 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useFocusTrap } from '../../lib/use-focus-trap';
import { VOCAB } from '../../lib/collection-vocabulary.js';
import { Button } from '../ui';
import ReviewCardItem from './ReviewCardItem';
function confidencePercent(confidence) {
if (confidence == null || Number.isNaN(confidence)) return null;
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
}
function CheckoutFooter({
selectedCount,
isProcessing,
commitError,
onCommitOwned,
onOpenListPicker,
}) {
const selectHintId = 'checkout-select-hint';
return (
<div
className="px-4 sm:px-6 py-4 glass-panel-strong"
style={{
borderTop: '1px solid var(--border)',
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
}}
>
{commitError && (
<div
className="mb-3 px-3 py-2 rounded-lg text-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
color: 'var(--color-error)',
}}
role="alert"
>
{commitError}
</div>
)}
{selectedCount === 0 && (
<p
id={selectHintId}
className="mb-2 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
Select at least one card
</p>
)}
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onCommitOwned}
disabled={selectedCount === 0}
loading={isProcessing}
aria-describedby={selectedCount === 0 ? selectHintId : undefined}
>
{selectedCount > 0
? `${VOCAB.ADD_TO_MY_COLLECTION} (${selectedCount})`
: VOCAB.ADD_TO_MY_COLLECTION}
</Button>
<Button
variant="ghost"
size="lg"
className="w-full mt-2"
onClick={onOpenListPicker}
disabled={selectedCount === 0 || isProcessing}
>
{VOCAB.ADD_TO_LIST}
</Button>
</div>
);
}
export function ScannerCheckoutContent({
queue,
onOpenListPicker,
onEmpty,
headerId = 'checkout-sheet-title',
}) {
const [commitError, setCommitError] = useState(null);
const initialSelectionDoneRef = useRef(false);
const cartCards = queue.scannedCards.filter((card) => !card.processed);
const selectedCount = cartCards.filter((card) => queue.selectedCards.has(card.id)).length;
const lowConfidenceCount = cartCards.filter((card) => {
const pct = confidencePercent(card.confidence);
return pct != null && pct < 70;
}).length;
useEffect(() => {
if (initialSelectionDoneRef.current) return;
cartCards.forEach((card) => {
if (!queue.selectedCards.has(card.id)) {
queue.toggleCardSelection(card.id);
}
});
initialSelectionDoneRef.current = true;
}, [cartCards, queue]);
useEffect(() => {
if (cartCards.length === 0) {
onEmpty?.();
}
}, [cartCards.length, onEmpty]);
const handleCommitOwned = async () => {
setCommitError(null);
const beforeIds = cartCards
.filter((card) => queue.selectedCards.has(card.id))
.map((card) => card.id);
if (beforeIds.length === 0) return;
const successfulIds = await queue.commitSelectedToOwned();
if (!successfulIds?.length) {
setCommitError('Could not add cards. Try again.');
}
};
return (
<>
<div className="px-4 sm:px-6 pb-2">
<h2
id={headerId}
className="text-lg font-semibold"
style={{ color: 'var(--text-primary)' }}
>
{cartCards.length} {cartCards.length === 1 ? 'card' : 'cards'} scanned
</h2>
{lowConfidenceCount > 0 && (
<p className="text-sm mt-1" style={{ color: 'var(--accent-ember)' }}>
{lowConfidenceCount} {lowConfidenceCount === 1 ? 'needs' : 'need'} review
</p>
)}
</div>
<div
className="flex-1 overflow-y-auto px-4 sm:px-6 py-3"
style={{ minHeight: 0 }}
>
{cartCards.map((card) => (
<ReviewCardItem
key={card.id}
card={card}
selected={queue.selectedCards.has(card.id)}
onToggleSelect={() => queue.toggleCardSelection(card.id)}
showCheckbox
confidence={card.confidence}
onIncrement={() => queue.incrementCardQuantity(card.id)}
onDecrement={() => queue.decrementCardQuantity(card.id)}
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
onRemove={() => queue.removeScannedCard(card.id)}
isAdding={queue.addingCardIds?.has(card.id)}
/>
))}
</div>
<CheckoutFooter
selectedCount={selectedCount}
isProcessing={queue.isProcessing}
commitError={commitError}
onCommitOwned={handleCommitOwned}
onOpenListPicker={onOpenListPicker}
/>
</>
);
}
export default function ScannerCheckoutSheet({
queue,
onClose,
onOpenListPicker,
trapActive = true,
}) {
const sheetRef = useFocusTrap(trapActive);
const backdropRef = useRef(null);
useEffect(() => {
const backdrop = backdropRef.current;
const sheet = sheetRef.current;
if (!backdrop || !sheet) return undefined;
requestAnimationFrame(() => {
backdrop.dataset.visible = 'true';
sheet.dataset.visible = 'true';
});
if (!trapActive) return undefined;
const handler = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose, sheetRef, trapActive]);
const handleBackdropClick = useCallback(
(e) => {
if (e.target === e.currentTarget) onClose();
},
[onClose]
);
return (
<div
ref={backdropRef}
className="fixed inset-0 z-40 checkout-backdrop"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="checkout-sheet-title"
>
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
>
<div className="relative flex items-center justify-center pt-3 pb-2 px-4">
<div
className="w-10 h-1 rounded-full"
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
aria-hidden="true"
/>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 flex items-center justify-center rounded-lg"
style={{
minWidth: 44,
minHeight: 44,
color: 'var(--text-secondary)',
}}
aria-label="Close cart"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={onOpenListPicker}
onEmpty={onClose}
/>
</div>
<style jsx>{`
.checkout-backdrop {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
-webkit-backdrop-filter 300ms ease-out;
}
.checkout-backdrop[data-visible='true'] {
background: var(--modal-scrim);
backdrop-filter: blur(var(--glass-blur-high));
-webkit-backdrop-filter: blur(var(--glass-blur-high));
}
.checkout-sheet {
max-height: 85vh;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.checkout-sheet[data-visible='true'] {
transform: translateY(0);
}
`}</style>
</div>
);
}

View file

@ -1,32 +1,25 @@
export default function ScannerCountPill({ count, onReview }) {
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
const handleClick = onOpenCheckout || onReview;
if (count <= 0) return null;
return (
<div className="glass-panel rounded-xl px-4 py-3 flex items-center justify-between">
<span
className="text-sm font-medium"
style={{ color: 'var(--text-primary)' }}
>
{count} {count === 1 ? 'card' : 'cards'} scanned
</span>
<button
type="button"
onClick={onReview}
className="text-sm font-medium underline-offset-2 hover:underline transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded px-2 py-1"
onClick={handleClick}
className="rounded-full px-4 py-2 text-sm font-semibold transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
background: 'var(--gradient-primary)',
color: 'var(--text-primary-dark)',
boxShadow: 'var(--ember-rim-pronounced)',
minHeight: 44,
minWidth: 44,
display: 'inline-flex',
alignItems: 'center',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={`Review ${count} scanned cards`}
>
Review
Review {count}
</button>
</div>
);
}

View file

@ -1,192 +1,48 @@
import { useMemo } from 'react';
import { Button } from '../ui';
import ReviewCardItem from './ReviewCardItem';
import { ScannerCheckoutContent } from './ScannerCheckoutSheet.js';
export default function ScannerReview({
queue,
collections,
decks,
deckMode,
onContinueScanning,
onNewSession,
sessionDestination,
variant,
onOpenListPicker,
}) {
const { scannedCards } = queue;
if (variant === 'side-panel') {
const cartCount = queue.scannedCards.filter((card) => !card.processed).length;
const unprocessedCards = useMemo(
() => scannedCards.filter((c) => !c.processed),
[scannedCards]
);
const processedCards = useMemo(
() => scannedCards.filter((c) => c.processed),
[scannedCards]
);
const deckProgress = deckMode
? { current: scannedCards.length, target: deckMode.targetSize }
: null;
const handleConfirmAll = () => {
if (unprocessedCards.length === 0) return;
const unprocessedIds = unprocessedCards.map((c) => c.id);
unprocessedIds.forEach((id) => queue.toggleCardSelection(id));
setTimeout(() => queue.handleBulkAction('owned'), 0);
};
if (scannedCards.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="flex flex-col h-full min-h-0 w-full" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
className="px-4 py-3"
style={{ borderBottom: '1px solid var(--border)' }}
>
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No cards to review
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
Cart
{cartCount > 0 && (
<span className="text-sm font-normal ml-1" style={{ color: 'var(--text-secondary)' }}>
({cartCount})
</span>
)}
</h2>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Scan some cards first, then come back to review and confirm them.
</div>
{cartCount === 0 ? (
<div className="flex-1 flex items-center justify-center px-4 py-8 text-center">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Scanned cards appear here. Use Review to commit when ready.
</p>
<Button variant="primary" size="lg" onClick={onContinueScanning}>
Start Scanning
</Button>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={onOpenListPicker}
headerId="checkout-side-panel-title"
/>
</div>
)}
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
Review{' '}
<span className="text-sm font-normal" style={{ color: 'var(--text-secondary)' }}>
({scannedCards.length} {scannedCards.length === 1 ? 'card' : 'cards'})
</span>
</h2>
<Button variant="ghost" size="sm" onClick={onContinueScanning}>
Continue
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
{/* Deck progress (if applicable) */}
{deckProgress && (
<div className="px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center justify-between text-sm mb-1.5">
<span style={{ color: 'var(--text-secondary)' }}>
Deck progress
</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{deckProgress.current} / {deckProgress.target}
</span>
</div>
<div
className="w-full h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
role="progressbar"
aria-valuenow={deckProgress.current}
aria-valuemin={0}
aria-valuemax={deckProgress.target}
aria-label="Deck building progress"
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, (deckProgress.current / deckProgress.target) * 100)}%`,
background: 'linear-gradient(90deg, var(--accent-ember), var(--accent-flame))',
}}
/>
</div>
</div>
)}
{/* Scrollable card list */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4" style={{ paddingBottom: '10rem' }}>
{/* Unprocessed cards first */}
{unprocessedCards.length > 0 && (
<div>
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Pending ({unprocessedCards.length})
</h3>
{unprocessedCards.map((card) => (
<ReviewCardItem
key={card.id}
card={card}
collections={collections}
decks={decks}
onIncrement={() => queue.incrementCardQuantity(card.id)}
onDecrement={() => queue.decrementCardQuantity(card.id)}
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
onRemove={() => queue.removeScannedCard(card.id)}
isAdding={false}
sessionDestination={sessionDestination}
/>
))}
</div>
)}
{/* Processed cards */}
{processedCards.length > 0 && (
<div className={unprocessedCards.length > 0 ? 'mt-4' : ''}>
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Confirmed ({processedCards.length})
</h3>
{processedCards.map((card) => (
<ReviewCardItem
key={card.id}
card={card}
collections={collections}
decks={decks}
onIncrement={() => {}}
onDecrement={() => {}}
onUpdateMetadata={() => {}}
onRemove={() => {}}
onUndo={() => queue.updateCardMetadata(card.id, { processed: false, processedAction: null })}
isAdding={false}
sessionDestination={sessionDestination}
/>
))}
</div>
)}
</div>
{/* Sticky bottom action bar */}
<div
className="sticky bottom-0 left-0 right-0 px-4 sm:px-6 py-4 glass-panel-strong"
style={{
borderTop: '1px solid var(--border)',
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
}}
>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={handleConfirmAll}
disabled={unprocessedCards.length === 0}
loading={queue.isProcessing}
>
{unprocessedCards.length === 0
? 'All cards confirmed'
: `Confirm All (${unprocessedCards.length})`}
</Button>
<button
type="button"
onClick={onNewSession}
className="w-full mt-2 py-2 text-sm font-medium text-center transition-opacity duration-150 hover:opacity-70"
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
>
New Session
</button>
</div>
</div>
);
return null;
}

View file

@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import GlassSurface from '../ui/GlassSurface.js';
const PEEK_DURATION_MS = 3000;
function formatConfidence(confidence) {
if (confidence == null || Number.isNaN(confidence)) return null;
const pct = confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
return `${pct}%`;
}
export default function ScannerScanPeek({ card, onOpenCheckout = () => {} }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timerId = window.setTimeout(() => {
setVisible(false);
}, PEEK_DURATION_MS);
return () => window.clearTimeout(timerId);
}, []);
if (!card || !visible) return null;
const cardName = card.name || card.card?.name || 'Card';
const thumbnail =
card.image_url ||
card.scanImageUrl ||
card.card?.image_url ||
null;
const confidenceLabel = formatConfidence(card.confidence);
return (
<div
className="absolute left-4 right-4 z-20 pointer-events-none"
style={{
bottom: 'calc(5.5rem + env(safe-area-inset-bottom, 0px))',
opacity: visible ? 1 : 0,
transform: `translateY(${visible ? '0' : '12px'})`,
transition: 'opacity 150ms ease, transform 150ms ease',
}}
>
<button
type="button"
onClick={onOpenCheckout}
className="w-full pointer-events-auto text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded-2xl"
style={{
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={`Open cart to review ${cardName}`}
>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-3 rounded-2xl px-3 py-2.5"
>
{thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element -- ephemeral scan thumbnail blob/url
<img
src={thumbnail}
alt=""
className="w-12 h-[4.25rem] object-cover rounded-md flex-shrink-0"
style={{ border: '1px solid var(--border)' }}
/>
) : (
<div
className="w-12 h-[4.25rem] rounded-md flex-shrink-0 flex items-center justify-center text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
aria-hidden="true"
>
?
</div>
)}
<div className="flex-1 min-w-0">
<p
className="text-sm font-semibold truncate"
style={{ color: 'var(--text-primary)' }}
>
{cardName}
</p>
{confidenceLabel && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{confidenceLabel} match
</p>
)}
</div>
</GlassSurface>
</button>
</div>
);
}

View file

@ -1,7 +1,65 @@
const ICON_MAP = {
success: { glyph: '✓', color: 'var(--color-success, #10B981)' },
error: { glyph: '✗', color: 'var(--color-error, #EF4444)' },
info: { glyph: '', color: 'var(--color-info, #3B82F6)' },
function ToastIcon({ type }) {
if (type === 'error') {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
}
if (type === 'info') {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
const ICON_COLORS = {
success: 'var(--color-success)',
error: 'var(--color-error)',
info: 'var(--color-info)',
};
/**
@ -12,7 +70,7 @@ const ICON_MAP = {
* auto-dismiss timer.
*/
export default function ScannerToast({ message, visible, type = 'success' }) {
const { glyph, color } = ICON_MAP[type] || ICON_MAP.success;
const color = ICON_COLORS[type] || ICON_COLORS.success;
return (
<div
@ -40,11 +98,11 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
}}
>
<span
className="text-sm font-bold flex-shrink-0"
className="flex-shrink-0"
style={{ color }}
aria-hidden="true"
>
{glyph}
<ToastIcon type={type} />
</span>
<span
className="text-sm font-medium truncate"

View file

@ -1,6 +1,7 @@
import { VOCAB } from './collection-vocabulary.js';
export const SCANNER_SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
export const SCANNER_CART_STORAGE_KEY = 'deckhearth:scanner-cart';
export const DEFAULT_SCANNER_DESTINATION = {
type: 'owned',
@ -43,6 +44,49 @@ export function saveScannerSession({ destination, gameFilter, scanDefaults }) {
);
}
export function loadScannerCart() {
if (typeof window === 'undefined') {
return null;
}
try {
const saved = sessionStorage.getItem(SCANNER_CART_STORAGE_KEY);
if (!saved) return null;
const parsed = JSON.parse(saved);
if (!parsed || !Array.isArray(parsed.scannedCards)) return null;
return {
scannedCards: parsed.scannedCards,
selectedCardIds: Array.isArray(parsed.selectedCardIds) ? parsed.selectedCardIds : [],
};
} catch {
return null;
}
}
function toPersistedCartCard(card) {
if (!card || typeof card !== 'object') return card;
const { capturedImage, ocrText, ...safeCard } = card;
return safeCard;
}
export function saveScannerCart({ scannedCards, selectedCardIds }) {
if (typeof window === 'undefined') return;
sessionStorage.setItem(
SCANNER_CART_STORAGE_KEY,
JSON.stringify({
scannedCards: (scannedCards || []).map(toPersistedCartCard),
selectedCardIds,
})
);
}
export function clearScannerCartStorage() {
if (typeof window === 'undefined') return;
sessionStorage.removeItem(SCANNER_CART_STORAGE_KEY);
}
export function createScanCardId() {
return Date.now() + Math.random();
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
detectCardShapesFromFrame,
DETECTION_START_DELAY_MS,
@ -12,11 +12,17 @@ import {
* Camera stream + OpenCV shape detection loop for the card scanner.
* Identification callbacks stay in the parent component.
*/
export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef }) {
export function useCameraScanner({
onError,
onVerifyCard,
verificationPausedRef,
facingMode = 'environment',
}) {
const [isStreaming, setIsStreaming] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [trackedCards, setTrackedCards] = useState([]);
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
const videoRef = useRef(null);
const canvasRef = useRef(null);
@ -27,11 +33,17 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
const trackedCardsRef = useRef([]);
const nextCardIdRef = useRef(1);
const isStreamingRef = useRef(false);
const activeFacingModeRef = useRef(activeFacingMode);
const facingModeInitializedRef = useRef(false);
useEffect(() => {
isStreamingRef.current = isStreaming;
}, [isStreaming]);
useEffect(() => {
activeFacingModeRef.current = activeFacingMode;
}, [activeFacingMode]);
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.getContext('2d', { willReadFrequently: true });
@ -117,7 +129,7 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
console.log('🎥 Starting camera...');
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
facingMode: activeFacingModeRef.current,
width: { ideal: 1280 },
height: { ideal: 720 },
aspectRatio: { ideal: 16 / 9 },
@ -207,6 +219,23 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
}, []);
const switchFacingMode = useCallback(() => {
setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
}, []);
useEffect(() => {
if (!facingModeInitializedRef.current) {
facingModeInitializedRef.current = true;
return;
}
if (!isStreamingRef.current) return;
stopCamera();
startCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles
}, [activeFacingMode]);
return {
videoRef,
canvasRef,
@ -218,5 +247,7 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
startCamera,
stopCamera,
streamRef,
facingMode: activeFacingMode,
switchFacingMode,
};
}

View file

@ -6,6 +6,7 @@ import {
identifyTrackedCardCapture,
resolveDisambiguationRefineAction,
submitScanForReview,
tryLayer1TextIdentify,
VISION_RATE_LIMIT_MS,
fetchVisionIdentify,
} from './scanner-card-identify.js';
@ -14,6 +15,31 @@ import {
* Card identification, disambiguation, and review-submission flow for the scanner.
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
*/
function readFileToImageData(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(imageData);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image from gallery'));
};
img.src = url;
});
}
export function useScannerIdentification({
onCardScanned,
onError,
@ -292,6 +318,26 @@ export function useScannerIdentification({
}
});
const identifyFromGalleryFile = async (file) => {
if (!file || verificationPausedRef?.current) return;
try {
const imageData = await readFileToImageData(file);
const authHeaders = getScanAuthHeaders();
const result = await tryLayer1TextIdentify(imageData, authHeaders);
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
if (result.handled && result.outcome) {
await applyIdentifyOutcome(syntheticTracker, imageData, result.outcome);
return;
}
reportScannerError('Could not identify card from gallery image');
} catch (error) {
reportScannerError(error.message || 'Gallery identify failed');
}
};
return {
disambiguation,
scanNotice,
@ -299,5 +345,6 @@ export function useScannerIdentification({
handleDisambiguationPick,
handleNotInCatalog,
cancelDisambiguation,
identifyFromGalleryFile,
};
}

View file

@ -7,22 +7,32 @@ import {
fetchBatchOwnership,
fetchScannerCollections,
fetchScannerDecks,
routeScannedCardToDestination,
} from './scanner-route-api.js';
import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js';
import {
loadScannerCart,
mergeScannedCardEntry,
saveScannerCart,
} from './scanner-session.js';
const UNDO_TTL_MS = 30_000;
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
const OWNERSHIP_DEBOUNCE_MS = 500;
const CART_PERSIST_DEBOUNCE_MS = 300;
/**
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
*/
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
const [scannedCards, setScannedCards] = useState([]);
const [scannedCards, setScannedCards] = useState(() => {
const cart = loadScannerCart();
return cart?.scannedCards ?? [];
});
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
const [selectedCards, setSelectedCards] = useState(new Set());
const [selectedCards, setSelectedCards] = useState(() => {
const cart = loadScannerCart();
return new Set(cart?.selectedCardIds ?? []);
});
const [bulkAction, setBulkAction] = useState('');
const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
@ -34,6 +44,23 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
const addingInFlightRef = useRef(new Set());
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
const cartPersistTimerRef = useRef(null);
const persistCart = (cards, selection) => {
saveScannerCart({
scannedCards: cards,
selectedCardIds: Array.from(selection),
});
};
useEffect(() => {
clearTimeout(cartPersistTimerRef.current);
cartPersistTimerRef.current = setTimeout(() => {
persistCart(scannedCards, selectedCards);
}, CART_PERSIST_DEBOUNCE_MS);
return () => clearTimeout(cartPersistTimerRef.current);
}, [scannedCards, selectedCards]);
useEffect(() => {
if (!user) return;
@ -135,7 +162,7 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
}, []);
const handleCardScanned = async (cardData) => {
const handleCardScanned = (cardData) => {
setAutoRouteError(null);
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
@ -144,28 +171,11 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
scanDefaults
);
setScannedCards(nextQueue);
if (!sessionDestination) return;
if (!cardEntry.databaseId) {
setAutoRouteError(
`"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.`
);
return;
}
try {
if (!tryBeginAdding(cardEntry.id)) return;
await routeScannedCardToDestination(cardEntry, sessionDestination);
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
} catch (error) {
console.error('Auto-route failed:', error);
setAutoRouteError(
`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`
);
} finally {
endAdding(cardEntry.id);
}
setSelectedCards((prev) => {
const next = new Set([...prev, cardEntry.id]);
persistCart(nextQueue, next);
return next;
});
};
const incrementCardQuantity = (cardId) => {
@ -231,9 +241,10 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
const handleBulkAction = async (actionOverride, targetOverride) => {
const action = actionOverride ?? bulkAction;
const target = targetOverride ?? bulkTarget;
if (!action || selectedCards.size === 0) return;
if (!action || selectedCards.size === 0) return [];
setIsProcessing(true);
const successfulIds = [];
try {
const cardsToProcess = scannedCards.filter((card) => selectedCards.has(card.id));
@ -242,6 +253,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
if (!tryBeginAdding(card.id)) continue;
try {
await addScannedCardToOwned(card);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to owned:', error);
} finally {
endAdding(card.id);
}
@ -249,6 +263,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
if (!tryBeginAdding(card.id)) continue;
try {
await addScannedCardToCollection(card, target);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to collection:', error);
} finally {
endAdding(card.id);
}
@ -256,16 +273,24 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
if (!tryBeginAdding(card.id)) continue;
try {
await addScannedCardToDeck(card, target);
successfulIds.push(card.id);
} catch (error) {
console.error('Error adding card to deck:', error);
} finally {
endAdding(card.id);
}
} else {
continue;
}
markCardAsProcessed(card.id, action);
}
setSelectedCards(new Set());
if (successfulIds.length > 0) {
setScannedCards((prev) => prev.filter((card) => !successfulIds.includes(card.id)));
setSelectedCards((prev) => {
const next = new Set(prev);
successfulIds.forEach((id) => next.delete(id));
return next;
});
}
setBulkAction('');
setBulkTarget('');
} catch (error) {
@ -273,8 +298,16 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
} finally {
setIsProcessing(false);
}
return successfulIds;
};
const commitSelectedToOwned = async () => handleBulkAction('owned');
const commitSelectedToCollection = async (collectionId) =>
handleBulkAction('collection', collectionId);
const unprocessedCount = scannedCards.filter((card) => !card.processed).length;
const createCollection = async () => {
if (!newCollectionName.trim()) return;
@ -339,6 +372,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
addSingleCardToCollection,
addSingleCardToDeck,
handleBulkAction,
commitSelectedToOwned,
commitSelectedToCollection,
unprocessedCount,
createCollection,
clearScannedCards,
removeScannedCard,

View file

@ -6,6 +6,19 @@ import AnimatedFireLogo from '../components/AnimatedFireLogo';
import { Button, Input } from '../components/ui';
import { VOCAB } from '../lib/collection-vocabulary.js';
export function isSafeAppReturnUrl(returnUrl, origin = '') {
if (typeof returnUrl !== 'string') return false;
if (!returnUrl.startsWith('/') || returnUrl.startsWith('//')) return false;
if (!origin) return false;
try {
const resolved = new URL(returnUrl, origin);
return resolved.origin === origin;
} catch {
return false;
}
}
export default function Login() {
const router = useRouter();
const [formData, setFormData] = useState({
@ -44,6 +57,14 @@ export default function Login() {
// Store the JWT token in localStorage
localStorage.setItem('auth_token', data.token);
const returnUrl =
typeof router.query.returnUrl === 'string' ? router.query.returnUrl : null;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
if (isSafeAppReturnUrl(returnUrl, origin)) {
router.push(returnUrl);
return;
}
// Redirect to admin panel or dashboard
if (data.user.role === 'admin') {
router.push('/admin/card-editor');

View file

@ -1,36 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import ScannerSetup from '../components/scanner/ScannerSetup';
import ScannerCamera from '../components/scanner/ScannerCamera';
import ScannerReview from '../components/scanner/ScannerReview';
import ScannerCheckoutSheet from '../components/scanner/ScannerCheckoutSheet';
import { Modal, Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import { useScannerSession } from '../lib/use-scanner-session.js';
import { useScannerQueue } from '../lib/use-scanner-queue.js';
import { useCameraScanner } from '../lib/use-camera-scanner.js';
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
import { clearScannerCartStorage } from '../lib/scanner-session.js';
import { collectionDisplayName } from '../lib/collection-vocabulary.js';
export default function Scanner() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [phase, setPhase] = useState('setup');
const [deckMode, setDeckMode] = useState(null);
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [isListPickerOpen, setIsListPickerOpen] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [listCommitError, setListCommitError] = useState(null);
const verificationPausedRef = useRef(false);
const {
sessionDestination,
setSessionDestination,
gameFilter,
setGameFilter,
scanDefaults,
setScanDefaults,
scanHistory,
saveScanToHistory,
} = useScannerSession();
const queue = useScannerQueue({ user, sessionDestination, scanDefaults, deckMode });
const { scanDefaults } = useScannerSession();
const queue = useScannerQueue({ user, scanDefaults, deckMode: null });
const onVerifyCardRef = useRef(null);
const verificationPausedRef = useRef(false);
const camera = useCameraScanner({
onError: (msg) => console.error('Camera error:', msg),
@ -47,12 +42,50 @@ export default function Scanner() {
verificationPausedRef,
});
const unprocessedCount = queue.unprocessedCount;
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
router.push(`/login?returnUrl=${encodeURIComponent('/scanner')}`);
}
}, [authLoading, user, router]);
useEffect(() => {
const mobileCheckoutPauses =
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 767px)').matches &&
isCheckoutOpen;
verificationPausedRef.current =
mobileCheckoutPauses ||
isListPickerOpen ||
Boolean(identification.disambiguation);
}, [isCheckoutOpen, isListPickerOpen, identification.disambiguation]);
const handleLeaveConfirm = () => {
clearScannerCartStorage();
queue.clearScannedCards();
setShowLeaveModal(false);
router.back();
};
const handleListPick = async (collectionId) => {
setListCommitError(null);
const selectedIds = queue.scannedCards
.filter((card) => queue.selectedCards.has(card.id))
.map((card) => card.id);
if (selectedIds.length === 0) return;
const successfulIds = await queue.commitSelectedToCollection(collectionId);
if (!successfulIds?.length) {
setListCommitError('Could not add cards to the list. Try again.');
return;
}
setIsListPickerOpen(false);
};
if (authLoading) {
return (
<Layout user={null}>
@ -71,51 +104,112 @@ export default function Scanner() {
}
return (
<Layout user={user}>
{phase === 'setup' && (
<ScannerSetup
sessionDestination={sessionDestination}
onDestinationChange={setSessionDestination}
gameFilter={gameFilter}
onGameFilterChange={setGameFilter}
collections={queue.collections}
decks={queue.decks}
deckMode={deckMode}
onDeckModeChange={setDeckMode}
onStartScanning={() => setPhase('scanning')}
scanHistory={scanHistory}
/>
)}
{phase === 'scanning' && (
<Layout user={user} chrome="immersive">
<div className="relative flex flex-col md:flex-row h-full min-h-0 max-md:fixed max-md:inset-0">
<div
className="flex-1 min-h-0 min-w-0"
inert={isCheckoutOpen ? true : undefined}
aria-hidden={isCheckoutOpen || undefined}
>
<ScannerCamera
queue={queue}
camera={camera}
identification={identification}
deckMode={deckMode}
onReview={() => setPhase('review')}
onStopSession={() => {
camera.stopCamera();
setPhase('review');
}}
sessionDestination={sessionDestination}
onBack={() =>
unprocessedCount > 0 ? setShowLeaveModal(true) : router.back()
}
onOpenCheckout={() => setIsCheckoutOpen(true)}
onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)}
latestPeekCard={queue.scannedCards[0] ?? null}
cartCount={unprocessedCount}
isCheckoutOpen={isCheckoutOpen}
verificationPausedRef={verificationPausedRef}
/>
</div>
<div className="md:hidden">
{isCheckoutOpen && (
<ScannerCheckoutSheet
queue={queue}
onClose={() => setIsCheckoutOpen(false)}
onOpenListPicker={() => setIsListPickerOpen(true)}
trapActive={!isListPickerOpen}
/>
)}
</div>
{phase === 'review' && (
<div
className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:border-l md:min-h-0"
style={{ borderColor: 'var(--border)' }}
>
<ScannerReview
queue={queue}
collections={queue.collections}
decks={queue.decks}
deckMode={deckMode}
onContinueScanning={() => setPhase('scanning')}
onNewSession={() => {
queue.clearScannedCards();
setPhase('setup');
}}
sessionDestination={sessionDestination}
variant="side-panel"
onOpenListPicker={() => setIsListPickerOpen(true)}
/>
</div>
<Modal
open={showLeaveModal}
onClose={() => setShowLeaveModal(false)}
title="Leave scanner?"
description={`${unprocessedCount} scanned ${unprocessedCount === 1 ? 'card' : 'cards'} haven't been added yet. Leaving will clear your cart.`}
>
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
<Button variant="secondary" onClick={() => setShowLeaveModal(false)}>
Keep scanning
</Button>
<Button variant="danger" onClick={handleLeaveConfirm}>
Leave
</Button>
</div>
</Modal>
<Modal
open={isListPickerOpen}
onClose={() => {
setIsListPickerOpen(false);
setListCommitError(null);
}}
title="Choose a List"
description="Add the selected cards to one of your lists."
>
{listCommitError && (
<div
className="mb-3 px-3 py-2 rounded-lg text-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
color: 'var(--color-error)',
}}
role="alert"
>
{listCommitError}
</div>
)}
<ul className="space-y-2 max-h-[50vh] overflow-y-auto">
{queue.collections.map((collection) => (
<li key={collection.id}>
<button
type="button"
onClick={() => handleListPick(collection.id)}
disabled={queue.isProcessing}
className="w-full text-left px-4 py-3 rounded-xl transition-opacity hover:opacity-80 disabled:opacity-50 glass-panel"
style={{ color: 'var(--text-primary)', minHeight: 44 }}
>
{collectionDisplayName(collection)}
</button>
</li>
))}
{queue.collections.length === 0 && (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
No lists yet. Create a list from the Lists page first.
</p>
)}
</ul>
</Modal>
</div>
</Layout>
);
}

View file

@ -134,3 +134,84 @@ describe('Layout — floating popovers use .glass-panel-strong (regression: Brie
expect(inlineStyle).toContain('--elevation-pronounced');
});
});
describe('Layout — immersive chrome (regression: scanner-mobile-checkout Brief 1)', () => {
afterEach(() => cleanup());
const testUser = { email: 'foo@bar.com', role: 'user' };
const mobileBottomNavSelector =
'.md\\:hidden.fixed.bottom-0.left-0.right-0.z-50';
it('hides the mobile bottom navigation when chrome is immersive', () => {
const { container } = render(
<Layout user={testUser} chrome="immersive">
page body
</Layout>
);
expect(container.querySelector(mobileBottomNavSelector)).toBeNull();
});
it('renders the mobile bottom navigation when chrome is default', () => {
const { container } = render(
<Layout user={testUser} chrome="default">
page body
</Layout>
);
expect(container.querySelector(mobileBottomNavSelector)).not.toBeNull();
});
it('keeps TopSearchBar in the DOM with a mobile-only hide wrapper when immersive', () => {
const { container } = render(
<Layout user={testUser} chrome="immersive">
page body
</Layout>
);
expect(
screen.getByLabelText('Open command palette to search')
).toBeTruthy();
const topBarWrapper = container.querySelector('.max-md\\:hidden');
expect(topBarWrapper).not.toBeNull();
expect(
topBarWrapper.querySelector('[aria-label="Open command palette to search"]')
).not.toBeNull();
});
it('does not wrap TopSearchBar in max-md:hidden when chrome is default', () => {
const { container } = render(
<Layout user={testUser} chrome="default">
page body
</Layout>
);
expect(container.querySelector('.max-md\\:hidden')).toBeNull();
expect(
screen.getByLabelText('Open command palette to search')
).toBeTruthy();
});
it('applies max-md:pb-0 and max-md:p-0 shell classes when immersive', () => {
const { container } = render(
<Layout user={testUser} chrome="immersive">
page body
</Layout>
);
const outerShell = container.firstChild;
expect(outerShell.className).toContain('max-md:p-0');
const mainColumn = container.querySelector('.flex-1.flex.flex-col.min-w-0');
expect(mainColumn.className).toContain('max-md:pb-0');
});
it('still renders Sign-in CTA when user is null and chrome is immersive', () => {
render(
<Layout user={null} chrome="immersive">
page body
</Layout>
);
const links = screen.getAllByRole('link', { name: /sign in/i });
expect(links.length).toBeGreaterThanOrEqual(1);
for (const link of links) {
expect(link.getAttribute('href')).toBe('/login');
}
});
});

View file

@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ScannerCheckoutSheet, { ScannerCheckoutContent } from '../../components/scanner/ScannerCheckoutSheet';
import { VOCAB } from '../../lib/collection-vocabulary.js';
function createQueueFixture({
scannedCards = [
{
id: 'card-1',
name: 'Lightning Bolt',
set: 'Alpha',
cardNumber: '161',
quantity: 1,
condition: 'NM',
isFoil: false,
processed: false,
confidence: 0.92,
},
{
id: 'card-2',
name: 'Counterspell',
set: 'Beta',
cardNumber: '54',
quantity: 1,
condition: 'NM',
isFoil: false,
processed: false,
confidence: 0.55,
},
],
selectedCardIds = [],
} = {}) {
const selectedCards = new Set(selectedCardIds);
return {
scannedCards,
collections: [{ id: 1, name: 'Trade Binder', is_system_collection: false }],
selectedCards,
isProcessing: false,
addingCardIds: new Set(),
toggleCardSelection: vi.fn((cardId) => {
if (selectedCards.has(cardId)) {
selectedCards.delete(cardId);
} else {
selectedCards.add(cardId);
}
}),
incrementCardQuantity: vi.fn(),
decrementCardQuantity: vi.fn(),
updateCardMetadata: vi.fn(),
removeScannedCard: vi.fn(),
commitSelectedToOwned: vi.fn(async () => {}),
commitSelectedToCollection: vi.fn(async () => {}),
};
}
describe('ScannerCheckoutSheet', () => {
afterEach(() => cleanup());
it('renders header count for scanned cards', () => {
const queue = createQueueFixture();
render(
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={vi.fn()}
/>
);
expect(screen.getByText('2 cards scanned')).toBeTruthy();
});
it('disables primary action when nothing is selected', () => {
const queue = createQueueFixture({ selectedCardIds: [] });
queue.toggleCardSelection = vi.fn();
render(
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={vi.fn()}
/>
);
const primaryButton = screen.getByRole('button', {
name: VOCAB.ADD_TO_MY_COLLECTION,
});
expect(primaryButton.disabled).toBe(true);
});
it('shows vocab labels on checkout actions', () => {
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
render(
<ScannerCheckoutSheet
queue={queue}
onClose={vi.fn()}
onOpenListPicker={vi.fn()}
/>
);
expect(
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
).toBeTruthy();
expect(screen.getByRole('button', { name: VOCAB.ADD_TO_LIST })).toBeTruthy();
});
it('renders checkout sheet dialog without crashing', () => {
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
expect(() => {
render(
<ScannerCheckoutSheet
queue={queue}
onClose={vi.fn()}
onOpenListPicker={vi.fn()}
/>
);
}).not.toThrow();
expect(screen.getByRole('dialog')).toBeTruthy();
});
it('does not show an error banner when commit returns successful ids', async () => {
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
queue.commitSelectedToOwned = vi.fn(async () => ['card-1']);
render(
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={vi.fn()}
/>
);
fireEvent.click(
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
);
await waitFor(() => {
expect(queue.commitSelectedToOwned).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('alert')).toBeNull();
});
it('shows an error banner when commit returns no successful ids', async () => {
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
queue.commitSelectedToOwned = vi.fn(async () => []);
render(
<ScannerCheckoutContent
queue={queue}
onOpenListPicker={vi.fn()}
/>
);
fireEvent.click(
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
);
expect(await screen.findByRole('alert')).toBeTruthy();
});
});

View file

@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest';
// @vitest-environment jsdom
import { describe, expect, it, beforeEach } from 'vitest';
import {
DEFAULT_SCANNER_DESTINATION,
destinationActionKey,
mergeScannedCardEntry,
SCANNER_CART_STORAGE_KEY,
loadScannerCart,
saveScannerCart,
clearScannerCartStorage,
} from '../../lib/scanner-session.js';
describe('destinationActionKey', () => {
@ -59,3 +64,55 @@ describe('mergeScannedCardEntry', () => {
expect(cardEntry.scanImageUrl).toBe('https://blob/new.jpg');
});
});
describe('scanner cart sessionStorage', () => {
const sampleCards = [
{
id: 1,
name: 'Pikachu',
set: 'Base',
quantity: 1,
condition: 'NM',
isFoil: false,
processed: false,
},
];
beforeEach(() => {
sessionStorage.clear();
});
it('round-trips cart rows and selection within a tab', () => {
saveScannerCart({ scannedCards: sampleCards, selectedCardIds: [1] });
const loaded = loadScannerCart();
expect(loaded).toEqual({ scannedCards: sampleCards, selectedCardIds: [1] });
expect(sessionStorage.getItem(SCANNER_CART_STORAGE_KEY)).toBeTruthy();
});
it('omits capturedImage and ocrText from persisted cart rows', () => {
saveScannerCart({
scannedCards: [
{
...sampleCards[0],
capturedImage: 'data:image/png;base64,AAAA',
ocrText: 'raw ocr',
},
],
selectedCardIds: [1],
});
const loaded = loadScannerCart();
expect(loaded.scannedCards[0].capturedImage).toBeUndefined();
expect(loaded.scannedCards[0].ocrText).toBeUndefined();
expect(loaded.scannedCards[0].name).toBe('Pikachu');
});
it('clearScannerCartStorage removes persisted cart state', () => {
saveScannerCart({ scannedCards: sampleCards, selectedCardIds: [1] });
clearScannerCartStorage();
expect(loadScannerCart()).toBeNull();
expect(sessionStorage.getItem(SCANNER_CART_STORAGE_KEY)).toBeNull();
});
});

View file

@ -0,0 +1,225 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
const routeApiMocks = vi.hoisted(() => ({
addScannedCardToOwned: vi.fn().mockResolvedValue(undefined),
addScannedCardToCollection: vi.fn().mockResolvedValue(undefined),
addScannedCardToDeck: vi.fn().mockResolvedValue(undefined),
routeScannedCardToDestination: vi.fn().mockResolvedValue(undefined),
fetchScannerCollections: vi.fn().mockResolvedValue([]),
fetchScannerDecks: vi.fn().mockResolvedValue([]),
fetchBatchOwnership: vi.fn().mockResolvedValue({}),
createScannerCollection: vi.fn(),
}));
vi.mock('../../lib/scanner-route-api.js', () => ({
addScannedCardToOwned: routeApiMocks.addScannedCardToOwned,
addScannedCardToCollection: routeApiMocks.addScannedCardToCollection,
addScannedCardToDeck: routeApiMocks.addScannedCardToDeck,
routeScannedCardToDestination: routeApiMocks.routeScannedCardToDestination,
fetchScannerCollections: routeApiMocks.fetchScannerCollections,
fetchScannerDecks: routeApiMocks.fetchScannerDecks,
fetchBatchOwnership: routeApiMocks.fetchBatchOwnership,
createScannerCollection: routeApiMocks.createScannerCollection,
}));
import { useScannerQueue } from '../../lib/use-scanner-queue.js';
import { clearScannerCartStorage } from '../../lib/scanner-session.js';
const {
addScannedCardToOwned,
routeScannedCardToDestination,
} = routeApiMocks;
const scanDefaults = { condition: 'NM', isFoil: false };
const user = { userId: 1, email: 'test@example.com', role: 'user' };
describe('useScannerQueue — cart model', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
});
afterEach(() => {
clearScannerCartStorage();
});
it('handleCardScanned enqueues locally without route helpers', async () => {
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: { type: 'owned', id: null, label: 'My Collection' },
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Pikachu',
set: 'Base',
databaseId: 101,
});
});
expect(routeScannedCardToDestination).not.toHaveBeenCalled();
expect(addScannedCardToOwned).not.toHaveBeenCalled();
expect(result.current.scannedCards).toHaveLength(1);
const card = result.current.scannedCards[0];
expect(card).toMatchObject({
name: 'Pikachu',
set: 'Base',
processed: false,
});
expect(card.id).toBeTruthy();
expect(result.current.selectedCards.has(card.id)).toBe(true);
});
it('handleBulkAction owned commits each selected card and removes them from the cart', async () => {
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: null,
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Bolt',
set: 'Alpha',
databaseId: 201,
});
});
const cardId = result.current.scannedCards[0].id;
await act(async () => {
await result.current.handleBulkAction('owned');
});
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
expect(addScannedCardToOwned).toHaveBeenCalledWith(
expect.objectContaining({ id: cardId, name: 'Bolt', set: 'Alpha' })
);
expect(result.current.scannedCards).toHaveLength(0);
expect(result.current.selectedCards.size).toBe(0);
});
it('commitSelectedToOwned returns successful ids instead of reading stale queue state', async () => {
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: null,
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Jace',
set: 'Origins',
databaseId: 301,
});
});
const cardId = result.current.scannedCards[0].id;
let successfulIds;
await act(async () => {
successfulIds = await result.current.commitSelectedToOwned();
});
expect(successfulIds).toEqual([cardId]);
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
expect(result.current.scannedCards).toHaveLength(0);
});
it('commitSelectedToOwned returns an empty array when the API fails', async () => {
addScannedCardToOwned.mockRejectedValueOnce(new Error('network'));
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: null,
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Jace',
set: 'Origins',
databaseId: 301,
});
});
let successfulIds;
await act(async () => {
successfulIds = await result.current.commitSelectedToOwned();
});
expect(successfulIds).toEqual([]);
expect(result.current.scannedCards).toHaveLength(1);
});
it('commitSelectedToOwned routes through handleBulkAction owned', async () => {
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: null,
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Jace',
set: 'Origins',
databaseId: 301,
});
});
await act(async () => {
await result.current.commitSelectedToOwned();
});
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
expect(result.current.scannedCards).toHaveLength(0);
});
it('persists cart state to sessionStorage on scan', async () => {
const { result } = renderHook(() =>
useScannerQueue({
user,
sessionDestination: null,
scanDefaults,
deckMode: null,
})
);
await act(async () => {
result.current.handleCardScanned({
name: 'Liliana',
set: 'Midnight',
databaseId: 401,
});
});
const cardId = result.current.scannedCards[0].id;
await waitFor(() => {
const raw = sessionStorage.getItem('deckhearth:scanner-cart');
expect(raw).toBeTruthy();
const parsed = JSON.parse(raw);
expect(parsed.scannedCards).toHaveLength(1);
expect(parsed.selectedCardIds).toEqual([cardId]);
});
expect(result.current.unprocessedCount).toBe(1);
});
});

View file

@ -2,7 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import Login from '../../pages/login.js';
import Login, { isSafeAppReturnUrl } from '../../pages/login.js';
vi.mock('next/router', () => ({
useRouter: () => ({ push: vi.fn() }),
@ -26,4 +26,14 @@ describe('Login page', () => {
expect(screen.queryByText(/quick login/i)).toBeNull();
expect(document.body.textContent).not.toMatch(/alice123|bob123/);
});
it('accepts same-origin relative return URLs and rejects open redirects', () => {
const origin = 'https://deckhearth.com';
expect(isSafeAppReturnUrl('/scanner', origin)).toBe(true);
expect(isSafeAppReturnUrl('/login', origin)).toBe(true);
expect(isSafeAppReturnUrl('//evil.example', origin)).toBe(false);
expect(isSafeAppReturnUrl('/\\evil.example', origin)).toBe(false);
expect(isSafeAppReturnUrl('https://evil.example', origin)).toBe(false);
expect(isSafeAppReturnUrl(null, origin)).toBe(false);
});
});