Word lookups now come from DreamDict's dict.db for every pair but Chinese — opened read-only beside petal.db, no service, nothing over the VPN, because a hover gloss has to answer in milliseconds. `Provider` is the two questions the popover and the tooltip already asked, so the embedded *Lexicon satisfies it with no changes at all; Set.For(lang) is the single place the choice between them is made. The prerequisite in the dreamdict repo turned out to be two things, not one: the module path was unfetchable *and* the query layer sat in internal/, which no other module may import whatever the module is called. Both fixed upstream. The plan's central assumption did not survive the data. It mapped Gloss ← Translate(word, "en", L1) one-to-one; against the real 452 MB database that table answers for 17% of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. Shared WordNet synsets answer for 61%, so DreamDict gained Equivalents() and Petal glosses through it. Ordering those was wrong in an instructive way too: sorting by frequency glosses "think" as lembrar, "remember", because lembrar is the commoner Portuguese word even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first asks the right question. The same measurement is why zh stays on ECDICT: DreamDict reaches a Chinese gloss for 53% of those words, ECDICT for nearly all of them. The plan said converge only if quality holds. It didn't, so nothing converged. Two decisions about failure worth keeping. A missing dict.db is not an error — a laptop checkout has never had one — but a present-and-never-imported one is, because that is a half-finished deploy. And a pt-PT writer with no dictionary falls back to the embedded datasets with the gloss suppressed, keeping definitions, synonyms and phonetics rather than blanking the popover: an empty field reads as "not found", the wrong language reads as broken. The new fields surface as an etymology line and a three-band chip. Three, not five: the difficulty score separates "everyday" from "you'll have to explain this" but cannot rank obfuscate against serendipity, and a finer scale would be a confident-looking lie. An unscored word gets no chip. Writing the tests found two bugs first — trimEtymology sliced by byte, which would have emitted invalid UTF-8 for exactly the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean; live smoke against the real dict.db with one instance flipped from zh to pt-PT mid-run. Not deployed: go.mod still replaces github.com/prosolis/dreamdict with ../dreamdict, so the Docker build needs the two upstream commits pushed and the replace dropped. The deployed dict.db also predates DreamDict's Spanish data. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
114 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)
- Multi-user groundwork (2026-07-26) — request-scoped identity. New
internal/auth:Middleware(Resolver)resolves the caller once per API request and stores the id in the context; handlers read it viaauth.UserID(r.Context())instead of namingdb.LocalUserID.StaticResolver(db.LocalUserID)keeps Petal single-user today. Auth itself is still deferred — but every query is now scoped to whoever the resolver says is calling, so landing Authentik is a one-line change inmain.goplus aResolverimplementation. - Copyleaks Tier-2 + webhook HMAC (still parked — needs a public webhook; revisit after Phase 15)
- Authentik OIDC + deploy: no longer deferred — expanded into Phases 15–17 below (decisions ratified 2026-07-26; see
MULTIUSER_PLAN.md). Deploy landed 2026-07-26 (Phase 15); Authentik itself already runs on the same VPS, so Phase 16 has its IdP waiting.
Execution phases 15–22 (added 2026-07-26)
Decisions behind these are ratified in MULTIUSER_PLAN.md (all OPENs settled) and SUGGESTIONS.md (the why; Q1–Q3 settled). Standing rules for every phase below:
- Isolation tests in the same commit as any new user-scoped endpoint (the
docs/isolation_test.gosuites are the template — this discipline caught a real unscoped-query bug once already). - LLM-minimalism (SUGGESTIONS §6): the LLM never gates essential functionality; new essential features are code+data first.
- Aesthetic + bilingual-in-the-pair copy remain acceptance criteria on every user-visible change.
- Verify per project convention: go build/vet/test, tsc, vite build, vitest, live smoke on a throwaway DB/port.
Phase 15 — Deploy plumbing (parodia.dev + headscale) ✅ (2026-07-26/27)
Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth (Phase 16) needs the stable BASE_URL/redirect URI this phase creates. Hostname: petal.parodia.dev (DNS already pointed at the VPS). Runbook: deploy/README.md.
- Dockerfile (multi-stage:
npm run build→go build→ alpine runtime) + docker-compose. CGO stays off (modernc SQLite is pure Go), so the runtime layer exists only for ffmpeg (read-aloud transcode) and tzdata (the bedtime nag + night mode read the local clock). Non-root;/datais the single writable mount..dockerignorekeeps the live DB and a stale localweb/distout of the image. - Traefik route + HTTPS on
petal.parodia.dev— labels follow the host's existing convention (externaltraefiknetwork,web-secureentrypoint,defaultcert resolver,compression@file) plus Petal's own header middleware. No host port is published; Traefik is the only way in.BASE_URL=https://petal.parodia.devset for Phase 16's redirect URI. LLM_ENDPOINT→ millenia's headscale address (100.64.0.2:8000);LLM_TIMEOUT30s → 90s for the WAN+VPN round trip (the voice/collocation passes send a whole document and the timeout is a hard deadline onComplete). Exposed with a forwarder, not a rebind (deploy/vllm-headscale-proxy.service, socat):vllm-chat.serviceis shared — Petal, Gogobee and Open WebUI all point at127.0.0.1:8000, and Open WebUI keeps its endpoint in its own database rather than in env, so rebinding meant editing three consumers and reloading a 35B AWQ model. The forwarder adds a second listener on100.64.0.2only (never0.0.0.0— the far end is a public host), zero downtime, zero consumer changes. Model isqwen3.6-35b. Verified end to end: a grammar checkpoint frompetal.parodia.devreturns real suggestions in ~3s over the VPN.- TTS — deviation from the plan, deliberate: Piper was not actually installed on parodia, and the
realaaccount has no lingering session to keep user systemd units alive. Runs as two sibling containers (piper-en,piper-zh) off one image, models cached in a shared volume, on an internal network with no published ports. pt-PT in Phase 21 is a fourth service, not a new image. Found + fixed while wiring: piper-tts 1.6.0 moved synthesis fromPOST /toPOST /synthesize(identical body); rather than pin both deployments to one release, the path is now config (TTS_PATH, default/so millenia is untouched). - Backups —
db.BackupusesVACUUM INTO, not a file copy: in WAL mode the newest committed pages may live inpetal.db-wal, and copying the three files separately can capture a torn mid-checkpoint state.VACUUM INTOreads one coherent snapshot including the WAL, takes no write lock (safe against the live app), and emits a single file with no companions; it refuses an existing destination so a failed run can't destroy the last good backup. Driven by a-backupflag on the binary.- On the VPS: folded into the host's existing
parodia-backup(age-encrypted, offsite to S3, 14-day retention, dead-man snitch) rather than a parallel cron — the user pointed out that layer already existed. Found a real bug while doing it: that script'ssqlite_dumphelper uses Pythoniterdump, which does not reproduce an FTS5 virtual table — it emitsdocuments_ftsas a rawsqlite_masterrow plus shadow tables, and replaying the result dies withno such table. Cross-document search would have been silently missing after any restore. Added asqlite_file_dumphelper usingVACUUM INTOinstead; round-trip verified (counts + a live FTSMATCH). - On millenia:
petal-backup.timer— the canonical instance had no scheduled backup at all (newest snapshot a month old), which mattered far more than the staging one. Nightly 03:20,Persistent=true(the box isn't on 24/7), snapshot → gzip → age-encrypt with the parodia public recipient → push to the VPS over headscale with a size check → prune both ends. Verified the pushed archive is real age ciphertext and that neither box can decrypt it.
- On the VPS: folded into the host's existing
- Migration decision: millenia stays canonical (user's call). The VPS runs an empty staging DB so she moves accounts exactly once, when Phase 16/17 land.
- Encryption at rest (not in the original plan — the user raised it mid-session, correctly). VPS data dir is now a LUKS2 volume (
deploy/setup-encrypted-data.sh), coveringpetal.db,images/and the TTS cache (synthesized audio of her sentences). LUKS-on-a-file rather than gocryptfs because SQLite in WAL mode needs a shared-memory index mapped consistently across processes and FUSE has a long history of mmap/locking differences. Key on the same box — a deliberate availability tradeoff, documented honestly: it stops a decommissioned disk or a raw block-device read, not anyone holding the whole VM image. Canary-verified: a marker written through the app is absent from the raw image and present through the mount. Two bugs caught by rehearsing a reboot rather than trusting the clean run — (1) mounting over a directory hides its contents rather than removing them, so the first pass left the original plaintextpetal.dband WAL on the unencrypted root filesystem, invisible under the mount (now shredded pre-mount, with a refusal if the mountpoint won't come up empty); (2)systemd-cryptsetupwasn't installed, so/etc/crypttabwas ignored entirely and the volume would never have unlocked at boot. Added a mount-liveness guard (.volume-okbind-mounted withcreate_host_path: false) so an unmounted volume is a loud container start failure instead of Petal quietly serving a blank database. ⚠️ A true reboot is untested — the VPS also runs matrix/lemmy/akkoma/gitea/authentik, so that's the user's call. millenia remains unencrypted at rest (LVM, no LUKS). - Supervision (also not in the original plan). millenia's Petal had been running as a bare
./petalwith PPID 1 — no unit, no screen session — so a crash or reboot left it silently down; nowpetal.service, verified bykill -9. Piper's silent-failure mode fixed: withRestartSec=3against systemd's default 10s window the burst limit was never reached, so a dead service looped 26,800+ times over a day without enteringfailed; both units now setStartLimitIntervalSec=300/StartLimitBurst=5. Still missing: an external probe (uptime-kuma monitors on/api/healthand/api/tts— needs the UI, written up indeploy/README.md§7). - Interim edge gate (not in the original plan; added once the instance was live). Petal authenticates nobody yet —
StaticResolverhands every request the samelocaluser — so on a public host the whole API was open to read/write and image upload. Traefik basic auth holds the door until Phase 16, with/api/healthexempt on its own higher-priority router. Deleted when OIDC lands. - Acceptance — verified over public HTTPS with the LLM link down (it genuinely is): Hunspell dictionaries 200, gloss + word lookup (incl. phonetic) 200, doc create/save, FTS search on 春天, md + docx export, vocab capture/list, read-aloud EN + zh (real mp3 via ffmpeg, cache hit on repeat, 404 for an unconfigured language so the client falls back).
POST /check→ the warm 502 that renders as 小助手在休息./api/healthpublic; HTTP 301 → HTTPS with a valid cert.
Phase 16 — Auth (in-app OIDC) + image-store ownership ✅ (2026-07-27) — live on petal.parodia.dev
Option B ratified. go-oidc + x/oauth2; config fields already existed. The Resolver seam from Phase 0 was the only integration point — no handler or query moved.
- OIDC login flow (
internal/auth/oidc.go):/auth/login→ Authentik →/auth/callback→ provision → session. state (cookie vs param, constant-time) + nonce (ID-token claim vs cookie, so a token minted for another attempt is refused) + PKCE S256. Discovery is lazy and retried: an Authentik outage blocks new logins but leaves every existing session working, since those need only Petal's own DB — the app must not fail to boot because the IdP is briefly down. sessionstable (migration0010); opaque token inpetal_session(HttpOnly,SameSite=Lax,Secureonly whenBASE_URLis https — flagging it on a plain-http dev server makes the browser silently drop it). The table stores only the token's SHA-256, so a DB copy yields no usable session. 30-day sliding expiry, all time math in SQLitedatetime()(canonical UTC), the extension throttled to one write per hour per session./auth/logoutdeletes the row, not just the cookie;RevokeAllsigns one writer out everywhere; expired rows pruned at startup.- Allowlist:
PETAL_ALLOWED_SUBS, comma-separated. Matches a subject id or an email, case-insensitively — a deliberate widening of the plan: a subject is an opaque uuid that doesn't exist until first login, so a subject-only list means letting someone in, reading a log line, and editing config. Empty = anyone Authentik authenticates. A rejected valid login gets the warm bilingual "这个 Petal 不是给你写的 · This Petal isn't yours to write in" page and no provisioned account. main.gopicks the resolver from config:SessionStore(which is itself theResolver) whenAUTHENTIK_URL/id/secret are all set,StaticResolver(local)otherwise — so local dev and every pre-auth deployment behave exactly as before./auth/*mounts on the root router, outside/api.- Frontend: one 401 interceptor in
api/client.ts(UnauthorizedError+ anonUnauthorizedhook coveringreq, the image upload and the SSE chat stream) →useSession→SignInOverlay(warm bilingual, editor still visible behind it — nothing has been taken away). Draft rescue (lib/drafts.ts): a save that 401s stashes its body inlocalStoragekeyed by doc id before anything else, auto-save then stops (further attempts would only 401 and re-stash), and opening that doc after re-login merges it back and schedules a save. StatusBar says 已保存在本机 · Kept on this device — where the writing is, not what failed. Sidebar footer gains the account + 退出 · Sign out (hidden when the id is stilllocal). - Image store ownership (OPEN #5):
imagestable (PRIMARY KEY (name, user_id)) — one row per owner, not one owner per file, so the same picture uploaded by two people is still stored once and dedup survives; the file is deleted only with its last row. Fetch joins on the caller and answers 404, not 403 (whether a hash exists is itself information).Cache-Controlwentpublic→private— a shared cache must never hand one writer's image to another. Files already on disk are claimed for the local user at startup (idempotent), because a row is now what makes an image fetchable and every picture already pasted into a document would otherwise 404. users.pair_lang(default'zh') added in the same migration; login refreshes email/display name but never touches it — it's Petal's setting, not the IdP's.- Tests:
session_test.go(lifecycle, expiry + prune, sliding renewal, hash-not-token storage, cross-user non-interchangeability,RevokeAll, FK cascade, middleware wiring, user upsert, allowlist matrix) andoidc_test.go— the whole round trip against a stub IdP (RSA-signed ID tokens, real discovery + JWKS): PKCE challenge present, verifier reaches the token endpoint, state mismatch → 400, replayed nonce from another attempt → 400, allowlist refusal → the bilingual 403 with no account created, provider error → 403, already-signed-in login short-circuits home.images/handler_test.gogained two-user isolation, cross-user dedup + last-owner file deletion, and backfill idempotency.web/src/lib/drafts.test.tscovers the rescue (round-trip, per-doc, take-consumes, expiry, corrupted entry, storage that throws). - A real bug the round-trip test caught: the one-shot state/nonce/PKCE cookies were cleared with
defer o.clearTemp(w)— which runs after the redirect has written the response header, so theSet-Cookiewas silently dropped and they lingered in the browser for their full 10 minutes. Now cleared up front. - Verified: go build/vet/test, tsc, vite build, vitest 76/76 all clean. Migration
0010applied to a copy of the live millenia DB (VACUUM INTOsnapshot): 10 migrations apply, documents/vocab/versions counts unchanged, FTS search still returns hits, the one existing image claimed,pair_langdefaulted. Live smoke against the binary on a throwaway DB: auth-off →/api/meislocaland everything 200s; auth-on →/api/docsand/api/me401,/api/healthstill public,/auth/loginwith an unreachable IdP renders the warm 503 page,/auth/logoutredirects home; a hand-inserted session row → 200 with the cookie, 401 without it, with a bad one, and once expired. - Deployed (user: "do it! register it!"). Provider + application registered in Authentik (slug
petal, confidential, strict redirecthttps://petal.parodia.dev/auth/callback, openid/profile/email, implicit-consent authorization flow), created throughak shellsince the available API token is a limited invite-minter bot..envfilled in on the VPS, image rebuilt, container recreated. The Traefik basic-auth gate is gone along with the separate unauthenticated/api/healthrouter that existed only to escape it — Petal 401s every/apiroute without a session, so an anonymous visitor gets the app shell and a redirect, and a second password in front of a real login is one more thing to lose. Verified over public HTTPS:/api/health200,/api/docs401 with no basic-auth challenge,/auth/login→ Authentik with state+nonce+PKCE in the URL, following it lands on the real sign-in page,/petal.svgserved as the favicon. The final step — typing her password — is hers. - Two bugs deploying caught that the whole test suite could not, both fatal before the login page ever renders: (1) the code trimmed the issuer's trailing slash, and Authentik's issuer has one — OIDC requires a byte-for-byte match, so discovery failed every time while the stub IdP (which advertised a slashless issuer) kept passing. Fixed, and the stub's issuer is now a knob with a regression test that ends in a slash. (2) A provider created through
ak shellrather than the admin UI comes up withgrant_types = [], which authentik reads as "no grant type is permitted here" and answers withinvalid_request/ The request is otherwise malformed. Both are written up indeploy/README.md§4. - Allowlist is currently
prosolis@proton.meonly. That Authentik instance fronts ~40 accounts across several applications, so an empty list was not an option, and guessing which account is hers would either lock her out or let a stranger in. Adding her is one line in.envplus a restart. Note that an Authentik account with no email set (e.g.akadmin) can't match an email-based entry — use its subject id.
Phase 17 — Migrate the local user ✅ (2026-07-27) — her writing now lives on her account
Script, app stopped, backup first (OPEN #4). The "she logs in once first" dependency turned out not to exist: authentik's default hashed_user_id sub mode makes the subject User.uid, which is derived from her user id and the instance secret — stable, and readable before she has ever signed in (ak shell -c "…User.objects.get(username='claire').uid"). So the data can move first, and she signs in to find her writing already there rather than to an empty Petal that fills in later.
scripts/migrate_local_user.*: single transaction,PRAGMA foreign_keys=OFF, re-pointdocuments/tags/vocab_wordsandimages(versions/suggestions follow parents;imagesis new in Phase 16 and carriesuser_iddirectly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has datascripts/migrate_local_user.py— dry-run by default,VACUUM INTObackup before touching anything, one transaction withPRAGMA foreign_keys=OFF, re-pointsdocuments/tags/vocab_words/images, deletes the old user row, and verifies every expected row actually moved (and that the source is left owning nothing) before it commits, rolling back otherwise. Refuses to merge into an account that already owns writing. Runbook in the script header.- The "is the app stopped?" guard needed a second attempt.
BEGIN EXCLUSIVE— the obvious check — passes straight through against a running but idle Petal, because in WAL mode it only conflicts with another writer. That is exactly the case the guard exists to catch, and it would have failed silently.PRAGMA locking_mode = EXCLUSIVEconflicts with any connection at all, since it locks the shared-memory index every WAL reader maps; verified against a live server. - Run for real. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image moved from
localonto5f47d955…(Claire,clairew8@pm.me). Sequence:-backupsnapshot of millenia's live DB (taken while it kept running —VACUUM INTOneeds no write lock), shipped to the VPS, installed over the throwaway staging database (kept aspetal.db.staging-*), started once so migration0010applied, stopped, migrated, started. Verified over public HTTPS with a short-lived probe session, then removed:/api/meis her, 8 documents listed, her image 200s, vocabulary garden and version history intact, search returns hits — and 401 without the cookie. - millenia is a frozen fallback, not a mirror (user's call: "both, VPS first"). It was left running and completely untouched, still serving the same writing under the pre-auth
localuser. The two diverge the moment anything is written on either, so it wants retiring rather than syncing. - A second bug in the guard, found by running it against production rather than a test file.
PRAGMA locking_mode = EXCLUSIVEkeeps holding the lock after being set back toNORMAL— SQLite only releases it on that connection's next database access — so on a WAL database the script locked itself out of its ownVACUUM INTObackup. It passed locally because the test database had come out ofVACUUM INTOand so wasn't in WAL mode at all — the same shape of miss as the trailing-slash issuer: the fixture didn't look like production. The probe now runs on its own connection and closes it, and the fix was re-verified against a database that had genuinely been served in WAL mode. - Startup crash averted while sequencing this: the image backfill claims unowned files for
local, which stops existing after the migration — a foreign-key error insideimages.New, whichmain.gotreats as fatal. Petal would have entered a crash loop the first time it started on a migrated database. The backfill now skips a missing owner (there is nothing to claim in that case anyway; the migration moves the image rows itself).
Phase 18 — Per-user, per-language client state ✅ (2026-07-27)
- Preferences namespaced by account (
web/src/lib/prefs.ts) —petal.sound,petal.petalsandpetal.companionnow read/write<base>.u.<userId>. The wrinkle is timing:sounds.tsandpetals.tsread their value at import time, long before/api/meanswers, so rather than block startup on the network for a mute flag, a read before the answer sees the legacy un-namespaced key (on a single-writer browser, exactly the right value) andsetPrefsScope— called fromuseSessionthe moment/api/meresolves — adopts it and firesonPrefsScopeChangeso each module re-reads.PetalCompanionre-reads too, unless she's already swapped mascots in the meantime. - Legacy adoption is a move, not a copy (user's call): the first account to sign in on a browser inherits whatever was set back when Petal had no accounts, and the key is then deleted so the second account starts from Petal's defaults rather than from a stranger's choices. An existing scoped value is never overwritten by the legacy one.
- Personal spell dictionary promoted to a server table (the plan's nice-to-have; user chose it) — new
internal/spellpackage + migration0011_personal_dictionary:personal_words (user_id, lang, word, created_at),PRIMARY KEY (user_id, lang, word), cascading with the account.GET/POST/DELETE /api/spell/words; adds are idempotent, a delete of a word that was never there is a success (the caller's intent already holds), and every response carries the full resulting list so the client never has to merge two views of one set. Namespacing it inlocalStorageinstead would have fragmented the list she already has across her laptop and tablet — strictly worse than before; a table means it follows her. langis the dictionary's language, not the writer's — an en-US personal word must not silence a pt-PT flag once the second pair ships. Normalised (trim/lowercase, defaulten) soEN/en/absent can't split one list into three.useSpellCheckeris server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7petal.spell.personalkey hands it over on first load — but only lets go of it once the server has accepted it, so a failed request costs nothing.addWordtakes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing.- Tests:
internal/spell/handlers_test.go— lifecycle (idempotent add, bulk add, repeat delete,[]notnull), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule two-user isolation (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it).web/src/lib/prefs.test.ts— legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety. - Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty.
- Rehearsed, then deployed (2026-07-27). The rehearsal the previous session couldn't run:
VACUUM INTOsnapshot of the live VPS database, pulled down, migrated by the Phase-18 binary — 11 migrations apply, every count unchanged (2 users, 8 documents, 33 versions, 103 suggestions, 3 vocab words, 1 image), FTS still matching,integrity_checkandforeign_key_checkboth clean,personal_wordspresent and empty. Then the deploy: off-box encrypted backup first,git pull+docker compose up -d --build, all three containers healthy,0011applied to the live DB with her writing untouched. Verified over public HTTPS:/api/health200,/api/docsand the new/api/spell/words401 without a session. - ⚠️ The authenticated live probe of
/api/spell/words(hand-inserted session row, as Phases 16/17 used) was blocked by this session's permission classifier — minting a session token reads as credential fabrication. Not worked around. The endpoint's full lifecycle is covered byinternal/spell/handlers_test.goand was smoke-tested end to end on a throwaway DB when it was built; what remains unproven in production is only that it answers 200 for a real cookie, which the shared middleware already governs for every other route.
Phase 19 — Langpack extraction (the copy chore) ✅ (2026-07-27)
Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).
- Every
中文 · Englishstring from the ~29 frontend files (plustips.ts,prose.ts,companions.ts,stats.ts) now lives inweb/src/i18n:types.ts(thePackshape),packs/zh.ts(today's copy, verbatim — sentinel assertions ini18n.test.tsguard against a quiet rewording),index.ts(the accessor). - Two access paths, matching where copy is built:
usePack()for components (auseSyncExternalStoresubscription, so a pack arriving after first paint re-renders), andpack()for the modules that compose a line when something happens rather than when something renders — the companion and the prose checker read it at call time, never at import time. - Anything with a value in it is a function on the pack, not a template assembled at the call site (
reviewDue(n),daysAgo(n),duplicateTitle(title), every prose rule). Word order isn't universal; a pack author must be able to move the number. English pluralisation moved into the pack with it. Linerenamed its Mandarin halfzh→nativethroughout (companion bubbles, tone/style pills, history badges, stat rows).gradeBandnow returns a band name rather than a label, and the roster constants (TONES,REWRITE_STYLES, export formats, companions) keep only value + emoji — the label is a pack lookup keyed by the same value, with a test asserting no roster entry is unlabelled.internal/llm/lang.go: the three prompts that name the writer's language — the collocation gloss, Ask Petal's "answer in her language", the explanation translator — take aLanginstead of saying "Simplified Chinese" outright. pt-PT is spelled "European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt itself, since a model that has read far more pt-BR needs telling.Whycarries her word for "why" (为什么 / porquê / …) so the tutor prompt still recognises the question. An unknown code falls back rather than erroring — a prompt is the wrong place to discover a config problem.- Wired to
users.pair_langon both sides:useSessioncallssetPackLangthe moment/api/meanswers, and each LLM handler reads the column in the row-scoped query it already ran (the one that proves she owns the document) rather than in a second lookup that could disagree with it. - Tests:
internal/llm/lang_test.go(fallback matrix; each prompt names the writer's language and not Chinese; the zh pair reads exactly as before),internal/suggestions/pairlang_test.go(the column reaches the model for collocation + translate, zh unchanged — verified to fail when the join is removed),web/src/i18n/i18n.test.ts(default before/api/me, fallback for an unshipped pair, no spurious notifications, verbatim sentinels, interpolation incl. plurals, no empty string anywhere in the pack, every companion/tone/style labelled). - Verified: go build/vet/test, tsc, vite build, vitest 90/90 clean; live smoke on a throwaway DB (:8074) — doc create/save, search, md export, spell add, warm 502 from the collocation pass with the LLM down; the built bundle still carries the zh strings.
- ⚠️ Not yet deployed. Pure refactor with no migration, so the deploy is a rebuild; still unshipped at the end of the session.
Phase 20 — DreamDict as a lexicon provider ✅ (2026-07-27)
Option 3 ratified (import package, read-only dict.db).
- The prerequisite was bigger than the plan thought. Renaming the module was necessary but not sufficient: DreamDict's query layer lived in
internal/dictionary, which no other module may import whatever the module is called. Both fixed upstream in one commit —module github.com/prosolis/dreamdict,internal/dictionary→dictionary, with a package comment saying why reading a built database is public API while building one stays internal.internal/loaderis untouched, and DreamDict's own tests pass unchanged. - Provider seam (
internal/lexicon/provider.go): aProvideris the two questions the popover and the tooltip have always asked (Lookup,Gloss), which the embedded*Lexiconalready satisfied unmodified.Set.For(lang)is the single place the choice is made.OpenDreamDictopensdict.dbread-only besidepetal.db; a missing file returns(nil, nil), not an error — a laptop checkout has never had one — while a file that is present but unimported does error, because that one is somebody's half-finished deploy. - The absent-dictionary case degrades better than "no data". A pt-PT writer with no
dict.dbfalls back to the embedded datasets with the gloss suppressed (glossless), so she keeps English definitions, synonyms and phonetics — all compiled into the binary and all correct for her — and loses only the translation. Handing her the Chinese gloss would be worse than handing her nothing: empty reads as "not found", wrong-language reads as Petal being broken. - pt-PT + fr + es wired to DreamDict; zh stays on ECDICT, and the routing test is the guard on that decision. The comparison the plan asked for was run against the real 452 MB database: DreamDict reaches a Chinese gloss for 53% of the 2,000 commonest English words, against ECDICT's essentially total coverage of them. Quality did not hold, so nothing converged. es routes to DreamDict from day one and simply finds no rows in the April build — which is the same code path as any unglossed word.
- The plan's central assumption was wrong, and measuring it is what found that.
Gloss ← Translate(word, "en", L1)was mapped 1:1 inMULTIUSER_PLAN.md; against real data that table answers for 17% of common English words into pt-PT (16% into fr). Wiktionary's translation sections are thin in the en→X direction. Going through shared Princeton WordNet synset ids instead answers for 61%, and it is where the words a learner wants live — "ephemeral", "think" and "quickly" have no en→pt-PT translation row at all. Newdictionary.Equivalents(word, from, to)upstream does that, falling back to the translations table, for 62% combined. A gloss absent five times in six is not a gloss. - Ranking, argued from a wrong answer. Ordering equivalents by target-word frequency glosses "think" as lembrar — "remember" — because lembrar is the commoner Portuguese word even though pensar shares six of think's synsets to lembrar's one. Counting shared senses first, frequency second, asks which candidate means the same thing most often: think → pensar; achar; lembrar, write → escrever, garden → jardim, house → casa before firma.
- The de-inflection walk (
candidates) is shared with the embedded path, becausedict.dbstores headwords — "running" has no row. The first candidate that has definitions becomes the headword every other field is read from, so one popover never mixes "running"'s frequency with "run"'s senses. The gloss walks separately, since a word can have an equivalent and no definition. - Surfaced where cheap: a band chip beside the phonetic (
wordband.ts) and an etymology line at the foot of the card. Following Phase 19,wordBandreturns a band name and the langpack owns the wording. Three bands, not five — the difficulty score is a heuristic over length and corpus counts, good enough to separate "everyday" from "you will need to explain this" and not good enough to rank obfuscate against serendipity; a finer scale would be a confident-looking lie. Thresholds come from the real distribution (136k headwords bunch between 0.45 and 0.60; the words a writer reaches for sit under 0.40). An unscored word renders no chip at all. - The pair language is read per request in
providerFor— a word lookup has no row-scoped query to piggyback on, unlike Phase 19's handlers — and a failed read falls back to today's embedded behaviour rather than failing the lookup.Cache-Controldropped frompublictoprivate: the same URL now answers in a different language per writer. - Tests:
internal/lexicon/dreamdict_test.go— fixture is a real dict.db on disk, so open/stat/seeded is the production path; missing vs. unseeded vs. unreadable, every field filled, gloss follows the writer not the word (pt-PT/fr/es/de), the synset path, de-inflection carrying all fields to one headword, miss-is-not-an-error, the NULL-difficulty sentinel, IPA chosen over CMU, and the handler tests (two writers/one URL/two languages, unknown caller, private caching). Upstream:Equivalentsordering, synset-over-translation, fallback. Frontend:wordband.test.ts(bands pinned to real scores; difficulty 0.0 is a score, not a missing value) and an i18n assertion that no band can be unlabelled. - Two bugs the tests found before the browser did:
trimEtymologysliced by byte, which would put invalid UTF-8 in the JSON for exactly the etymologies that matter (ἐφήμερος, ephemerus), and its ellipsis path overran its own cap. - Verified: go build/vet/test, tsc, vite build, vitest 96/96 clean, both repos. Live smoke on a throwaway DB (:8075) against the real 452 MB
dict.db— startup logs the languages it actually got, zh unchanged, then the same instance flipped to pt-PT and re-queried. - Deploy documented (
deploy/README.md§4b,DICT_PATHthrough Dockerfile/compose/.env.example):dict.dbships into the data dir, stays out of the backups because they namepetal.dbexplicitly, and is rebuildable from public data. - ⚠️ Not deployed, and two things are outstanding. (1)
go.modcarries areplaceto../dreamdict; the Docker build needs the two dreamdict commits pushed and the replace dropped. (2) The deployeddict.dbis from 2026-04-04 and has no Spanish data and none of the intervening loader fixes — rebuild it (needs the es sources downloaded on millenia) before the Spanish pair ships. Neither blocks pt-PT or French.
Phase 21 — The pt-PT pair (first Latin pair, proves the model)
SUGGESTIONS §1/§3/§3a. French and Spanish follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model.
- Hunspell pt-PT vendored like en-US; both-dictionaries spellcheck (flag only if wrong in both; pills from both) — the no-detector stance, Q1 settled
- Gloss/WordCard both directions; on en/pt collisions show both compactly, never hide either
- Prompts pinned to European Portuguese, never pt-BR (explicit in every prompt); pt-PT langpack copy written and reviewed by a pt-PT speaker before trusted
- Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (
length_scale) while in there (SUGGESTIONS §5e) - Companion tips/cheers/bedtime lines in the pt-PT pack (the kitten speaks pt+en to this user)
- Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change
Phase 22 — Learning loop + code-first layers
Each item independent and small; order within is free (SUGGESTIONS §5–§6).
- Growth journal (Q3 settled): local aggregation over accepted suggestions; growth-only, self-comparison-only framing; feeds companion cheers
- Plant accepted collocations in the vocabulary garden as phrase cards (scheduler unchanged)
- Daily writing invitation from the companion (no streaks, declining is fine)
- False-friend list per pair (curated data, WordCard heads-up + gentle flag)
- Embedded miscollocation list (code-first under the collocation family; LLM adds the long tail when reachable)
- Grammar lite rule-pack as a fourth suggestion family: instant, offline, precision-over-recall (near-certain or silent); per-pair L1-interference rules; sourcing per SUGGESTIONS Q6 (hand-curate vs mine LanguageTool's corpus — decide at build time)
Later / explicitly not now
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
Spanish pair — gated on DreamDict growing an es datasetungated 2026-07-26 (DreamDict added Spanish). Now a normal follow-on pair after pt-PT, alongside fr — see Phases 20/21.- Reactive-animation puppy companion — wishlist, low priority;
companions.tsroster + mood engine is the drop-in point - Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
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-07-27: Phase 20 — DreamDict becomes the dictionary for every pair but Chinese (user: "let's continue the build plan"; scope confirmed: build the seam against the existing April
dict.db, rebuild it later, code + local verification only). The prerequisite was bigger than the plan recorded: renaming DreamDict's module path was necessary but useless on its own, because the query layer lived ininternal/dictionaryand no module may import another'sinternal. Both fixed upstream — the package is nowdictionary, with a comment saying why reading a built database is public API while the loaders that build one stay internal. In Petal,Provideris the two questions the popover already asked, so the embedded*Lexiconsatisfied it with no changes at all, andSet.For(lang)is the one place the choice is made. The measurement is the story of the phase.MULTIUSER_PLAN.mdmappedGloss ← Translate(word, "en", L1)1:1; against the real 452 MB database that table answers for 17% of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. The shared-synset path answers for 61%, so a new upstreamEquivalentsqueries that and falls back to translations for 62% combined. Then the ordering was wrong in an instructive way: sorting by target frequency glosses "think" as lembrar — "remember" — because lembrar is commoner in Portuguese, even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first fixes it (think → pensar; write → escrever; garden → jardim). The same measurement is what kept zh on ECDICT: DreamDict reaches a Chinese gloss for 53% of those words where ECDICT reaches nearly all — the plan said converge only if quality holds, and it didn't. Two other decisions worth keeping: a missingdict.dbis not an error (a laptop has never had one) but a present-and-unimported one is; and a pt-PT writer without a dictionary falls back to the embedded datasets with the gloss suppressed, keeping the English half rather than blanking the popover — an empty field reads as "not found", the wrong language reads as broken. The new fields surface as three bands, not five, because the difficulty score can separate "everyday" from "you'll have to explain this" but cannot rank obfuscate against serendipity, and a finer scale would be a confident-looking lie. Writing the tests found two bugs first:trimEtymologysliced by byte, which would have emitted invalid UTF-8 for precisely the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean in both repos; live smoke on a throwaway DB against the real dictionary, one instance flipped from zh to pt-PT mid-run. Not deployed:go.modstill carries areplaceto../dreamdict(the two upstream commits need pushing first), and the deployeddict.dbpredates DreamDict's Spanish support and needs rebuilding before the es pair ships. - 2026-07-27: Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a
VACUUM INTOsnapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean,personal_wordscreated empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy,0011applied to the live DB with her writing untouched,/api/spell/words401 without a session over public HTTPS). One check was refused and not worked around: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. Phase 19 then lifted every中文 · Englishliteral out of ~29 files intoweb/src/i18n— onePacktype, a verbatimzhpack, and two access paths chosen by when copy is built:usePack()for components,pack()for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: every string with a value in it is a function (reviewDue(n),daysAgo(n), even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; andgradeBandreturns a band name rather than a label. On the server,internal/llm/lang.goreplaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled "European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question.pair_langis read in the row-scoped query each handler already ran, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. Phase 19 is not deployed — no migration, so it's a rebuild whenever the user wants it. - 2026-07-27: Phase 18 — the browser's settings become her settings (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes server-side rather than being namespaced in place, and the pre-account
localStoragekeys are adopted then rescoped by the first writer to sign in. Newweb/src/lib/prefs.tsnamespacespetal.sound/petal.petals/petal.companionby user id; the interesting part is timing — those modules read their value at import time, before/api/mecan possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) andsetPrefsScope, called fromuseSession, adopts it and notifies every reader. Adoption moves rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. Newinternal/spellpackage + migration0011:personal_wordskeyed(user_id, lang, word), wherelangis the dictionary's language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships.useSpellCheckernow replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. The reason for the server table over cheaper namespacing: keying the existing list by user inlocalStoragewould have fragmented the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. Not deployed — and the customary rehearsal of0011against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plainCREATE TABLE, so lower-risk than0005/0010, but the convention exists for a reason). - 2026-07-27: Phase 17 — Claire's writing moved onto her real account (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account").
scripts/migrate_local_user.py: dry-run by default, ownVACUUM INTObackup, one transaction with foreign keys off, re-pointsdocuments/tags/vocab_words/images, verifies every expected row moved before committing. The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false: authentik'shashed_user_idsub isUser.uid, derived from her id and the instance secret, so it is readable in advance and the data could move first; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to5f47d955…, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). Three bugs, each found by a different kind of contact with reality: (1) the image backfill claims files forlocal, which stops existing after a migration — a foreign-key error insideimages.New, whichmain.gotreats as fatal, so Petal would have crash-looped on first start against a migrated database; (2)BEGIN EXCLUSIVEwas the wrong liveness check, since in WAL mode it only conflicts with another writer and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement,PRAGMA locking_mode = EXCLUSIVE, holds its lock past being reset toNORMAL, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come fromVACUUM INTOand wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production. - 2026-07-27: Phase 16 built — Petal authenticates for itself (user: "let's continue the build plan"; box access granted mid-session). New
internal/authsurface on top of the Phase-0Resolverseam:session.go(opaque cookie, SHA-256-at-rest, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune),oidc.go(login/callback/logout with state + nonce + PKCE, lazy retried discovery so an IdP outage can't stop Petal booting or invalidate live sessions),users.go(provisioning upsert keyed onsub,/api/me, allowlist). Migration0010landssessions,imagesandusers.pair_langtogether.main.gopicks the resolver from config, so a laptop build is unchanged. Image ownership closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403,Cache-Controldropped toprivate, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a draft rescue to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. Three deliberate deviations from the plan, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance);SESSION_SECRETwas dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed(name, user_id)rather than owned singly, which is what preserves deduplication. A real bug caught by writing the round-trip test rather than by reading the code: the one-shot state/nonce/PKCE cookies were cleared in adefer, i.e. after the redirect had already written the header, so the clearingSet-Cookiewas silently dropped. Verified: full go/tsc/vite/vitest suites, migration0010against aVACUUM INTOcopy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). Then deployed (user: "do it! register it!"): provider + application registered in Authentik viaak shell,.envfilled in, image rebuilt, and the Traefik basic-auth gate removed — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's trailing slash is significant (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with emptygrant_types, which authentik answers withinvalid_requestbefore the login page renders. Verified over public HTTPS: health 200,/api/docs401 with no basic-auth challenge,/auth/login→ Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a drawn sakura (web/public/petal.svg) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve/media). The allowlist isprosolis@proton.meonly — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one.envline and a restart. - 2026-07-27: Phase 15 finished off on the two boxes (user granted millenia access mid-session:
ssh reala@192.168.1.212, and parodia isssh reala@100.64.0.1over headscale). LLM link: rather than rebinding vLLM as planned,deploy/vllm-headscale-proxy.service(socat) adds a listener on100.64.0.2only —vllm-chat.serviceis shared with Gogobee and Open WebUI (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. Backups: the user pointed out the VPS already has daily provider VM backups and an age-encrypted offsiteparodia-backupjob, so Petal was folded into the latter instead of running a parallel cron — and doing so exposed a real bug in that job'ssqlite_dumphelper: Python'siterdumpdoes not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with aVACUUM INTO-based helper, round-trip verified). The bigger find: millenia, which holds her actual writing, had no scheduled backup at all — nowpetal-backup.timer, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. Encryption at rest (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images and the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs under the mount, andsystemd-cryptsetupwasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a.volume-okguard so an unmounted volume fails loudly instead of serving a blank DB. Millenia hygiene: Piper had been dead since the Jul 26 reboot — 26,800+ failed restarts, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved/usr/bin/python33.13→3.14 and the venv'ssite-packageswent invisible; venv recreated (lands Piper 1.6.0, which is whatTTS_PATHexists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is nowpetal.service(verified bykill -9); the Piper units gotStartLimitIntervalSec/Burstso a broken service entersfailedinstead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest. - 2026-07-26: Phase 15 complete — Petal is deployed at https://petal.parodia.dev (user: "let's start this build plan"; scope confirmed as artifacts plus the actual deploy, millenia stays canonical, hostname
petal.parodia.dev). Stack:Dockerfile(node → go → alpine; CGO off, so the runtime layer carries only ffmpeg + tzdata),docker-compose.ymlbehind the host's existing Traefik, and two Piper sidecars instead of the planned host systemd units — Piper turned out never to have been installed on the VPS and the account has no lingering session, so containers on an internal network with no published ports are both simpler and tighter. Three real problems found by deploying rather than by planning: (1) the image'spetaluser (uid 10001) has no claim on a bind-mounted host directory → SQLiteunable to open database file (14)and a restart loop; the container now runs as the stack directory's owner (still non-root, and the host account keeps write access the backup script needs); (2) piper-tts 1.6.0 moved synthesis fromPOST /toPOST /synthesizewith an identical body → every read-aloud 405'd; rather than pin both deployments to one Piper release the path became config (TTS_PATH, default/, so millenia is untouched); (3) once the instance was live it was a public, writable, unauthenticated API — Petal authenticates nobody yet, so Traefik basic auth now holds the door until Phase 16, with/api/healthexempt on its own higher-priority router. Backups:db.BackupviaVACUUM INTO(WAL-coherent, no write lock, single file, refuses to overwrite) behind a-backupflag so the nightly job snapshots the running container;deploy/backup-petal.shcompresses, pushes to millenia with a size check, prunes both sides; cron at 03:15; restore documented and verified by round-tripping an archive through the binary. Tests:internal/db/backup_test.go(WAL capture, seeded user survives, no-wal/-shmcompanions, refuses an existing destination, missing source),internal/ttspath-normalisation + configured-path. go build/vet/test clean. Acceptance verified over public HTTPS with the LLM link genuinely down: dictionaries, gloss, word lookup + phonetic, doc create/save, CJK FTS search, md/docx export, vocab capture, read-aloud EN + zh (real mp3, cache hit, 404-fallback for an unconfigured language) — all fine;/check→ the warm 502 that renders as 小助手在休息; health public, HTTP→HTTPS with a valid cert. Two items outstanding, both needing millenia access I don't have: vLLM isn't bound to its headscale interface (so no AI pass works yet), and parodia's ssh key isn't authorized on millenia (so backups are VPS-local only — not yet a real off-box backup). Both have one-command fixes indeploy/README.md§3 and §5. Also this session: DreamDict gained Spanish, so the es pair is no longer gated — folded into Phases 20/21 and the "Later" bucket. Next: Phase 16 (auth) — Authentik already runs on the same VPS. - 2026-07-26: Product direction + execution plan ratified (user: "make it so, number one"). New
SUGGESTIONS.md(product rationale for the language-learning direction): the pair model — every user gets one (English + X) pair, X ∈ {zh, pt-PT, fr, maybe es}, bilingual UI in the pair, type in either language, direction inferred (no detector: both-dictionaries spellcheck, show-both gloss on collision); langpacks keyed by X; LLM-minimalism as a standing principle (LLM is garnish, never a gatekeeper — grammar-lite rule pack + embedded miscollocation list planned as code-first layers). Deployment settled: Petal on the parodia.dev VPS, vLLM on millenia over headscale (the only cross-VPN dependency; Piper is VPS-local). AllMULTIUSER_PLAN.mdOPENs ratified: in-app OIDC (B), 30-day sliding sessions, allowlist, migration script, image-store fix with auth, DreamDict via package import (Option 3, module rename prereq in the dreamdict repo), zh stays on ECDICT until compared. Everything expanded into Phases 15–22 above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15. - 2026-07-26: Multi-user groundwork (user: "let's start preparing Petal for multi-user support"; scope agreed as plumbing-only, aimed at Authentik). New
internal/authpackage — context-carried identity (WithUser/UserID), aResolverseam (Resolve(*http.Request) (string, error)),StaticResolverfor today's single user, andMiddlewarethat 401s anything unresolved.main.gosplits/apiinto a public group (/health,/version— a monitoring probe must not need a session) and an authenticated group carrying everything else. All ~35db.LocalUserIDcall sites acrossdocs/suggestions/vocabnow read the caller from the request; helpers that had no request in scope (fetch,ownsDoc,ownsTag,tagsByDoc,fetchVersion,passportVersions,fetchPending, vocabfetch) take an explicituserIDparam.UserIDreturns""rather than panicking when middleware is absent, so a mis-wired route fails closed (every query isWHERE user_id = ?→ matches nothing). Two real access-control gaps found and fixed while threading:setStatus(accept/dismiss) updated a suggestion by bare id with no ownership check at all, andfetchPending/listForDocread a document's suggestions bydoc_idalone — a leak of the quoted source sentences. Both now scope throughdocuments.user_id. A third bug was caught by the new tests, not by the compiler:docs.fetchgained auserIDparameter but kept bindingdb.LocalUserIDin the query — legal Go (unused params compile), silently unscoped, and it would have shipped. New tests:internal/auth/auth_test.go(round-trip, absent-context, both 401 paths) and two-user isolation suites (docs/isolation_test.go,suggestions/isolation_test.go) that mount the same routers twice behind two resolvers over one DB and assert a stranger gets 404 on get/update/delete/export/passport/snapshot/version-preview/restore/tag-assign/tag-rename/tag-delete/suggestion-accept/dismiss, sees nothing in list/search/version-list, and leaves the owner's data untouched. go build/vet/test all clean. Still global, deliberately out of scope (flagged for the auth phase): the image store is content-addressed with no per-user association or DB row — any authenticated user holding a hash can fetch any image (capability-URL security, needs a table + migration to fix);export-allis correctly scoped; frontendlocalStoragekeys (petal.spell.personal,petal.companion, sound/petals prefs) are per-browser, not per-account, so they'd bleed across users sharing a device. - 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).