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
@@ -4,6 +4,7 @@ import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
import { usePack } from '../../i18n'
interface Props {
wordCount: number
@@ -30,6 +31,7 @@ const STORAGE_KEY = 'petal.companion'
// break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage).
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
const t = usePack()
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
wordCount,
saveStatus,
@@ -115,7 +117,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
style={{ color: 'var(--color-muted)' }}
>
· Choose a companion
{t.companion.choose}
</p>
{COMPANIONS.map((c) => {
const active = c.id === companion.id
@@ -138,7 +140,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
<span className="flex-1">
<span className="font-bold">{c.zh}</span>{' '}
<span className="font-bold">{t.companion.names[c.id] ?? c.name}</span>{' '}
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
</span>
{active && <span style={{ color: 'var(--color-accent)' }}></span>}
@@ -169,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
className="font-bold leading-snug"
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
>
{bubble.zh}
{bubble.native}
</p>
<p
className="mt-0.5 leading-snug"
+1 -7
View File
@@ -7,8 +7,7 @@ import parrot from './animations/parrot.json'
export interface Companion {
id: string
name: string // English label
zh: string // Chinese label (she reads Mandarin — north star Note #17)
name: string // English label; the writer's-language name lives in the langpack
emoji: string // shown in the picker + used as the per-mood fallback base
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
animations: Partial<Record<Mood, object>>
@@ -26,7 +25,6 @@ export const COMPANIONS: Companion[] = [
{
id: 'cat',
name: 'Sleepy Cat',
zh: '瞌睡猫',
emoji: '😴',
alwaysAsleep: true,
animations: {
@@ -40,7 +38,6 @@ export const COMPANIONS: Companion[] = [
{
id: 'dog',
name: 'Happy Dog',
zh: '开心狗',
emoji: '🐶',
animations: {
idle: happyDog,
@@ -53,7 +50,6 @@ export const COMPANIONS: Companion[] = [
{
id: 'wiggle-dog',
name: 'Wiggle Dog',
zh: '摇尾狗',
emoji: '🐕',
animations: {
idle: wiggleDog,
@@ -65,7 +61,6 @@ export const COMPANIONS: Companion[] = [
{
id: 'butterfly',
name: 'Butterfly',
zh: '蝴蝶',
emoji: '🦋',
animations: {
idle: butterfly,
@@ -77,7 +72,6 @@ export const COMPANIONS: Companion[] = [
{
id: 'parrot',
name: 'Parrot',
zh: '鹦鹉',
emoji: '🦜',
flip: true, // asset faces left; mirror it to face into the page
animations: {
+28 -22
View File
@@ -5,7 +5,8 @@
// trust far faster than a missed one earns it, so we only speak up when a
// pattern is a high-confidence, genuinely-common English mistake.
//
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
// Each finding becomes a native-language-first bubble Line (see tips.ts), often
// quoting
// a short slice of her own sentence so the advice clearly belongs to *this*
// paragraph and not a generic tip jar.
//
@@ -17,8 +18,13 @@
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
// fix-bearing hints so the same span never appears as both a bubble and a card.
import { pack } from '../../i18n'
import type { Line } from './tips'
// The pair's prose copy. Read per finding rather than captured once, so a rule
// that fires after the writer's language is known speaks the right one.
const P = () => pack().prose
export interface ProseHint extends Line {
// Stable, content-derived id so the same untouched sentence isn't re-flagged
// every cadence; the companion remembers recently-shown ids.
@@ -108,7 +114,7 @@ function runOns(text: string, out: ProseHint[]) {
out.push({
id: `runon:${key(s)}`,
rule: 'runon',
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
native: P().longSentence,
en: `This one runs long — try splitting it: “${anchor(s)}`,
})
}
@@ -170,7 +176,7 @@ function commaSplices(text: string, out: ProseHint[]) {
out.push({
id: `splice:${key(m[0])}`,
rule: 'splice',
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
native: P().commaSplice,
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
})
}
@@ -195,7 +201,7 @@ function antecedents(text: string, out: ProseHint[]) {
out.push({
id: `antecedent:${key(s)}`,
rule: 'antecedent',
zh: `${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
native: P().vagueThis(m[1]),
en: `${m[1]}” here is a little vague — name what it refers to.`,
})
}
@@ -226,7 +232,7 @@ function oxford(text: string, out: ProseHint[]) {
out.push({
id: `oxford:${key(m[0])}`,
rule: 'oxford',
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
native: P().oxfordComma,
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
})
}
@@ -246,7 +252,7 @@ function introComma(text: string, out: ProseHint[]) {
out.push({
id: `introcomma:${m[1].toLowerCase()}`,
rule: 'introcomma',
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
native: P().transitionComma(m[1]),
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
})
break
@@ -265,7 +271,7 @@ function sentenceCaps(text: string, out: ProseHint[]) {
out.push({
id: 'cap-sentence',
rule: 'cap-sentence',
zh: '每个句子的开头,用大写字母开始吧。',
native: P().capitalizeSentence,
en: 'Start each new sentence with a capital letter.',
})
}
@@ -289,7 +295,7 @@ function doubles(text: string, out: ProseHint[]) {
out.push({
id: `double:${from}:${key(m[0])}`,
rule: 'double',
zh: `${m[1]}” 好像写了两遍,检查一下哦。`,
native: P().repeatedWord(m[1]),
en: `${m[1]} ${m[1]}” — looks like a word got doubled.`,
fix: { from, to, replacement: m[1] },
})
@@ -311,7 +317,7 @@ function pronounI(text: string, out: ProseHint[]) {
out.push({
id: 'cap-i',
rule: 'cap-i',
zh: '英文里的 “I”(我)任何时候都要大写哦。',
native: P().capitalizeI,
en: 'In English, “I” is always written as a capital letter.',
})
}
@@ -328,7 +334,7 @@ function spaceBeforePunct(text: string, out: ProseHint[]) {
out.push({
id: `space-punct:${from}`,
rule: 'space-punct',
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
native: P().spaceBeforePunct,
en: 'No space before punctuation — it tucks right against the word.',
fix: { from, to, replacement: m[1] + m[2] },
})
@@ -351,7 +357,7 @@ function spaceAfterPunct(text: string, out: ProseHint[]) {
out.push({
id: `space-after:${from}`,
rule: 'space-after',
zh: '逗号、句号后面要空一格,再接下一个词。',
native: P().spaceAfterPunct,
en: 'Add a space after a comma or period before the next word.',
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
})
@@ -378,7 +384,7 @@ function articles(text: string, out: ProseHint[]) {
out.push({
id: `article-an:${m.index}`,
rule: 'article',
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
native: P().articleAn(m[2]),
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
})
@@ -389,7 +395,7 @@ function articles(text: string, out: ProseHint[]) {
out.push({
id: `article-a:${m.index}`,
rule: 'article',
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
native: P().articleA(m[2]),
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
})
@@ -410,7 +416,7 @@ function uncountables(text: string, out: ProseHint[]) {
out.push({
id: `uncountable:${m.index}`,
rule: 'uncountable',
zh: `${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
native: P().uncountable(word, singular),
en: `${word}” is uncountable — drop the “s”: just “${singular}”.`,
fix: { from: m.index, to: m.index + word.length, replacement: singular },
})
@@ -439,7 +445,7 @@ function properCaps(text: string, out: ProseHint[]) {
out.push({
id: `propercap:${m.index}`,
rule: 'propercap',
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
native: P().capitalizeProper(fixed),
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
})
@@ -485,7 +491,7 @@ function subjectVerbAgreement(text: string, out: ProseHint[]) {
out.push({
id: `sva:${m.index}`,
rule: 'sva',
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
native: P().thirdPersonS(m[2], fixed),
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
})
@@ -515,7 +521,7 @@ function pluralAfterNumber(text: string, out: ProseHint[]) {
out.push({
id: `plural:${m.index}`,
rule: 'plural',
zh: `${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
native: P().pluralAfter(m[1], m[2]),
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
})
@@ -537,7 +543,7 @@ function doubleDeterminer(text: string, out: ProseHint[]) {
out.push({
id: `doubledet:${m.index}`,
rule: 'doubledet',
zh: `${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
native: P().doubleDeterminer(m[1], m[3]),
en: `${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
// Drop the article (m[1]); keep the second determiner, casing preserved.
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
@@ -561,7 +567,7 @@ function thereIsPlural(text: string, out: ProseHint[]) {
out.push({
id: `thereis:${m.index}`,
rule: 'thereis',
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
native: P().thereArePlural(m[2]),
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement },
})
@@ -581,7 +587,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
out.push({
id: `its-own:${m.index}`,
rule: 'its',
zh: '“its” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
native: P().itsOwn,
en: '“its” means “it is” — the possessive is “its”: “its own”.',
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
})
@@ -592,7 +598,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
out.push({
id: `its-article:${m.index}`,
rule: 'its',
zh: `这里应该是 “its ${m[1]}”(it is),“its” 是“它的”。`,
native: P().itsIs(m[1]),
en: `Here it should be “its ${m[1]}” (it is); “its” means belonging to it.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
})
@@ -617,7 +623,7 @@ function thanThen(text: string, out: ProseHint[]) {
out.push({
id: `than:${m.index}`,
rule: 'than',
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
native: P().thanNotThen(m[1]),
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
})
+19 -84
View File
@@ -1,93 +1,28 @@
// Bilingual companion copy — Mandarin first (she writes/reads in Mandarin), with
// a warm English subtitle. Kept gentle and encouraging, never scolding. The
// bubble renders zh prominently and en underneath. (Spec north star: CJK is
// first-class; Ask Petal & companion answer in Mandarin.)
// The companion's line *selection*. The lines themselves moved to the langpack
// in Phase 19 (`src/i18n`) — the kitten speaks whichever pair its writer is in,
// and the copy for a pair belongs with the rest of that pair's copy rather than
// next to the timing rules that decide when to say it.
//
// Everything here is read at call time, never at import time, so a bubble
// composed after /api/me answers is in the right language.
export interface Line {
zh: string
en: string
}
import { pack, type Line } from '../../i18n'
// Played when a suggestion is accepted or a milestone hits — pure warmth.
export const ENCOURAGEMENTS: Line[] = [
{ zh: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
{ zh: '你写得越来越好了 ✨', en: "You're getting better and better." },
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
{ zh: '继续保持,加油!', en: 'Keep going — youve got this!' },
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' },
{ zh: '这个词用得真好 🌷', en: 'Thats such a good word choice.' },
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
]
export type { Line }
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
// checker (prose.ts) finds nothing concrete to point at in her current text.
export const TIPS: Line[] = [
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
{ zh: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
{ zh: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
{ zh: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
{ zh: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
{ zh: '复数别忘了加 stwo apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
]
export const encouragements = (): Line[] => pack().companion.encouragements
export const tips = (): Line[] => pack().companion.tips
export const breaks = (): Line[] => pack().companion.breaks
export const bedtime = (): Line[] => pack().companion.bedtime
export const errors = (): Line[] => pack().companion.errors
export const greeting = (): Line => pack().companion.greeting
export const welcomeBack = (): Line => pack().companion.welcomeBack
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
// Shown after a long stretch of continuous writing.
export const BREAKS: Line[] = [
{ zh: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ zh: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
{ zh: '看看远方,放松一下眼睛 🌿', 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. zh stays gentle; the English subtitle carries the wink.
export const BEDTIME: Line[] = [
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// A few old Chinese proverbs on sleep (zh = 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.
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', 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.' },
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
]
// First hello when the app opens.
export const GREETING: Line = {
zh: '嗨~我在这儿陪你写作哦 🐱',
en: "Hi! I'm right here keeping you company. 🐱",
}
// Welcome-back nudge after she returns from an idle pause.
export const WELCOME_BACK: Line = {
zh: '欢迎回来 ✨ 我们继续吧!',
en: 'Welcome back ✨ lets keep going!',
}
// Gentle "haiya, something went wrong" lines — paired with the error sound when
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
export const ERRORS: Line[] = [
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
]
// Word-count milestones worth a little cheer — every 100 words, on up.
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
// not copy: the same in every language.
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
export function milestoneLine(n: number): Line {
return {
zh: `哇!已经 ${n} 个词了,太厉害了 🎉`,
en: `Wow — ${n} words already! Amazing. 🎉`,
}
}
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
export function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
+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
}