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
12 changed files with 42 additions and 1100 deletions
Showing only changes of commit 484bd02f9a - Show all commits

View file

@ -7,7 +7,7 @@ success_metric: |
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
status: shipped
created: 2026-08-14
depends_on:
- scanner-rebuild

View file

@ -1,44 +0,0 @@
import { useRef } from 'react';
import { useCameraScanner } from '../lib/use-camera-scanner.js';
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
import CameraScannerView from './CameraScannerView.js';
export default function CameraScanner({ onCardScanned, onError }) {
const verificationPausedRef = useRef(false);
const onVerifyCardRef = useRef(() => {});
const camera = useCameraScanner({
onError,
verificationPausedRef,
onVerifyCard: (card) => onVerifyCardRef.current(card),
});
const identification = useScannerIdentification({
onCardScanned,
onError,
videoRef: camera.videoRef,
canvasRef: camera.canvasRef,
onVerifyCardRef,
verificationPausedRef,
});
return (
<CameraScannerView
videoRef={camera.videoRef}
canvasRef={camera.canvasRef}
detectionCanvasRef={camera.detectionCanvasRef}
isStreaming={camera.isStreaming}
isDetecting={camera.isDetecting}
trackedCards={camera.trackedCards}
videoMetrics={camera.videoMetrics}
startCamera={camera.startCamera}
stopCamera={camera.stopCamera}
scanNotice={identification.scanNotice}
disambiguation={identification.disambiguation}
submittingReview={identification.submittingReview}
onPick={identification.handleDisambiguationPick}
onNotInCatalog={identification.handleNotInCatalog}
onCancelDisambiguation={identification.cancelDisambiguation}
/>
);
}

View file

@ -1,251 +0,0 @@
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
const CONFIRMED_COLOR = '#10B981';
const SCANNED_COLOR = '#3B82F6';
function overlayBorderColor(status) {
if (status === 'scanned') return SCANNED_COLOR;
return CONFIRMED_COLOR;
}
function overlayGlowColor(status) {
if (status === 'scanned') return `${SCANNED_COLOR}50`;
return `${CONFIRMED_COLOR}50`;
}
export default function CameraScannerView({
videoRef,
canvasRef,
detectionCanvasRef,
isStreaming,
isDetecting,
trackedCards,
videoMetrics,
startCamera,
stopCamera,
scanNotice,
disambiguation,
submittingReview,
onPick,
onNotInCatalog,
onCancelDisambiguation,
}) {
const foundCards = trackedCards.filter(
(card) => card.status === 'confirmed' || card.status === 'scanned'
);
const foundCardCount = foundCards.length;
return (
<div className="w-full h-full flex flex-col">
{scanNotice && (
<div
className="mx-4 mt-3 px-4 py-3 rounded-lg text-sm border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--accent-ember)',
color: 'var(--text-primary)',
}}
role="status"
>
{scanNotice}
</div>
)}
<div
className="flex-1 relative rounded-2xl overflow-hidden mb-4"
style={{
backgroundColor: 'var(--bg-tertiary)',
border: '2px solid var(--border)',
}}
>
<video
ref={videoRef}
className={`absolute inset-0 w-full h-full object-cover ${isStreaming ? 'block' : 'hidden'}`}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{isStreaming && videoMetrics.width > 0 && videoMetrics.height > 0 && foundCards.map((card) => (
<div
key={card.id}
className="absolute border-2 rounded-lg transition-all duration-200"
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}%`,
borderColor: overlayBorderColor(card.status),
borderWidth: '3px',
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
}}
>
<div
className="absolute -top-8 left-0 px-3 py-1 rounded-full text-xs font-bold text-white shadow-lg"
style={{ backgroundColor: overlayBorderColor(card.status) }}
>
{card.status === 'scanned' ? '📸 Scanned' : '✅ Card Found'}
</div>
</div>
))}
{isStreaming && (
<div className="absolute top-4 left-4 right-4 flex justify-between items-center">
{isDetecting && (
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
🎯 Scanning {foundCardCount > 0 && `(${foundCardCount} found)`}
</div>
</div>
)}
<div className="flex-1"></div>
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
LIVE
</div>
</div>
</div>
)}
{isStreaming && (
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
<div className="flex items-center gap-4">
<button
onClick={stopCamera}
className="w-16 h-16 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-105 transition-all duration-200 backdrop-blur-sm"
style={{
backgroundColor: 'rgba(239, 68, 68, 0.9)',
border: '3px solid rgba(255, 255, 255, 0.3)',
}}
aria-label="Stop camera"
>
<div className="w-6 h-6 bg-white rounded-sm" aria-hidden="true"></div>
</button>
</div>
</div>
)}
{!isStreaming && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="absolute inset-0 opacity-5">
<div
className="w-full h-full"
style={{
backgroundImage: `radial-gradient(circle at 25% 25%, var(--text-primary) 2px, transparent 2px),
radial-gradient(circle at 75% 75%, var(--text-primary) 2px, transparent 2px)`,
backgroundSize: '50px 50px',
}}
></div>
</div>
<div className="text-center z-10 mb-8">
<div className="font-semibold text-2xl mb-3" style={{ color: 'var(--text-primary)' }}>
Camera Ready
</div>
<div className="text-lg opacity-75 mb-6" style={{ color: 'var(--text-secondary)' }}>
Position trading cards in view for smart detection
</div>
</div>
<button
onClick={startCamera}
className="w-20 h-20 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-110 transition-all duration-300 relative overflow-hidden group"
style={{
backgroundColor: 'var(--accent-ember)',
border: '4px solid rgba(255, 255, 255, 0.2)',
}}
aria-label="Start camera"
>
<div
className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
style={{
background: 'radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%)',
}}
></div>
<div className="relative z-10" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<div className="absolute inset-0 rounded-full border-2 border-white opacity-60 animate-ping"></div>
</button>
<div className="mt-8 text-center max-w-md">
<div className="grid grid-cols-2 gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-gold)' }}></div>
<span>AI Detection</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-ember)' }}></div>
<span>Real-time Tracking</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-flame)' }}></div>
<span>Smart Recognition</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span>Auto Scanning</span>
</div>
</div>
</div>
</div>
)}
</div>
<canvas ref={canvasRef} className="hidden" />
<canvas ref={detectionCanvasRef} className="hidden" />
{isStreaming && (
<div
className="glass-panel rounded-xl p-3"
>
<div className="flex items-center gap-3 mb-2">
<div
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-ember)' }}
>
<span className="text-white text-xs">🎯</span>
</div>
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
Smart Detection Active
</h3>
</div>
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Shape recognition</span>
</div>
<div className="flex items-center gap-2">
<span className="text-blue-500"></span>
<span>Server identification</span>
</div>
<div className="flex items-center gap-2">
<span className="text-yellow-500"></span>
<span>Position tracking</span>
</div>
<div className="flex items-center gap-2">
<span className="text-purple-500"></span>
<span>Database lookup</span>
</div>
</div>
</div>
)}
<ScanDisambiguationDialog
disambiguation={disambiguation}
submittingReview={submittingReview}
onPick={onPick}
onNotInCatalog={onNotInCatalog}
onCancel={onCancelDisambiguation}
/>
</div>
);
}

View file

@ -1,327 +0,0 @@
import CameraScanner from './CameraScanner';
import ScannerDestinationPicker from './ScannerDestinationPicker';
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
import OCRSettings from './OCRSettings';
import { Modal, Input, Button } from './ui';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function ScannerPageView({
gameFilter,
onGameFilterChange,
sessionDestination,
onDestinationChange,
scanDefaults,
onScanDefaultsChange,
queue,
showOCRSettings,
onOpenOCRSettings,
onCloseOCRSettings,
onScannerError,
}) {
return (
<div className="h-full flex flex-col">
<div className="px-6 pt-6 pb-4">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
🃏 Card Scanner
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Pick a destination once every scan lands there until you change it
</p>
</div>
<ScannerDestinationPicker
gameFilter={gameFilter}
onGameFilterChange={onGameFilterChange}
destination={sessionDestination}
onDestinationChange={onDestinationChange}
collections={queue.collections}
decks={queue.decks}
disabled={queue.isProcessing}
/>
{queue.autoRouteError && (
<div
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--accent-flame)',
color: 'var(--text-primary)',
}}
role="alert"
>
{queue.autoRouteError}
</div>
)}
<div
className="glass-panel mx-6 mb-4 rounded-xl p-4 flex flex-wrap items-end gap-4"
>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
Defaults for new scans
</h2>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Applied to each card when it enters the queue
</p>
</div>
<label className="flex flex-col gap-1 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Condition</span>
<select
value={scanDefaults.condition}
onChange={(e) => onScanDefaultsChange({ condition: e.target.value })}
className="px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{CONDITION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm pb-2 cursor-pointer" style={{ color: 'var(--text-primary)' }}>
<input
type="checkbox"
checked={scanDefaults.isFoil}
onChange={(e) => onScanDefaultsChange({ isFoil: e.target.checked })}
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
<div className="lg:col-span-3 flex flex-col">
<div
className="glass-panel flex-1 rounded-xl p-6 flex flex-col"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Camera Scanner
</h2>
<button
onClick={onOpenOCRSettings}
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
OCR Settings
</button>
</div>
<div className="flex-1">
<CameraScanner onCardScanned={queue.handleCardScanned} onError={onScannerError} />
</div>
</div>
</div>
<div className="lg:col-span-2 flex flex-col">
<div
className="glass-panel flex-1 rounded-xl p-6 flex flex-col"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Scanned Cards
</h2>
<div className="flex items-center gap-2">
<div
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
aria-atomic="true"
>
{queue.scannedCards.length} cards
</div>
{queue.scannedCards.length > 0 && (
<button
onClick={queue.clearScannedCards}
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
aria-label="Clear all scanned cards from queue"
>
Clear All
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{queue.scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
<div className="font-medium">No cards scanned yet</div>
<div className="text-sm">Start scanning to see cards here</div>
</div>
) : (
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
{queue.scannedCards.map((card) => (
<li key={card.id}>
<ScannedCardItem
card={card}
collections={queue.collections}
decks={queue.decks}
selected={queue.selectedCards.has(card.id)}
onToggleSelect={() => queue.toggleCardSelection(card.id)}
onIncrement={() => queue.incrementCardQuantity(card.id)}
onDecrement={() => queue.decrementCardQuantity(card.id)}
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
onMarkOwned={() => queue.addSingleCardToOwned(card)}
onAddToCollection={(collectionId) => queue.addSingleCardToCollection(card, collectionId)}
onAddToDeck={(deckId) => queue.addSingleCardToDeck(card, deckId)}
onRemove={() => queue.removeScannedCard(card.id)}
isAdding={queue.addingCardIds.has(card.id)}
/>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
{queue.selectedCards.size > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div
className="glass-panel-strong rounded-2xl px-6 py-4 flex items-center gap-4 max-w-4xl"
role="toolbar"
aria-label="Bulk actions for selected scanned cards"
>
<div className="flex items-center gap-2" aria-live="polite" aria-atomic="true">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: 'var(--accent-ember)' }}
>
{queue.selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{queue.selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<div className="flex items-center gap-3">
<button
onClick={() => queue.handleBulkAction('owned')}
disabled={queue.isProcessing}
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
<span aria-hidden="true">💎 </span>
{VOCAB.ADD_TO_MY_COLLECTION}
</button>
{queue.collections.length > 0 && (
<select
onChange={(e) => {
const collectionId = e.target.value;
e.target.value = '';
if (collectionId) {
queue.handleBulkAction('collection', collectionId);
}
}}
disabled={queue.isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
aria-label="Add selected cards to list"
>
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{queue.collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{queue.decks.length > 0 && (
<select
onChange={(e) => {
const deckId = e.target.value;
e.target.value = '';
if (deckId) {
queue.handleBulkAction('deck', deckId);
}
}}
disabled={queue.isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
aria-label="Add selected cards to deck"
>
<option value="">🃏 Add to Deck</option>
{queue.decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<button
onClick={queue.clearSelection}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
aria-label="Clear selection"
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
)}
<Modal
open={queue.showCreateCollection}
onClose={() => queue.setShowCreateCollection(false)}
title="Create New List"
size="md"
>
<Input
id="create-collection-name"
label="List name"
placeholder="List name..."
value={queue.newCollectionName}
onChange={(e) => queue.setNewCollectionName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
queue.createCollection();
}
}}
className="mb-4"
/>
<div className="flex gap-3">
<Button
variant="primary"
onClick={queue.createCollection}
disabled={!queue.newCollectionName.trim()}
className="flex-1"
>
Create
</Button>
<Button
variant="secondary"
onClick={() => queue.setShowCreateCollection(false)}
className="flex-1"
>
Cancel
</Button>
</div>
</Modal>
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
</div>
);
}

View file

@ -1,51 +0,0 @@
export default function DeckModeIndicator({ current, target, game }) {
const progress = target > 0 ? Math.min(current / target, 1) : 0;
const isComplete = current >= target;
return (
<div className="mt-2 px-1">
{/* Progress track */}
<div
className="w-full rounded-full overflow-hidden"
style={{
height: 6,
backgroundColor: 'var(--bg-tertiary)',
}}
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={target}
aria-label={`Deck progress: ${current} of ${target} cards`}
>
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${progress * 100}%`,
backgroundColor: isComplete
? 'var(--accent-gold)'
: 'var(--accent-ember)',
}}
/>
</div>
{/* Labels */}
<div className="flex items-center justify-between mt-1.5">
<span
className="text-xs font-medium"
style={{
color: isComplete ? 'var(--accent-gold)' : 'var(--text-secondary)',
textShadow: isComplete ? '0 0 8px var(--accent-gold)' : 'none',
}}
>
{isComplete ? 'Deck complete!' : `${current}/${target} cards`}
</span>
<span
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{game}
</span>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { useEffect, useRef, useState } from 'react';
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
import GlassSurface from '../ui/GlassSurface.js';
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
@ -61,9 +62,11 @@ export default function ReviewCardItem({
return (
<div
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
className={`rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
style={{
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : '3px solid transparent',
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : undefined,
}}
>
<div className="flex gap-3">
@ -158,10 +161,14 @@ export default function ReviewCardItem({
</svg>
</button>
{menuOpen && (
<div
<GlassSurface
as="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)' }}
tint="high"
rim="subtle"
blur="mid"
elevation="ambient"
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1"
>
<button
type="button"
@ -205,7 +212,7 @@ export default function ReviewCardItem({
>
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
</button>
</div>
</GlassSurface>
)}
</div>
) : !card.processed ? (

View file

@ -2,6 +2,7 @@ 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 GlassSurface from '../ui/GlassSurface.js';
import ReviewCardItem from './ReviewCardItem';
function confidencePercent(confidence) {
@ -20,8 +21,9 @@ function CheckoutFooter({
return (
<div
className="px-4 sm:px-6 py-4 glass-panel-strong"
className="px-4 sm:px-6 py-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderTop: '1px solid var(--border)',
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
}}
@ -213,9 +215,12 @@ export default function ScannerCheckoutSheet({
aria-modal="true"
aria-labelledby="checkout-sheet-title"
>
<div
<GlassSurface
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
tint="low"
rim="subtle"
blur="mid"
className="fixed bottom-0 left-0 right-0 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
@ -245,7 +250,7 @@ export default function ScannerCheckoutSheet({
onOpenListPicker={onOpenListPicker}
onEmpty={onClose}
/>
</div>
</GlassSurface>
<style jsx>{`
.checkout-backdrop {

View file

@ -1,17 +1,22 @@
import GlassSurface from '../ui/GlassSurface.js';
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
const handleClick = onOpenCheckout || onReview;
if (count <= 0) return null;
return (
<button
<GlassSurface
as="button"
type="button"
onClick={handleClick}
tint="mid"
rim="ember-pronounced"
blur="mid"
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={{
background: 'var(--gradient-primary)',
color: 'var(--text-primary-dark)',
boxShadow: 'var(--ember-rim-pronounced)',
minHeight: 44,
minWidth: 44,
'--tw-ring-color': 'var(--accent-ember)',
@ -20,6 +25,6 @@ export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
aria-label={`Review ${count} scanned cards`}
>
Review {count}
</button>
</GlassSurface>
);
}

View file

@ -1,379 +0,0 @@
import { useState, useId } from 'react';
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
import { Button } from '../ui';
const GAMES = [
{ value: 'All', label: 'Auto-detect' },
{ value: 'mtg', label: 'Magic' },
{ value: 'pokemon', label: 'Pokémon' },
{ value: 'lorcana', label: 'Lorcana' },
];
const DECK_SIZES = [
{ value: 40, label: '40' },
{ value: 60, label: '60' },
{ value: 99, label: '99' },
];
const DESTINATION_TYPES = [
{ value: 'owned', label: VOCAB.MY_COLLECTION },
{ value: 'collection', label: VOCAB.LIST },
{ value: 'deck', label: 'Deck' },
];
function ToggleGroup({ options, value, onChange, name, className = '' }) {
return (
<div className={`inline-flex rounded-xl overflow-hidden ${className}`} role="radiogroup" aria-label={name}>
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className="px-4 py-2.5 text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset min-w-[44px] min-h-[44px] flex items-center justify-center"
style={{
backgroundColor: selected ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: selected ? '#ffffff' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
function ScanHistoryList({ history }) {
const [expanded, setExpanded] = useState(false);
const panelId = useId();
if (!history || history.length === 0) return null;
const formatDate = (iso) => {
try {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
} catch {
return 'Unknown date';
}
};
const destinationLabel = (dest) => {
if (!dest) return 'Unknown';
if (dest.type === 'owned') return VOCAB.MY_COLLECTION;
return dest.label || dest.type;
};
return (
<div className="mt-6">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={panelId}
className="flex items-center gap-2 text-sm font-medium w-full py-2 focus:outline-none focus-visible:ring-2 rounded-lg px-1 min-h-[44px]"
style={{
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="transition-transform duration-200"
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Recent Sessions
</button>
{expanded && (
<ul id={panelId} className="mt-2 space-y-2">
{history.map((entry, i) => (
<li
key={entry.date || i}
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--text-primary)' }}>
{entry.cardCount} {entry.cardCount === 1 ? 'card' : 'cards'}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{destinationLabel(entry.destination)}
{entry.game && entry.game !== 'All' ? ` · ${entry.game}` : ''}
</span>
</div>
<span className="text-xs tabular-nums" style={{ color: 'var(--text-tertiary)' }}>
{formatDate(entry.date)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
export default function ScannerSetup({
sessionDestination,
onDestinationChange,
gameFilter,
onGameFilterChange,
collections,
decks,
deckMode,
onDeckModeChange,
onStartScanning,
scanHistory,
}) {
const destSelectId = useId();
const gameGroupId = useId();
const deckModeId = useId();
const deckSizeId = useId();
const destinationType = sessionDestination?.type ?? 'owned';
const handleDestinationTypeChange = (type) => {
if (type === 'owned') {
onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION });
} else if (type === 'collection') {
const first = collections?.[0];
onDestinationChange(
first
? { type: 'collection', id: first.id, label: collectionDisplayName(first) }
: { type: 'collection', id: null, label: VOCAB.LIST }
);
} else if (type === 'deck') {
const first = decks?.[0];
onDestinationChange(
first
? { type: 'deck', id: first.id, label: first.name }
: { type: 'deck', id: null, label: 'Deck' }
);
}
};
const handleListSelect = (e) => {
const id = parseInt(e.target.value, 10);
const found = collections.find((c) => c.id === id);
if (found) {
onDestinationChange({
type: 'collection',
id: found.id,
label: collectionDisplayName(found),
});
}
};
const handleDeckSelect = (e) => {
const id = parseInt(e.target.value, 10);
const found = decks.find((d) => d.id === id);
if (found) {
onDestinationChange({ type: 'deck', id: found.id, label: found.name });
}
};
const handleDeckModeToggle = () => {
if (deckMode) {
onDeckModeChange(null);
} else {
onDeckModeChange({ game: gameFilter, targetSize: 60 });
}
};
const handleDeckSizeChange = (size) => {
onDeckModeChange({ ...deckMode, targetSize: size });
};
const selectStyle = {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
borderColor: 'var(--border)',
};
return (
<div className="px-4 sm:px-6 py-6 max-w-lg mx-auto">
<div className="glass-panel rounded-2xl p-5 sm:p-6 space-y-6">
<header>
<h1
className="text-xl sm:text-2xl font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Card Scanner
</h1>
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
Choose where scanned cards will go
</p>
</header>
{/* Destination picker */}
<fieldset>
<legend className="text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Destination
</legend>
<ToggleGroup
options={DESTINATION_TYPES}
value={destinationType}
onChange={handleDestinationTypeChange}
name="Scan destination"
className="w-full"
/>
{destinationType === 'collection' && (
<div className="mt-3">
<label htmlFor={destSelectId} className="sr-only">
Choose a list
</label>
<select
id={destSelectId}
value={sessionDestination?.id ?? ''}
onChange={handleListSelect}
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
style={{
...selectStyle,
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{(!collections || collections.length === 0) && (
<option value="" disabled>
No lists available
</option>
)}
{collections?.map((c) => (
<option key={c.id} value={c.id}>
{collectionDisplayName(c)}
</option>
))}
</select>
</div>
)}
{destinationType === 'deck' && (
<div className="mt-3">
<label htmlFor={destSelectId} className="sr-only">
Choose a deck
</label>
<select
id={destSelectId}
value={sessionDestination?.id ?? ''}
onChange={handleDeckSelect}
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
style={{
...selectStyle,
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{(!decks || decks.length === 0) && (
<option value="" disabled>
No decks available
</option>
)}
{decks?.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
)}
</fieldset>
{/* Game filter */}
<fieldset>
<legend
id={gameGroupId}
className="text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Game
</legend>
<ToggleGroup
options={GAMES}
value={gameFilter}
onChange={onGameFilterChange}
name="Game filter"
className="w-full"
/>
</fieldset>
{/* Deck mode toggle */}
<div className="flex items-center justify-between gap-3 py-1">
<label
htmlFor={deckModeId}
className="text-sm font-medium cursor-pointer select-none"
style={{ color: 'var(--text-primary)' }}
>
Deck Mode
</label>
<button
id={deckModeId}
type="button"
role="switch"
aria-checked={Boolean(deckMode)}
onClick={handleDeckModeToggle}
className="relative inline-flex h-7 w-12 shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: deckMode ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span
aria-hidden="true"
className="pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200"
style={{
backgroundColor: '#ffffff',
transform: deckMode ? 'translate(22px, 4px)' : 'translate(4px, 4px)',
}}
/>
</button>
</div>
{deckMode && (
<fieldset className="pl-1">
<legend
id={deckSizeId}
className="text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Deck Size
</legend>
<ToggleGroup
options={DECK_SIZES}
value={deckMode.targetSize}
onChange={handleDeckSizeChange}
name="Deck size"
/>
</fieldset>
)}
{/* Start scanning CTA */}
<Button
variant="primary"
size="lg"
onClick={onStartScanning}
className="w-full"
>
Start Scanning
</Button>
{/* Scan history */}
<ScanHistoryList history={scanHistory} />
</div>
</div>
);
}

View file

@ -1,3 +1,5 @@
import GlassSurface from '../ui/GlassSurface.js';
function ToastIcon({ type }) {
if (type === 'error') {
return (
@ -87,15 +89,13 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
}}
>
<div
className="flex items-center gap-2 rounded-full px-4 py-3 shadow-lg pointer-events-auto"
style={{
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.9)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: '1px solid var(--border)',
maxWidth: 280,
}}
<GlassSurface
tint="high"
blur="low"
rim="subtle"
elevation="ambient"
className="flex items-center gap-2 rounded-full px-4 py-3 pointer-events-auto"
style={{ maxWidth: 280 }}
>
<span
className="flex-shrink-0"
@ -110,7 +110,7 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
>
{message}
</span>
</div>
</GlassSurface>
</div>
);
}

View file

@ -13,7 +13,7 @@ function getFocusableElements(container) {
* Trap focus inside a container while `active` and restore focus on close.
* Returns a ref to attach to the dialog panel (not the backdrop).
*
* Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the
* Used by call sites where the
* caller wants the hook to own the container ref. New code that needs
* to share / forward the panel ref should use the default export
* `useFocusTrapContainer` instead see Modal.js for the pattern.

View file

@ -2,8 +2,6 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { VOCAB } from './collection-vocabulary.js';
const STORAGE_KEY = 'deckhearth:scanner-session';
const HISTORY_KEY = 'deckhearth:scan-history';
const MAX_HISTORY = 5;
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
@ -40,8 +38,6 @@ export function useScannerSession() {
const saved = readJson(STORAGE_KEY, {});
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
});
const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
const isInitialRender = useRef(true);
useEffect(() => {
@ -72,23 +68,6 @@ export function useScannerSession() {
setScanDefaults((current) => ({ ...current, ...patch }));
}, []);
const saveScanToHistory = useCallback(
(cardCount) => {
setScanHistory((prev) => {
const entry = {
date: new Date().toISOString(),
cardCount,
destination: sessionDestination,
game: gameFilter,
};
const next = [entry, ...prev].slice(0, MAX_HISTORY);
writeJson(HISTORY_KEY, next);
return next;
});
},
[sessionDestination, gameFilter]
);
return {
sessionDestination,
setSessionDestination: handleDestinationChange,
@ -96,7 +75,5 @@ export function useScannerSession() {
setGameFilter: handleGameFilterChange,
scanDefaults,
setScanDefaults: handleScanDefaultsChange,
scanHistory,
saveScanToHistory,
};
}