fix(scanner): close redesign a11y audit findings #45

Merged
varutasu merged 1 commit from fix/scanner-redesign-a11y-fixes into main 2026-05-27 15:22:18 -04:00
6 changed files with 162 additions and 36 deletions

View file

@ -24,7 +24,7 @@ Reports posted to [PR #44](https://github.com/varutasu/tcg-vault/pull/44#issueco
| Slug | Priority | Source |
| --- | --- | --- |
| `scanner-redesign-a11y-fixes` | P1 | A11y audit — ownership badge role, modal focus traps, unlabeled selects/buttons |
| `scanner-redesign-a11y-fixes` | P1 | **In progress** — PR pending |
| `scanner-user-cards-quantity-guard` | P2 | Reviewer — parseInt/NaN guard parity with decks handler |
| `test-scanner-redesign-surfaces` | P2 | Reviewer — unit tests for new components + upload route |
| `document-condition-foil-destination-semantics` | P3 | Reviewer — clarify or migrate condition/foil for collection/deck rows |

View file

@ -0,0 +1,30 @@
---
name: scanner-redesign-a11y-fixes
classification: fix
success_metric: |
Post-audit a11y gaps from audit-redesign-scanner-flow-44 closed: ownership
badge exposed to AT, icon/select controls labeled, modals trap focus, queue
uses list semantics and live count updates.
depends_on:
- redesign-scanner-flow
status: open
created: 2026-05-27
---
# Convoy: scanner-redesign-a11y-fixes
Closes P1 follow-up from `audit-redesign-scanner-flow-44`.
## Scope
- `components/ScannedCardItem.js` — ownership `role="status"`, labeled selects/buttons
- `components/CameraScanner.js` — camera control labels, disambiguation focus trap, video label, heading fix
- `pages/scanner.js` — create-collection modal a11y, bulk toolbar labels, queue list semantics, live count
- `lib/use-focus-trap.js` (new) — shared modal focus trap
## Acceptance criteria
1. Ownership badge announced by screen readers.
2. All icon-only and placeholder `<select>` controls have accessible names.
3. Disambiguation + create-collection modals trap focus and restore on close.
4. Scanned cards queue uses list semantics; count updates are polite live region.

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useFocusTrap } from '../lib/use-focus-trap.js';
async function uploadScanCapture(imageData) {
const response = await fetch('/api/scan/upload-image', {
@ -46,6 +47,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
const activeVerificationRef = useRef(0);
const lastErrorAtRef = useRef(0);
const disambiguationRefineRef = useRef(null);
const disambiguationDialogRef = useFocusTrap(Boolean(disambiguation));
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -826,6 +828,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{/* Card Detection Overlays - Only when streaming */}
@ -907,9 +910,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
backgroundColor: 'rgba(239, 68, 68, 0.9)',
border: '3px solid rgba(255, 255, 255, 0.3)'
}}
title="Stop Camera"
aria-label="Stop camera"
>
<div className="w-6 h-6 bg-white rounded-sm"></div>
<div className="w-6 h-6 bg-white rounded-sm" aria-hidden="true"></div>
</button>
</div>
</div>
@ -945,6 +948,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
backgroundColor: 'var(--accent-ember)',
border: '4px solid rgba(255, 255, 255, 0.2)'
}}
aria-label="Start camera"
>
{/* Button Glow Effect */}
<div className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
@ -953,7 +957,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
}}></div>
{/* Play Icon */}
<div className="relative z-10">
<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>
@ -999,9 +1003,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
<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>
<h4 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
Smart Detection Active
</h4>
</h3>
</div>
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-2">
@ -1027,6 +1031,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
{disambiguation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
<div
ref={disambiguationDialogRef}
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
role="dialog"

View file

@ -109,11 +109,13 @@ export default function ScannedCardItem({
</h3>
{card.isExisting && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
Found in database
<span aria-hidden="true"> </span>
<span>Found in database</span>
</div>
)}
{ownedQuantity !== null && ownedQuantity > 0 && (
<div
role="status"
className="text-xs font-medium mt-1"
style={{ color: 'var(--text-secondary)' }}
aria-label={`You already own ${ownedQuantity} copies of this card`}
@ -218,16 +220,17 @@ export default function ScannedCardItem({
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
💎 Mark Owned
<span aria-hidden="true">💎 </span>
Mark Owned
</button>
<button
type="button"
onClick={onRemove}
className="px-3 py-2 rounded text-sm hover:opacity-80"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
title="Remove"
aria-label={`Remove ${card.name} from scan queue`}
>
🗑
<span aria-hidden="true">🗑</span>
</button>
</div>
@ -246,6 +249,7 @@ export default function ScannedCardItem({
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a collection`}
>
<option value="">📚 Add to Collection</option>
{collections.map((collection) => (
@ -270,6 +274,7 @@ export default function ScannedCardItem({
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a deck`}
>
<option value="">🃏 Add to Deck</option>
{decks.map((deck) => (

64
lib/use-focus-trap.js Normal file
View file

@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true'
);
}
/**
* Trap focus inside a modal while `active` and restore focus on close.
* Returns a ref to attach to the dialog panel (not the backdrop).
*/
export function useFocusTrap(active) {
const containerRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
if (!active) return;
previouslyFocusedRef.current = document.activeElement;
const container = containerRef.current;
if (!container) return;
const focusFirst = () => {
const nodes = getFocusableElements(container);
nodes[0]?.focus();
};
focusFirst();
const handleKeyDown = (event) => {
if (event.key !== 'Tab') return;
const nodes = getFocusableElements(container);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
const previous = previouslyFocusedRef.current;
if (previous && typeof previous.focus === 'function') {
previous.focus();
}
};
}, [active]);
return containerRef;
}

View file

@ -8,6 +8,7 @@ import OCRSettings from '../components/OCRSettings';
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth';
import { useFocusTrap } from '../lib/use-focus-trap.js';
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
@ -43,6 +44,7 @@ export default function Scanner() {
const [decks, setDecks] = useState([]);
const [showCreateCollection, setShowCreateCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const createCollectionDialogRef = useFocusTrap(showCreateCollection);
const [showOCRSettings, setShowOCRSettings] = useState(false);
// Bulk action states
@ -560,7 +562,12 @@ export default function Scanner() {
Scanned Cards
</h2>
<div className="flex items-center gap-2">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<div
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
aria-atomic="true"
>
{scannedCards.length} cards
</div>
{scannedCards.length > 0 && (
@ -580,17 +587,17 @@ export default function Scanner() {
{/* Scanned Cards Queue - Scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-3">
{scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2">📱</div>
<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>
) : (
scannedCards.map((card) => (
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
{scannedCards.map((card) => (
<li key={card.id}>
<ScannedCardItem
key={card.id}
card={card}
collections={collections}
decks={decks}
@ -604,13 +611,14 @@ export default function Scanner() {
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
onRemove={() => removeScannedCard(card.id)}
/>
))
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
</div>
{/* Floating Bulk Actions Toolbar */}
{selectedCards.size > 0 && (
@ -644,7 +652,8 @@ export default function Scanner() {
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' }}
>
💎 Mark Owned
<span aria-hidden="true">💎 </span>
Mark Owned
</button>
{collections.length > 0 && (
@ -659,6 +668,7 @@ export default function Scanner() {
disabled={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 collection"
>
<option value="">📚 Add to Collection</option>
{collections.map(collection => (
@ -681,6 +691,7 @@ export default function Scanner() {
disabled={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>
{decks.map(deck => (
@ -700,9 +711,9 @@ export default function Scanner() {
onClick={() => setSelectedCards(new Set())}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
title="Clear Selection"
aria-label="Clear selection"
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
@ -714,11 +725,22 @@ export default function Scanner() {
{/* Create Collection Modal */}
{showCreateCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="rounded-xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
<div
ref={createCollectionDialogRef}
className="rounded-xl p-6 max-w-md w-full mx-4"
style={{ backgroundColor: 'var(--bg-secondary)' }}
role="dialog"
aria-modal="true"
aria-labelledby="create-collection-title"
>
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection
</h3>
<label htmlFor="create-collection-name" className="sr-only">
Collection name
</label>
<input
id="create-collection-name"
type="text"
placeholder="Collection name..."
value={newCollectionName}