keyboard contracts and spoken strings for the a11y layer
This commit is contained in:
@@ -70,7 +70,7 @@ Instances talk to each other over ActivityPub, and nobody stands in the middle.
|
|||||||
|
|
||||||
**Comfort and access**
|
**Comfort and access**
|
||||||
- Light, dark, and system themes, warm palettes, and text scaling up to 200%
|
- 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
|
- 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
|
- Colorblind palettes and reduced-motion cell animations
|
||||||
- Sound effects and a tor-friendly mode that keeps the page light for slow connections
|
- Sound effects and a tor-friendly mode that keeps the page light for slow connections
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
// already selected, do nothing
|
// already selected, do nothing
|
||||||
} else {
|
} else {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
|
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
|
||||||
document.body.appendChild(canvas)
|
document.body.appendChild(canvas)
|
||||||
@@ -213,6 +214,7 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
|
|||||||
},
|
},
|
||||||
}).then(() => canvas.remove())
|
}).then(() => canvas.remove())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconConfettiFilled size={16} />
|
<IconConfettiFilled size={16} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: number
|
value: number
|
||||||
min?: 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) {
|
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 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 (
|
return (
|
||||||
<div className={`cv-number ${className || ''}`} style={style}
|
<div className={`cv-number ${className || ''}`} style={style}
|
||||||
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel}>
|
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}
|
||||||
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min} aria-label="Decrease">-</button>
|
aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}>
|
||||||
<span className="cv-number-value">{value}</span>
|
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min}
|
||||||
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max} aria-label="Increase">+</button>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,14 @@ export default function Select({ value, options, onChange, className, style }: P
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const dropRef = useRef<HTMLDivElement>(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||||
const listboxId = useId()
|
const listboxId = useId()
|
||||||
|
|
||||||
const selected = options.find(o => String(o.value) === String(value))
|
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(() => {
|
const updatePos = useCallback(() => {
|
||||||
if (!triggerRef.current) return
|
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 })
|
setPos({ top: rect.bottom, left: rect.left, width: rect.width })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// move real focus to the option matching the current value (or the first)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
updatePos()
|
updatePos()
|
||||||
|
const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0]
|
||||||
|
target?.focus()
|
||||||
|
}, [open, selectedIndex, updatePos])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
function onDown(e: MouseEvent) {
|
function onDown(e: MouseEvent) {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (target instanceof Node && dropRef.current && !dropRef.current.contains(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)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') setOpen(false)
|
|
||||||
}
|
|
||||||
window.addEventListener('scroll', updatePos, true)
|
window.addEventListener('scroll', updatePos, true)
|
||||||
document.addEventListener('mousedown', onDown)
|
document.addEventListener('mousedown', onDown)
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', updatePos, true)
|
window.removeEventListener('scroll', updatePos, true)
|
||||||
document.removeEventListener('mousedown', onDown)
|
document.removeEventListener('mousedown', onDown)
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
}
|
}
|
||||||
}, [open, updatePos])
|
}, [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 (
|
return (
|
||||||
<div className={`cv-select ${className || ''}`} style={style}>
|
<div className={`cv-select ${className || ''}`} style={style}>
|
||||||
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
|
<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>
|
<span>{selected?.label || String(value)}</span>
|
||||||
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
||||||
</button>
|
</button>
|
||||||
{open && createPortal(
|
{open && createPortal(
|
||||||
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
|
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
|
||||||
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
|
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
|
||||||
{options.map(o => (
|
{options.map((o, i) => (
|
||||||
<button
|
<button
|
||||||
key={String(o.value)}
|
key={String(o.value)}
|
||||||
|
ref={el => { optionRefs.current[i] = el }}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={String(o.value) === String(value)}
|
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' : ''}`}
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { Direction } from '../types'
|
import type { Direction } from '../types'
|
||||||
|
import i18n from '../i18n'
|
||||||
import * as narrator from '../narrator'
|
import * as narrator from '../narrator'
|
||||||
|
|
||||||
function readCurrentCell() {
|
function readCurrentCell() {
|
||||||
|
const t = i18n.t
|
||||||
const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState()
|
const { puzzle, selectedCell, entries, direction, wordSpans, activeClue } = useStore.getState()
|
||||||
if (!puzzle || !selectedCell) {
|
if (!puzzle || !selectedCell) {
|
||||||
narrator.speak('No cell selected')
|
narrator.speak(t('a11y.noCellSelected'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { row, col } = selectedCell
|
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 pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0
|
||||||
const len = span?.cells.length || 0
|
const len = span?.cells.length || 0
|
||||||
|
|
||||||
let msg = `Row ${row + 1}, Column ${col + 1}`
|
let msg = t('grid.cellLabel', { row: row + 1, col: col + 1 })
|
||||||
if (num) msg += `, number ${num}`
|
if (num) msg += t('grid.cellNumber', { num })
|
||||||
msg += letter ? `, letter ${letter}` : ', empty'
|
msg += letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')
|
||||||
msg += `, ${direction}`
|
msg += `, ${t(direction === 'across' ? 'game.across' : 'game.down')}`
|
||||||
if (pos && len) msg += `, position ${pos} of ${len}`
|
if (pos && len) msg += t('a11y.position', { pos, len })
|
||||||
narrator.speak(msg)
|
narrator.speak(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readWord(dir: Direction) {
|
function readWord(dir: Direction) {
|
||||||
|
const t = i18n.t
|
||||||
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
|
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
|
||||||
if (!puzzle || !selectedCell) {
|
if (!puzzle || !selectedCell) {
|
||||||
narrator.speak('No cell selected')
|
narrator.speak(t('a11y.noCellSelected'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const span = wordSpans.find(s =>
|
const span = wordSpans.find(s =>
|
||||||
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
|
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
|
||||||
)
|
)
|
||||||
if (!span) {
|
if (!span) {
|
||||||
narrator.speak(`No ${dir} word here`, false)
|
narrator.speak(t(dir === 'across' ? 'a11y.noAcrossWord' : 'a11y.noDownWord'), false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || 'blank')
|
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.empty')).join(', ')
|
||||||
narrator.speak(`${dir === 'across' ? 'Across' : 'Down'} ${span.number}, ${span.cells.length} letters: ${letters.join(', ')}.`, false)
|
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() {
|
function readCurrentClue() {
|
||||||
|
const t = i18n.t
|
||||||
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
||||||
if (!puzzle || activeClue === null) {
|
if (!puzzle || activeClue === null) {
|
||||||
narrator.speak('No clue selected')
|
narrator.speak(t('a11y.noClueSelected'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
|
||||||
@@ -55,20 +60,22 @@ function readCurrentClue() {
|
|||||||
const len = span?.cells.length || 0
|
const len = span?.cells.length || 0
|
||||||
const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0
|
const filled = span ? span.cells.filter(([r, c]) => entries[r]?.[c]).length : 0
|
||||||
const v = narratorSettings.verbosity
|
const v = narratorSettings.verbosity
|
||||||
|
const dirLabel = t(direction === 'across' ? 'game.across' : 'game.down')
|
||||||
|
|
||||||
if (v === 'brief') {
|
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') {
|
} 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 {
|
} 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() {
|
function readPuzzleOverview() {
|
||||||
|
const t = i18n.t
|
||||||
const { puzzle, entries, wordSpans } = useStore.getState()
|
const { puzzle, entries, wordSpans } = useStore.getState()
|
||||||
if (!puzzle) {
|
if (!puzzle) {
|
||||||
narrator.speak('No puzzle loaded')
|
narrator.speak(t('a11y.noPuzzleLoaded'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let totalCells = 0
|
let totalCells = 0
|
||||||
@@ -88,7 +95,10 @@ function readPuzzleOverview() {
|
|||||||
const remaining = totalClues - completedWords
|
const remaining = totalClues - completedWords
|
||||||
|
|
||||||
narrator.speak(
|
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()
|
readCurrentCell()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key === '?' || key === '/') {
|
if (key === '?' || key === '/' || (e.altKey && key.toLowerCase() === 'p')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
readCurrentClue()
|
readCurrentClue()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
import i18n from '../i18n'
|
||||||
import * as narrator from '../narrator'
|
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 m = Math.floor(secs / 60)
|
||||||
const s = secs % 60
|
const s = secs % 60
|
||||||
if (m === 0) return `${s} second${s !== 1 ? 's' : ''}`
|
if (m === 0) return t('a11y.seconds', { count: s })
|
||||||
if (s === 0) return `${m} minute${m !== 1 ? 's' : ''}`
|
if (s === 0) return t('a11y.minutes', { count: m })
|
||||||
return `${m} minute${m !== 1 ? 's' : ''} ${s} second${s !== 1 ? 's' : ''}`
|
return `${t('a11y.minutes', { count: m })} ${t('a11y.seconds', { count: s })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNarrator() {
|
export function useNarrator() {
|
||||||
@@ -27,11 +28,12 @@ export function useNarrator() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useStore.subscribe((state, prev) => {
|
const unsub = useStore.subscribe((state, prev) => {
|
||||||
if (!state.narratorEnabled || !state.puzzle) return
|
if (!state.narratorEnabled || !state.puzzle) return
|
||||||
|
const t = i18n.t
|
||||||
const v = state.narratorSettings.verbosity
|
const v = state.narratorSettings.verbosity
|
||||||
|
|
||||||
// direction change
|
// direction change
|
||||||
if (state.direction !== prevDirection.current && prevDirection.current !== null) {
|
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
|
prevDirection.current = state.direction
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export function useNarrator() {
|
|||||||
|
|
||||||
const { row, col } = state.selectedCell
|
const { row, col } = state.selectedCell
|
||||||
const letter = state.entries[row]?.[col] || ''
|
const letter = state.entries[row]?.[col] || ''
|
||||||
const cellContent = letter ? letter : 'empty'
|
const cellContent = letter ? letter : t('a11y.empty')
|
||||||
|
|
||||||
if (v === 'brief') {
|
if (v === 'brief') {
|
||||||
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
|
narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
|
||||||
@@ -75,8 +77,8 @@ export function useNarrator() {
|
|||||||
const old = prevEntries.current[r]?.[c] || ''
|
const old = prevEntries.current[r]?.[c] || ''
|
||||||
if (cur && !old) {
|
if (cur && !old) {
|
||||||
if (v === 'brief') narrator.speak(cur, false)
|
if (v === 'brief') narrator.speak(cur, false)
|
||||||
else if (v === 'normal') narrator.speak(`Entered ${cur}`, false)
|
else if (v === 'normal') narrator.speak(t('a11y.entered', { letter: cur }), false)
|
||||||
else narrator.speak(`Entered ${cur} at Row ${r + 1}, Column ${c + 1}`, false)
|
else narrator.speak(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }), false)
|
||||||
|
|
||||||
// check word completion
|
// check word completion
|
||||||
const span = state.wordSpans.find(s =>
|
const span = state.wordSpans.find(s =>
|
||||||
@@ -88,7 +90,7 @@ export function useNarrator() {
|
|||||||
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
|
||||||
if (!wasFilled) {
|
if (!wasFilled) {
|
||||||
const word = span.cells.map(([wr, wc]) => state.entries[wr][wc]).join('')
|
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
|
const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
|
||||||
|
|
||||||
if (v === 'brief') {
|
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') {
|
} 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 {
|
} 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
|
const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
|
||||||
if (letter && pos) {
|
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
|
// puzzle solved
|
||||||
if (state.solved && !prevSolved.current) {
|
if (state.solved && !prevSolved.current) {
|
||||||
const time = formatTime(state.elapsed)
|
const time = formatTime(state.elapsed, t)
|
||||||
narrator.speak(`Puzzle complete. Time: ${time}`)
|
narrator.speak(t('a11y.solvedTime', { time }))
|
||||||
}
|
}
|
||||||
prevSolved.current = state.solved
|
prevSolved.current = state.solved
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,6 +79,34 @@
|
|||||||
"autoCheckTooltip": "Auto-check - marks wrong letters as you type",
|
"autoCheckTooltip": "Auto-check - marks wrong letters as you type",
|
||||||
"shareResult": "Share result"
|
"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": {
|
"grid": {
|
||||||
"ariaLabel": "Crossword puzzle, {{width}} by {{height}}",
|
"ariaLabel": "Crossword puzzle, {{width}} by {{height}}",
|
||||||
"blackCell": "Black cell",
|
"blackCell": "Black cell",
|
||||||
@@ -928,7 +956,7 @@
|
|||||||
"activityPubDomain": "your public domain",
|
"activityPubDomain": "your public domain",
|
||||||
"activityPubActor": "optional, defaults to \"cruciverb\"",
|
"activityPubActor": "optional, defaults to \"cruciverb\"",
|
||||||
"activityPubReady": "Database tables are ready. RSA keys generate on first startup.",
|
"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.",
|
"apHumorDesc": "Custom taglines, sign-offs, and community middles added to daily ActivityPub posts. One entry per line.",
|
||||||
"dailyTaglines": "Daily taglines",
|
"dailyTaglines": "Daily taglines",
|
||||||
"dailySignoffs": "Daily sign-offs",
|
"dailySignoffs": "Daily sign-offs",
|
||||||
|
|||||||
Reference in New Issue
Block a user