Phase 19: the copy stops being hardcoded Mandarin

Every `中文 · English` string moves out of ~29 components into
web/src/i18n: one Pack type, a verbatim zh pack, and two ways to read
it — usePack() for components, pack() for the modules that build a line
when something happens rather than when something renders.

Anything with a value in it is a function on the pack rather than a
template at the call site, English pluralisation included: word order
isn't universal, and a pack author has to be able to move the number.
The roster constants (tones, rewrite styles, export formats, companions)
keep only value + emoji, so a label can't drift from its key.

On the server, internal/llm/lang.go replaces "Simplified Chinese" in the
three prompts that actually name her language. pt-PT is spelled
"European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt
itself, and each Lang carries her word for "why" so the tutor prompt
still recognises the question when she asks it her way.

pair_lang reaches the model through the row-scoped query each handler
already ran — the one that proves she owns the document — rather than a
second lookup that could disagree with it.

Also records Phase 18's deploy: migration 0011 rehearsed against a copy
of the live VPS database, then applied for real.
This commit is contained in:
prosolis
2026-07-27 08:37:05 -07:00
parent 30d5e691c9
commit 336cae93e0
45 changed files with 1331 additions and 371 deletions
+25 -21
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import { usePack, type Line } from '../../i18n'
// 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
@@ -40,6 +41,7 @@ function blankOut(sentence: string, word: string): string {
}
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)
@@ -137,7 +139,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="词汇花园 · Vocabulary Garden"
aria-label={t.garden.title}
tabIndex={-1}
className="relative flex h-full w-full max-w-[420px] flex-col"
style={{
@@ -151,9 +153,9 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div>
<div className="text-base font-extrabold text-plum">🌷 · Vocabulary Garden</div>
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{queue ? '复习中 · Reviewing — recall, then grade yourself' : 'Words you looked up, blooming as you learn them'}
{queue ? t.garden.reviewing : t.garden.subtitle}
</div>
</div>
<button
@@ -222,6 +224,7 @@ function GardenView({
// 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 && (
@@ -234,7 +237,7 @@ function GardenView({
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
{due.length} · Review {due.length} due 🌸
{t.garden.reviewDue(due.length)}
</button>
</div>
)}
@@ -255,8 +258,8 @@ function GardenView({
<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">
<br />
{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.
@@ -302,7 +305,7 @@ function GardenView({
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style={{ background: 'var(--color-accent)', color: '#fff' }}
>
· due
{t.garden.due}
</span>
)}
</button>
@@ -316,7 +319,7 @@ function GardenView({
</p>
)}
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
{w.reps} · seen {w.reps}× · {w.interval_days}d
{t.garden.seen(w.reps, w.interval_days)}
</div>
<div className="flex items-center gap-2">
{speechSupported() && (
@@ -326,7 +329,7 @@ function GardenView({
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 && (
@@ -336,7 +339,7 @@ function GardenView({
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)' }}
>
📄 · Source
{t.garden.source}
</button>
)}
<button
@@ -345,7 +348,7 @@ function GardenView({
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>
@@ -362,7 +365,7 @@ function GardenView({
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
>
🐱💤 {words.length} · {words.length} blossom{words.length > 1 ? 's' : ''} growing
{t.garden.growing(words.length)}
</div>
)}
</>
@@ -386,6 +389,7 @@ function ReviewSession({
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.
@@ -410,7 +414,7 @@ function ReviewSession({
{cursor + 1} / {queue.length}
</span>
<button type="button" onClick={onQuit} className="font-semibold underline">
· End
{t.garden.end}
</button>
</div>
@@ -433,7 +437,7 @@ function ReviewSession({
{prompt}
</div>
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
{production ? t.garden.promptProduction : t.garden.promptRecognition}
</div>
{revealed && (
@@ -492,13 +496,13 @@ function ReviewSession({
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
· Show answer
{t.garden.showAnswer}
</button>
) : (
<div className="grid grid-cols-3 gap-2">
<GradeButton color="var(--color-peach)" zh="再来" en="Again" onClick={() => onGrade('again')} />
<GradeButton color="var(--color-mint)" zh="记得" en="Good" onClick={() => onGrade('good')} />
<GradeButton color="var(--color-honey)" zh="太简单" en="Easy" onClick={() => onGrade('easy')} />
<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>
@@ -506,7 +510,7 @@ function ReviewSession({
)
}
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) {
function GradeButton({ color, label, onClick }: { color: string; label: Line; onClick: () => void }) {
return (
<button
type="button"
@@ -514,8 +518,8 @@ function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
style={{ background: color }}
>
<span className="text-sm font-extrabold">{zh}</span>
<span className="text-[11px] font-semibold opacity-80">{en}</span>
<span className="text-sm font-extrabold">{label.native}</span>
<span className="text-[11px] font-semibold opacity-80">{label.en}</span>
</button>
)
}