Let the garden keep what she was given, not only what she sought
Two halves of the same idea, both read out of work Petal already records. Planting: an accepted collocation is a learnable chunk, so it becomes a phrase card. The scheduler didn't need to know — a three-word chunk climbs the ladder exactly like a looked-up word. What needed care was deciding what *isn't* a chunk (single words are word choice; a six-word-plus "collocation" is a rewritten sentence, and sentences make miserable flashcards), and that the example must be the *corrected* sentence — the stored draft still holds the phrasing she just left behind. Re-accepting the same chunk leaves the existing card alone rather than resetting a schedule it has been climbing. The whole thing is best-effort: accepting an edit must never fail because a flashcard couldn't be made. The growth journal: kept this month beside kept the month before, the phrasing that stuck, the patterns that faded. The queries were the easy part; the honesty is the feature. "Stuck" needs the phrase in a *second* document, because one document is just the edit where she left it. "Faded" says nothing at all unless she has been writing lately — otherwise a month away from Petal comes back to her as progress, which is the one way this could lie. And a suggestion had to start recording when she *decided* it, not when the model proposed it, so 0012 adds resolved_at and backfills the old rows to their created_at. It lives as a second tab in the garden, and it feeds the kitten: after an accept she now sometimes hears something true of her alone, once per line, half the time, never waited for. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -160,6 +160,19 @@ export interface Suggestion {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// The growth journal (GET /api/suggestions/growth). `kept`/`kept_before` are
|
||||
// the last thirty days and the thirty before them — the only comparison Petal
|
||||
// draws is with her own past self. `stuck` is phrasing she was given that now
|
||||
// turns up across her own documents; `faded` is what she used to be corrected
|
||||
// on and hasn't been lately. Both lists are empty when the data isn't there:
|
||||
// nothing here is padded to fill a page.
|
||||
export interface GrowthJournal {
|
||||
kept: number
|
||||
kept_before: number
|
||||
stuck: { phrase: string; docs: number }[]
|
||||
faded: { pattern: string; times: number }[]
|
||||
}
|
||||
|
||||
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
|
||||
// The frontend owns mechanics detection; the backend only persists these as the
|
||||
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
|
||||
@@ -267,6 +280,10 @@ export const api = {
|
||||
// opening bubble (the explanation itself stays English in the card body).
|
||||
translateSuggestion: (id: string) =>
|
||||
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
|
||||
// The growth journal: her own accepted edits read back as patterns. Purely a
|
||||
// read-side view of a table Petal already keeps, computed locally with no
|
||||
// model call, so it costs nothing and leaves nothing.
|
||||
growth: () => req<GrowthJournal>('/suggestions/growth'),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const growth = vi.fn()
|
||||
vi.mock('../../api/client', () => ({ api: { growth: () => growth() } }))
|
||||
|
||||
import { personalCheer, resetPersonalCheersForTests, warmPersonalCheers } from './journalCheers'
|
||||
import { resetPackForTests, setPackLang } from '../../i18n'
|
||||
|
||||
const journal = {
|
||||
kept: 4,
|
||||
kept_before: 2,
|
||||
stuck: [{ phrase: 'make a decision', docs: 3 }],
|
||||
faded: [{ pattern: '在 the morning', times: 2 }],
|
||||
}
|
||||
|
||||
// Let the warm-up promise settle.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
describe('personal cheers', () => {
|
||||
beforeEach(() => {
|
||||
resetPersonalCheersForTests()
|
||||
resetPackForTests()
|
||||
growth.mockReset()
|
||||
})
|
||||
|
||||
it('says nothing before the journal has arrived — the cheer never waits', () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('serves each personal line once, then falls silent', async () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
|
||||
const first = personalCheer()
|
||||
const second = personalCheer()
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(first!.en).not.toBe(second!.en)
|
||||
// Both lines used: personal praise repeated is wallpaper, so the caller is
|
||||
// handed back to the generic pool instead.
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches once however often it is warmed', async () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
warmPersonalCheers()
|
||||
expect(growth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is silent when the journal fails, rather than failing visibly', async () => {
|
||||
growth.mockRejectedValue(new Error('offline'))
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('speaks the pair the writer is in, resolved at call time', async () => {
|
||||
growth.mockResolvedValue({ ...journal, faded: [] })
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
|
||||
setPackLang('pt-PT')
|
||||
const line = personalCheer()
|
||||
expect(line).not.toBeNull()
|
||||
expect(line!.native).toContain('make a decision')
|
||||
expect(line!.native).not.toBe(line!.en)
|
||||
// The English half is the same sentence in every pack.
|
||||
expect(line!.en).toContain('make a decision')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// Personal material for the companion, drawn from the growth journal.
|
||||
//
|
||||
// The kitten's cheers are warm but generic — they'd be the same words for
|
||||
// anybody. The journal already knows things that are true of *this* writer and
|
||||
// nobody else ("you're using 'make a decision' on your own now"), and that is a
|
||||
// far better thing to hear after accepting an edit. §5a's own example.
|
||||
//
|
||||
// Three rules keep it from wearing out:
|
||||
// * A given line is served once per session. Personal praise repeated is
|
||||
// wallpaper, and wallpaper is worse than the generic cheer it replaced.
|
||||
// * The journal is fetched lazily, on the first accept, and never awaited —
|
||||
// the first cheer of a session is generic, and that's fine.
|
||||
// * Lines are built at call time from the pack, like every other companion
|
||||
// line, so a bubble composed after /api/me is in the right language.
|
||||
|
||||
import { api, type GrowthJournal } from '../../api/client'
|
||||
import { pack, type Line } from '../../i18n'
|
||||
|
||||
let journal: GrowthJournal | null = null
|
||||
let inFlight = false
|
||||
let served = new Set<string>()
|
||||
|
||||
// warmPersonalCheers starts the one fetch this module ever needs. Safe to call
|
||||
// often; a failure is silent and simply leaves the companion with its generic
|
||||
// pool, which is exactly the pre-journal behaviour.
|
||||
export function warmPersonalCheers(): void {
|
||||
if (journal || inFlight) return
|
||||
inFlight = true
|
||||
api
|
||||
.growth()
|
||||
.then((j) => {
|
||||
journal = j
|
||||
})
|
||||
.catch(() => {
|
||||
/* no journal, no personal cheer — never a visible failure */
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
// personalCheer returns an unserved line about her own writing, or null when
|
||||
// there is none — the caller falls back to the generic pool.
|
||||
export function personalCheer(): Line | null {
|
||||
if (!journal) return null
|
||||
const t = pack()
|
||||
const candidates = [
|
||||
...journal.stuck.map((s) => ({ key: `stuck:${s.phrase}`, line: () => t.journal.cheerStuck(s.phrase) })),
|
||||
...journal.faded.map((f) => ({ key: `faded:${f.pattern}`, line: () => t.journal.cheerFaded(f.pattern) })),
|
||||
].filter((c) => !served.has(c.key))
|
||||
if (candidates.length === 0) return null
|
||||
const chosen = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
served.add(chosen.key)
|
||||
return chosen.line()
|
||||
}
|
||||
|
||||
// Test seam, matching resetPackForTests: module state is per-session by design.
|
||||
export function resetPersonalCheersForTests(): void {
|
||||
journal = null
|
||||
inFlight = false
|
||||
served = new Set()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { personalCheer, warmPersonalCheers } from './journalCheers'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
import { isBedtime } from '../../lib/night'
|
||||
|
||||
@@ -197,7 +198,13 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
firstAccept.current = false
|
||||
return
|
||||
}
|
||||
say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true })
|
||||
// Prefer something true of her own writing over a line that would fit
|
||||
// anybody — but only sometimes, so the personal ones stay a small surprise
|
||||
// rather than the new default. The journal is fetched on this first accept
|
||||
// and never awaited: the cheer goes out now, personal or not.
|
||||
warmPersonalCheers()
|
||||
const personal = Math.random() < 0.5 ? personalCheer() : null
|
||||
say({ ...(personal ?? pick(encouragements())), tone: 'cheer' }, { celebrate: true })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [acceptTick])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import { usePack, type Line } from '../../i18n'
|
||||
import { JournalView } from './JournalView'
|
||||
|
||||
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||
// grown into a blossom that opens further the more she remembers it, plus a
|
||||
@@ -51,6 +52,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<'garden' | 'journal'>('garden')
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
|
||||
@@ -155,7 +157,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{queue ? t.garden.reviewing : t.garden.subtitle}
|
||||
{queue ? t.garden.reviewing : tab === 'journal' ? t.journal.subtitle : t.garden.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -171,6 +173,31 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* The two halves of learning: words and phrasing she has collected
|
||||
(garden), and how her writing has changed (journal). A review
|
||||
session takes over the panel entirely — mid-flashcard is no moment
|
||||
to be offered a different page. */}
|
||||
{!queue && (
|
||||
<div className="flex gap-1 px-4 pt-3">
|
||||
{(['garden', 'journal'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
className="flex-1 rounded-full px-3 py-1.5 text-xs font-bold"
|
||||
style={{
|
||||
background: tab === id ? 'var(--color-surface-alt)' : 'transparent',
|
||||
border: `1px solid ${tab === id ? 'var(--color-border)' : 'transparent'}`,
|
||||
color: tab === id ? 'var(--color-plum)' : 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
{id === 'garden' ? t.journal.tabGarden : t.journal.tabJournal}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queue ? (
|
||||
<ReviewSession
|
||||
queue={queue}
|
||||
@@ -180,6 +207,8 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
onGrade={grade}
|
||||
onQuit={() => setQueue(null)}
|
||||
/>
|
||||
) : tab === 'journal' ? (
|
||||
<JournalView />
|
||||
) : (
|
||||
<GardenView
|
||||
words={words}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type GrowthJournal } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// The growth journal — the garden's second tab.
|
||||
//
|
||||
// It lives here rather than behind its own button because it is the same idea:
|
||||
// the garden shows what she is learning as objects, the journal shows it as
|
||||
// change over time. Both are read back out of work she already did.
|
||||
//
|
||||
// What this component deliberately does NOT render: a score, a percentage, a
|
||||
// target, a streak, or anything at all when there is nothing honest to say. The
|
||||
// backend already refuses to report growth for a month she was simply away; the
|
||||
// only job left here is to stay quiet when it hands back an empty journal.
|
||||
|
||||
export function JournalView() {
|
||||
const t = usePack()
|
||||
const [journal, setJournal] = useState<GrowthJournal | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api
|
||||
.growth()
|
||||
.then((j) => live && setJournal(j))
|
||||
.catch(() => live && setError(true))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrap>
|
||||
<p className="text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Couldn’t read your journal just now.
|
||||
</p>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
if (!journal) {
|
||||
return (
|
||||
<Wrap>
|
||||
<p className="text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Loading…
|
||||
</p>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const nothingYet = journal.kept === 0 && journal.stuck.length === 0 && journal.faded.length === 0
|
||||
if (nothingYet) {
|
||||
return (
|
||||
<Wrap>
|
||||
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||||
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||||
<p className="text-sm leading-relaxed">{t.journal.empty}</p>
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<div className="flex flex-col gap-5">
|
||||
{journal.kept > 0 && (
|
||||
<Section head={t.journal.keptHead}>
|
||||
<p className="text-sm font-bold text-plum">{t.journal.kept(journal.kept)}</p>
|
||||
{journal.kept_before > 0 && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.journal.keptBefore(journal.kept_before)}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{journal.stuck.length > 0 && (
|
||||
<Section head={t.journal.stuckHead}>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{journal.stuck.map((s) => (
|
||||
<li key={s.phrase} className="flex items-start gap-2 text-sm leading-snug text-plum">
|
||||
<span aria-hidden>🌸</span>
|
||||
<span>{t.journal.stuck(s.phrase, s.docs)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{journal.faded.length > 0 && (
|
||||
<Section head={t.journal.fadedHead}>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{journal.faded.map((f) => (
|
||||
<li key={f.pattern} className="flex items-start gap-2 text-sm leading-snug text-plum">
|
||||
<span aria-hidden>🌿</span>
|
||||
<span>{t.journal.faded(f.pattern, f.times)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
function Wrap({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
}
|
||||
|
||||
function Section({ head, children }: { head: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl px-4 py-3"
|
||||
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="mb-1.5 text-[11px] font-extrabold uppercase tracking-wide" style={{ color: 'var(--color-muted)' }}>
|
||||
{head}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -97,6 +97,9 @@ describe('the zh pack', () => {
|
||||
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 天前')
|
||||
expect(zh.journal.kept(1)).toContain('1 thing you took on board')
|
||||
expect(zh.journal.kept(9)).toContain('9 things you took on board')
|
||||
expect(zh.journal.stuck('make a decision', 3)).toContain('3 of your pieces')
|
||||
})
|
||||
|
||||
// A pack with a hole in it renders an empty label rather than failing, which
|
||||
@@ -194,6 +197,20 @@ describe('the pt-PT pack', () => {
|
||||
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
|
||||
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
|
||||
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
|
||||
expect(ptPT.journal.kept(1)).toContain('1 coisa que')
|
||||
expect(ptPT.journal.kept(5)).toContain('5 coisas que')
|
||||
})
|
||||
|
||||
// The growth journal is the one surface that talks about her progress, so it
|
||||
// is the one most easily spoiled by a stray comparison. The rule is enforced
|
||||
// in SQL on the backend; here it is enforced in the copy.
|
||||
it('keeps the journal to growth and to her own past self', () => {
|
||||
const text = JSON.stringify({ zh: zh.journal, pt: ptPT.journal }, (_k, v) =>
|
||||
typeof v === 'function' ? JSON.stringify(v(2, 3)) : v,
|
||||
)
|
||||
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada', '错误']) {
|
||||
expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad)
|
||||
}
|
||||
})
|
||||
|
||||
it('says the collision line the zh pair never needed', () => {
|
||||
|
||||
@@ -258,6 +258,30 @@ export const ptPT: Pack = {
|
||||
gradeEasy: { native: 'Fácil', en: 'Easy' },
|
||||
},
|
||||
|
||||
journal: {
|
||||
tabGarden: '🌷 Jardim · Garden',
|
||||
tabJournal: '🌱 Progresso · Growth',
|
||||
subtitle: 'Your own writing, month by month — only ever you and your past self',
|
||||
empty: 'Escreve mais um pouco — esta página nasce do teu próprio trabalho. · Keep writing; this page grows out of your own work.',
|
||||
keptHead: 'Este mês · This month',
|
||||
kept: (n) => `${n} coisa${n === 1 ? '' : 's'} que aproveitaste · ${n} thing${n === 1 ? '' : 's'} you took on board`,
|
||||
keptBefore: (n) => `${n} no mês anterior · ${n} the month before`,
|
||||
stuckHead: 'Ficou contigo · Stayed with you',
|
||||
stuck: (phrase, docs) =>
|
||||
`«${phrase}» — já a usas sozinha, em ${docs} textos teus · now in ${docs} of your pieces`,
|
||||
fadedHead: 'Já não precisas de corrigir · You stopped needing this',
|
||||
faded: (pattern, times) =>
|
||||
`«${pattern}» — ${times}× nessa altura, nenhuma este mês · ${times}× back then, none this month`,
|
||||
cheerStuck: (phrase) => ({
|
||||
native: `Já escreves «${phrase}» sozinha! 🌱`,
|
||||
en: `You’re using “${phrase}” on your own now! 🌱`,
|
||||
}),
|
||||
cheerFaded: (pattern) => ({
|
||||
native: `Há já algum tempo que «${pattern}» não precisa de correção 😌`,
|
||||
en: `“${pattern}” hasn’t needed fixing in a while 😌`,
|
||||
}),
|
||||
},
|
||||
|
||||
history: {
|
||||
title: 'Histórico · History',
|
||||
kinds: {
|
||||
|
||||
@@ -244,6 +244,29 @@ export const zh: Pack = {
|
||||
gradeEasy: { native: '太简单', en: 'Easy' },
|
||||
},
|
||||
|
||||
journal: {
|
||||
tabGarden: '🌷 花园 · Garden',
|
||||
tabJournal: '🌱 成长 · Growth',
|
||||
subtitle: 'Your own writing, month by month — only ever you and your past self',
|
||||
empty: '再写一阵子,这里就会长出东西来。· Keep writing — this page grows out of your own work.',
|
||||
keptHead: '这个月 · This month',
|
||||
kept: (n) => `你采纳了 ${n} 处建议 · ${n} thing${n === 1 ? '' : 's'} you took on board`,
|
||||
keptBefore: (n) => `上个月是 ${n} 处 · ${n} the month before`,
|
||||
stuckHead: '记住了 · Stayed with you',
|
||||
stuck: (phrase, docs) => `“${phrase}” — 你后来又自己用了,出现在 ${docs} 篇里 · now in ${docs} of your pieces`,
|
||||
fadedHead: '不再需要改了 · You stopped needing this',
|
||||
faded: (pattern, times) =>
|
||||
`“${pattern}” — 以前改过 ${times} 次,这个月一次都没有 · ${times}× back then, none this month`,
|
||||
cheerStuck: (phrase) => ({
|
||||
native: `“${phrase}” 你现在自己就会用了!🌱`,
|
||||
en: `You’re using “${phrase}” on your own now! 🌱`,
|
||||
}),
|
||||
cheerFaded: (pattern) => ({
|
||||
native: `好久没见你写错 “${pattern}” 了 😌`,
|
||||
en: `“${pattern}” hasn’t needed fixing in a while 😌`,
|
||||
}),
|
||||
},
|
||||
|
||||
history: {
|
||||
title: '历史 · History',
|
||||
kinds: {
|
||||
|
||||
@@ -199,6 +199,34 @@ export interface Pack {
|
||||
gradeEasy: Line
|
||||
}
|
||||
|
||||
// The growth journal, a second tab inside the garden: what she has been
|
||||
// learning, read back out of her own accepted edits.
|
||||
//
|
||||
// Every line here is bound by two rules the backend enforces in SQL, and the
|
||||
// copy must not undo them: it reports growth rather than tallying mistakes,
|
||||
// and the only writer it ever compares her to is herself. A pack author has
|
||||
// room to change the warmth and the word order; there is no room for a line
|
||||
// that grades her, congratulates her on beating anyone, or invents a streak.
|
||||
journal: {
|
||||
tabGarden: string
|
||||
tabJournal: string
|
||||
subtitle: string
|
||||
// Nothing to say yet — a quiet month stays quiet rather than being padded.
|
||||
empty: string
|
||||
keptHead: string
|
||||
kept: (n: number) => string
|
||||
// Last month's number, offered flat: no better, no worse, just her own past.
|
||||
keptBefore: (n: number) => string
|
||||
stuckHead: string
|
||||
stuck: (phrase: string, docs: number) => string
|
||||
fadedHead: string
|
||||
faded: (pattern: string, times: number) => string
|
||||
// The journal's material, handed to the companion. Genuinely personal
|
||||
// praise beats a generic cheer, which is the whole point of §5a.
|
||||
cheerStuck: (phrase: string) => Line
|
||||
cheerFaded: (pattern: string) => Line
|
||||
}
|
||||
|
||||
history: {
|
||||
title: string
|
||||
kinds: Record<string, Line> // manual | auto | pre_restore
|
||||
|
||||
Reference in New Issue
Block a user