keyboard contracts and spoken strings for the a11y layer

This commit is contained in:
2026-08-20 15:26:00 +03:00
parent a2938e0d64
commit 306c15de65
7 changed files with 172 additions and 61 deletions
+1 -1
View File
@@ -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
+15 -13
View File
@@ -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())
}
}
}}
>
+23 -4
View File
@@ -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 (
<div className={`cv-number ${className || ''}`} style={style}
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel}>
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min} aria-label="Decrease">-</button>
<span className="cv-number-value">{value}</span>
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max} aria-label="Increase">+</button>
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}
aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}>
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min}
aria-label={t('ui.decrease')} tabIndex={-1}>-</button>
<span className="cv-number-value" aria-hidden="true">{value}</span>
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max}
aria-label={t('ui.increase')} tabIndex={-1}>+</button>
</div>
)
}
+58 -8
View File
@@ -19,10 +19,14 @@ export default function Select({ value, options, onChange, className, style }: P
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(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 (
<div className={`cv-select ${className || ''}`} style={style}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}>
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}
onKeyDown={onTriggerKeyDown}>
<span>{selected?.label || String(value)}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
{open && createPortal(
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
{options.map(o => (
{options.map((o, i) => (
<button
key={String(o.value)}
ref={el => { optionRefs.current[i] = el }}
role="option"
aria-selected={String(o.value) === String(value)}
tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1}
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false) }}
onClick={() => { onChange(o.value); close() }}
onKeyDown={e => onOptionKeyDown(e, i)}
type="button"
>
{o.label}
+27 -17
View File
@@ -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
+17 -15
View File
@@ -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
})
+29 -1
View File
@@ -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",