From 306c15de6591f0592e39c86dcc59a26e0a916c02 Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 20 Aug 2026 15:26:00 +0300 Subject: [PATCH] keyboard contracts and spoken strings for the a11y layer --- README.md | 2 +- frontend/src/components/MultiplayerPanel.tsx | 28 ++++---- frontend/src/components/ui/NumberInput.tsx | 29 +++++++-- frontend/src/components/ui/Select.tsx | 68 +++++++++++++++++--- frontend/src/hooks/useKeyboard.ts | 44 ++++++++----- frontend/src/hooks/useNarrator.ts | 32 ++++----- frontend/src/locales/en.json | 30 ++++++++- 7 files changed, 172 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 559bd90..0e82db4 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Instances talk to each other over ActivityPub, and nobody stands in the middle. **Comfort and access** - Light, dark, and system themes, warm palettes, and text scaling up to 200% -- A screen-reader native grid: every cell is focusable and announced with its number, letter, and state, and Alt+A / Alt+D read the across and down words aloud (/ reads the clue, space reads the cell, Ctrl+H the puzzle overview) +- A screen-reader native grid: every cell is focusable and announced with its number, letter, and state, and Alt+A / Alt+D read the across and down words aloud (/ or Alt+P reads the clue, space reads the cell, Ctrl+H the puzzle overview) - A voice narrator with three verbosity levels and an output mode (voice, screen reader, or both) so blind players get the same flow through their own screen reader - Colorblind palettes and reduced-motion cell animations - Sound effects and a tor-friendly mode that keeps the page light for slow connections diff --git a/frontend/src/components/MultiplayerPanel.tsx b/frontend/src/components/MultiplayerPanel.tsx index 43c5d97..907814e 100644 --- a/frontend/src/components/MultiplayerPanel.tsx +++ b/frontend/src/components/MultiplayerPanel.tsx @@ -199,19 +199,21 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) { // already selected, do nothing } else { const rect = e.currentTarget.getBoundingClientRect() - const canvas = document.createElement('canvas') - canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999' - document.body.appendChild(canvas) - const myConfetti = confetti.create(canvas, { resize: true }) - myConfetti({ - particleCount: 60, - spread: 70, - startVelocity: 25, - origin: { - x: (rect.left + rect.width / 2) / window.innerWidth, - y: (rect.top + rect.height / 2) / window.innerHeight, - }, - }).then(() => canvas.remove()) + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + const canvas = document.createElement('canvas') + canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999' + document.body.appendChild(canvas) + const myConfetti = confetti.create(canvas, { resize: true }) + myConfetti({ + particleCount: 60, + spread: 70, + startVelocity: 25, + origin: { + x: (rect.left + rect.width / 2) / window.innerWidth, + y: (rect.top + rect.height / 2) / window.innerHeight, + }, + }).then(() => canvas.remove()) + } } }} > diff --git a/frontend/src/components/ui/NumberInput.tsx b/frontend/src/components/ui/NumberInput.tsx index 17aa280..3c491e5 100644 --- a/frontend/src/components/ui/NumberInput.tsx +++ b/frontend/src/components/ui/NumberInput.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next' + interface Props { value: number min?: number @@ -9,14 +11,31 @@ interface Props { } export default function NumberInput({ value, min = 0, max = 999, onChange, className, style, 'aria-label': ariaLabel }: Props) { + const { t } = useTranslation() const clamp = (n: number) => Math.min(max, Math.max(min, n)) + const onKeyDown = (e: React.KeyboardEvent) => { + let next: number | null = null + if (e.key === 'ArrowUp') next = value + 1 + else if (e.key === 'ArrowDown') next = value - 1 + else if (e.key === 'PageUp') next = value + 10 + else if (e.key === 'PageDown') next = value - 10 + else if (e.key === 'Home') next = min + else if (e.key === 'End') next = max + if (next === null) return + e.preventDefault() + onChange(clamp(next)) + } + return (
- - {value} - + role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} + aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}> + + +
) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx index edb0aa8..5a4cee8 100644 --- a/frontend/src/components/ui/Select.tsx +++ b/frontend/src/components/ui/Select.tsx @@ -19,10 +19,14 @@ export default function Select({ value, options, onChange, className, style }: P const [open, setOpen] = useState(false) const triggerRef = useRef(null) const dropRef = useRef(null) + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]) const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) const listboxId = useId() const selected = options.find(o => String(o.value) === String(value)) + const selectedIndex = options.findIndex(o => String(o.value) === String(value)) + + const close = useCallback(() => setOpen(false), []) const updatePos = useCallback(() => { if (!triggerRef.current) return @@ -30,9 +34,16 @@ export default function Select({ value, options, onChange, className, style }: P setPos({ top: rect.bottom, left: rect.left, width: rect.width }) }, []) + // move real focus to the option matching the current value (or the first) useEffect(() => { if (!open) return updatePos() + const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0] + target?.focus() + }, [open, selectedIndex, updatePos]) + + useEffect(() => { + if (!open) return function onDown(e: MouseEvent) { const target = e.target if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) && @@ -40,36 +51,75 @@ export default function Select({ value, options, onChange, className, style }: P setOpen(false) } } - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') setOpen(false) - } window.addEventListener('scroll', updatePos, true) document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) return () => { window.removeEventListener('scroll', updatePos, true) document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) } }, [open, updatePos]) + const moveFocus = useCallback((delta: number) => { + const refs = optionRefs.current + const current = refs.findIndex(el => el === document.activeElement) + const next = Math.min(refs.length - 1, Math.max(0, (current >= 0 ? current : 0) + delta)) + refs[next]?.focus() + }, []) + + // trigger-level keys: arrows cycle values like a native select while closed + const onTriggerKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + const delta = e.key === 'ArrowDown' ? 1 : -1 + const next = Math.min(options.length - 1, Math.max(0, selectedIndex + delta)) + if (next !== selectedIndex && options[next]) onChange(options[next].value) + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setOpen(!open) + return + } + if (e.key === 'Escape') { + close() + } + } + + const onOptionKeyDown = (e: React.KeyboardEvent, index: number) => { + if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(1); return } + if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(-1); return } + if (e.key === 'Home') { e.preventDefault(); optionRefs.current[0]?.focus(); return } + if (e.key === 'End') { e.preventDefault(); optionRefs.current[options.length - 1]?.focus(); return } + if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); return } + if (e.key === 'Tab') { close(); return } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const o = options[index] + if (o) { onChange(o.value); close(); triggerRef.current?.focus() } + } + } + return (
{open && createPortal(
- {options.map(o => ( + {options.map((o, i) => (
) -} +} \ No newline at end of file diff --git a/frontend/src/hooks/useKeyboard.ts b/frontend/src/hooks/useKeyboard.ts index ad7ea73..9e4babb 100644 --- a/frontend/src/hooks/useKeyboard.ts +++ b/frontend/src/hooks/useKeyboard.ts @@ -1,12 +1,14 @@ import { useEffect } from 'react' import { useStore } from '../store' import type { Direction } from '../types' +import i18n from '../i18n' import * as narrator from '../narrator' function readCurrentCell() { + const t = i18n.t const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState() if (!puzzle || !selectedCell) { - narrator.speak('No cell selected') + narrator.speak(t('a11y.noCellSelected')) return } const { row, col } = selectedCell @@ -16,35 +18,38 @@ function readCurrentCell() { const pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0 const len = span?.cells.length || 0 - let msg = `Row ${row + 1}, Column ${col + 1}` - if (num) msg += `, number ${num}` - msg += letter ? `, letter ${letter}` : ', empty' - msg += `, ${direction}` - if (pos && len) msg += `, position ${pos} of ${len}` + let msg = t('grid.cellLabel', { row: row + 1, col: col + 1 }) + if (num) msg += t('grid.cellNumber', { num }) + msg += letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty') + msg += `, ${t(direction === 'across' ? 'game.across' : 'game.down')}` + if (pos && len) msg += t('a11y.position', { pos, len }) narrator.speak(msg) } function readWord(dir: Direction) { + const t = i18n.t const { puzzle, selectedCell, entries, wordSpans } = useStore.getState() if (!puzzle || !selectedCell) { - narrator.speak('No cell selected') + narrator.speak(t('a11y.noCellSelected')) return } const span = wordSpans.find(s => s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col) ) if (!span) { - narrator.speak(`No ${dir} word here`, false) + narrator.speak(t(dir === 'across' ? 'a11y.noAcrossWord' : 'a11y.noDownWord'), false) return } - const letters = span.cells.map(([r, c]) => entries[r]?.[c] || 'blank') - narrator.speak(`${dir === 'across' ? 'Across' : 'Down'} ${span.number}, ${span.cells.length} letters: ${letters.join(', ')}.`, false) + const letters = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.empty')).join(', ') + const dirLabel = t(dir === 'across' ? 'game.across' : 'game.down') + narrator.speak(t('a11y.wordReading', { dir: dirLabel, number: span.number, length: span.cells.length, letters }), false) } function readCurrentClue() { + const t = i18n.t const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState() if (!puzzle || activeClue === null) { - narrator.speak('No clue selected') + narrator.speak(t('a11y.noClueSelected')) return } const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down @@ -55,20 +60,22 @@ function readCurrentClue() { const len = span?.cells.length || 0 const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0 const v = narratorSettings.verbosity + const dirLabel = t(direction === 'across' ? 'game.across' : 'game.down') if (v === 'brief') { - narrator.speak(`${activeClue} ${direction}: ${clue.text}`) + narrator.speak(t('a11y.clueBrief', { number: activeClue, dir: dirLabel, text: clue.text })) } else if (v === 'normal') { - narrator.speak(`${activeClue} ${direction}, ${len} letters: ${clue.text}`) + narrator.speak(t('a11y.clueNormal', { number: activeClue, dir: dirLabel, length: len, text: clue.text })) } else { - narrator.speak(`${activeClue} ${direction}, ${len} letters, ${filled} filled: ${clue.text}`) + narrator.speak(t('a11y.clueVerbose', { number: activeClue, dir: dirLabel, length: len, filled, text: clue.text })) } } function readPuzzleOverview() { + const t = i18n.t const { puzzle, entries, wordSpans } = useStore.getState() if (!puzzle) { - narrator.speak('No puzzle loaded') + narrator.speak(t('a11y.noPuzzleLoaded')) return } let totalCells = 0 @@ -88,7 +95,10 @@ function readPuzzleOverview() { const remaining = totalClues - completedWords narrator.speak( - `${puzzle.width} by ${puzzle.height} puzzle, ${filledCells} of ${totalCells} cells filled, ${remaining} clues remaining` + t('a11y.overview', { + width: puzzle.width, height: puzzle.height, + filled: filledCells, total: totalCells, remaining, + }) ) } @@ -152,7 +162,7 @@ export function useKeyboard() { readCurrentCell() return } - if (key === '?' || key === '/') { + if (key === '?' || key === '/' || (e.altKey && key.toLowerCase() === 'p')) { e.preventDefault() readCurrentClue() return diff --git a/frontend/src/hooks/useNarrator.ts b/frontend/src/hooks/useNarrator.ts index 86486e4..4215ab9 100644 --- a/frontend/src/hooks/useNarrator.ts +++ b/frontend/src/hooks/useNarrator.ts @@ -1,13 +1,14 @@ import { useEffect, useRef } from 'react' import { useStore } from '../store' +import i18n from '../i18n' import * as narrator from '../narrator' -function formatTime(secs: number): string { +function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string { const m = Math.floor(secs / 60) const s = secs % 60 - if (m === 0) return `${s} second${s !== 1 ? 's' : ''}` - if (s === 0) return `${m} minute${m !== 1 ? 's' : ''}` - return `${m} minute${m !== 1 ? 's' : ''} ${s} second${s !== 1 ? 's' : ''}` + if (m === 0) return t('a11y.seconds', { count: s }) + if (s === 0) return t('a11y.minutes', { count: m }) + return `${t('a11y.minutes', { count: m })} ${t('a11y.seconds', { count: s })}` } export function useNarrator() { @@ -27,11 +28,12 @@ export function useNarrator() { useEffect(() => { const unsub = useStore.subscribe((state, prev) => { if (!state.narratorEnabled || !state.puzzle) return + const t = i18n.t const v = state.narratorSettings.verbosity // direction change if (state.direction !== prevDirection.current && prevDirection.current !== null) { - narrator.speak(`Now solving ${state.direction}`) + narrator.speak(t('a11y.nowSolving', { dir: t(state.direction === 'across' ? 'game.across' : 'game.down') })) } prevDirection.current = state.direction @@ -42,7 +44,7 @@ export function useNarrator() { const { row, col } = state.selectedCell const letter = state.entries[row]?.[col] || '' - const cellContent = letter ? letter : 'empty' + const cellContent = letter ? letter : t('a11y.empty') if (v === 'brief') { narrator.speak(`Row ${row + 1}, Column ${col + 1}`) @@ -75,8 +77,8 @@ export function useNarrator() { const old = prevEntries.current[r]?.[c] || '' if (cur && !old) { if (v === 'brief') narrator.speak(cur, false) - else if (v === 'normal') narrator.speak(`Entered ${cur}`, false) - else narrator.speak(`Entered ${cur} at Row ${r + 1}, Column ${c + 1}`, false) + else if (v === 'normal') narrator.speak(t('a11y.entered', { letter: cur }), false) + else narrator.speak(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }), false) // check word completion const span = state.wordSpans.find(s => @@ -88,7 +90,7 @@ export function useNarrator() { const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc]) if (!wasFilled) { const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('') - setTimeout(() => narrator.speak(`Word complete: ${word.split('').join(' ')}`, false), 300) + setTimeout(() => narrator.speak(t('a11y.wordComplete', { word: word.split('').join(' ') }), false), 300) } } } @@ -108,11 +110,11 @@ export function useNarrator() { const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0 if (v === 'brief') { - narrator.speak(`${state.activeClue} ${state.direction}: ${clue.text}`, false) + narrator.speak(`${state.activeClue} ${t(state.direction === 'across' ? 'game.across' : 'game.down')}: ${clue.text}`, false) } else if (v === 'normal') { - narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters: ${clue.text}`, false) + narrator.speak(t('a11y.clueNormal', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, text: clue.text }), false) } else { - narrator.speak(`${state.activeClue} ${state.direction}, ${len} letters, ${filled} filled: ${clue.text}`, false) + narrator.speak(t('a11y.clueVerbose', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, filled, text: clue.text }), false) } } } @@ -129,7 +131,7 @@ export function useNarrator() { ) const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0 if (letter && pos) { - narrator.speak(`Revealed letter ${letter} at position ${pos}`, false) + narrator.speak(t('a11y.revealed', { letter, pos }), false) } } } @@ -138,8 +140,8 @@ export function useNarrator() { // puzzle solved if (state.solved && !prevSolved.current) { - const time = formatTime(state.elapsed) - narrator.speak(`Puzzle complete. Time: ${time}`) + const time = formatTime(state.elapsed, t) + narrator.speak(t('a11y.solvedTime', { time })) } prevSolved.current = state.solved }) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 39ef943..d55e1e8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -79,6 +79,34 @@ "autoCheckTooltip": "Auto-check - marks wrong letters as you type", "shareResult": "Share result" }, + "a11y": { + "noCellSelected": "No cell selected", + "noClueSelected": "No clue selected", + "noPuzzleLoaded": "No puzzle loaded", + "noAcrossWord": "No across word here", + "noDownWord": "No down word here", + "position": ", position {{pos}} of {{len}}", + "wordReading": "{{dir}} {{number}}, {{length}} letters: {{letters}}", + "clueBrief": "{{number}} {{dir}}: {{text}}", + "clueNormal": "{{number}} {{dir}}, {{length}} letters: {{text}}", + "clueVerbose": "{{number}} {{dir}}, {{length}} letters, {{filled}} filled: {{text}}", + "empty": "empty", + "nowSolving": "Now solving {{dir}}", + "entered": "Entered {{letter}}", + "enteredAt": "Entered {{letter}} at Row {{row}}, Column {{col}}", + "wordComplete": "Word complete: {{word}}", + "revealed": "Revealed letter {{letter}} at position {{pos}}", + "solvedTime": "Puzzle complete. Time: {{time}}", + "seconds_one": "{{count}} second", + "seconds_other": "{{count}} seconds", + "minutes_one": "{{count}} minute", + "minutes_other": "{{count}} minutes", + "overview": "{{width}} by {{height}} puzzle, {{filled}} of {{total}} cells filled, {{remaining}} clues remaining" + }, + "ui": { + "increase": "Increase", + "decrease": "Decrease" + }, "grid": { "ariaLabel": "Crossword puzzle, {{width}} by {{height}}", "blackCell": "Black cell", @@ -928,7 +956,7 @@ "activityPubDomain": "your public domain", "activityPubActor": "optional, defaults to \"cruciverb\"", "activityPubReady": "Database tables are ready. RSA keys generate on first startup.", - "apHumor": "AP post humor", + "apHumor": "ActivityPub post humor", "apHumorDesc": "Custom taglines, sign-offs, and community middles added to daily ActivityPub posts. One entry per line.", "dailyTaglines": "Daily taglines", "dailySignoffs": "Daily sign-offs",