s.number === activeClue && s.direction === direction)
- const pos = span ? span.cells.findIndex(([r, c]) => r === row && c === col) + 1 : 0
- const len = span?.cells.length || 0
-
- 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(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(t(dir === 'across' ? 'a11y.noAcrossWord' : 'a11y.noDownWord'), false)
- return
- }
- 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(t('a11y.noClueSelected'))
- return
- }
- const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
- const clue = clues.find(c => c.number === activeClue)
- if (!clue) return
-
- const span = wordSpans.find(s => s.number === activeClue && s.direction === direction)
- 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(t('a11y.clueBrief', { number: activeClue, dir: dirLabel, text: clue.text }))
- } else if (v === 'normal') {
- narrator.speak(t('a11y.clueNormal', { number: activeClue, dir: dirLabel, length: len, text: clue.text }))
- } else {
- 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(t('a11y.noPuzzleLoaded'))
- return
- }
- let totalCells = 0
- let filledCells = 0
- for (let r = 0; r < puzzle.height; r++) {
- for (let c = 0; c < puzzle.width; c++) {
- if (puzzle.cells[r][c] !== 'black') {
- totalCells++
- if (entries[r]?.[c]) filledCells++
- }
- }
- }
- const totalClues = puzzle.clues_across.length + puzzle.clues_down.length
- const completedWords = wordSpans.filter(s =>
- s.cells.every(([r, c]) => entries[r]?.[c])
- ).length
- const remaining = totalClues - completedWords
-
- narrator.speak(
- t('a11y.overview', {
- width: puzzle.width, height: puzzle.height,
- filled: filledCells, total: totalCells, remaining,
- })
- )
-}
+import { announce } from '../narrator'
function getGridEl(): HTMLElement | null {
- return document.querySelector('.grid-wrapper')
+ return document.querySelector('.grid-sr-grid')
}
-// tracks whether focus (or the last pointer interaction) is inside the grid,
-// so typing after clicking a header button does not fill grid cells
-let gridHasFocus = false
-
-function updateGridFocus(target: EventTarget | null) {
- gridHasFocus = !!getGridEl()?.contains(document.activeElement) || (target instanceof Element && !!getGridEl()?.contains(target))
+// Announce the current clue and the fill pattern of its word (e.g. "1 Across,
+// 6 letters: P, blank, blank, blank, blank, T. What is a baby boar called.").
+function readWordPattern() {
+ const t = i18n.t
+ const { puzzle, wordSpans, entries, activeClue, direction } = useStore.getState()
+ if (!puzzle || activeClue === null) {
+ announce(t('a11y.noClueSelected'))
+ return
+ }
+ const span = wordSpans.find((s) => s.number === activeClue && s.direction === direction)
+ if (!span) return
+ const pattern = span.cells.map(([r, c]) => entries[r]?.[c] || t('a11y.blank')).join(', ')
+ const clues = direction === 'across' ? puzzle.clues_across : puzzle.clues_down
+ const clue = clues.find((c) => c.number === activeClue)
+ const dir = t(direction === 'across' ? 'game.across' : 'game.down')
+ announce(t('a11y.wordPattern', {
+ number: activeClue,
+ dir,
+ length: span.cells.length,
+ pattern,
+ clue: clue ? clue.text : '',
+ }))
}
export function useKeyboard() {
@@ -119,100 +36,42 @@ export function useKeyboard() {
const typeLetter = useStore((s) => s.typeLetter)
const deleteLetter = useStore((s) => s.deleteLetter)
const moveSelection = useStore((s) => s.moveSelection)
+ const moveRowEdge = useStore((s) => s.moveRowEdge)
const toggleDirection = useStore((s) => s.toggleDirection)
- const tabClue = useStore((s) => s.tabClue)
- const narratorEnabled = useStore((s) => s.narratorEnabled)
const undo = useStore((s) => s.undo)
const redo = useStore((s) => s.redo)
useEffect(() => {
if (!puzzle) return
-
- gridHasFocus = !!getGridEl()?.contains(document.activeElement)
-
- const onPointerDown = (e: PointerEvent) => updateGridFocus(e.target)
- const onFocusIn = (e: FocusEvent) => updateGridFocus(e.target)
- document.addEventListener('pointerdown', onPointerDown, true)
- document.addEventListener('focusin', onFocusIn)
-
- // give the grid initial keyboard focus when nothing else is focused
- if (document.activeElement === document.body || document.activeElement === null) {
- const firstCell = getGridEl()?.querySelector('button.grid-sr-cell')
- if (firstCell instanceof HTMLButtonElement) firstCell.focus()
- }
+ const grid = getGridEl()
+ if (!grid) return
function onKeyDown(e: KeyboardEvent) {
- if (
- e.target instanceof HTMLInputElement ||
- e.target instanceof HTMLTextAreaElement ||
- e.target instanceof HTMLSelectElement
- )
- return
-
- // only handle letter input when the grid area has focus
- const gridFocused = gridHasFocus || !!getGridEl()?.contains(document.activeElement)
-
const key = e.key
- const narr = useStore.getState().narratorEnabled
+ const ctrl = e.ctrlKey || e.metaKey
- // narrator-only shortcuts - only when grid is focused
- if (narr && gridFocused) {
- if (key === ' ') {
- e.preventDefault()
- readCurrentCell()
- return
- }
- if (key === '?' || key === '/' || (e.altKey && key.toLowerCase() === 'p')) {
- e.preventDefault()
- readCurrentClue()
- return
- }
- if ((e.ctrlKey && key.toLowerCase() === 'h') || key === 'F1') {
- e.preventDefault()
- readPuzzleOverview()
- return
- }
- if (e.altKey && key.toLowerCase() === 'a') {
- e.preventDefault()
- readWord('across')
- return
- }
- if (e.altKey && key.toLowerCase() === 'd') {
- e.preventDefault()
- readWord('down')
- return
- }
- if (key === 'Tab') {
- e.preventDefault()
- tabClue(!e.shiftKey)
- // clue announcement handled by useNarrator hook
- return
- }
- }
-
- if (gridFocused && e.ctrlKey && key.toLowerCase() === 'z' && !e.shiftKey) {
+ if (ctrl && key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault()
undo()
return
}
- if (gridFocused && ((e.ctrlKey && key.toLowerCase() === 'y') || (e.ctrlKey && e.shiftKey && key.toLowerCase() === 'z'))) {
+ if (ctrl && (key.toLowerCase() === 'y' || (e.shiftKey && key.toLowerCase() === 'z'))) {
e.preventDefault()
redo()
return
}
+ if (e.altKey && key.toLowerCase() === 'w') {
+ e.preventDefault()
+ readWordPattern()
+ return
+ }
- if (gridFocused && key.length === 1 && key >= 'a' && key <= 'z') {
+ if (!e.ctrlKey && !e.metaKey && !e.altKey && key.length === 1 &&
+ ((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z'))) {
e.preventDefault()
typeLetter(key)
return
}
- if (gridFocused && key.length === 1 && key >= 'A' && key <= 'Z') {
- e.preventDefault()
- typeLetter(key)
- return
- }
-
- if (!gridFocused) return
switch (key) {
case 'Backspace':
@@ -239,22 +98,22 @@ export function useKeyboard() {
e.preventDefault()
moveSelection(0, 1)
break
+ case 'Home':
+ e.preventDefault()
+ moveRowEdge(true)
+ break
+ case 'End':
+ e.preventDefault()
+ moveRowEdge(false)
+ break
case ' ':
e.preventDefault()
toggleDirection()
break
- case 'Tab':
- e.preventDefault()
- tabClue(!e.shiftKey)
- break
}
}
- document.addEventListener('keydown', onKeyDown)
- return () => {
- document.removeEventListener('keydown', onKeyDown)
- document.removeEventListener('pointerdown', onPointerDown, true)
- document.removeEventListener('focusin', onFocusIn)
- }
- }, [puzzle, typeLetter, deleteLetter, moveSelection, toggleDirection, tabClue, narratorEnabled, undo, redo])
-}
+ grid.addEventListener('keydown', onKeyDown)
+ return () => grid.removeEventListener('keydown', onKeyDown)
+ }, [puzzle, typeLetter, deleteLetter, moveSelection, moveRowEdge, toggleDirection, undo, redo])
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useNarrator.ts b/frontend/src/hooks/useNarrator.ts
index 4215ab9..045a024 100644
--- a/frontend/src/hooks/useNarrator.ts
+++ b/frontend/src/hooks/useNarrator.ts
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { useStore } from '../store'
import i18n from '../i18n'
-import * as narrator from '../narrator'
+import { announce } from '../narrator'
function formatTime(secs: number, t: (key: string, opts?: { count: number }) => string): string {
const m = Math.floor(secs / 60)
@@ -12,77 +12,54 @@ function formatTime(secs: number, t: (key: string, opts?: { count: number }) =>
}
export function useNarrator() {
- const prevCell = useRef<{ row: number; col: number } | null>(null)
- const prevDirection = useRef
(null)
+ const prevClueKey = useRef(null)
const prevEntries = useRef(null)
- const prevActiveClue = useRef(null)
const prevSolved = useRef(false)
- // sync settings to narrator module whenever they change
- const narratorSettings = useStore(s => s.narratorSettings)
- useEffect(() => {
- narrator.updateSettings(narratorSettings)
- }, [narratorSettings])
-
- // subscribe to store changes
useEffect(() => {
const unsub = useStore.subscribe((state, prev) => {
- if (!state.narratorEnabled || !state.puzzle) return
+ if (!state.puzzle) return
const t = i18n.t
const v = state.narratorSettings.verbosity
- // direction change
- if (state.direction !== prevDirection.current && prevDirection.current !== null) {
- narrator.speak(t('a11y.nowSolving', { dir: t(state.direction === 'across' ? 'game.across' : 'game.down') }))
- }
- prevDirection.current = state.direction
+ // Active clue change (moving into a new word or toggling direction).
+ if (state.activeClue !== null) {
+ const key = `${state.activeClue}-${state.direction}`
+ if (key !== prevClueKey.current) {
+ const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
+ const clue = clues.find((c) => c.number === state.activeClue)
+ if (clue) {
+ const span = state.wordSpans.find((s) => s.number === state.activeClue && s.direction === state.direction)
+ const len = span?.cells.length || 0
+ const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
+ const dir = t(state.direction === 'across' ? 'game.across' : 'game.down')
- // cell selection
- if (state.selectedCell &&
- (state.selectedCell.row !== prevCell.current?.row ||
- state.selectedCell.col !== prevCell.current?.col)) {
-
- const { row, col } = state.selectedCell
- const letter = state.entries[row]?.[col] || ''
- const cellContent = letter ? letter : t('a11y.empty')
-
- if (v === 'brief') {
- narrator.speak(`Row ${row + 1}, Column ${col + 1}`)
- } else if (v === 'normal') {
- narrator.speak(`Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`)
- } else {
- const puzzle = state.puzzle
- let neighbors = ''
- if (col > 0 && puzzle.cells[row][col - 1] !== 'black') {
- const l = state.entries[row][col - 1]
- neighbors += l ? `${l} to the left` : 'blank to the left'
+ if (v === 'brief') {
+ announce(t('a11y.clueBrief', { number: state.activeClue, dir, text: clue.text }))
+ } else if (v === 'normal') {
+ announce(t('a11y.clueNormal', { number: state.activeClue, dir, length: len, text: clue.text }))
+ } else {
+ announce(t('a11y.clueVerbose', { number: state.activeClue, dir, length: len, filled, text: clue.text }))
+ }
}
- if (row > 0 && puzzle.cells[row - 1]?.[col] !== 'black') {
- const l = state.entries[row - 1]?.[col]
- if (neighbors) neighbors += ', '
- neighbors += l ? `${l} above` : 'blank above'
- }
- let msg = `Row ${row + 1}, Column ${col + 1}, ${cellContent}, ${state.direction}`
- if (neighbors) msg += `. Neighboring: ${neighbors}`
- narrator.speak(msg)
}
}
- prevCell.current = state.selectedCell
+ prevClueKey.current = state.activeClue !== null ? `${state.activeClue}-${state.direction}` : null
- // letter entry - detect new letter in the grid
+ // letter entry and deletion
if (prevEntries.current && state.entries !== prevEntries.current) {
for (let r = 0; r < state.entries.length; r++) {
for (let c = 0; c < state.entries[r].length; c++) {
const cur = state.entries[r][c]
const old = prevEntries.current[r]?.[c] || ''
if (cur && !old) {
- if (v === 'brief') narrator.speak(cur, 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)
+ if (v === 'brief') announce(cur)
+ else if (v === 'normal') announce(t('a11y.entered', { letter: cur }))
+ else announce(t('a11y.enteredAt', { letter: cur, row: r + 1, col: c + 1 }))
- // check word completion
- const span = state.wordSpans.find(s =>
- s.cells.some(([sr, sc]) => sr === r && sc === c)
+ // word completion
+ const span = state.wordSpans.find((s) =>
+ s.cells.some(([sr, sc]) => sr === r && sc === c),
)
if (span) {
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
@@ -90,62 +67,58 @@ 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(t('a11y.wordComplete', { word: word.split('').join(' ') }), false), 300)
+ setTimeout(() => announce(t('a11y.wordComplete', { word: word.split('').join(' ') })), 300)
}
}
}
+ } else if (!cur && old) {
+ if (v !== 'brief') announce(t('a11y.deleted'))
}
}
}
}
prevEntries.current = state.entries
- // clue focus change
- if (state.activeClue !== null && state.activeClue !== prevActiveClue.current) {
- const clues = state.direction === 'across' ? state.puzzle.clues_across : state.puzzle.clues_down
- const clue = clues.find(c => c.number === state.activeClue)
- if (clue) {
- const span = state.wordSpans.find(s => s.number === state.activeClue && s.direction === state.direction)
- const len = span?.cells.length || 0
- const filled = span ? span.cells.filter(([r, c]) => state.entries[r]?.[c]).length : 0
-
- if (v === 'brief') {
- narrator.speak(`${state.activeClue} ${t(state.direction === 'across' ? 'game.across' : 'game.down')}: ${clue.text}`, false)
- } else if (v === 'normal') {
- 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(t('a11y.clueVerbose', { number: state.activeClue, dir: t(state.direction === 'across' ? 'game.across' : 'game.down'), length: len, filled, text: clue.text }), false)
- }
- }
- }
- prevActiveClue.current = state.activeClue
-
- // hint reveal - detect newly revealed cells
- if (prev.cellStatus !== state.cellStatus && prevEntries.current) {
+ // correct / incorrect / revealed cell feedback
+ if (prev.cellStatus !== state.cellStatus) {
+ let correct = 0
+ let incorrect = 0
+ const revealed: [number, number][] = []
for (let r = 0; r < state.cellStatus.length; r++) {
for (let c = 0; c < state.cellStatus[r].length; c++) {
- if (state.cellStatus[r][c] === 'revealed' && prev.cellStatus[r]?.[c] !== 'revealed') {
- const letter = state.entries[r][c]
- const span = state.wordSpans.find(s =>
- s.cells.some(([sr, sc]) => sr === r && sc === c)
- )
- const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
- if (letter && pos) {
- narrator.speak(t('a11y.revealed', { letter, pos }), false)
- }
- }
+ const ns = state.cellStatus[r][c]
+ const ps = prev.cellStatus[r]?.[c]
+ if (ns === ps) continue
+ if (ns === 'correct') correct++
+ else if (ns === 'incorrect') incorrect++
+ else if (ns === 'revealed') revealed.push([r, c])
}
}
+
+ if (incorrect > 0) {
+ announce(incorrect === 1 ? t('a11y.incorrect') : t('a11y.incorrectMany', { count: incorrect }))
+ } else if (correct > 0) {
+ announce(correct === 1 ? t('a11y.correct') : t('a11y.correctMany', { count: correct }))
+ }
+
+ for (const [r, c] of revealed) {
+ const letter = state.entries[r][c]
+ const span = state.wordSpans.find((s) =>
+ s.cells.some(([sr, sc]) => sr === r && sc === c),
+ )
+ const pos = span ? span.cells.findIndex(([sr, sc]) => sr === r && sc === c) + 1 : 0
+ if (letter && pos) announce(t('a11y.revealed', { letter, pos }))
+ }
}
// puzzle solved
if (state.solved && !prevSolved.current) {
const time = formatTime(state.elapsed, t)
- narrator.speak(t('a11y.solvedTime', { time }))
+ announce(t('a11y.solvedTime', { time }))
}
prevSolved.current = state.solved
})
return unsub
}, [])
-}
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useStylus.ts b/frontend/src/hooks/useStylus.ts
deleted file mode 100644
index b1112d7..0000000
--- a/frontend/src/hooks/useStylus.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// stylus handling is now built into InkOverlay.tsx directly
-// this file kept for import compatibility
-export function useStylus(
- _canvasRef: React.RefObject,
- _svgRef: React.RefObject,
-) {}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 93d6141..bb49736 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -86,6 +86,36 @@
cursor: default;
}
+.grid-sr-cell:focus-visible {
+ outline: none;
+ box-shadow: inset 0 0 0 3px var(--accent);
+}
+
+.editor-grid-wrap {
+ position: relative;
+}
+
+.editor-sr-grid {
+ position: absolute;
+ inset: 0;
+ display: grid;
+}
+
+.editor-sr-cell {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ pointer-events: none;
+ outline: none;
+ cursor: default;
+}
+
+.editor-sr-cell:focus-visible {
+ outline: none;
+ box-shadow: inset 0 0 0 3px var(--accent);
+}
+
/* hidden typing field so touch devices can summon the on-screen keyboard */
.grid-type-input {
position: fixed;
@@ -567,6 +597,20 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
.grid-wrapper:focus-within {
outline: 2px solid var(--accent); outline-offset: 2px;
}
+
+.clue-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.clue-nav-current {
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--text-tertiary);
+}
@keyframes gridReveal {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
@@ -1195,8 +1239,6 @@ input[type="range"] { accent-color: var(--accent); }
color: var(--text-tertiary); margin-top: 0.125rem;
}
.settings-narrator-controls { display: flex; flex-direction: column; gap: 0.5rem; }
-.settings-narrator-detail { display: flex; flex-direction: column; gap: 0.75rem; padding-top: 0.5rem; }
-.settings-narrator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.settings-narrator-item { display: flex; flex-direction: column; gap: 0.25rem; }
.settings-label {
display: block; font-family: var(--font-mono); font-size: 0.563rem; font-weight: 500;
@@ -3383,15 +3425,6 @@ svg text {
}
.header-room-tag:hover { border-color: var(--border); }
-.header-narrator-tag {
- display: inline-flex; align-items: center; gap: 0.25rem;
- font-family: var(--font-mono); font-size: 0.563rem;
- letter-spacing: 0.06em; color: var(--accent);
- padding: 0.125rem 0.5rem;
- border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
- opacity: 0.8;
-}
-
/* welcome join room */
.welcome-join-room {
display: flex; align-items: center; gap: 0.375rem;
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index d55e1e8..f09452f 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -91,9 +91,19 @@
"clueNormal": "{{number}} {{dir}}, {{length}} letters: {{text}}",
"clueVerbose": "{{number}} {{dir}}, {{length}} letters, {{filled}} filled: {{text}}",
"empty": "empty",
+ "blank": "blank",
+ "wordPattern": "{{number}} {{dir}}, {{length}} letters: {{pattern}}. {{clue}}",
+ "clueNavigation": "Clue navigation",
+ "previousClue": "Previous clue",
+ "nextClue": "Next clue",
"nowSolving": "Now solving {{dir}}",
"entered": "Entered {{letter}}",
"enteredAt": "Entered {{letter}} at Row {{row}}, Column {{col}}",
+ "deleted": "Deleted",
+ "correct": "Correct",
+ "correctMany": "{{count}} correct",
+ "incorrect": "Incorrect",
+ "incorrectMany": "{{count}} incorrect",
"wordComplete": "Word complete: {{word}}",
"revealed": "Revealed letter {{letter}} at position {{pos}}",
"solvedTime": "Puzzle complete. Time: {{time}}",
@@ -118,6 +128,8 @@
"cellIncorrect": ", incorrect",
"cellRevealed": ", revealed",
"cellPencil": ", pencil letter {{letter}}",
+ "cellWord": ", {{number}} {{dir}}",
+ "cellClue": ", {{number}} {{dir}}: {{clue}}",
"handwritingCanvas": "Handwriting input area"
},
"welcome": {
@@ -163,8 +175,6 @@
"aboutInstance": "About this instance",
"signInPasskey": "Sign in with passkey",
"keyboardShortcuts": "Keyboard shortcuts",
- "narrator": "Narrator",
- "narratorActive": "Narrator active",
"newPuzzle": "New puzzle",
"startNewPuzzle": "Start a new puzzle",
"custom": "Custom",
@@ -191,20 +201,19 @@
"subtitle": "Available while the grid is focused",
"navigation": "Navigation",
"editing": "Editing",
- "narrator": "Narrator mode",
"arrows": "Move between cells",
"tabNext": "Jump to next clue",
"tabPrev": "Jump to previous clue",
"space": "Toggle across / down",
"escape": "Deselect cell",
+ "homeEnd": "Jump to start / end of row",
+ "reading": "Reading",
+ "readWord": "Read current clue and word",
"letters": "Enter a letter",
"backspace": "Clear current cell",
"delete": "Clear and move back",
"ctrlZ": "Undo last entry",
- "ctrlY": "Redo last entry",
- "question": "Re-read current clue",
- "ctrlH": "Puzzle overview",
- "f1": "Puzzle overview"
+ "ctrlY": "Redo last entry"
},
"difficulty": {
"veryEasy": "Very easy",
@@ -565,23 +574,13 @@
"pushComingSoon": "Coming soon"
},
"narrator": {
- "title": "Narrator",
- "narratorMode": "Narrator mode",
- "hint": "Speaks cell positions, clues, and game events aloud",
- "speechRate": "Speech rate: {{rate}}x",
- "pitch": "Pitch: {{pitch}}x",
- "voice": "Voice",
- "defaultVoice": "Default",
+ "title": "Screen reader",
+ "hint": "Announces cell positions, clues, and game events to screen readers and braille displays",
"verbosity": "Verbosity",
"brief": "Brief",
"normal": "Normal",
"verbose": "Verbose",
- "output": "Output",
- "outputVoice": "Voice",
- "outputScreenReader": "Screen reader",
- "outputBoth": "Both",
- "soundEffects": "Sound effects",
- "notSupported": "Your browser does not support speech synthesis"
+ "soundEffects": "Sound effects"
},
"session": {
"duration": "Session duration",
@@ -700,6 +699,11 @@
"rotationalSymmetry": "Rotational symmetry",
"mirrorSymmetry": "Mirror symmetry",
"noSymmetry": "No symmetry",
+ "symmetry": "Symmetry",
+ "width": "Width",
+ "height": "Height",
+ "delete": "Delete",
+ "offensivePattern": "That grid pattern is not allowed",
"checkAnswers": "Check answers",
"cluesClickToEdit": "Clues (click to edit)",
"writeClue": "Write a clue...",
diff --git a/frontend/src/narrator.ts b/frontend/src/narrator.ts
index 6364d49..de6d016 100644
--- a/frontend/src/narrator.ts
+++ b/frontend/src/narrator.ts
@@ -1,98 +1,37 @@
export interface NarratorSettings {
- enabled: boolean
- rate: number
- pitch: number
- voice: string
verbosity: 'brief' | 'normal' | 'verbose'
- mode: 'voice' | 'screenreader' | 'both'
sounds: boolean
}
export const defaultNarratorSettings: NarratorSettings = {
- enabled: false,
- rate: 1.0,
- pitch: 1.0,
- voice: '',
verbosity: 'normal',
- mode: 'voice',
sounds: true,
}
-let settings: NarratorSettings = { ...defaultNarratorSettings }
-
-export function updateSettings(s: NarratorSettings) {
- settings = s
-}
-
-export function isSupported(): boolean {
- return 'speechSynthesis' in window
-}
-
-export function getVoices(): SpeechSynthesisVoice[] {
- if (!isSupported()) return []
- return window.speechSynthesis.getVoices()
-}
-
-export function cancel() {
- if (!isSupported()) return
- window.speechSynthesis.cancel()
-}
-
-// a polite live region that screen readers announce from, created on demand
+// A polite live region that the screen reader (speech and braille) announces
+// from. There is no self-voicing here: announcing through the accessibility
+// tree lets the user's own screen reader and braille display do the reading.
let srRegion: HTMLElement | null = null
+let regionTimer: number | undefined
function getSrRegion(): HTMLElement {
if (!srRegion) {
srRegion = document.createElement('div')
srRegion.setAttribute('aria-live', 'polite')
srRegion.setAttribute('role', 'status')
+ srRegion.setAttribute('aria-atomic', 'true')
srRegion.className = 'visually-hidden'
document.body.appendChild(srRegion)
}
return srRegion
}
-function announceToScreenReader(text: string) {
+export function announce(text: string) {
const region = getSrRegion()
- const msg = document.createElement('span')
- msg.textContent = `${text} `
- region.appendChild(msg)
- while (region.children.length > 6) {
- const first = region.firstChild
- if (first) region.removeChild(first)
- }
- setTimeout(() => msg.remove(), 15000)
-}
-
-export function speak(text: string, interrupt = true) {
- if (!settings.enabled) return
-
- const mode = settings.mode
- const wantsVoice = (mode === 'voice' || mode === 'both') && isSupported()
- const wantsSr = mode === 'screenreader' || mode === 'both' || !isSupported()
-
- if (wantsSr) announceToScreenReader(text)
- if (!wantsVoice) return
-
- if (interrupt) cancel()
-
- const utterance = new SpeechSynthesisUtterance(text)
- utterance.rate = settings.rate
- utterance.pitch = settings.pitch
-
- if (settings.voice) {
- const voices = getVoices()
- const match = voices.find(v => v.name === settings.voice)
- if (match) utterance.voice = match
- }
-
- window.speechSynthesis.speak(utterance)
-}
-
-// voices load async - call this early so they're ready
-export function preloadVoices() {
- if (!isSupported()) return
- getVoices()
-}
-
-preloadVoices()
\ No newline at end of file
+ window.clearTimeout(regionTimer)
+ // Clear then set in a later task so repeated identical text still re-announces.
+ region.textContent = ''
+ regionTimer = window.setTimeout(() => {
+ region.textContent = text
+ }, 0)
+}
\ No newline at end of file
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index cf9ef4f..736e1ee 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -138,9 +138,7 @@ interface PuzzleStore {
pencilEntries: string[][]
togglePencilMode: () => void
- narratorEnabled: boolean
narratorSettings: NarratorSettings
- setNarratorEnabled: (enabled: boolean) => void
setNarratorSettings: (settings: Partial) => void
partySubmitAnswer: (answer: string) => void
@@ -168,6 +166,7 @@ interface PuzzleStore {
typeLetter: (letter: string) => void
deleteLetter: () => void
moveSelection: (dr: number, dc: number) => void
+ moveRowEdge: (home: boolean) => void
tabClue: (forward: boolean) => void
deselectCell: () => void
validateSolution: () => Promise
@@ -638,16 +637,10 @@ export const useStore = create((set, get) => ({
pencilMode: false,
pencilEntries: [],
- narratorEnabled: false,
narratorSettings: { ...defaultNarratorSettings },
- setNarratorEnabled: (enabled: boolean) => {
- set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } })
- },
-
setNarratorSettings: (partial: Partial) => {
- const next = { ...get().narratorSettings, ...partial }
- set({ narratorSettings: next, narratorEnabled: next.enabled })
+ set({ narratorSettings: { ...get().narratorSettings, ...partial } })
},
undo: () => {
@@ -1335,6 +1328,26 @@ export const useStore = create((set, get) => ({
}
},
+ moveRowEdge: (home) => {
+ const { selectedCell, puzzle, wordSpans, direction } = get()
+ if (!selectedCell || !puzzle) return
+ const row = selectedCell.row
+ const width = puzzle.width
+ let col: number | null = null
+ if (home) {
+ for (let c = 0; c < width; c++) {
+ if (puzzle.cells[row][c] !== 'black') { col = c; break }
+ }
+ } else {
+ for (let c = width - 1; c >= 0; c--) {
+ if (puzzle.cells[row][c] !== 'black') { col = c; break }
+ }
+ }
+ if (col === null) return
+ const span = findClueForCell(wordSpans, row, col, direction)
+ set({ selectedCell: { row, col }, activeClue: span?.number ?? get().activeClue })
+ },
+
tabClue: (forward) => {
const { wordSpans, direction, activeClue, puzzle } = get()
if (!puzzle) return