Files
petal/BUILD_PLAN.md
T
prosolis d01a0f1f0a Document the Phase 15 deploy and ungate the Spanish pair
deploy/README.md becomes the real runbook: the VPS stack, Traefik, the
headscale LLM link, the interim edge gate, backups and restore. The
millenia Piper notes move to an appendix -- that instance still runs
them, and it is still canonical.

Two items are called out as outstanding rather than done, because both
need access to millenia: vLLM is not bound to its headscale interface,
so no AI pass works from the VPS yet, and parodia's ssh key is not
authorized there, so backups are VPS-local only -- which is not a backup
in the sense that matters. Each has its one-command fix written down.

Also folds in DreamDict gaining Spanish: es was explicitly gated on that
dataset existing, so it moves from "Later / not now" to a normal
follow-on pair after pt-PT, and the Phase 20 provider seam should cover
it from the start.
2026-07-26 23:27:06 -07:00

73 KiB
Raw Blame History

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 the user_id column 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": false on every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separate thinking field and exhausts num_predict before emitting any answer in content (empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
  • Suggestion anchoring: resolve by original string in ProseMirror coords at render time; stored from_pos/to_pos are 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/config env 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 in cmd/server/main.go
  • Dev workflow documented in README; .env.example added
  • Verified end-to-end: binary serves /api/health + embedded SPA + SPA fallback

Phase 1 — Data layer

  • SQLite (modernc) init + migrations (internal/db/db.go) — versioned schema_migrations runner, WAL + foreign keys, single writer conn
  • Models: User, Document, Suggestion (internal/db/models.go) — + type/status constants
  • Seed hardcoded local user (idempotent on startup)
  • Schema includes voice in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration)
  • db.Open wired into cmd/server/main.go; db_test.go covers 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 to local user, partial-update via COALESCE so rename and full save share one PUT; handlers_test.go covers 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, with saveNow() flush before doc switch/create
  • StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
  • Keep content (Tiptap JSON) and content_text (plain) in sync on save — editor emits both + word_count together

Phase 3 — LLM grammar checkpoint

  • LLMClient interface + factory (internal/llm/client.go) — chat-model fallback in factory; doc/history truncation helpers
  • vllm.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/doc RateLimiter), prompts.go — brace-matched JSON salvage from model output, empty-original drop, type normalization
  • POST /api/docs/:id/check (+ GET /api/docs/:id/suggestions, POST /api/suggestions/:id/{accept,dismiss}) in internal/suggestions; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current set
  • useCheckpoint (4s debounce) + breathing rose checkpoint dot in StatusBar
  • SuggestionHighlight (ProseMirror decorations, re-anchored by original string 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/chat SSE streaming; server-side context injection — internal/suggestions/chat.go loads the suggestion + parent doc in one user-scoped query, extracts the \n\n-bounded paragraph around from_pos (falls back to truncated doc when from_pos == -1), injects it via AskPetalSystemPrompt, streams event: token/event: done SSE 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.tsx holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via streamSuggestionChat (fetch + ReadableStream, not EventSource). SuggestionCard gains 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 existing AskPetalSystemPrompt + 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 (no TruncateDoc), explicit "Check my voice 🍯" toolbar action — internal/llm/voice.go (RunVoice, VoiceInterval 20s floor, MaxTokens 2048), voiceSystemPrompt/VoiceMessages in prompts.go (standalone — not bundled with the grammar checkpoint per spec)
  • voice suggestion type, honey decoration — type already in schema/CSS; voice flags carry replacement: null → stored "", SuggestionCard hides the diff row + Accept (Dismiss only)
  • Family-scoped pending sets: grammar and voice are independent passes sharing the suggestions table. replacePending now scopes its DELETE by family (pendingScope: grammar = type != 'voice', voice = type = 'voice'), so neither pass wipes the other's pending flags. Both /check and /voice return 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, useCheckpoint gains voicing/runVoice (shares the run-token guard), Toolbar honey "Check my voice 🍯" pill (loading→"Reading…"), StatusBar breathing honey dot "Reading your voice…". check/voice collapsed into a shared runPass server-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): findRange anchors 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 — @theme tokens + Google Fonts (landed in Phase 0, in use throughout)
  • Shape language, shadows, transitions — --radius-*, --shadow-soft, global 200ms ease on 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/--dy inline), spawned in EditorCore.handleAccept at the card position, auto-cleared after 720ms
  • Distraction-free mode — entered on editor focus (EditorCore onFocusApp.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 via handleChromeDown + canvasRef containment check)
  • Companion mascot (web/src/components/Companion/) — cozy corner mascot that reacts to the writing session. useCompanion is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); PetalCompanion renders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via lottie-web/build/player/lottie_light (offline, no eval/CDN) behind a LottiePlayer wrapper 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.ts roster): 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 in localStorage (petal.companion). Add a companion = drop a pure-vector Lottie JSON in animations/ + append a COMPANIONS entry. napping = companion.alwaysAsleep || mood === 'sleeping' drives the sway/zzz. Each asset is auto-cropped to its content bbox by LottiePlayer. App feeds it editTick/acceptTick + wordCount/saveStatus. All copy bilingual in tips.ts. (resolveJsonModule enabled in tsconfig for the JSON import.)

Phase 7 — Spell check

  • nspell browser-side (en-US), vendor dictionaries — Hunspell en.aff/en.dic (from dictionary-en, now a devDep) vendored into web/public/dictionaries/en/ (+ upstream LICENSE); Vite copies them to dist/, the Go binary embeds them. ~550KB .dic stays out of the JS bundle, fetched as a static asset.
  • useSpellChecker hook (App-level, loads once per session not per doc) — fetches aff+dic, builds an nspell instance, replays a personal word list from localStorage (petal.spell.personal); addWord persists + bumps a version so the checker's identity changes and consumers re-decorate. Exposes a minimal SpellChecker ({ correct, suggest }). Ambient types in src/types/nspell.d.ts (package ships none).
  • SpellCheck Tiptap 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). Reuses mapOffset (now exported from SuggestionHighlight) for atom-aware offset→PM-pos mapping. wordAt(doc, pos) resolves the exact span under a click (robust to duplicate misspellings).
  • MisspellCard popover + EditorCore wiring — soft rose wavy underline (.petal-misspelling, pastel take on the red squiggle, not classic red). Click a flagged word → posAtCoordswordAt opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via insertContentAt) + "添加到词典 · 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, NASA ok, add() persists).

Phase 8 — Trust foundation (version history + export)

  • Version historydocument_versions table (migration 0003), 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 in update only 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 on documents. versions_test.go covers 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 5987 filename*=UTF-8'' so Chinese titles download cleanly. GET /api/docs/:id/export?format=. PDF is client-side via the browser print dialog + a @media print stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). export_test.go asserts every format incl. valid-zip docx with CJK.
  • FrontendExportMenu (download links + Print/PDF) and HistoryPanel (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an editorEpoch bump). Both bilingual zh-first, matching chrome. Wired into the title row; .petal-no-print strips all chrome for print.
  • Stop saving empty docs — blank Untitled drafts now self-discard: handleCreate reuses an existing blank instead of stacking another; openDoc deletes 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 via scripts/build_gloss.py: frequency-gated to rank ≤50k, [网络]/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). Lexicon gains a gloss map + Gloss(word) (same candidates() de-inflection as defs/syns); Result gains a Gloss field. Two surfaces: the right-click WordCard now leads with the 中文 gloss, and a new lightweight GET /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; reuses wordAt so CJK is never glossed — it's the source language), wired into EditorCore's onMouseMove/onMouseLeave with 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 from ToneSelect/styleGuidance). Picking a style calls POST /api/docs/:id/rewrite ({text, style}{rewrite}), shown in a RewritePreview (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via insertContentAt over the captured PM range. Backend: llm.RunRewrite (one-shot Complete, RewriteMaxRunes 2000 cap, cleanRewrite strips stray wrapping quotes) + rewriteSystemTemplate/styleGuidance in prompts.go; handler in internal/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: lexicon gloss + Lookup-includes-gloss + inflection/miss; suggestions rewrite 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 for river/inflected/CJK-empty/nonsense-empty, word/happy carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
  • Known limitation: Gloss tries 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, not river). The base form always glosses correctly; acceptable.

Phase 10 — Organization & polish

  • Cross-document search (FTS5) — migration 0004 adds a documents_fts virtual table over title + content_text using the trigram tokenizer (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by AFTER INSERT/UPDATE/DELETE triggers on documents, 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 a LIKE scan 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…\x02 sentinels; the client splits on these to highlight without innerHTML. Owner-scoped, capped at 50 hits. Frontend SearchBox in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open.
  • Tags (organize) — migration 0004 adds tags (user-scoped, UNIQUE(user_id, name), color = palette key) + document_tags join (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 one tagsByDoc query, 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 via tagColorVar.
  • 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-suggestion opens its card via the editor click handler, and a pointerdown outside the card/highlight dismisses it (mouse users keep the hover bridge).
  • Warm LLM-down failure statesuseCheckpoint now tracks an llmDown flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The StatusBar shows 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)SearchHighlight ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). FindReplace bar (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 the WordCard (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 backupGET /api/docs/export-all?format=md|docx|… zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated petal-backup-YYYY-MM-DD.zip). Static route takes priority over /{id} in chi — covered by TestExportAll. 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.py extracts ECDICT's phonetic column (same source/freq-gate as the gloss); phonetic.json.gz embedded + lazily loaded; Result.Phonetic resolved 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 --seed mode (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|rivers all return phonetic (riversriver de-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.goCollocationInterval (25s) + RunCollocation(...), reusing the existing ParseCheckpoint parser (same as RunVoice). Whole-document (no TruncateDoc), tone passed through.
  • internal/llm/prompts.goCollocationMessages(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) + migration 0005_collocation_suggestion_typerebuilds the suggestions table (new table w/ extended CHECK, copy rows, drop, rename, recreate idx_suggestions_doc_id), since SQLite can't ALTER a CHECK. Verified against a copy of the live DB (5 migrations apply cleanly, collocation insert accepted, rows preserved).
  • internal/suggestions/handlers.gocollocationScope (deleteWhere: "type = 'collocation'", forceType: collocation), CollocationLimit on Handler (+ wired in New), POST /{id}/collocation, normalizeType extended. Also fixed grammarScope from type != '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.ts collocation entry (--color-blossom warm pink, "Word pairing" label); client.ts collocationDoc(docId) + SuggestionType extended; index.css token + .petal-suggestion-collocation decoration; useCheckpoint collocating/runCollocation (mirrors runVoice, shares the run-token guard); Toolbar blossom "Make it sound natural 🌸" pill (→ "Reading…"); StatusBar breathing blossom dot "Finding natural phrasing…"; threaded through EditorCore/App. Renders straight into the existing SuggestionRail/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/vocab package + migration 0006_vocab_garden (0005 was taken by collocation) — vocab_words table: word, gloss, phonetic, example (sentence captured at lookup), doc_id (ON DELETE SET NULL so 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.openWordLookup fires POST /api/vocab after 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-side datetime('now') comparison — avoids JS local-vs-UTC parsing bugs), POST /api/vocab/{id}/review (grade → reschedule via datetime('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 to HistoryPanel): 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 on WordCard alongside 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.tsENCOURAGEMENTS grown from 5→10 bilingual zh-first lines so cheers rotate fresher. New BEDTIME: 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). Own lastBedtime ref + BEDTIME_GAP 30min cooldown; respects PROACTIVE_GAP. New BubbleTone 'bedtime' paces readBubbleMs (BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobs BEDTIME_FROM/BEDTIME_TO so the 11pm4am 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 modeweb/src/lib/night.ts centralizes isBedtime() + the BEDTIME_FROM/BEDTIME_TO window (shared with the companion nag so they always agree). web/src/hooks/useNightMode.ts re-checks every 60s and toggles a petal-night class on <html>. index.css adds an html.petal-night block 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 via var(), 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 #fff override is inside @media print).
  • Falling starsPetalFall gains a night prop. 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.52.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 wires const 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 via auth.UserID(r.Context()) instead of naming db.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 in main.go plus a Resolver implementation.
  • Copyleaks Tier-2 + webhook HMAC (still parked — needs a public webhook; revisit after Phase 15)
  • Authentik OIDC + deploy: no longer deferred — expanded into Phases 1517 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 1522 (added 2026-07-26)

Decisions behind these are ratified in MULTIUSER_PLAN.md (all OPENs settled) and SUGGESTIONS.md (the why; Q1Q3 settled). Standing rules for every phase below:

  • Isolation tests in the same commit as any new user-scoped endpoint (the docs/isolation_test.go suites 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, two items outstanding on millenia)

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 buildgo 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; /data is the single writable mount. .dockerignore keeps the live DB and a stale local web/dist out of the image.
  • Traefik route + HTTPS on petal.parodia.dev — labels follow the host's existing convention (external traefik network, web-secure entrypoint, default cert 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.dev set for Phase 16's redirect URI.
  • LLM_ENDPOINT → millenia's headscale address (100.64.0.2:8000); LLM_TIMEOUT 30s → 90s for the WAN+VPN round trip (the voice/collocation passes send a whole document and the timeout is a hard deadline on Complete). ⚠️ Outstanding, needs millenia access: vLLM currently refuses connections from the VPS — it isn't bound to the headscale interface. Petal degrades correctly meanwhile (verified). Fix + model-id capture documented in deploy/README.md §3.
  • TTS — deviation from the plan, deliberate: Piper was not actually installed on parodia, and the reala account 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 from POST / to POST /synthesize (identical body); rather than pin both deployments to one release, the path is now config (TTS_PATH, default / so millenia is untouched).
  • Off-VPS nightly backup + restore — db.Backup uses VACUUM INTO, not a file copy: in WAL mode the newest committed pages may live in petal.db-wal, and copying the three files separately can capture a torn mid-checkpoint state. VACUUM INTO reads 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 -backup flag on the binary, so the nightly job snapshots the running container. deploy/backup-petal.sh gzips, pushes to millenia over headscale with a post-transfer size check, and prunes both sides (7d local / 30d remote). Cron installed at 03:15. Restore documented + verified. ⚠️ Outstanding, needs millenia access: parodia's ssh key isn't authorized on millenia, so REMOTE_HOST is empty and backups are VPS-local only — the one command to fix it is in deploy/README.md §5.
  • 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.
  • Interim edge gate (not in the original plan; added once the instance was live). Petal authenticates nobody yet — StaticResolver hands every request the same local user — 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/health exempt 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/health public; HTTP 301 → HTTPS with a valid cert.

Phase 16 — Auth (in-app OIDC) + image-store ownership

Option B ratified. go-oidc + x/oauth2; config fields already exist. The Resolver seam from Phase 0 is the only integration point.

  • OIDC login flow: /auth/login → Authentik → /auth/callback (state/CSRF checked) → provision user (upsert from sub/email/name) → session
  • sessions table migration (id, user_id, expires_at, created_at, user_agent); opaque cookie (HttpOnly, SameSite=Lax, Secure when https); 30-day sliding expiry; /auth/logout revokes server-side
  • Allowlist: PETAL_ALLOWED_SUBS env (comma-separated); rejected valid logins get a warm bilingual page, not an error dump
  • SessionResolver replaces StaticResolver in main.go (keep StaticResolver for dev via env flag)
  • Frontend 401 interceptor in api/client.ts: halt auto-save, preserve the draft (localStorage keyed by doc id), warm bilingual "请重新登录 · Please sign in again" overlay, resume cleanly after re-login — a 401 mid-draft must never lose writing
  • Image store ownership (same phase, per OPEN #5): images table migration (hash, user_id, content_type, size, created_at); fetch joins on caller; dedup preserved (one file, N rows); last-row delete removes the file; backfill existing images to the current user
  • users.pair_lang column (default 'zh') in this phase's provisioning migration — read by Phase 19+, cheap to add now
  • Isolation tests: sessions (expiry, revocation, cross-user), images, allowlist paths

Phase 17 — Migrate the local user

Script, app stopped, backup first (OPEN #4). Depends on: she logs in once so her OIDC sub exists.

  • scripts/migrate_local_user.*: single transaction, PRAGMA foreign_keys=OFF, re-point documents/tags/vocab_words (versions/suggestions follow parents), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
  • Runbook documented in the script header; dry-run mode

Phase 18 — Per-user, per-language client state

  • Namespace localStorage keys by user id once known: petal.spell.personal, petal.companion, sound/petals prefs
  • Personal spell dictionary keyed by user + language (en and pt-PT word lists must not merge); consider promoting it to a server table so it follows her across devices (nice-to-have, decide during)

Phase 19 — Langpack extraction (the copy chore)

Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).

  • Extract the 中文 · English strings from the ~29 frontend files + companion tips.ts into a langpack copy module keyed by the pair's X; today's strings become the zh pack verbatim
  • Parameterize internal/llm/prompts.go bilingual copy the same way (explanation language, "natives usually say…" framing, both-directions preamble)
  • Wire pack selection to users.pair_lang; acceptance: pixel-identical UI for the zh pair, vitest snapshots unchanged

Phase 20 — DreamDict as a lexicon provider

Option 3 ratified (import package, read-only dict.db). Prerequisite in the dreamdict repo: rename its module path (or add a replace for dev).

  • Provider seam behind the existing lexicon interface; DreamDict provider opens dict.db read-only (modernc driver, second handle beside petal.db); graceful "no data" when the file is absent
  • pt-PT + fr + es wired to DreamDict (nothing to regress); zh stays on ECDICT until compared on real lookups from her documents — converge only if quality holds
    • es is no longer gated (2026-07-26): DreamDict grew Spanish support, so the "maybe es" in the pair model is now a real option and the provider seam should cover it from day one — it costs nothing here and saves re-opening the package later.
  • Surface the new fields where cheap: frequency/difficulty chip in WordCard; etymology line (cognate hook for en-natives)
  • Deploy: dict.db ships in the data dir alongside petal.db

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 1921 prove the pair model
  • Spanish pair — gated on DreamDict growing an es dataset ungated 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.ts roster + 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-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.yml behind 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's petal user (uid 10001) has no claim on a bind-mounted host directory → SQLite unable 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 from POST / to POST /synthesize with 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/health exempt on its own higher-priority router. Backups: db.Backup via VACUUM INTO (WAL-coherent, no write lock, single file, refuses to overwrite) behind a -backup flag so the nightly job snapshots the running container; deploy/backup-petal.sh compresses, 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/-shm companions, refuses an existing destination, missing source), internal/tts path-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 in deploy/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). All MULTIUSER_PLAN.md OPENs 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 1522 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/auth package — context-carried identity (WithUser/UserID), a Resolver seam (Resolve(*http.Request) (string, error)), StaticResolver for today's single user, and Middleware that 401s anything unresolved. main.go splits /api into a public group (/health, /version — a monitoring probe must not need a session) and an authenticated group carrying everything else. All ~35 db.LocalUserID call sites across docs/suggestions/vocab now read the caller from the request; helpers that had no request in scope (fetch, ownsDoc, ownsTag, tagsByDoc, fetchVersion, passportVersions, fetchPending, vocab fetch) take an explicit userID param. UserID returns "" rather than panicking when middleware is absent, so a mis-wired route fails closed (every query is WHERE 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, and fetchPending/listForDoc read a document's suggestions by doc_id alone — a leak of the quoted source sentences. Both now scope through documents.user_id. A third bug was caught by the new tests, not by the compiler: docs.fetch gained a userID parameter but kept binding db.LocalUserID in 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-all is correctly scoped; frontend localStorage keys (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/pendingScope machinery — llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint), collocationSystemPrompt/CollocationMessages (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration 0005 rebuilds the suggestions table to extend the type CHECK (SQLite can't ALTER a CHECK), collocationScope + CollocationLimit + POST /{id}/collocation. Caught a latent bug: grammarScope was type != 'voice' → would wipe collocation flags; fixed to type NOT IN ('voice','collocation'). Frontend: --color-blossom pink, "Make it sound natural 🌸" toolbar pill, collocating/runCollocation in useCheckpoint, StatusBar dot — all into the existing rail/card. Phase 13: new internal/vocab package — migration 0006_vocab_garden (vocab_words, SM-2-lite columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)), scheduler.go (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), handlers.go (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite datetime() so stored values stay canonical-UTC). Auto-capture wired into EditorCore.openWordLookup (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on WordCard. GardenPanel slide-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, vocab scheduler_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: ENCOURAGEMENTS 5→10 lines; new BEDTIME array (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; own lastBedtime ref + 30min BEDTIME_GAP, respects PROACTIVE_GAP; new 'bedtime' BubbleTone lingers ~4s longer. Night mode (added same session, user request): lib/night.ts centralizes isBedtime() + window (now shared by the nag too); hooks/useNightMode.ts toggles petal-night on <html> (60s re-check); index.css html.petal-night re-points only the palette tokens → whole UI flips via var() (no component edits), 600ms dusk fade, print stays white; PetalFall gains a night prop → 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 is BEDTIME_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 & ReplaceSearchHighlight decoration extension + FindReplace bar (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 (refactored handleContextMenu → shared openWordLookup(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) via scripts/build_phonetic.py + embedded phonetic.json.gz + Result.Phonetic + WordCard /ˈrɪvər/ line — full 46,579-word dataset built from ECDICT (the csv re-download worked; --seed mode kept as a csv-free fallback). Also folded in this session: the selection-bubble vs copy/paste fix (bubble deferred to pointer-up + container pointer-events:none so 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_fts trigram virtual table with sync triggers + back-fill); db.Tag model + color constants; internal/docs/tags.go (tag CRUD + idempotent assignment + tagsByDoc helper, 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/search in main.go. Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten DocList/DocListItem (chips + filter bar + search), api.search/tag methods + splitSnippet/tagColorVar; responsive sidebar drawer (hamburger + scrim, <768px) + pointer:coarse tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); useCheckpoint llmDown flag → 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 (07) + post-v1 product (810) 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.py builds internal/lexicon/data/gloss.json.gz from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: lexicon gloss 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: GlossTip hover tooltip (350ms delay, reuses wordAt, CJK-safe) + gloss line in WordCard; SelectionBubble + RewritePreview wired through EditorCore (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); api.glossWord/api.rewriteSelection; CSS for the three new surfaces (+ print-hidden). Tests added in lexicon and suggestions. 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 into update, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and internal/docs/export.go (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). DocumentVersion model + kind constants. Tests: versions_test.go, export_test.go (incl. valid-zip docx assertion). Frontend: api.client version/export methods; ExportMenu + HistoryPanel components wired into the title row; @media print stylesheet + .petal-no-print for the browser PDF path; editorEpoch remount on restore. Empty-doc fix in App.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 local user.
  • 2026-06-25: Phase 1 complete. internal/db package: 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 into main.go; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes petal.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.go walks the full lifecycle. Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush), EditorCore (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar, rewritten App.tsx orchestrating load/select/create/delete with optimistic sidebar patching. .petal-prose styles (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: LLMClient interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), prompts.go (checkpoint + Ask Petal templates), checkpoint.go (brace-matched JSON salvage, per-doc 30s RateLimiter, 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 by strings.Index (advisory only). Frontend: useCheckpoint (4s debounce, loads existing on doc open, run-token guards stale responses), SuggestionHighlight Tiptap extension rendering ProseMirror decorations re-anchored by original string 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, no TruncateDoc, MaxTokens 2048, VoiceInterval 20s per-doc floor), standalone voiceSystemPrompt/VoiceMessages (not bundled with the grammar checkpoint). internal/suggestions: POST /api/docs/:id/voice route; check/voice collapsed into a shared runPass(limiter, pass, scope); pendingScope makes replacePending family-aware (grammar deletes type != 'voice', voice deletes type = '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, useCheckpointvoicing/runVoice (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in Toolbar (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in StatusBar. Voice flags' replacement: null round-trips to ""; SuggestionCard already 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: findRange is single-textblock, so a voice passage crossing a \n\n paragraph 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 (wraps lottie-web light 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 into animations/index.ts is the only change to upgrade to real animation. Library decision: Lottie via lottie-web (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires editTick/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), a Confetti component in EditorCore spawned at the accepted card's position on handleAccept and cleared after 720ms (timer cleaned up on unmount). Distraction-free mode: EditorCore gains an onFocusApp focusMode state; the doc-list sidebar is wrapped in .petal-sidebar and 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 (handleChromeDown checks canvasRef containment; 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.dicweb/public/dictionaries/en/ (dictionary-en moved 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 a localStorage personal word list, addWord persists + bumps a version to re-decorate; src/types/nspell.d.ts supplies the missing types. SpellCheck extension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exported mapOffset; wordAt for 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 (07) 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, reuses AskPetalSystemPrompt/TrimHistory), internal/suggestions/chat.go (POST /api/suggestions/:id/chat — one user-scoped join loads the suggestion + parent content_text, surroundingParagraph extracts the \n\n-bounded paragraph at from_pos with whole-doc fallback, streams event: token/event: done SSE frames with JSON-encoded data, X-Accel-Buffering: no, real http.Flusher per 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, surroundingParagraph unit). 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, done terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: Phase 5 (voice consistency pass, Tier 1).