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
54 KiB
Petal — Build Plan & Progress
Multi-session build. Source of truth for what's done and what's next. Update the checkboxes as work completes. petal-spec.md is the design spec; this file tracks execution.
Decisions locked in (see also memory: petal-design-north-star)
- Auth deferred — no Authentik yet. Seed a single hardcoded local user (
id = "local"); keep theuser_idcolumn so auth drops in later without a schema migration. - Copyleaks deferred — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
- Traefik/deploy deferred — local dev first.
- LLM: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
- Reasoning models: the Ollama client sends
"think": falseon every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separatethinkingfield and exhaustsnum_predictbefore emitting any answer incontent(empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
- Reasoning models: the Ollama client sends
- Suggestion anchoring: resolve by
originalstring in ProseMirror coords at render time; storedfrom_pos/to_posare plaintext offsets for server-side use only. (Spec Note #6.) - Aesthetic is an acceptance criterion: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.
Phases
Phase 0 — Foundation / scaffold ✅
git init,.gitignore, remote → gitea.parodia.dev/drwily/petal- Go module (
go.mod), directory skeleton per spec internal/configenv loading (local-dev defaults; auth/copyleaks fields kept for later)- Vite + React 19 + TS + Tailwind v4 scaffold in
web/(design tokens in@theme, Google fonts) - Frontend embedded via
web/embed.go(go:embed all:dist) + SPA handler incmd/server/main.go - Dev workflow documented in README;
.env.exampleadded - Verified end-to-end: binary serves
/api/health+ embedded SPA + SPA fallback
Phase 1 — Data layer ✅
- SQLite (modernc) init + migrations (
internal/db/db.go) — versionedschema_migrationsrunner, WAL + foreign keys, single writer conn - Models: User, Document, Suggestion (
internal/db/models.go) — + type/status constants - Seed hardcoded
localuser (idempotent on startup) - Schema includes
voicein suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration) db.Openwired intocmd/server/main.go;db_test.gocovers migrate/seed idempotency, CHECK constraint, FK cascade
Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅
- Doc handlers: list/create/get/update/delete (
internal/docs/handlers.go) — chi sub-router mounted at/api/docs, all scoped tolocaluser, partial-update via COALESCE so rename and full save share one PUT;handlers_test.gocovers the lifecycle - Frontend DocList sidebar (create/rename/delete) —
DocList/DocListItem, optimistic title/word-count patching - Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline
Toolbar(B/I/U, H1/H2, bullets, align) useAutoSave(1.5s debounce) → PUT /api/docs/:id, withsaveNow()flush before doc switch/create- StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
- Keep
content(Tiptap JSON) andcontent_text(plain) in sync on save — editor emits both + word_count together
Phase 3 — LLM grammar checkpoint ✅
LLMClientinterface + factory (internal/llm/client.go) — chat-model fallback in factory; doc/history truncation helpersvllm.go(OpenAI-compat),ollama.go(native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE)checkpoint.go(30s/docRateLimiter),prompts.go— brace-matched JSON salvage from model output, empty-original drop, type normalizationPOST /api/docs/:id/check(+GET /api/docs/:id/suggestions,POST /api/suggestions/:id/{accept,dismiss}) ininternal/suggestions; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current setuseCheckpoint(4s debounce) + breathing rose checkpoint dot in StatusBarSuggestionHighlight(ProseMirror decorations, re-anchored byoriginalstring on every doc change — not stored marks) +SuggestionCard(accept applies replacement in-editor then PATCHes; dismiss)- Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
Phase 4 — Ask Petal (conversational follow-up) ✅
POST /api/suggestions/:id/chatSSE streaming; server-side context injection —internal/suggestions/chat.goloads the suggestion + parent doc in one user-scoped query, extracts the\n\n-bounded paragraph aroundfrom_pos(falls back to truncated doc whenfrom_pos == -1), injects it viaAskPetalSystemPrompt, streamsevent: token/event: doneSSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.- AskPetal component, token-by-token render, no persistence —
AskPetal.tsxholds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams viastreamSuggestionChat(fetch + ReadableStream, not EventSource).SuggestionCardgains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded. - CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the
'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body. internal/llm/chat.go:StreamAskPetal(max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop\n\n\n) reusing the existingAskPetalSystemPrompt+TrimHistory. Backend stays interface-only; the SSE handler never touches a concrete client.
Phase 5 — Voice consistency pass (Tier 1) ✅
POST /api/docs/:id/voice, whole-document (noTruncateDoc), explicit "Check my voice 🍯" toolbar action —internal/llm/voice.go(RunVoice,VoiceInterval20s floor, MaxTokens 2048),voiceSystemPrompt/VoiceMessagesinprompts.go(standalone — not bundled with the grammar checkpoint per spec)voicesuggestion type, honey decoration — type already in schema/CSS; voice flags carryreplacement: null→ stored"",SuggestionCardhides the diff row + Accept (Dismiss only)- Family-scoped pending sets: grammar and voice are independent passes sharing the suggestions table.
replacePendingnow scopes its DELETE by family (pendingScope: grammar =type != 'voice', voice =type = 'voice'), so neither pass wipes the other's pending flags. Both/checkand/voicereturn the unified pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency). - Frontend:
api.voiceDoc,useCheckpointgainsvoicing/runVoice(shares the run-token guard),Toolbarhoney "Check my voice 🍯" pill (loading→"Reading…"),StatusBarbreathing honey dot "Reading your voice…".check/voicecollapsed into a sharedrunPassserver-side. - Tests:
TestVoicePassCoexists(grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list[grammar, voice]). - Known limitation (carried from Phase 3):
findRangeanchors within a single textblock, so a voice passage spanning a paragraph break (\n\n) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.
Phase 6 — Design system & polish ✅
- Full pastel tokens, Nunito + Lora + JetBrains Mono —
@themetokens + Google Fonts (landed in Phase 0, in use throughout) - Shape language, shadows, transitions —
--radius-*,--shadow-soft, global200ms easeon interactive elements - Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot) — fade-float + breathing dot already live; accept confetti added this phase: CSS-only 4-dot burst (
petal-confetti/@keyframes petal-confetti, direction via--dx/--dyinline), spawned inEditorCore.handleAcceptat the card position, auto-cleared after 720ms - Distraction-free mode — entered on editor focus (
EditorCoreonFocus→App.setFocusMode), the doc-list sidebar slides left + collapses to 0 width (.petal-sidebar/.petal-sidebar-hidden, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar viahandleChromeDown+canvasRefcontainment check) - Companion mascot (
web/src/components/Companion/) — cozy corner mascot that reacts to the writing session.useCompanionis the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back);PetalCompanionrenders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation vialottie-web/build/player/lottie_light(offline, no eval/CDN) behind aLottiePlayerwrapper that auto-crops the asset to its content bbox (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. Selectable companions (companions.tsroster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between 瞌睡猫 Sleepy Cat (sleeping-cat.json,alwaysAsleep→ every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and 开心狗 Happy Dog (happy-dog.json, awake/bouncy; naps via 😴 emoji). Choice persists inlocalStorage(petal.companion). Add a companion = drop a pure-vector Lottie JSON inanimations/+ append aCOMPANIONSentry.napping = companion.alwaysAsleep || mood === 'sleeping'drives the sway/zzz. Each asset is auto-cropped to its content bbox byLottiePlayer. App feeds iteditTick/acceptTick+wordCount/saveStatus. All copy bilingual intips.ts. (resolveJsonModuleenabled in tsconfig for the JSON import.)
Phase 7 — Spell check ✅
- nspell browser-side (en-US), vendor dictionaries — Hunspell
en.aff/en.dic(fromdictionary-en, now a devDep) vendored intoweb/public/dictionaries/en/(+ upstreamLICENSE); Vite copies them todist/, the Go binary embeds them. ~550KB.dicstays out of the JS bundle, fetched as a static asset. useSpellCheckerhook (App-level, loads once per session not per doc) —fetches aff+dic, builds annspellinstance, replays a personal word list fromlocalStorage(petal.spell.personal);addWordpersists + bumps aversionso the checker's identity changes and consumers re-decorate. Exposes a minimalSpellChecker({correct,suggest}). Ambient types insrc/types/nspell.d.ts(package ships none).SpellCheckTiptap extension — ProseMirror decorations (no stored marks, same as the AI-suggestion layer), recomputed on doc edit / caret move / checker swap. English-only tokenizer (/[A-Za-z][A-Za-z']*/) so CJK is never tokenized → never flagged (north-star: the user writes Mandarin + English); skips <2-char tokens and all-caps acronyms, trims edge apostrophes. Exempts the word under the caret (no jitter mid-typing). ReusesmapOffset(now exported fromSuggestionHighlight) for atom-aware offset→PM-pos mapping.wordAt(doc, pos)resolves the exact span under a click (robust to duplicate misspellings).MisspellCardpopover + EditorCore wiring — soft rose wavy underline (.petal-misspelling, pastel take on the red squiggle, not classic red). Click a flagged word →posAtCoords→wordAtopens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace viainsertContentAt) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.- Verified: tsc clean, vite build OK (dict in
dist/dictionaries/en/), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (helllo→hello,recieve→receive,写作untokenized,NASAok,add()persists).
Phase 8 — Trust foundation (version history + export) ✅
- Version history —
document_versionstable (migration0003), full-body snapshots that cascade with the doc. Kinds:auto(throttled background, ≥3min apart, max 40/doc, pruned),manual(explicit restore point),pre_restore(safety copy taken before a restore, so restore is undoable). Snapshot taken post-save inupdateonly when a real body came through and content changed (empties + bare renames never snapshot). Endpoints:GET/POST /api/docs/:id/versions,GET /api/docs/:id/versions/:vid,POST /api/docs/:id/versions/:vid/restore. All scoped to the owner via a join ondocuments.versions_test.gocovers lifecycle/throttle/restore/pre_restore/404. - Export — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / docx (
export.go), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987filename*=UTF-8''so Chinese titles download cleanly.GET /api/docs/:id/export?format=. PDF is client-side via the browser print dialog + a@media printstylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat).export_test.goasserts every format incl. valid-zip docx with CJK. - Frontend —
ExportMenu(download links + Print/PDF) andHistoryPanel(slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via aneditorEpochbump). Both bilingual zh-first, matching chrome. Wired into the title row;.petal-no-printstrips all chrome for print. - Stop saving empty docs — blank
Untitleddrafts now self-discard:handleCreatereuses an existing blank instead of stacking another;openDocdeletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.) - Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a
pre_restore, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
Phase 9 — ESL superpowers ✅
- Inline Chinese gloss (offline) — embedded English→Chinese dictionary (
internal/lexicon/data/gloss.json.gz, ~1.3MB, 57k common words built from ECDICT viascripts/build_gloss.py: frequency-gated to rank ≤50k,[网络]/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars).Lexicongains aglossmap +Gloss(word)(samecandidates()de-inflection as defs/syns);Resultgains aGlossfield. Two surfaces: the right-click WordCard now leads with the 中文 gloss, and a new lightweightGET /api/gloss/{word}(→{word, gloss}, cached) backs the hover tooltip. Offline + instant, works with the LLM down (north-star reliability). Frontend:GlossTip(dark pointer-events-none bubble under the resting word; 350ms hover delay; reuseswordAtso CJK is never glossed — it's the source language), wired intoEditorCore'sonMouseMove/onMouseLeavewith a request-token guard, suppressed during selection/preview/other popovers. - "Say it more naturally" / tone-rewrite — selecting text pops a
SelectionBubble(✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored fromToneSelect/styleGuidance). Picking a style callsPOST /api/docs/:id/rewrite({text, style}→{rewrite}), shown in aRewritePreview(original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor viainsertContentAtover the captured PM range. Backend:llm.RunRewrite(one-shot Complete,RewriteMaxRunes2000 cap,cleanRewritestrips stray wrapping quotes) +rewriteSystemTemplate/styleGuidanceinprompts.go; handler ininternal/suggestions/rewrite.go(owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). Stateless — not persisted as a suggestion; the version history captures the resulting doc change. - Tests:
lexicongloss + Lookup-includes-gloss + inflection/miss;suggestionsrewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss forriver/inflected/CJK-empty/nonsense-empty,word/happycarries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle. - Known limitation:
Glosstries the literal form first (matching defs/syns ordering), so an inflected word that is itself a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g.rivers→ the proper-noun "Rivers" sense, notriver). The base form always glosses correctly; acceptable.
Phase 10 — Organization & polish ✅
- Cross-document search (FTS5) — migration
0004adds adocuments_ftsvirtual table overtitle+content_textusing thetrigramtokenizer (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync byAFTER INSERT/UPDATE/DELETEtriggers ondocuments, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately).GET /api/search?q=(internal/docs/search.go): queries of ≥3 runes use the FTS index (fast,ORDER BY rank); shorter queries fall back to aLIKEscan so 2-character Chinese words (e.g. 公园) still resolve. Snippets are built in Go from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in\x01…\x02sentinels; the client splits on these to highlight withoutinnerHTML. Owner-scoped, capped at 50 hits. FrontendSearchBoxin the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open. - Tags (organize) — migration
0004addstags(user-scoped,UNIQUE(user_id, name),color= palette key) +document_tagsjoin (both sides cascade).internal/docs/tags.go:GET/POST/PATCH/DELETE /api/tags(create is idempotent on name; unknown colors coerced to rose) +POST /api/docs/:id/tags/DELETE /api/docs/:id/tags/:tagId(owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in onetagsByDocquery, no N+1). Frontend:useTags(roster + counts),TagChip,TagPicker(assign existing / create-and-attach with a color swatch), tag chips on each doc row, a filter bar (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens viatagColorVar. - Tablet / touch polish — responsive sidebar: below 768px it becomes an overlay drawer toggled by a header hamburger, with a scrim (auto-closes on doc select).
@media (pointer: coarse)enlarges tap targets (.petal-tap≥44px,.petal-tap-sm≥36px) and reveals the hover-only row actions (tag/delete). Tap-to-open for AI-suggestion cards (no hover on touch): a tap on a.petal-suggestionopens its card via the editor click handler, and apointerdownoutside the card/highlight dismisses it (mouse users keep the hover bridge). - Warm LLM-down failure states —
useCheckpointnow tracks anllmDownflag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). TheStatusBarshows a gentle bilingual note — 🌙 小助手在休息 · Petal's helper is resting · 文字已保存 — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9. - Tests:
tags_test.go(full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s),search_test.go(EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, edit re-indexes via the update trigger). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (petal-tag-chip/petal-scrim/petal-drawer-open/pointer:coarse) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately. - Known limitations: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
Phase 11 — Writer power-ups ✅
- In-document Find & Replace (Ctrl/Cmd+F) —
SearchHighlightProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded).FindReplacebar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one. - Read-aloud / TTS (
web/src/audio/speech.ts) — Web Speech API, offline, feature-detected. 🔊 in theWordCard(pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line. - Keyboard + touch access to the ESL helpers — refactored the right-click word-lookup into a position-based
openWordLookup(pos); now also driven by Ctrl/Cmd+D (look up the word at the caret) and a touch long-press (~500ms, the touch equivalent of right-click). Ctrl/Cmd+J rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.) - Whole-corpus backup —
GET /api/docs/export-all?format=md|docx|…zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; datedpetal-backup-YYYY-MM-DD.zip). Static route takes priority over/{id}in chi — covered byTestExportAll. Sidebar footer "备份 · Back up all: Word / Markdown" download links. - Smart typography (
Typography.ts) — dependency-free input rules: curly quotes, em-dash (--), ellipsis (...). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able. - Org niceties — duplicate-a-document (App
handleDuplicate→ "… (副本)", copies body/tone, not tags), sidebar sort (Recent / Title / Longest), and a document outline popover in the toolbar (headings → click to scroll, indented by level). - English phonetic (pivot from pinyin) — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently).
scripts/build_phonetic.pyextracts ECDICT'sphoneticcolumn (same source/freq-gate as the gloss);phonetic.json.gzembedded + lazily loaded;Result.Phoneticresolved via the same de-inflecting candidate walk; shown as/ˈrɪvər/in the WordCard. Full dataset built from ECDICT: 46,579 words (361KB gz), in line with the other lexicon assets. The script also has a--seedmode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist. - Verified: go build/vet/test (incl. new
TestExportAll), tsc, vite build all clean; live smoke vs throwaway binaries —/word/river|running|serendipity|riversall return phonetic (rivers→riverde-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with/{id}.
Phase 12 — Collocation coach ✅ (2026-06-26)
Why: ESL writers nail grammar but miss which words go together — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't wrong, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. Build this first — it's small and de-risks the migration-rebuild pattern Phase 13 also needs.
Key insight: the suggestion pipeline is already generic over a pass + a pendingScope "family" (runPass in internal/suggestions/handlers.go; grammar + voice already prove it). Collocation drops in as a third family and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer.
internal/llm/collocation.go—CollocationInterval(25s) +RunCollocation(...), reusing the existingParseCheckpointparser (same asRunVoice). Whole-document (noTruncateDoc), tone passed through.internal/llm/prompts.go—CollocationMessages(contentText, tone)+collocationSystemPrompt. The prompt flags only non-wrong-but-non-native word pairings ("do a decision" → "make a decision"), explicitly defers grammar/spelling to the grammar family, and frames every explanation as warm "Natives usually say…" with a Mandarin gloss — never "error/wrong/mistake".internal/db/models.go(SuggestionTypeCollocation) + migration0005_collocation_suggestion_type— rebuilds thesuggestionstable (new table w/ extended CHECK, copy rows, drop, rename, recreateidx_suggestions_doc_id), since SQLite can'tALTERa CHECK. Verified against a copy of the live DB (5 migrations apply cleanly, collocation insert accepted, rows preserved).internal/suggestions/handlers.go—collocationScope(deleteWhere: "type = 'collocation'",forceType: collocation),CollocationLimitonHandler(+ wired inNew),POST /{id}/collocation,normalizeTypeextended. Also fixedgrammarScopefromtype != 'voice'→type NOT IN ('voice','collocation')so a grammar checkpoint no longer wipes the collocation pending flags (the third family must survive like voice does).- Frontend:
suggestionMeta.tscollocation entry (--color-blossomwarm pink, "Word pairing" label);client.tscollocationDoc(docId)+SuggestionTypeextended;index.csstoken +.petal-suggestion-collocationdecoration;useCheckpointcollocating/runCollocation(mirrorsrunVoice, shares the run-token guard);Toolbarblossom "Make it sound natural 🌸" pill (→ "Reading…");StatusBarbreathing blossom dot "Finding natural phrasing…"; threaded throughEditorCore/App. Renders straight into the existingSuggestionRail/SuggestionCard(Accept applies the native pairing). - Verified: go build/vet/test (
TestCollocationPassCoexists— three families coexist, grammar checkpoint doesn't wipe voice/collocation), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (dead LLM) → collocation route returns the warm 502 like check/voice.
Phase 13 — Vocabulary garden (spaced repetition) ✅ (2026-06-26)
Why: the lexicon (internal/lexicon) is a stateless static-dataset lookup — nothing records which words she's looked up. Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build after Phase 12.
- New
internal/vocabpackage + migration0006_vocab_garden(0005 was taken by collocation) —vocab_wordstable:word, gloss, phonetic, example(sentence captured at lookup),doc_id(ON DELETE SET NULLso a word outlives its source doc), + SM-2-lite scheduling:due_at, interval_days, ease, reps, lapses, last_reviewed;UNIQUE(user_id, word)+idx_vocab_due. - Auto-capture:
EditorCore.openWordLookupfiresPOST /api/vocabafter a successful lookup — only for words the dictionary actually knows (a real gloss or definition), so typos/proper-noun lookups don't clutter the garden. Captures the surrounding sentence (sentenceAround) +doc_id. Idempotent upsert: re-looking-up a word refreshes its gloss/phonetic/example but never resets its schedule. - Endpoints (
internal/vocab/handlers.go):POST /api/vocab(upsert; new word →due_at = datetime('now','+1 day')),GET /api/vocab/due(due now, server-sidedatetime('now')comparison — avoids JS local-vs-UTC parsing bugs),POST /api/vocab/{id}/review(grade → reschedule viadatetime('now','+N days')),GET /api/vocab(full garden),DELETE /api/vocab/{id}. All owner-scoped. - SR scheduler (
internal/vocab/scheduler.go): gentle SM-2-lite / Leitner ladder (1d → 3d → 7d → 16d → 35d, then geometric by ease). "again" steps back to 1d + counts a lapse + nudges ease down (floored at 1.3) — no harsh wipe; "good" climbs one rung; "easy" climbs a rung and a bit more + raises ease. No streaks to break. - Frontend
GardenPanel(slide-over drawer, sibling toHistoryPanel): each word a blossom that opens further with reps (🌱→🌿→🌷→🌸→🌺); a "复习 N 个词 · Review N due" button; per-word detail (phonetic/example/source-doc/remove); footer "🐱💤 N 朵花在花园里" — the sleepy kitten napping among the blossoms. Flashcard review: due queue, the example sentence with the word blanked (blankOut), flip to reveal word+phonetic+gloss+sentence, again/good/easy grades; direction alternates by cursor parity for recognition (EN→中文) and production (中文→EN). A 🤍/💚 "save to garden" toggle onWordCardalongside the silent auto-capture. Opened from a global 🌷 词汇花园 button in the app header. All copy bilingual zh-first. - Verified: go build/vet/test (
scheduler_test.go— ladder/again-gentle/easy-further;handlers_test.go— capture/upsert/due/review/delete lifecycle + empty-word 400 + doc-delete SET NULL), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (throwaway DB) — full capture→list→due→review→delete flow + 400 on bad grade verified end-to-end.
Phase 14 — Companion warmth + bedtime nag + night mode ✅
Why: the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of encouraging phrases so cheers don't repeat as quickly; (2) when she's still writing late at night (≥11pm), the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm night mode — dark moonlit theme + the falling petals become falling stars. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only.
web/src/components/Companion/tips.ts—ENCOURAGEMENTSgrown from 5→10 bilingual zh-first lines so cheers rotate fresher. NewBEDTIME: Line[]array — four warm/playful lines (user-supplied English wit: "I bet your bed is missing you right now", "A tired writer is a bad writer", "Sleep is a wondrous enabler", "Hear that? No… everyone is sleeping and you should be too") with gentle Mandarin leads.web/src/components/Companion/useCompanion.ts— bedtime check folded into the existing 10s heartbeat (after the idle-return + break check, before the generic tip):isBedtime()= local hour ≥ 23 or < 4 (new Date().getHours(), the writer's machine clock). Only fires while actively writing (idle branch returns first). OwnlastBedtimeref +BEDTIME_GAP30min cooldown; respectsPROACTIVE_GAP. NewBubbleTone'bedtime'pacesreadBubbleMs(BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobsBEDTIME_FROM/BEDTIME_TOso the 11pm–4am range is one edit to retune.- No tone-based bubble styling exists, so no CSS needed; the tone is metadata for pacing only. Sound stays the rotating pop.
- Night mode —
web/src/lib/night.tscentralizesisBedtime()+ theBEDTIME_FROM/BEDTIME_TOwindow (shared with the companion nag so they always agree).web/src/hooks/useNightMode.tsre-checks every 60s and toggles apetal-nightclass on<html>.index.cssadds anhtml.petal-nightblock that only re-points the palette tokens (--color-bg/surface/border/plum/muted/accent…) to a dark moonlit set — every Tailwind color utility reads them viavar(), so the whole UI flips with zero component changes (verified:.text-plum{color:var(--color-plum)}). Accent/type colors kept (they pop on dark); 600ms bg/color fade for a gentle dusk transition; print stays white (the#fffoverride is inside@media print). - Falling stars —
PetalFallgains anightprop. Particles are chunky cartoon power stars (makeCartoonStar, Mario/Kirby-style: fat 5-point shape, glossy radial fill, puffy round-join colored outline, corner shine + soft glow halo so they pop off the dark sky) in 5 candy colors (CARTOON_COLORS), mixed ~70/30 with small four-point twinkle sparkles (makeStarSprite/STAR_PALETTE) for depth. Every star clearly spins (random direction, ~0.5–2.2 rad/s so the quick ones really whirl while slow ones drift for contrast; guaranteed min speed since a 5-point star is symmetric every 72°), falls straight down (no sway — that's a petal thing), and shimmers via a shallow alpha pulse (not blink). Effect re-inits on the day↔night flip. App wiresconst night = useNightMode()→<PetalFall night={night} />. All sprites are canvas-drawn (offline, no asset files) —sprites[]is an image array, so a real PNG/SVG star could drop in later without restructuring. - Verified: tsc clean, vite build OK, companion vitest 45/45. Real-browser screenshots (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion).
Deferred (post-v1-local)
- Authentik OIDC auth + session middleware ← on hold: user doing foundational work first
- Copyleaks Tier-2 + webhook HMAC
- Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
Next-up (post-v1 product, agreed with user 2026-06-26)
- Phase 9 — ESL superpowers: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
- Phase 10 — organization & polish: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
- Phase 12 — collocation coach: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. ✅ (see Phase 12 above)
- Phase 13 — vocabulary garden: spaced-repetition review built from looked-up words, surfaced as a blooming garden. ✅ (see Phase 13 above)
- Phase 14 — companion warmth + bedtime nag + night mode: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
Session log
- 2026-06-26: Phases 12 + 13 complete (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). Phase 12: collocation drops in as a third suggestion family reusing the whole
runPass/pendingScopemachinery —llm/collocation.go(RunCollocation, 25s floor, reusesParseCheckpoint),collocationSystemPrompt/CollocationMessages(warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration0005rebuilds the suggestions table to extend thetypeCHECK (SQLite can't ALTER a CHECK),collocationScope+CollocationLimit+POST /{id}/collocation. Caught a latent bug:grammarScopewastype != 'voice'→ would wipe collocation flags; fixed totype NOT IN ('voice','collocation'). Frontend:--color-blossompink, "Make it sound natural 🌸" toolbar pill,collocating/runCollocationinuseCheckpoint, StatusBar dot — all into the existing rail/card. Phase 13: newinternal/vocabpackage — migration0006_vocab_garden(vocab_words, SM-2-lite columns, doc_idON DELETE SET NULL,UNIQUE(user_id,word)),scheduler.go(Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming),handlers.go(capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLitedatetime()so stored values stay canonical-UTC). Auto-capture wired intoEditorCore.openWordLookup(only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle onWordCard.GardenPanelslide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests:TestCollocationPassCoexists, vocabscheduler_test.go+handlers_test.go, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. Remaining: only the deferred infra bucket — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik". - 2026-06-26: Phase 14 complete (companion warmth + bedtime nag + night mode).
tips.ts:ENCOURAGEMENTS5→10 lines; newBEDTIMEarray (4 lines, user-supplied English wit + gentle Mandarin leads).useCompanion.ts: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; ownlastBedtimeref + 30minBEDTIME_GAP, respectsPROACTIVE_GAP; new'bedtime'BubbleTonelingers ~4s longer. Night mode (added same session, user request):lib/night.tscentralizesisBedtime()+ window (now shared by the nag too);hooks/useNightMode.tstogglespetal-nighton<html>(60s re-check);index.csshtml.petal-nightre-points only the palette tokens → whole UI flips viavar()(no component edits), 600ms dusk fade, print stays white;PetalFallgains anightprop → chunky cartoon power stars (makeCartoonStar, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App:useNightMode()→<PetalFall night={night}/>. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window isBEDTIME_FROM/BEDTIME_TO(local clock) for easy retune. - 2026-06-26: Phase 11 complete (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc Find & Replace —
SearchHighlightdecoration extension +FindReplacebar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) read-aloud Web Speech util + 🔊 in WordCard & selection bubble; (3) keyboard/touch access — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactoredhandleContextMenu→ sharedopenWordLookup(pos)); (4) export-all backup zip (GET /api/docs/export-all,TestExportAll, sidebar download links); (5) smart typography input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) duplicate doc + sidebar sort + outline popover; (7) English phonetic (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) viascripts/build_phonetic.py+ embeddedphonetic.json.gz+Result.Phonetic+ WordCard/ˈrɪvər/line — full 46,579-word dataset built from ECDICT (the csv re-download worked;--seedmode kept as a csv-free fallback). Also folded in this session: the selection-bubble vs copy/paste fix (bubble deferred to pointer-up + containerpointer-events:noneso it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user. - 2026-06-26: Phase 10 complete (organization & polish). Scope confirmed with user: all four areas, tags (not folders), FTS5 search. Backend: migration
0004_tags_and_search(tags + document_tags +documents_ftstrigram virtual table with sync triggers + back-fill);db.Tagmodel + color constants;internal/docs/tags.go(tag CRUD + idempotent assignment +tagsByDochelper, doc list now carries tags);internal/docs/search.go(GET /api/search, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted/api/tags+/api/searchin main.go. Frontend:useTags,TagChip/TagPicker/SearchBox, rewrittenDocList/DocListItem(chips + filter bar + search),api.search/tag methods +splitSnippet/tagColorVar; responsive sidebar drawer (hamburger + scrim, <768px) +pointer:coarsetap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch);useCheckpointllmDownflag → warm bilingual "小助手在休息" StatusBar note. Tests:tags_test.go,search_test.go(incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. All v1 phases (0–7) + post-v1 product (8–10) done. Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user. - 2026-06-26: Phase 9 complete (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an offline EC dictionary (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a selection bubble. Data:
scripts/build_gloss.pybuildsinternal/lexicon/data/gloss.json.gzfrom ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend:lexicongloss map +Gloss()/Result.Gloss+GET /api/gloss/{word};llm.RunRewrite+ rewrite prompt/styleGuidance;internal/suggestions/rewrite.go(POST /api/docs/:id/rewrite, stateless, owner-scoped). Frontend:GlossTiphover tooltip (350ms delay, reuseswordAt, CJK-safe) + gloss line inWordCard;SelectionBubble+RewritePreviewwired throughEditorCore(onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch);api.glossWord/api.rewriteSelection; CSS for the three new surfaces (+ print-hidden). Tests added inlexiconandsuggestions. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: Phase 10 (organization & polish). - 2026-06-26: Phase 8 complete (Trust foundation: version history + export) + empty-doc fix. Backend: migration
0003_document_versions;internal/docs/versions.go(throttled auto-snapshot wired intoupdate, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) andinternal/docs/export.go(pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987).DocumentVersionmodel + kind constants. Tests:versions_test.go,export_test.go(incl. valid-zip docx assertion). Frontend:api.clientversion/export methods;ExportMenu+HistoryPanelcomponents wired into the title row;@media printstylesheet +.petal-no-printfor the browser PDF path;editorEpochremount on restore. Empty-doc fix inApp.tsx(blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; Phase 9 (ESL gloss + tone-rewrite) next, then Phase 10 (search/folders/polish); auth/deploy (was Phase 11) shelved until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: Phase 9. - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
- 2026-06-25: Phase 0 complete. Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: Phase 1 (data layer) — SQLite via modernc, models, seed
localuser. - 2026-06-25: Phase 1 complete.
internal/dbpackage: modernc.org/sqlite (pulled go toolchain → 1.25),Open()does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired intomain.go; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writespetal.db. Next: Phase 2 (document CRUD + auto-save) — first "it works" milestone. - 2026-06-25: Phase 2 complete. Backend
internal/docs: chi sub-router (list/create/get/update/delete) mounted at/api/docs, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors;handlers_test.gowalks the full lifecycle. Frontend:api/client.ts,useAutoSave(1.5s debounce +saveNowflush),EditorCore(Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) +Toolbar,DocList/DocListItem,StatusBar, rewrittenApp.tsxorchestrating load/select/create/delete with optimistic sidebar patching..petal-prosestyles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: Phase 3 (LLM grammar checkpoint). - 2026-06-25: Phase 3 complete. Backend
internal/llm:LLMClientinterface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream),prompts.go(checkpoint + Ask Petal templates),checkpoint.go(brace-matched JSON salvage, per-doc 30sRateLimiter, doc/history truncation).internal/suggestions:/api/docs/:id/check+:id/suggestions+/api/suggestions/:id/{accept,dismiss}; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located bystrings.Index(advisory only). Frontend:useCheckpoint(4s debounce, loads existing on doc open, run-token guards stale responses),SuggestionHighlightTiptap extension rendering ProseMirror decorations re-anchored byoriginalstring on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms),SuggestionCard(type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified:I has→0:5,two apple→6:15) and 502 path when LLM unreachable. Next: Phase 4 (Ask Petal SSE chat). - 2026-06-25: Phase 5 complete. Tier-1 voice-consistency pass. Backend:
internal/llm/voice.go(RunVoice— whole document, noTruncateDoc, MaxTokens 2048,VoiceInterval20s per-doc floor), standalonevoiceSystemPrompt/VoiceMessages(not bundled with the grammar checkpoint).internal/suggestions:POST /api/docs/:id/voiceroute;check/voicecollapsed into a sharedrunPass(limiter, pass, scope);pendingScopemakesreplacePendingfamily-aware (grammar deletestype != 'voice', voice deletestype = 'voice'), so the two passes never clobber each other's pending flags; both endpoints now return the unified pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend:api.voiceDoc,useCheckpoint→voicing/runVoice(shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill inToolbar(→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" inStatusBar. Voice flags'replacement: nullround-trips to"";SuggestionCardalready hides the diff row + Accept for those. Tests:TestVoicePassCoexists(coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified[grammar@0, voice@62 (empty replacement)], grammar preserved. Known limitation:findRangeis single-textblock, so a voice passage crossing a\n\nparagraph break won't decorate (deferred). Next: Phase 6 (design system & polish). - 2026-06-25: Companion kitten added (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges.
web/src/components/Companion/:useCompanion(behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags),tips.ts(all copy bilingual, zh-first),LottiePlayer(wrapslottie-weblight build — offline, no eval/CDN fetch, so it bundles into the Go binary),PetalCompanion(kitten + CJK-first speech bubble). Ships working today with an emoji-kitten placeholder (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON intoanimations/index.tsis the only change to upgrade to real animation. Library decision: Lottie vialottie-web(not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wireseditTick/acceptTick/wordCount/saveStatus. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. TODO (user): source a Lottie cat asset to replace the emoji placeholder. - 2026-06-25: Phase 6 complete. Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. Accept confetti: CSS-only burst (
.petal-confetti-dot+@keyframes petal-confetti, each dot's trajectory from inline--dx/--dy), aConfetticomponent inEditorCorespawned at the accepted card's position onhandleAcceptand cleared after 720ms (timer cleaned up on unmount). Distraction-free mode:EditorCoregains anonFocus→AppfocusModestate; the doc-list sidebar is wrapped in.petal-sidebarand collapses via.petal-sidebar-hidden(width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (handleChromeDowncheckscanvasRefcontainment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: Phase 7 (browser-side spell check, nspell en-US). - 2026-06-25: Phase 7 complete. Browser-side spell check (nspell, en-US). Vendored Hunspell
en.aff/en.dic→web/public/dictionaries/en/(dictionary-enmoved to devDep; dict served as a static asset + embedded in the binary, kept out of the JS bundle).useSpellChecker(App-level, loads once/session) builds the nspell instance, replays alocalStoragepersonal word list,addWordpersists + bumps a version to re-decorate;src/types/nspell.d.tssupplies the missing types.SpellCheckextension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exportedmapOffset;wordAtfor click→span).MisspellCard: rose wavy underline, bilingual card with correction pills + add-to-dictionary. tsc/vite/go all clean; live server serves both dict files; nspell behavior smoke-tested. All v1 phases (0–7) done. Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion. - 2026-06-25: Phase 4 complete. Backend:
internal/llm/chat.go(StreamAskPetal— conversational sampling params, reusesAskPetalSystemPrompt/TrimHistory),internal/suggestions/chat.go(POST /api/suggestions/:id/chat— one user-scoped join loads the suggestion + parentcontent_text,surroundingParagraphextracts the\n\n-bounded paragraph atfrom_poswith whole-doc fallback, streamsevent: token/event: doneSSE frames with JSON-encoded data,X-Accel-Buffering: no, realhttp.Flusherper chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend:streamSuggestionChat(fetch + ReadableStream SSE parser, abortable),AskPetal.tsx(in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret),SuggestionCard"Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests:chat_test.go(streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404,surroundingParagraphunit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack,doneterminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: Phase 5 (voice consistency pass, Tier 1).