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
+7 -4
View File
@@ -18,6 +18,7 @@ import { useSession } from './hooks/useSession'
import { takeDraft } from './lib/drafts'
import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall'
import { usePack } from './i18n'
import { useNightMode } from './hooks/useNightMode'
import { playSuggestionSound } from './audio/sounds'
@@ -30,6 +31,7 @@ export default function App() {
// Who's writing, and whether the server still recognises them. `signedOut`
// flips the moment any call comes back 401.
const { me, signedOut } = useSession()
const t = usePack()
// A real account to sign out of, as opposed to the hardcoded local user a
// build without auth configured runs as.
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
@@ -239,7 +241,8 @@ export default function App() {
setDocText('')
}, [saveNow, isBlankDraft])
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
// Duplicate a document: copy its body/tone into a fresh doc under the pack's
// "copy" title,
// then open the copy. Tags aren't carried over (a fresh start for the copy).
const handleDuplicate = useCallback(
async (id: string) => {
@@ -247,7 +250,7 @@ export default function App() {
await saveNow() // flush in case we're duplicating the open doc
const src = await api.getDoc(id)
const fresh = await api.createDoc()
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled')
const updated = await api.updateDoc(fresh.id, {
title: dupTitle,
content: src.content,
@@ -451,7 +454,7 @@ export default function App() {
}}
>
<span aria-hidden>🌷</span>
<span></span>
<span>{t.app.garden}</span>
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
</button>
</header>
@@ -514,7 +517,7 @@ export default function App() {
}}
>
<span aria-hidden>🕘</span>
<span></span>
<span>{t.app.history}</span>
<span style={{ color: 'var(--color-muted)' }}>· History</span>
</button>
<div className="petal-no-print">