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** **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
+15 -13
View File
@@ -199,19 +199,21 @@ 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()
const canvas = document.createElement('canvas') if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999' const canvas = document.createElement('canvas')
document.body.appendChild(canvas) canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
const myConfetti = confetti.create(canvas, { resize: true }) document.body.appendChild(canvas)
myConfetti({ const myConfetti = confetti.create(canvas, { resize: true })
particleCount: 60, myConfetti({
spread: 70, particleCount: 60,
startVelocity: 25, spread: 70,
origin: { startVelocity: 25,
x: (rect.left + rect.width / 2) / window.innerWidth, origin: {
y: (rect.top + rect.height / 2) / window.innerHeight, x: (rect.left + rect.width / 2) / window.innerWidth,
}, y: (rect.top + rect.height / 2) / window.innerHeight,
}).then(() => canvas.remove()) },
}).then(() => canvas.remove())
}
} }
}} }}
> >
+24 -5
View File
@@ -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>
) )
} }
+59 -9
View File
@@ -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}
@@ -80,4 +130,4 @@ export default function Select({ value, options, onChange, className, style }: P
)} )}
</div> </div>
) )
} }
+27 -17
View File
@@ -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
+17 -15
View File
@@ -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
}) })
+29 -1
View File
@@ -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",