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:
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -82,11 +82,14 @@ export function computeStats(text: string, wordCount: number): WritingStats {
|
||||
}
|
||||
}
|
||||
|
||||
// gradeBand turns a Flesch–Kincaid 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 Flesch–Kincaid 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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user