Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 306c15de65 | |||
| a2938e0d64 |
@@ -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
|
||||
|
||||
@@ -535,7 +535,7 @@ export default function AnalyticsDashboard({ onClose }: { onClose: () => void })
|
||||
|
||||
const presetDonutData = useMemo(() => {
|
||||
if (!data?.by_preset) return []
|
||||
const colors = ['var(--accent)', 'rgba(212,67,47,0.7)', 'rgba(212,67,47,0.45)', 'rgba(212,67,47,0.25)', 'rgba(212,67,47,0.12)']
|
||||
const colors = ['var(--accent)', 'color-mix(in srgb, var(--accent) 70%, var(--bg))', 'color-mix(in srgb, var(--accent) 50%, var(--bg))', 'color-mix(in srgb, var(--accent) 30%, var(--bg))', 'color-mix(in srgb, var(--accent) 15%, var(--bg))']
|
||||
return presetOrder
|
||||
.filter(k => data.by_preset[k]?.count > 0)
|
||||
.map((k, i) => ({
|
||||
|
||||
@@ -107,8 +107,8 @@ export default function Grid() {
|
||||
x={x} y={y}
|
||||
width={CELL} height={CELL}
|
||||
fill={fill}
|
||||
stroke="var(--cell-border)"
|
||||
strokeWidth={0.5}
|
||||
stroke={isSelected ? 'var(--accent)' : 'var(--cell-border)'}
|
||||
strokeWidth={isSelected ? 2 : 0.5}
|
||||
/>
|
||||
{!isBlack && num && (
|
||||
<text
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -80,4 +130,4 @@ export default function Select({ value, options, onChange, className, style }: P
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
+35
-25
@@ -13,58 +13,62 @@
|
||||
--text-secondary: #454545;
|
||||
--text-tertiary: #4a4a4a;
|
||||
--border: #e0dcd8;
|
||||
--border-light: #c5c0ba;
|
||||
--accent: #d4432f;
|
||||
--accent-hover: #ba3825;
|
||||
--accent-soft: rgba(212, 67, 47, 0.06);
|
||||
--border-light: #8f8b85;
|
||||
--accent: #9c2418;
|
||||
--accent-hover: #7e1d13;
|
||||
--accent-soft: rgba(156, 36, 24, 0.07);
|
||||
--btn-primary-text: #ffffff;
|
||||
--cell-white: #ffffff;
|
||||
--cell-black: #0d0d0d;
|
||||
--cell-selected: #f5c888;
|
||||
--cell-active: #f0d8a8;
|
||||
--cell-border: #8a8480;
|
||||
--correct: #2d7a4f;
|
||||
--incorrect: #c93030;
|
||||
--revealed: #3570a8;
|
||||
--correct: #18603a;
|
||||
--incorrect: #a90f1c;
|
||||
--revealed: #1d5286;
|
||||
--ink-color: #3a2820;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.06);
|
||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.08);
|
||||
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
--color-error: #ef4444;
|
||||
--color-success: #22c55e;
|
||||
--color-error: #a32020;
|
||||
--color-success: #0f612f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
||||
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
||||
--border: #2a2a2a; --border-light: #222;
|
||||
--accent: #e8594a; --accent-hover: #d04a3c; --accent-soft: rgba(232, 89, 74, 0.1);
|
||||
--border: #6b6b6b; --border-light: #8a8a8a;
|
||||
--accent: #ee8a82; --accent-hover: #f5a49d; --accent-soft: rgba(238, 138, 130, 0.12); --btn-primary-text: #161313;
|
||||
--cell-white: #1e1e1e; --cell-black: #090909;
|
||||
--cell-selected: #5a3818; --cell-active: #4a3020; --cell-border: #555555;
|
||||
--correct: #4cb87a; --incorrect: #e05050; --revealed: #5a9ad8; --ink-color: #d0b090;
|
||||
--cell-selected: #8a6a33; --cell-active: #7c6a3a; --cell-border: #7a7a7a;
|
||||
--correct: #4cb87a; --incorrect: #ed9f9f; --revealed: #7bb4e8; --ink-color: #d0b090;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
||||
--color-error: #f87171;
|
||||
--color-error: #f4a3a3;
|
||||
--color-success: #4ade80;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #111111; --bg-alt: #191919; --surface: #1a1a1a; --surface-raised: #222;
|
||||
--text: #ededed; --text-secondary: #b5b5b5; --text-tertiary: #ababab;
|
||||
--border: #2a2a2a; --border-light: #222;
|
||||
--accent: #e8594a; --accent-hover: #d04a3c; --accent-soft: rgba(232, 89, 74, 0.1);
|
||||
--border: #6b6b6b; --border-light: #8a8a8a;
|
||||
--accent: #ee8a82; --accent-hover: #f5a49d; --accent-soft: rgba(238, 138, 130, 0.12); --btn-primary-text: #161313;
|
||||
--cell-white: #1e1e1e; --cell-black: #090909;
|
||||
--cell-selected: #5a3818; --cell-active: #4a3020; --cell-border: #555555;
|
||||
--correct: #4cb87a; --incorrect: #e05050; --revealed: #5a9ad8; --ink-color: #d0b090;
|
||||
--cell-selected: #8a6a33; --cell-active: #7c6a3a; --cell-border: #7a7a7a;
|
||||
--correct: #4cb87a; --incorrect: #ed9f9f; --revealed: #7bb4e8; --ink-color: #d0b090;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2); --shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.4);
|
||||
--color-error: #f87171;
|
||||
--color-error: #f4a3a3;
|
||||
--color-success: #4ade80;
|
||||
}
|
||||
[data-colorblind="protanopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
||||
[data-colorblind="deuteranopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
||||
[data-colorblind="tritanopia"] { --correct: #ef4444; --incorrect: #06b6d4; }
|
||||
[data-colorblind="protanopia"] { --correct: #1d6fe0; --incorrect: #b45309; }
|
||||
:root[data-theme="dark"][data-colorblind="protanopia"] { --correct: #7db4f0; --incorrect: #f5b078; }
|
||||
[data-colorblind="deuteranopia"] { --correct: #1d6fe0; --incorrect: #b45309; }
|
||||
:root[data-theme="dark"][data-colorblind="deuteranopia"] { --correct: #7db4f0; --incorrect: #f5b078; }
|
||||
[data-colorblind="tritanopia"] { --correct: #dc2626; --incorrect: #0b6a82; }
|
||||
:root[data-theme="dark"][data-colorblind="tritanopia"] { --correct: #f1a0a0; --incorrect: #74d0e8; }
|
||||
|
||||
.grid-sr-grid {
|
||||
position: absolute;
|
||||
@@ -163,7 +167,7 @@ body { font-family: var(--font-body); background: var(--bg); color: var(--text);
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--btn-primary-text); border-color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
||||
.btn-sm { font-size: 0.625rem; padding: 0.5rem 0.75rem; min-height: 2.75rem; min-width: 2.75rem; }
|
||||
.btn-ghost { border-color: transparent; }
|
||||
@@ -2648,7 +2652,7 @@ svg text {
|
||||
.cv-select { position: relative; display: inline-block; }
|
||||
.cv-select-trigger {
|
||||
display: flex; align-items: center; gap: 6px; justify-content: space-between;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
background: var(--surface); border: 1px solid var(--border-light);
|
||||
color: var(--text); cursor: pointer; min-width: 6rem;
|
||||
font-family: var(--font-mono); font-size: 0.688rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
@@ -2675,7 +2679,7 @@ svg text {
|
||||
|
||||
.cv-number {
|
||||
display: inline-flex; align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.cv-number-btn {
|
||||
background: var(--surface); border: none; cursor: pointer;
|
||||
@@ -3890,3 +3894,9 @@ svg text {
|
||||
.settings-body { flex-direction: column; }
|
||||
.settings-sidebar { flex-direction: row; overflow-x: auto; }
|
||||
}
|
||||
|
||||
/* focus-visible contracts for composite controls */
|
||||
.cv-number-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cv-select-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cv-select-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user