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
@@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
import { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
import { usePack, type Pack } from '../../i18n'
interface Props {
docs: DocSummary[]
@@ -21,10 +22,10 @@ interface Props {
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
type SortMode = 'recent' | 'title' | 'longest'
const SORTS: { value: SortMode; label: string }[] = [
{ value: 'recent', label: '最近 · Recent' },
{ value: 'title', label: '标题 · Title' },
{ value: 'longest', label: '字数 · Longest' },
const SORTS: { value: SortMode; label: (t: Pack) => string }[] = [
{ value: 'recent', label: (t) => t.docs.sortRecent },
{ value: 'title', label: (t) => t.docs.sortTitle },
{ value: 'longest', label: (t) => t.docs.sortLongest },
]
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
@@ -41,6 +42,7 @@ export function DocList({
onCreateTag,
account,
}: Props) {
const t = usePack()
// Active tag filter (null = show all). Cleared automatically if the tag
// disappears from the roster.
const [filterId, setFilterId] = useState<string | null>(null)
@@ -108,7 +110,7 @@ export function DocList({
>
{SORTS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
{s.label(t)}
</option>
))}
</select>
@@ -144,7 +146,7 @@ export function DocList({
className="flex items-center gap-2 px-1 pt-1 text-xs"
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
>
<span className="font-semibold"> · Back up all:</span>
<span className="font-semibold">{t.docs.backUpAll}</span>
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
Word
</a>
@@ -169,7 +171,7 @@ export function DocList({
className="shrink-0 font-bold hover:underline"
style={{ color: 'var(--color-accent-hover)' }}
>
退 · Sign out
{t.docs.signOut}
</a>
</div>
)}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { TagChip } from './TagChip'
import { TagPicker } from './TagPicker'
import { usePack } from '../../i18n'
interface Props {
doc: DocSummary
@@ -27,6 +28,7 @@ export function DocListItem({
onCreateTag,
}: Props) {
const [picking, setPicking] = useState(false)
const t = usePack()
const assignedIds = new Set(doc.tags.map((t) => t.id))
return (
@@ -70,7 +72,7 @@ export function DocListItem({
<button
type="button"
aria-label="Duplicate document"
title="副本 · Duplicate"
title={t.docs.duplicate}
onClick={(e) => {
e.stopPropagation()
onDuplicate()
+5 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api, splitSnippet, type SearchResult } from '../../api/client'
import { usePack } from '../../i18n'
interface Props {
// Called when a result is chosen — opens that document.
@@ -12,6 +13,7 @@ const DEBOUNCE_MS = 220
// debounces a full-text query; results drop in below with a highlighted snippet.
// Clearing (× or empty) returns the sidebar to the normal document list.
export function SearchBox({ onSelect }: Props) {
const t = usePack()
const [q, setQ] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [busy, setBusy] = useState(false)
@@ -60,7 +62,7 @@ export function SearchBox({ onSelect }: Props) {
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索 · Search"
placeholder={t.docs.searchPlaceholder}
aria-label="Search documents"
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
style={{
@@ -88,11 +90,11 @@ export function SearchBox({ onSelect }: Props) {
<div className="petal-search-results flex flex-col gap-0.5">
{busy && results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· Searching
{t.docs.searching}
</p>
) : results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· No matches
{t.docs.noMatches}
</p>
) : (
results.map((r) => (
+4 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
import { usePack } from '../../i18n'
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
@@ -15,6 +16,7 @@ interface Props {
// existing tag to attach/detach it, or type a new name (with a color swatch) to
// create-and-attach. Closes on outside click or Escape.
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
const t = usePack()
const [name, setName] = useState('')
const [color, setColor] = useState<TagColor>('rose')
const ref = useRef<HTMLDivElement>(null)
@@ -58,7 +60,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
}}
>
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Tags
{t.docs.tags}
</div>
{roster.length > 0 && (
@@ -114,7 +116,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
placeholder="新标签 · New tag"
placeholder={t.docs.newTagPlaceholder}
aria-label="New tag name"
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
style={{