Files
petal/web/src/components/Garden/GardenPanel.tsx
T
prosolis 466055020f The garden learns which language a card is in, and read-aloud stops guessing
Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:45:26 -07:00

583 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { docLang as docLocale, speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import { usePack, type Line } from '../../i18n'
import { JournalView } from './JournalView'
// 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
// gentle spaced-repetition review. Words are captured automatically on lookup
// (zero effort), so the garden fills itself as she writes. The sleepy kitten
// naps among the blossoms — the same companion gag, at rest in her little
// meadow. Bilingual, zh-first, to match Petal's chrome.
interface Props {
onClose: () => void
// Open the document a word was met in (so "where did I see this?" is one tap).
onOpenDoc?: (docId: string) => void
}
// blossom maps a word's successful-review count to how bloomed its flower looks:
// a fresh seedling opens into a full blossom as it's remembered. No wilting — a
// forgotten word just stops climbing, never shames.
function blossom(reps: number): string {
if (reps <= 0) return '🌱'
if (reps <= 2) return '🌿'
if (reps <= 4) return '🌷'
if (reps <= 6) return '🌸'
return '🌺'
}
// blankOut hides the target word in its example sentence with a soft blank, so a
// flashcard can quiz recall in context. Whole-word, case-insensitive.
function blankOut(sentence: string, word: string): string {
if (!sentence) return ''
try {
const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi')
return sentence.replace(re, '____')
} catch {
return sentence
}
}
export function GardenPanel({ onClose, onOpenDoc }: Props) {
const t = usePack()
const [words, setWords] = useState<VocabWord[] | null>(null)
const [due, setDue] = useState<VocabWord[]>([])
const [error, setError] = useState(false)
// Review session state: the queue (snapshot of due at start), a cursor, and
// whether the current card's answer is revealed.
const [queue, setQueue] = useState<VocabWord[] | null>(null)
const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
const [tab, setTab] = useState<'garden' | 'journal'>('garden')
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)
// Settle the two requests independently: a failed /due shouldn't blank the
// whole garden when the word list loaded fine. Only listVocab failing is a
// true error state; a dueVocab failure just hides the review button.
const [allRes, dueRes] = await Promise.allSettled([api.listVocab(), api.dueVocab()])
if (allRes.status === 'fulfilled') setWords(allRes.value)
else setError(true)
if (dueRes.status === 'fulfilled') setDue(dueRes.value)
else setDue([])
}, [])
useEffect(() => {
void load()
}, [load])
// Escape closes the panel (or ends a review session back to the garden).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (queue) setQueue(null)
else onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, queue])
const startReview = useCallback(() => {
if (due.length === 0) return
setQueue(due)
setCursor(0)
setRevealed(false)
}, [due])
const grade = useCallback(
async (g: VocabGrade) => {
if (!queue) return
const card = queue[cursor]
if (card) {
try {
await api.reviewVocab(card.id, g)
} catch {
/* keep going — a failed grade just won't reschedule */
}
}
const nextCursor = cursor + 1
if (nextCursor >= queue.length) {
// Session done — refresh the garden and drop back to it.
setQueue(null)
void load()
} else {
setCursor(nextCursor)
setRevealed(false)
}
},
[queue, cursor, load],
)
const removeWord = useCallback(async (id: string) => {
setWords((prev) => (prev ? prev.filter((w) => w.id !== id) : prev))
setDue((prev) => prev.filter((w) => w.id !== id))
try {
await api.deleteVocab(id)
} catch {
void load()
}
}, [load])
return (
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
<div
className="absolute inset-0"
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
onClick={() => (queue ? setQueue(null) : onClose())}
/>
<aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={t.garden.title}
tabIndex={-1}
className="relative flex h-full w-full max-w-[420px] flex-col"
style={{
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}}
>
<header
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div>
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{queue ? t.garden.reviewing : tab === 'journal' ? t.journal.subtitle : t.garden.subtitle}
</div>
</div>
<button
type="button"
aria-label="Close garden"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
style={{ color: 'var(--color-muted)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
</button>
</header>
{/* The two halves of learning: words and phrasing she has collected
(garden), and how her writing has changed (journal). A review
session takes over the panel entirely — mid-flashcard is no moment
to be offered a different page. */}
{!queue && (
<div className="flex gap-1 px-4 pt-3">
{(['garden', 'journal'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
aria-pressed={tab === id}
className="flex-1 rounded-full px-3 py-1.5 text-xs font-bold"
style={{
background: tab === id ? 'var(--color-surface-alt)' : 'transparent',
border: `1px solid ${tab === id ? 'var(--color-border)' : 'transparent'}`,
color: tab === id ? 'var(--color-plum)' : 'var(--color-muted)',
}}
>
{id === 'garden' ? t.journal.tabGarden : t.journal.tabJournal}
</button>
))}
</div>
)}
{queue ? (
<ReviewSession
queue={queue}
cursor={cursor}
revealed={revealed}
onReveal={() => setRevealed(true)}
onGrade={grade}
onQuit={() => setQueue(null)}
/>
) : tab === 'journal' ? (
<JournalView />
) : (
<GardenView
words={words}
due={due}
error={error}
expanded={expanded}
onToggleExpand={(id) => setExpanded((cur) => (cur === id ? null : id))}
onStartReview={startReview}
onRemove={removeWord}
onOpenDoc={onOpenDoc}
onRetry={load}
/>
)}
</aside>
</div>
)
}
// --- garden grid ------------------------------------------------------------
function GardenView({
words,
due,
error,
expanded,
onToggleExpand,
onStartReview,
onRemove,
onOpenDoc,
onRetry,
}: {
words: VocabWord[] | null
due: VocabWord[]
error: boolean
expanded: string | null
onToggleExpand: (id: string) => void
onStartReview: () => void
onRemove: (id: string) => void
onOpenDoc?: (docId: string) => void
onRetry: () => void
}) {
// Index the due cards once so the per-word "due" check below is O(1), not a
// linear scan of `due` for every word in the garden.
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
const t = usePack()
return (
<>
{due.length > 0 && (
<div className="px-4 pt-4">
<button
type="button"
onClick={onStartReview}
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
{t.garden.reviewDue(due.length)}
</button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{error ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Couldnt load your garden just now.
<button onClick={onRetry} className="ml-1 font-bold text-plum underline">
Try again
</button>
</div>
) : words === null ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Loading
</div>
) : words.length === 0 ? (
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
<div className="mb-2 text-4xl">🌱🐱💤</div>
<p className="text-sm leading-relaxed">
{t.garden.emptyLead}<br />
{t.garden.emptyHint}
</p>
<p className="mt-2 text-xs">
Your garden is empty. Look up an English word (right-click it) and itll sprout here.
</p>
</div>
) : (
<ul className="flex flex-col gap-1.5">
{words.map((w) => {
const open = expanded === w.id
const isDue = dueIds.has(w.id)
return (
<li key={w.id}>
<button
type="button"
onClick={() => onToggleExpand(w.id)}
className="flex w-full items-center gap-2.5 rounded-2xl px-3 py-2.5 text-left"
style={{
background: open ? 'var(--color-surface-alt)' : 'transparent',
border: `1px solid ${open ? 'var(--color-border)' : 'transparent'}`,
}}
>
<span className="text-xl" aria-hidden>
{blossom(w.reps)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
{/* Only a card in her own language is marked. An English
garden with a badge on every blossom would be a
garden with no badges at all; the marker exists so
the rare Portuguese word is legible among them. */}
{w.lang === 'pair' && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold lowercase"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
>
{t.nativeName}
</span>
)}
</span>
{(w.gloss || w.definition) && (
<span
className="truncate text-xs"
style={{
color: 'var(--color-muted)',
fontFamily: w.gloss
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
: 'var(--font-body)',
}}
>
{w.gloss || w.definition}
</span>
)}
</span>
{isDue && (
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style={{ background: 'var(--color-accent)', color: '#fff' }}
>
{t.garden.due}
</span>
)}
</button>
{open && (
<div className="mb-1 ml-9 mr-2 mt-1 flex flex-col gap-2 text-xs" style={{ color: 'var(--color-plum)' }}>
{w.phonetic && <span style={{ color: 'var(--color-muted)' }}>/{w.phonetic}/</span>}
{w.example && (
<p className="italic leading-snug" style={{ fontFamily: 'var(--font-body)' }}>
{w.example}
</p>
)}
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
{t.garden.seen(w.reps, w.interval_days)}
</div>
<div className="flex items-center gap-2">
{speechSupported() && (
<button
type="button"
onClick={() => speak(w.word, docLocale(w.word, w.lang))}
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)' }}
>
{t.garden.readAloud}
</button>
)}
{w.doc_id && onOpenDoc && (
<button
type="button"
onClick={() => onOpenDoc(w.doc_id as string)}
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)' }}
>
{t.garden.source}
</button>
)}
<button
type="button"
onClick={() => onRemove(w.id)}
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ color: 'var(--color-muted)' }}
>
{t.garden.remove}
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
{!error && words && words.length > 0 && (
<div
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
>
{t.garden.growing(words.length)}
</div>
)}
</>
)
}
// --- flashcard review -------------------------------------------------------
function ReviewSession({
queue,
cursor,
revealed,
onReveal,
onGrade,
onQuit,
}: {
queue: VocabWord[]
cursor: number
revealed: boolean
onReveal: () => void
onGrade: (g: VocabGrade) => void
onQuit: () => void
}) {
const t = usePack()
const card = queue[cursor]
// The meaning shown/asked is the Chinese gloss, or the English definition when
// a word has no gloss — so definition-only words are still reviewable.
const meaning = card?.gloss || card?.definition || ''
// Alternate the quiz direction so she practices both recognition (see the
// English, recall the meaning) and production (see the meaning, recall the
// English word). Parity of the cursor keeps it deterministic within a session.
const production = cursor % 2 === 1 && !!meaning
const prompt = useMemo(() => {
if (!card) return ''
if (production) return meaning
return card.example ? blankOut(card.example, card.word) : card.word
}, [card, production, meaning])
if (!card) return null
return (
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-3 flex items-center justify-between text-xs" style={{ color: 'var(--color-muted)' }}>
<span>
{cursor + 1} / {queue.length}
</span>
<button type="button" onClick={onQuit} className="font-semibold underline">
{t.garden.end}
</button>
</div>
{/* The card */}
<div
className="flex flex-1 flex-col items-center justify-center rounded-3xl px-5 py-8 text-center"
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
>
<div className="mb-2 text-3xl" aria-hidden>
{blossom(card.reps)}
</div>
<div
className="text-xl font-extrabold leading-snug text-plum"
style={{
fontFamily: production
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
: 'var(--font-body)',
}}
>
{prompt}
</div>
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
{production ? t.garden.promptProduction : t.garden.promptRecognition}
</div>
{revealed && (
<div className="mt-5 w-full border-t pt-4" style={{ borderColor: 'var(--color-border)' }}>
<div className="flex items-center justify-center gap-2">
<span className="text-lg font-extrabold text-plum">{card.word}</span>
{speechSupported() && (
<>
<button
type="button"
onClick={() => speak(card.word, docLocale(card.word, card.lang))}
aria-label={`Pronounce ${card.word}`}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
style={{ background: 'var(--color-surface)' }}
>
🔊
</button>
{/* A word she has just failed to recall is exactly the word
worth hearing stretched out. */}
<button
type="button"
onClick={() => speak(card.word, docLocale(card.word, card.lang), true)}
aria-label={`Pronounce ${card.word} slowly`}
title={t.garden.readSlowly}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
style={{ background: 'var(--color-surface)' }}
>
🐢
</button>
</>
)}
</div>
{card.phonetic && (
<div className="mt-0.5 text-xs" style={{ color: 'var(--color-muted)' }}>
/{card.phonetic}/
</div>
)}
{meaning && (
<div
className="mt-1 text-sm font-semibold"
style={{
color: 'var(--color-accent-hover)',
// Chinese gloss gets the CJK stack; an English definition fallback
// reads better in the body font.
fontFamily: card.gloss
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
: 'var(--font-body)',
}}
>
{meaning}
</div>
)}
{card.example && (
<p className="mt-2 text-xs italic leading-snug" style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
{card.example}
</p>
)}
</div>
)}
</div>
{/* Controls */}
<div className="mt-4">
{!revealed ? (
<button
type="button"
onClick={onReveal}
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
{t.garden.showAnswer}
</button>
) : (
<div className="grid grid-cols-3 gap-2">
<GradeButton color="var(--color-peach)" label={t.garden.gradeAgain} onClick={() => onGrade('again')} />
<GradeButton color="var(--color-mint)" label={t.garden.gradeGood} onClick={() => onGrade('good')} />
<GradeButton color="var(--color-honey)" label={t.garden.gradeEasy} onClick={() => onGrade('easy')} />
</div>
)}
</div>
</div>
)
}
function GradeButton({ color, label, onClick }: { color: string; label: Line; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
style={{ background: color }}
>
<span className="text-sm font-extrabold">{label.native}</span>
<span className="text-[11px] font-semibold opacity-80">{label.en}</span>
</button>
)
}