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:
@@ -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', {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
|
||||
59
web/src/hooks/useFocusTrap.ts
Normal file
59
web/src/hooks/useFocusTrap.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user