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
+9 -7
View File
@@ -1,5 +1,6 @@
import type { WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import { usePack } from '../../i18n'
// WordCard is the right-click popover for any word: its dictionary definition(s)
// on top and tappable synonym pills below. Clicking a synonym replaces the word
@@ -20,6 +21,7 @@ interface Props {
}
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
const t = usePack()
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const gloss = info?.gloss ?? ''
@@ -47,7 +49,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
>
· Word
{t.editor.word}
</span>
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
{word}
@@ -59,7 +61,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
onClick={onToggleSave}
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
aria-pressed={saved}
title={saved ? '已在词汇花园 · In your garden (tap to remove)' : '加入词汇花园 · Save to garden'}
title={saved ? t.editor.inGarden : t.editor.saveToGarden}
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
style={{
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
@@ -73,7 +75,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
type="button"
onClick={() => speak(word)}
aria-label={`Pronounce ${word}`}
title="朗读 · Read aloud"
title={t.editor.readAloud}
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
@@ -111,14 +113,14 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
style={{ background: 'var(--color-accent)' }}
aria-hidden
/>
· Looking up
{t.editor.lookingUp}
</div>
)}
{definitions.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Definition
{t.editor.definition}
</p>
<ol className="space-y-1.5">
{definitions.map((m, i) => (
@@ -143,7 +145,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
{synonyms.length > 0 && (
<div className="mt-3">
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Synonyms <span className="font-normal">( · tap to swap)</span>
{t.editor.synonyms} <span className="font-normal">({t.editor.tapToSwap})</span>
</p>
<div className="flex flex-wrap gap-1.5">
{synonyms.map((s) => (
@@ -165,7 +167,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
{empty && (
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
· Nothing found for this word
{t.editor.nothingFound}
</p>
)}
</div>