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
+18 -18
View File
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import {
BEDTIME,
BREAKS,
ENCOURAGEMENTS,
ERRORS,
GREETING,
MILESTONES,
TIPS,
WELCOME_BACK,
bedtime,
breaks,
encouragements,
errors,
greeting,
milestoneLine,
pick,
tips,
welcomeBack,
type Line,
} from './tips'
import { analyzeProse } from './prose'
@@ -60,11 +60,11 @@ const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
const now = () => Date.now()
// Reading time for a bubble: a base floor by tone, stretched by the combined
// length of the Mandarin + English lines so denser advice stays up long enough
// length of the native + English lines so denser advice stays up long enough
// to actually finish reading.
function readBubbleMs(b: Bubble): number {
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
const chars = b.zh.length + b.en.length
const chars = b.native.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
}
@@ -138,9 +138,9 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
if (hint) {
lastRule.current = hint.rule
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
return { zh: hint.zh, en: hint.en, tone: 'tip' }
return { native: hint.native, en: hint.en, tone: 'tip' }
}
return { ...pick(TIPS), tone: 'tip' }
return { ...pick(tips()), tone: 'tip' }
}, [])
const dismiss = useCallback(() => {
@@ -167,7 +167,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
// Opening hello (once), after a short beat so it doesn't race the first paint.
useEffect(() => {
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
const id = setTimeout(() => say({ ...greeting(), tone: 'tip' }), 1200)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -183,7 +183,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
if (sleeping.current) {
sleeping.current = false
sessionStart.current = now() // a fresh stretch starts on return
say({ ...WELCOME_BACK, tone: 'cheer' })
say({ ...welcomeBack(), tone: 'cheer' })
} else if (mood === 'sleeping') {
setMood('idle')
}
@@ -197,7 +197,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
firstAccept.current = false
return
}
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acceptTick])
@@ -229,7 +229,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return
}
lastError.current = t
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
say({ ...pick(errors()), tone: 'error' }, { sound: 'error' })
}, [say])
useEffect(() => {
@@ -246,7 +246,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) {
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
say({ ...pick(encouragements()), tone: 'cheer' }, { proactive: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStatus])
@@ -267,7 +267,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
lastBreak.current = t
sessionStart.current = t
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
say({ ...pick(breaks()), tone: 'break' }, { proactive: true })
return
}
@@ -276,7 +276,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
// returned if she's away/napping).
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
lastBedtime.current = t
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
say({ ...pick(bedtime()), tone: 'bedtime' }, { proactive: true })
return
}