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
+122
View File
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
import { zh } from './packs/zh'
beforeEach(() => {
resetPackForTests()
})
describe('pack selection', () => {
it('speaks the default pair before /api/me answers', () => {
// Modules that build copy at import time (the companion, the prose checker)
// read the pack before the session is known. That read must not be blank.
expect(pack()).toBe(zh)
expect(pack().code).toBe('zh')
})
it('falls back rather than blanking on a pair with no pack yet', () => {
// A pair_lang the deployment has no copy for is a deployment that got ahead
// of its translation. She should still get a working editor.
setPackLang('pt-PT')
expect(pack()).toBe(zh)
setPackLang('klingon')
expect(pack()).toBe(zh)
setPackLang('')
expect(pack()).toBe(zh)
setPackLang(null)
expect(pack()).toBe(zh)
setPackLang(undefined)
expect(pack()).toBe(zh)
})
// Only one pack ships today, so a *real* switch can't be exercised yet; what
// can be is the other half of that contract — that a no-op never wakes every
// reader in the app. The switching path gets its test when pt-PT lands.
it('never notifies readers when nothing actually changed', () => {
const seen = vi.fn()
onPackChange(seen)
setPackLang('zh') // already the current pack — nothing changed
expect(seen).not.toHaveBeenCalled()
// Standing in for a second pack until one ships: switching to something
// unshipped resolves back to zh, which is also not a change.
setPackLang('fr')
expect(seen).not.toHaveBeenCalled()
})
it('stops notifying after unsubscribe', () => {
const seen = vi.fn()
const off = onPackChange(seen)
off()
setPackLang('zh')
expect(seen).not.toHaveBeenCalled()
})
})
describe('the zh pack', () => {
// Sentinels: a handful of strings copied from the pre-Phase-19 source. The
// point of the extraction was that nothing she reads changed, and a reworded
// label would otherwise be invisible in a diff of this size.
it('carries the original copy verbatim', () => {
expect(zh.docs.searchPlaceholder).toBe('搜索 · Search')
expect(zh.editor.addToDictionary).toBe('添加到词典 · Add to dictionary')
expect(zh.status.savedLocally).toBe('已保存在本机 · Kept on this device')
expect(zh.garden.titleWithFlower).toBe('🌷 词汇花园 · Vocabulary Garden')
expect(zh.companion.greeting).toEqual({
native: '嗨~我在这儿陪你写作哦 🐱',
en: "Hi! I'm right here keeping you company. 🐱",
})
expect(zh.prose.itsOwn).toBe('“its” = “it is”;表示“它的”要用 “its”,所以是 “its own”。')
})
it('renders its interpolated lines with the value in place', () => {
expect(zh.app.duplicateTitle('Spring')).toBe('Spring (副本)')
expect(zh.companion.milestone(300).native).toBe('哇!已经 300 个词了,太厉害了 🎉')
expect(zh.prose.articleAn('apple')).toContain('“an apple”')
expect(zh.prose.uncountable('informations', 'information')).toContain('“information”')
expect(zh.garden.reviewDue(4)).toBe('复习 4 个词 · Review 4 due 🌸')
// English pluralisation is the pack's job, not the call site's.
expect(zh.garden.growing(1)).toContain('1 blossom growing')
expect(zh.garden.growing(2)).toContain('2 blossoms growing')
expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前')
expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前')
})
// A pack with a hole in it renders an empty label rather than failing, which
// is exactly the kind of thing that reaches production. Types catch a missing
// *key*; only this catches an empty *value*.
it('has no empty strings anywhere', () => {
const empties: string[] = []
const walk = (node: unknown, path: string) => {
if (typeof node === 'string') {
if (node.trim() === '') empties.push(path)
return
}
if (typeof node === 'function') return // exercised above
if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
}
}
walk(zh, '')
expect(empties).toEqual([])
})
it('labels every companion in the roster and every tone the editor offers', async () => {
const { COMPANIONS } = await import('../components/Companion/companions')
for (const c of COMPANIONS) {
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
}
const { TONES } = await import('../components/Editor/ToneSelect')
for (const tone of TONES) {
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
}
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
for (const style of REWRITE_STYLES) {
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
}
})
})
+70
View File
@@ -0,0 +1,70 @@
// Which langpack Petal is speaking, and how the app asks for it.
//
// The pair language is a property of the *writer* (`users.pair_lang`), so it
// isn't known until /api/me answers. That's the same timing problem prefs.ts
// solved for the mute toggle, and the answer is the same shape: a module-level
// value with a setter and a subscription, rather than blocking startup on the
// network before anything can render.
//
// The difference is what a pre-answer read should see. A preference has a right
// answer in localStorage; a langpack does not, and guessing wrong would flash
// one language and then swap it. So the default is simply zh — every writer
// today is on the zh pair, `users.pair_lang` defaults to 'zh', and a new pair
// only ever arrives with an answer from the server behind it.
import { useSyncExternalStore } from 'react'
import type { Pack, PairLang } from './types'
import { zh } from './packs/zh'
export type { Pack, PairLang, Line } from './types'
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
// is this line plus the file, and TypeScript then names every string it owes.
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
const DEFAULT_LANG: PairLang = 'zh'
type Listener = () => void
let current: Pack = zh
const listeners = new Set<Listener>()
// pack returns the langpack in force right now. For code outside React —
// modules that build a line when something happens rather than when something
// renders (the companion, the prose checker).
export function pack(): Pack {
return current
}
// setPackLang names the pair this writer is in. Called once, from useSession,
// as soon as /api/me answers.
//
// An unknown or unshipped language falls back to the default rather than
// throwing or rendering blanks: a `pair_lang` Petal has no pack for is a
// deployment that got ahead of its copy, and the writer should still see a
// working editor in a language she may not prefer.
export function setPackLang(lang: string | undefined | null): void {
const next = PACKS[(lang || DEFAULT_LANG) as PairLang] ?? PACKS[DEFAULT_LANG] ?? zh
if (next === current) return
current = next
listeners.forEach((fn) => fn())
}
// onPackChange fires when the pair language changes. Returns an unsubscribe.
export function onPackChange(fn: Listener): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// usePack is the React side: a component reads copy with it and re-renders if
// the pair language arrives (or changes) later.
export function usePack(): Pack {
return useSyncExternalStore(onPackChange, pack, pack)
}
// resetPackForTests puts the module back to its initial state. Tests only.
export function resetPackForTests(): void {
current = zh
listeners.clear()
}
+290
View File
@@ -0,0 +1,290 @@
// The Mandarin pack — Petal's original copy, moved here unchanged.
//
// Every string in this file was a literal somewhere in the app before Phase 19.
// It is deliberately verbatim, down to the punctuation: the zh pair is in daily
// use, and a langpack extraction that quietly reworded anything would be a
// product change wearing a refactor's clothes.
//
// Mandarin first, English underneath — she reads Chinese faster, and the
// English subtitle is what she's here to learn.
import type { Pack } from '../types'
export const zh: Pack = {
code: 'zh',
nativeName: '中文',
app: {
duplicateTitle: (title) => `${title} (副本)`,
garden: '词汇花园',
history: '历史',
},
auth: {
title: '请重新登录',
titleEn: 'Please sign in again',
bodyWithDraft: '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。',
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
bodyPlain: '登录状态过期了。你的文字都已经保存好了。',
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
signIn: '去登录 · Sign in',
},
companion: {
choose: '选个小伙伴 · Choose a companion',
// Played when a suggestion is accepted or a milestone hits — pure warmth.
encouragements: [
{ native: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
{ native: '你写得越来越好了 ✨', en: "You're getting better and better." },
{ native: '我很喜欢这个改法 💕', en: 'I really like that change.' },
{ native: '继续保持,加油!', en: 'Keep going — youve got this!' },
{ native: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' },
{ native: '这个词用得真好 🌷', en: 'Thats such a good word choice.' },
{ native: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ native: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
{ native: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
{ native: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
],
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
// checker finds nothing concrete to point at in her current text.
tips: [
{ native: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
{ native: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
{ native: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
{ native: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
{ native: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
{ native: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
{ native: '复数别忘了加 stwo apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
],
// Shown after a long stretch of continuous writing.
breaks: [
{ native: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ native: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
{ native: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
],
// Shown when she's still writing late at night (≥11pm). Caring, a little
// playful — the kitten is always asleep, so "you should be too" lands as a
// gag, never a scold. The native line stays gentle; English carries the wink.
bedtime: [
{ native: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
{ native: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ native: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
{ native: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// A few old Chinese proverbs on sleep (the native line is a faithful
// rendering of the English sense — the verified classical 原文 isn't
// recoverable; swap in the exact source text if you have it). They read a
// touch wittier/wiser than the gentle lines above, which suits a
// late-night nudge.
{ native: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
{ native: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
{ native: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
],
greeting: { native: '嗨~我在这儿陪你写作哦 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
welcomeBack: { native: '欢迎回来 ✨ 我们继续吧!', en: 'Welcome back ✨ lets keep going!' },
errors: [
{ native: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
{ native: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ native: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
],
milestone: (words: number) => ({
native: `哇!已经 ${words} 个词了,太厉害了 🎉`,
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// Keyed by the companion ids in Companion/companions.ts.
names: {
cat: '瞌睡猫',
dog: '开心狗',
'wiggle-dog': '摇尾狗',
butterfly: '蝴蝶',
parrot: '鹦鹉',
},
},
prose: {
longSentence: '这句话有点长啦,分成两三句会更清楚 🌸',
commaSplice: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
vagueThis: (word) => `${word}” 指代不太清楚,最好点明它指的是什么(比如 “${word} idea / change…”)。`,
oxfordComma: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
transitionComma: (word) => `开头的过渡词后面加个逗号:“${word}, …”。`,
capitalizeSentence: '每个句子的开头,用大写字母开始吧。',
repeatedWord: (word) => `${word}” 好像写了两遍,检查一下哦。`,
capitalizeI: '英文里的 “I”(我)任何时候都要大写哦。',
spaceBeforePunct: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
spaceAfterPunct: '逗号、句号后面要空一格,再接下一个词。',
articleAn: (word) => `元音开头的词前用 “an”:“an ${word}”。`,
articleA: (word) => `辅音开头的词前用 “a”:“a ${word}”。`,
uncountable: (word, singular) => `${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
capitalizeProper: (fixed) => `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
thirdPersonS: (subject, verb) => `主语是 he/she/it 时,动词要加 -s:“${subject} ${verb}”。`,
pluralAfter: (determiner, noun) => `${determiner}” 后面的名词要用复数:“${determiner} ${noun}s”。`,
doubleDeterminer: (first, second) => `${first} ${second}” 用了两个限定词,留一个就好(比如去掉 “${first}”)。`,
thereArePlural: (noun) => `后面是复数时用 “there are”:“there are ${noun}…”。`,
itsOwn: '“its” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
itsIs: (rest) => `这里应该是 “its ${rest}”(it is),“its” 是“它的”。`,
thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`,
},
docs: {
sortRecent: '最近 · Recent',
sortTitle: '标题 · Title',
sortLongest: '字数 · Longest',
backUpAll: '备份 · Back up all:',
signOut: '退出 · Sign out',
duplicate: '副本 · Duplicate',
searchPlaceholder: '搜索 · Search',
searching: '查找中… · Searching…',
noMatches: '没有找到 · No matches',
tags: '标签 · Tags',
newTagPlaceholder: '新标签 · New tag',
},
editor: {
askPlaceholder: 'Ask why… / 问为什么…',
findPlaceholder: '查找 · Find',
findNone: '无 · 0',
matchCase: 'Match case · 区分大小写',
close: 'Close · 关闭',
replacePlaceholder: '替换为 · Replace',
replace: '替换',
replaceAll: '全部',
spelling: '拼写 · Spelling',
noSuggestions: '没有建议 · No suggestions',
addToDictionary: '添加到词典 · Add to dictionary',
readSelection: '朗读所选 · Read selection aloud',
rewrite: '改写 · Rewrite',
rewriting: '改写中… · Rewriting…',
rewriteFailed: '改写失败,请再试一次 · Couldnt rewrite — try again',
cancel: '取消 · Cancel',
retry: '重试 · Retry',
useThis: '用这个 · Use this',
word: '词语 · Word',
inGarden: '已在词汇花园 · In your garden (tap to remove)',
saveToGarden: '加入词汇花园 · Save to garden',
readAloud: '朗读 · Read aloud',
lookingUp: '查找中… · Looking up…',
definition: '释义 · Definition',
synonyms: '近义词 · Synonyms',
tapToSwap: '点击替换 · tap to swap',
nothingFound: '没有找到这个词 · Nothing found for this word',
},
styles: {
natural: { native: '更自然', en: 'Natural' },
academic: { native: '学术', en: 'Academic' },
professional: { native: '专业', en: 'Professional' },
casual: { native: '轻松', en: 'Casual' },
humorous: { native: '幽默', en: 'Humorous' },
creative: { native: '创意', en: 'Creative' },
persuasive: { native: '说服', en: 'Persuasive' },
},
tones: {
general: { native: '通用', en: 'General' },
academic: { native: '学术', en: 'Academic' },
professional: { native: '专业', en: 'Professional' },
casual: { native: '轻松', en: 'Casual' },
humorous: { native: '幽默', en: 'Humorous' },
creative: { native: '创意', en: 'Creative' },
persuasive: { native: '说服', en: 'Persuasive' },
},
exports: {
label: '导出',
print: '打印 / PDF',
formats: {
md: { native: 'Markdown', en: 'Markdown (.md)' },
docx: { native: 'Word 文档', en: 'Word (.docx)' },
html: { native: '网页', en: 'Web page (.html)' },
txt: { native: '纯文本', en: 'Plain text (.txt)' },
},
},
garden: {
title: '词汇花园 · Vocabulary Garden',
titleWithFlower: '🌷 词汇花园 · Vocabulary Garden',
reviewing: '复习中 · Reviewing — recall, then grade yourself',
subtitle: 'Words you looked up, blooming as you learn them',
reviewDue: (n) => `复习 ${n} 个词 · Review ${n} due 🌸`,
emptyLead: '你的花园还空着。',
emptyHint: '右键点一个英文单词查它的意思——它就会在这里发芽。',
due: '待复习 · due',
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
readAloud: '🔊 朗读',
source: '📄 出处 · Source',
remove: '🗑 移除',
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
end: '结束 · End',
promptProduction: '这个中文意思的英文单词是?· Which English word?',
promptRecognition: '这个词什么意思?· What does this mean?',
showAnswer: '翻看答案 · Show answer',
gradeAgain: { native: '再来', en: 'Again' },
gradeGood: { native: '记得', en: 'Good' },
gradeEasy: { native: '太简单', en: 'Easy' },
},
history: {
title: '历史 · History',
kinds: {
manual: { native: '保存点', en: 'Saved point' },
auto: { native: '自动', en: 'Auto' },
pre_restore: { native: '恢复前', en: 'Before restore' },
},
justNow: 'just now · 刚刚',
minutesAgo: (n) => `${n} min ago · ${n} 分钟前`,
hoursAgo: (n) => `${n} hr ago · ${n} 小时前`,
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · ${n} 天前`,
preview: '预览 · Preview',
restoring: 'Restoring…',
restoreThis: '恢复这个版本 · Restore this version',
passport: '📜 写作证明 · Writing passport',
keepFullHistory: '保留完整历史 · Keep full history',
},
status: {
savedLocally: '已保存在本机 · Kept on this device',
helperRestingNative: '小助手在休息',
helperRestingEn: "· Petal's helper is resting · 文字已保存",
soundsOn: '声音开 · Sounds on',
soundsOff: '声音关 · Sounds off',
petalsOn: '花瓣开 · Petals on',
petalsOff: '花瓣关 · Petals off',
statsTitle: '写作统计 · Writing stats',
stats: {
words: { native: '字数', en: 'Words' },
characters: { native: '字符', en: 'Characters' },
sentences: { native: '句子', en: 'Sentences' },
paragraphs: { native: '段落', en: 'Paragraphs' },
pages: { native: '页数', en: 'Pages' },
readingTime: { native: '阅读时间', en: 'Reading time' },
avgWordLength: { native: '平均词长', en: 'Avg word length' },
variety: { native: '词汇丰富度', en: 'Word variety' },
readability: { native: '阅读难度', en: 'Reading level' },
},
readability: {
easy: { native: '简单', en: 'Easy' },
standard: { native: '标准', en: 'Standard' },
fairlyHard: { native: '偏难', en: 'Fairly hard' },
advanced: { native: '较难', en: 'Advanced' },
},
},
toolbar: {
untitledHeading: '(无标题)',
outline: '大纲 · Outline',
outlineHint: '用 H1/H2/H3 添加标题,这里就会出现导航。',
},
update: {
available: '有新版本啦',
refresh: '刷新 · Refresh',
dismiss: '稍后再说 · Dismiss',
},
}
+216
View File
@@ -0,0 +1,216 @@
// The shape of a langpack.
//
// Petal gives every writer one (English + X) pair: she reads and writes in
// English, and Petal explains itself in both. Until now X was hardcoded as
// Mandarin — every label in the app carried its own `中文 · English` string
// literal. A pack is that copy lifted out and keyed by X, so adding pt-PT is
// writing one file rather than re-editing twenty-nine.
//
// Two conventions worth keeping when a new pack is written:
//
// * `native` is the writer's own language, `en` is English. Where a label
// shows both in one line the pack holds the *whole* rendered string
// (`'搜索 · Search'`), not the two halves — order and separator are a
// typographic choice each language gets to make.
// * Anything with a value in it is a function, not a template assembled at
// the call site. Word order is not universal, and a pack author must be
// able to move the number.
// PairLang is the X in the (English + X) pair. It matches `users.pair_lang`.
export type PairLang = 'zh' | 'pt-PT' | 'fr' | 'es'
// Line is a two-line piece of copy: the writer's language on top, English
// underneath. The companion bubble and the small pill labels render both.
export interface Line {
native: string
en: string
}
export interface Pack {
// code identifies the pack; `nativeName` is how the language names itself,
// for anywhere Petal has to say which pair this is.
code: PairLang
nativeName: string
app: {
// A duplicated document's title. A function, not a suffix: where the marker
// goes is the pack's business.
duplicateTitle: (title: string) => string
garden: string
history: string
}
auth: {
title: string
titleEn: string
bodyWithDraft: string
bodyWithDraftEn: string
bodyPlain: string
bodyPlainEn: string
signIn: string
}
companion: {
choose: string
encouragements: Line[]
tips: Line[]
breaks: Line[]
bedtime: Line[]
greeting: Line
welcomeBack: Line
errors: Line[]
milestone: (words: number) => Line
// Mascot names, keyed by the companion ids in companions.ts.
names: Record<string, string>
}
// Rule-based prose notes (see Companion/prose.ts). Detection is English
// grammar and stays in the checker; only the explanation belongs to the pair.
prose: {
longSentence: string
commaSplice: string
vagueThis: (word: string) => string
oxfordComma: string
transitionComma: (word: string) => string
capitalizeSentence: string
repeatedWord: (word: string) => string
capitalizeI: string
spaceBeforePunct: string
spaceAfterPunct: string
articleAn: (word: string) => string
articleA: (word: string) => string
uncountable: (word: string, singular: string) => string
capitalizeProper: (fixed: string) => string
thirdPersonS: (subject: string, verb: string) => string
pluralAfter: (determiner: string, noun: string) => string
doubleDeterminer: (first: string, second: string) => string
thereArePlural: (noun: string) => string
itsOwn: string
itsIs: (rest: string) => string
thanNotThen: (word: string) => string
}
docs: {
sortRecent: string
sortTitle: string
sortLongest: string
backUpAll: string
signOut: string
duplicate: string
searchPlaceholder: string
searching: string
noMatches: string
tags: string
newTagPlaceholder: string
}
editor: {
askPlaceholder: string
findPlaceholder: string
findNone: string
matchCase: string
close: string
replacePlaceholder: string
replace: string
replaceAll: string
spelling: string
noSuggestions: string
addToDictionary: string
readSelection: string
// The rewrite preview.
rewrite: string
rewriting: string
rewriteFailed: string
cancel: string
retry: string
useThis: string
// Word card.
word: string
inGarden: string
saveToGarden: string
readAloud: string
lookingUp: string
definition: string
synonyms: string
tapToSwap: string
nothingFound: string
}
// Rewrite styles and document tones share a vocabulary; both are Line-labelled
// pills, keyed by the style/tone value.
styles: Record<string, Line>
tones: Record<string, Line>
exports: {
label: string
print: string
formats: Record<string, Line> // keyed by export format
}
garden: {
title: string
titleWithFlower: string
reviewing: string
subtitle: string
reviewDue: (n: number) => string
emptyLead: string
emptyHint: string
due: string
seen: (reps: number, intervalDays: number) => string
readAloud: string
source: string
remove: string
growing: (n: number) => string
end: string
promptProduction: string
promptRecognition: string
showAnswer: string
gradeAgain: Line
gradeGood: Line
gradeEasy: Line
}
history: {
title: string
kinds: Record<string, Line> // manual | auto | pre_restore
justNow: string
minutesAgo: (n: number) => string
hoursAgo: (n: number) => string
daysAgo: (n: number) => string
preview: string
restoring: string
restoreThis: string
passport: string
keepFullHistory: string
}
status: {
savedLocally: string
helperRestingNative: string
helperRestingEn: string
soundsOn: string
soundsOff: string
petalsOn: string
petalsOff: string
statsTitle: string
stats: Record<string, Line> // stat labels, keyed by the stat id
readability: {
easy: Line
standard: Line
fairlyHard: Line
advanced: Line
}
}
toolbar: {
untitledHeading: string
outline: string
outlineHint: string
}
update: {
available: string
refresh: string
dismiss: string
}
}