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
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
import { usePack } from '../../i18n'
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
// the sound toggle in the status bar. Some people find the drifting petals
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
// Mandarin first), and the choice persists across reloads.
export function PetalsToggle() {
const t = usePack()
const [on, setOn] = useState(isPetalsEnabled)
// Stay in sync if the setting is flipped elsewhere.
@@ -22,7 +24,7 @@ export function PetalsToggle() {
type="button"
onClick={toggle}
aria-pressed={on}
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
title={on ? t.status.petalsOn : t.status.petalsOff}
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
+3 -1
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
import { usePack } from '../../i18n'
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
// when sounds are turned back on so the choice is audible.
export function SoundToggle() {
const t = usePack()
const [on, setOn] = useState(isSoundEnabled)
// Stay in sync if the setting is flipped elsewhere.
@@ -22,7 +24,7 @@ export function SoundToggle() {
type="button"
onClick={toggle}
aria-pressed={on}
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
title={on ? t.status.soundsOn : t.status.soundsOff}
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
+19 -17
View File
@@ -1,9 +1,10 @@
import { useMemo } from 'react'
import { computeStats, gradeBand } from './stats'
import { usePack } from '../../i18n'
// StatsPanel is the popover that opens above the word count: a small grid of
// writing statistics computed from the live document. Bilingual zh·en labels to
// match Petal's chrome. Reading level shows a friendly band, not just a number.
// writing statistics computed from the live document. Labels are the writer's
// pair; the reading level shows a friendly band, not just a number.
interface Props {
text: string
@@ -11,33 +12,34 @@ interface Props {
}
interface Row {
zh: string
native: string
en: string
value: string
}
export function StatsPanel({ text, wordCount }: Props) {
const t = usePack()
const rows = useMemo<Row[]>(() => {
const L = t.status.stats
const s = computeStats(text, wordCount)
const band = gradeBand(s.gradeLevel)
const fmt = (n: number, d = 0) =>
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
return [
{ zh: '字数', en: 'Words', value: fmt(s.words) },
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
{ ...L.words, value: fmt(s.words) },
{ ...L.characters, value: fmt(s.characters) },
{ ...L.sentences, value: fmt(s.sentences) },
{ ...L.paragraphs, value: fmt(s.paragraphs) },
{ ...L.pages, value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
{ ...L.readingTime, value: readingTime(s.readingTimeMin) },
{ ...L.avgWordLength, value: `${fmt(s.avgWordLength, 1)}` },
{ ...L.variety, value: `${fmt(s.variety * 100)}%` },
{
zh: '阅读难度',
en: 'Reading level',
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
...L.readability,
value: s.words > 0 ? `${t.status.readability[band].en} · ${fmt(s.gradeLevel, 1)}` : '—',
},
]
}, [text, wordCount])
}, [text, wordCount, t])
return (
<div
@@ -54,14 +56,14 @@ export function StatsPanel({ text, wordCount }: Props) {
}}
>
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Writing stats
{t.status.statsTitle}
</p>
<dl className="space-y-1.5">
{rows.map((r) => (
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
<dt style={{ color: 'var(--color-muted)' }}>
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
{r.zh}
{r.native}
</span>{' '}
{r.en}
</dt>
+18 -13
View File
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
import { PetalsToggle } from './PetalsToggle'
import { SoundToggle } from './SoundToggle'
import { usePack, type Pack } from '../../i18n'
interface Props {
wordCount: number
@@ -20,16 +21,19 @@ interface Props {
llmDown: boolean
}
const SAVE_LABEL: Record<SaveStatus, string> = {
idle: '',
pending: 'Editing…',
saving: 'Saving…',
saved: 'Saved just now',
error: "Couldn't save",
// The session lapsed. Say where the writing is, not what failed — it's safe
// on this device and goes up the moment she signs back in.
'signed-out': '已保存在本机 · Kept on this device',
}
// Save-state labels. English except for the lapsed-session case, which is the
// one a writer reads with her heart in her mouth — that one comes from her pack.
const saveLabel = (status: SaveStatus, t: Pack): string =>
({
idle: '',
pending: 'Editing…',
saving: 'Saving…',
saved: 'Saved just now',
error: "Couldn't save",
// The session lapsed. Say where the writing is, not what failed — it's safe
// on this device and goes up the moment she signs back in.
'signed-out': t.status.savedLocally,
})[status]
// StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
@@ -45,7 +49,8 @@ interface Indicator {
}
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
const label = SAVE_LABEL[saveStatus]
const t = usePack()
const label = saveLabel(saveStatus, t)
const indicators: Indicator[] = [
{
@@ -127,8 +132,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
style={{ color: 'var(--color-honey)' }}
>
<span aria-hidden>🌙</span>
<span></span>
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
<span>{t.status.helperRestingNative}</span>
<span style={{ opacity: 0.75 }}>{t.status.helperRestingEn}</span>
</span>
</>
)}
+10 -7
View File
@@ -82,11 +82,14 @@ export function computeStats(text: string, wordCount: number): WritingStats {
}
}
// gradeBand turns a FleschKincaid grade into a friendly bilingual descriptor —
// far more useful to an ESL writer than a bare number.
export function gradeBand(grade: number): { zh: string; en: string } {
if (grade <= 5) return { zh: '简单', en: 'Easy' }
if (grade <= 8) return { zh: '标准', en: 'Standard' }
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
return { zh: '较难', en: 'Advanced' }
// gradeBand turns a FleschKincaid grade into a friendly band — far more useful
// to an ESL writer than a bare number. It returns the band's name, not its
// label: the wording belongs to the writer's langpack.
export type ReadabilityBand = 'easy' | 'standard' | 'fairlyHard' | 'advanced'
export function gradeBand(grade: number): ReadabilityBand {
if (grade <= 5) return 'easy'
if (grade <= 8) return 'standard'
if (grade <= 12) return 'fairlyHard'
return 'advanced'
}