Code-review follow-ups: httputil, validation caps, a11y

Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
  ServerError); drop the triple-duplicated helpers in docs, suggestions,
  vocab. ServerError now logs the real error and returns a generic 500 so
  raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
  instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
  phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
  /api/images (own 10 MiB limit).

Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
  array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
  on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.

Tests: add doc_id-validation and field-clamp coverage; full suite green.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 16:59:23 -07:00
parent 4161830da6
commit 8c6bc1604b
18 changed files with 436 additions and 204 deletions
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react'
// useFocusTrap makes a slide-over / modal keyboard-accessible. Attach the
// returned ref to the dialog container and, while it's mounted, it:
// • moves focus into the panel on open (so Tab/Escape work without a click),
// • keeps Tab/Shift+Tab cycling within the panel instead of escaping to the
// page behind the backdrop, and
// • restores focus to whatever was focused before it opened on unmount.
// Escape handling stays with each panel, which has its own close semantics.
export function useFocusTrap<T extends HTMLElement>() {
const ref = useRef<T>(null)
useEffect(() => {
const node = ref.current
if (!node) return
// Remember where focus was so we can hand it back when the panel closes.
const previouslyFocused = document.activeElement as HTMLElement | null
const focusables = () =>
Array.from(
node.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.offsetParent !== null)
// Focus the first interactive element, or the container itself as a fallback.
const first = focusables()[0]
if (first) first.focus()
else node.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const items = focusables()
if (items.length === 0) {
e.preventDefault()
return
}
const firstEl = items[0]
const lastEl = items[items.length - 1]
const active = document.activeElement
if (e.shiftKey && active === firstEl) {
e.preventDefault()
lastEl.focus()
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault()
firstEl.focus()
}
}
node.addEventListener('keydown', onKeyDown)
return () => {
node.removeEventListener('keydown', onKeyDown)
previouslyFocused?.focus?.()
}
}, [])
return ref
}