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:
prosolis
2026-07-27 14:16:59 -07:00
parent 7b845644be
commit e9b8595456
19 changed files with 1328 additions and 5 deletions
@@ -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()
}
+8 -1
View File
@@ -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])
+30 -1
View File
@@ -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}
+123
View File
@@ -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)' }}>
Couldnt 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>
)
}