Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
ServerError); drop the triple-duplicated helpers in docs, suggestions,
vocab. ServerError now logs the real error and returns a generic 500 so
raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
/api/images (own 10 MiB limit).
Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.
Tests: add doc_id-validation and field-clamp coverage; full suite green.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Correctness:
- useCheckpoint: clear the busy flag unconditionally so overlapping
explicit passes don't strand each other's spinner; explicit actions
now also supersede a queued auto-check and clear the stranded
"checking" dot. Deduped runVoice/runCollocation into runExplicitPass.
- EditorCore: token-guard the auto-capture so a late capture can't
resurrect a removed word; move toggleSaveWord side effects out of the
setWordInfo updater (StrictMode double-fire); fix sentenceAround offset
desync via shared exampleAt (textBetween + parentOffset, single resolve);
optimistic saved state so the heart doesn't flash unsaved.
- vocab capture: normalize word to lower+trim (matches lexicon) so
"Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList.
- GardenPanel: Promise.allSettled so a /due failure doesn't blank the
whole garden; scrim click during review ends the review (mirrors Esc);
gate footer on !error; O(1) due lookup via a Set.
Features requested in review:
- Definition-only review card: add vocab_words.definition (migration
0007) as an English fallback meaning, threaded through capture and used
by review/garden when there's no Chinese gloss.
- Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't
push a word out of rotation for years.
Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback,
TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
+ collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
(SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
collocating/runCollocation in useCheckpoint, StatusBar dot
Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
"again", no streak-shaming), handlers (capture-upsert/list/due/
review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
footer; opened from a global 🌷 header button
Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Companion warmth:
- ENCOURAGEMENTS grown 5->10; new BEDTIME lines (warm/playful, zh-first)
- useCompanion heartbeat gains a bedtime branch (>=11pm, while actively
writing) gated by a 30min cooldown; new 'bedtime' BubbleTone paces it
Night mode (auto at ~11pm, local clock):
- lib/night.ts centralizes isBedtime() + the window, shared with the nag
- useNightMode toggles a `petal-night` class on <html>; index.css re-points
only the palette tokens, so every Tailwind color utility flips via var()
with no component edits (600ms dusk fade; print stays white)
- PetalFall gains a `night` prop: chunky cartoon power stars (makeCartoonStar,
5 candy colors) mixed ~70/30 with twinkle sparkles; each star spins at its
own rate (~0.5-2.2 rad/s, random direction), falls straight down, shimmers
Verified: go vet/test, tsc, vitest 51/51, vite build; real-browser
screenshots/video (clock mocked to 23:30).
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
A paste fires exactly one grammar checkpoint, and a failed one never retried
until the next keystroke — stranding the writer on "Petal's helper is resting"
after a paste. Long docs make it worse: their 15-25s checks have a wide window
to catch a transient 502 from the shared Ollama (co-tenant apps load other
models and evict the 9B). A failed pass also burned the per-document rate-limit
slot, so a retry within 30s hit the throttle path and got an empty set back.
- llm.RateLimiter.Release rolls back a slot when its pass fails; Allow now
returns the recorded timestamp so Release only frees its own slot.
- suggestions.runPass releases the slot on LLM failure before returning 502.
- useCheckpoint auto-retries a failed checkpoint with backoff (3/12/35s),
keeping the breathing dot up and only flagging "resting" once retries exhaust.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.
Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.
Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.
Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).
Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Companion: bump the corner mascot ~12% (128→144) and make animation
resolution robust — fall back to the idle clip when a mood has no clip of
its own, and pin the always-asleep cat to one pose so its Lottie never
reloads. This stops a bare emoji from replacing the cute companion on idle.
Update notifications: serve a build id at /api/version (a hash of the
embedded dist/index.html, which changes on every frontend deploy). The
client records its load-time id, polls every 90s (and on tab focus), and
floats a Mandarin-first "new version available" banner offering a refresh
when the id changes.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Vendored Hunspell en.aff/en.dic into web/public/dictionaries/en/ (served as a
static asset + embedded in the binary, kept out of the JS bundle). dictionary-en
moved to a devDep — only used to source the files.
- useSpellChecker (App-level, loads once/session): fetches the dict, builds an
nspell instance, replays a localStorage personal word list; addWord persists
and bumps a version so consumers re-decorate. Ambient types in
src/types/nspell.d.ts (the package ships none).
- SpellCheck Tiptap extension: misspellings as ProseMirror decorations (no
stored marks), recomputed on edit / caret move / checker swap. Latin-only
tokenizer so CJK is never flagged; skips short tokens + all-caps acronyms;
exempts the caret word to avoid mid-typing jitter. Reuses mapOffset (now
exported from SuggestionHighlight); wordAt resolves the exact span on click.
- MisspellCard: soft rose wavy underline, bilingual popover with up to 5 nspell
corrections (click to replace) + add-to-dictionary. Closes on outside-pointer,
edit, or doc switch.
Chinese spell check intentionally omitted — nspell is dictionary-based and
English-only; Chinese typos (homophone 别字) need an LLM, out of v1 scope.
tsc/vite/go build+vet clean; live server serves both dict files; nspell
behavior smoke-tested.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages
that read tonally out of place (formal/over-polished/paraphrased-too-closely),
as honey-decorated `voice` flags with no correction (awareness-only).
- internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc),
MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt
in prompts.go (not bundled with the grammar checkpoint, per spec).
- internal/suggestions: POST /api/docs/:id/voice. replacePending is now
family-scoped (pendingScope) so grammar and voice never clobber each other's
pending flags; both passes return the unified pending set. check/voice share
one runPass helper. TestVoicePassCoexists covers both directions.
- Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my
voice" toolbar pill, breathing honey dot in StatusBar.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.
internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.
Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.
Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Backend internal/docs: chi sub-router (list/create/get/update/delete)
mounted at /api/docs, scoped to the local user. Create uses RETURNING;
update is a COALESCE partial-update so rename and full editor save share
one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle.
Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush
before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/
Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar,
and an App.tsx that orchestrates load/select/create/delete with
optimistic sidebar patching. content + content_text + word_count are
emitted together on every edit. .petal-prose styling (Lora body, Nunito
headings).
Verified: tsc clean, vite build, go build, full CRUD smoke test incl.
CJK title round-trip and SPA serve.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd