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

View File

@@ -28,7 +28,12 @@ let current: { audio: HTMLAudioElement; url: string } | null = null
// a newer tap can detect it's stale and bow out instead of double-playing.
let requestSeq = 0
function stopCurrent(): void {
// stopSpeech halts any read-aloud in flight — both the server-audio element and
// the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows
// out instead of playing late. Exported so a panel can cancel audio on unmount,
// keeping a word's pronunciation from outliving the panel that started it.
export function stopSpeech(): void {
requestSeq++
if (current) {
current.audio.pause()
URL.revokeObjectURL(current.url)
@@ -77,7 +82,7 @@ export function detectLang(text: string): string {
// with no configured voice).
export function speak(text: string, lang = detectLang(text)): void {
if (!text.trim()) return
stopCurrent()
stopSpeech()
const seq = ++requestSeq
fetch('/api/tts', {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap'
// GardenPanel is the vocabulary garden: every word the writer has looked up,
// grown into a blossom that opens further the more she remembers it, plus a
@@ -48,6 +49,12 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
const panelRef = useFocusTrap<HTMLElement>()
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
// when the panel closes. Cancel any in-flight audio on unmount so it can't
// outlive the garden.
useEffect(() => stopSpeech, [])
const load = useCallback(async () => {
setError(false)
@@ -127,6 +134,11 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
/>
<aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="词汇花园 · Vocabulary Garden"
tabIndex={-1}
className="relative flex h-full w-full max-w-[420px] flex-col"
style={{
background: 'var(--color-surface)',

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type Document, type DocumentVersion } from '../../api/client'
import { useFocusTrap } from '../../hooks/useFocusTrap'
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
// document, newest first, with a one-click preview and restore. It's the safety
@@ -41,6 +42,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
const [selected, setSelected] = useState<DocumentVersion | null>(null)
const [preview, setPreview] = useState<DocumentVersion | null>(null)
const [busy, setBusy] = useState(false)
const panelRef = useFocusTrap<HTMLElement>()
const load = useCallback(async () => {
setError(false)
@@ -99,6 +101,11 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
/>
<aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="历史 · History"
tabIndex={-1}
className="relative flex h-full w-full max-w-[380px] flex-col"
style={{
background: 'var(--color-surface)',

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { Fragment, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
import { SoundToggle } from './SoundToggle'
@@ -30,8 +30,40 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations).
// The live "Petal is working" indicators. Each is a breathing dot + label shown
// while its pass is in flight; driving them from one array keeps the markup (and
// the "nothing in flight" check below) in lockstep as passes are added.
interface Indicator {
active: boolean
color: string
title: string
label: string
}
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
const label = SAVE_LABEL[saveStatus]
const indicators: Indicator[] = [
{
active: checking,
color: 'var(--color-accent)',
title: 'Petal is reading your writing…',
label: 'Checking…',
},
{
active: voicing,
color: 'var(--color-honey)',
title: 'Petal is reading your voice…',
label: 'Reading your voice…',
},
{
active: collocating,
color: 'var(--color-blossom)',
title: 'Petal is looking for more natural word pairings…',
label: 'Finding natural phrasing…',
},
]
const anyBusy = indicators.some((i) => i.active)
// The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false)
const statsRef = useRef<HTMLDivElement>(null)
@@ -68,43 +100,21 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
</button>
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
</div>
{checking && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }}
/>
Checking
</span>
</>
)}
{voicing && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-honey)' }}
/>
Reading your voice
</span>
</>
)}
{collocating && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is looking for more natural word pairings…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-blossom)' }}
/>
Finding natural phrasing
</span>
</>
)}
{llmDown && !checking && !voicing && !collocating && (
{indicators
.filter((i) => i.active)
.map((i) => (
<Fragment key={i.label}>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title={i.title}>
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: i.color }}
/>
{i.label}
</span>
</Fragment>
))}
{llmDown && !anyBusy && (
<>
<span aria-hidden>·</span>
<span

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
}