Files
petal/BUILD_PLAN.md
prosolis 9576340391 Phase 14: bedtime nag + night mode (dark theme + falling stars)
Companion warmth:
- ENCOURAGEMENTS grown 5->10; new BEDTIME lines (warm/playful, zh-first)
- useCompanion heartbeat gains a bedtime branch (>=11pm, while actively
  writing) gated by a 30min cooldown; new 'bedtime' BubbleTone paces it

Night mode (auto at ~11pm, local clock):
- lib/night.ts centralizes isBedtime() + the window, shared with the nag
- useNightMode toggles a `petal-night` class on <html>; index.css re-points
  only the palette tokens, so every Tailwind color utility flips via var()
  with no component edits (600ms dusk fade; print stays white)
- PetalFall gains a `night` prop: chunky cartoon power stars (makeCartoonStar,
  5 candy colors) mixed ~70/30 with twinkle sparkles; each star spins at its
  own rate (~0.5-2.2 rad/s, random direction), falls straight down, shimmers

Verified: go vet/test, tsc, vitest 51/51, vite build; real-browser
screenshots/video (clock mocked to 23:30).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:10:36 -07:00

50 KiB
Raw Permalink 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 🔜 (planned 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). No new parsing.
  • internal/llm/prompts.goCollocationMessages(contentText, tone). This prompt is the whole feature: flag only non-wrong-but-non-native word pairings; explicitly defer real grammar errors to the grammar family; phrase every explanation as warm "natives usually say…" with a Mandarin gloss — never "error/wrong".
  • internal/db/models.go + migration 0005 — new suggestion type collocation. ⚠️ The type column has a CHECK(...) and SQLite can't ALTER a CHECK → migration must rebuild the suggestions table (create new w/ extended CHECK, copy rows, drop, rename, recreate idx_suggestions_doc_id). Not a one-line ALTER.
  • internal/suggestions/handlers.go — add collocationScope (deleteWhere: "type = 'collocation'", forceType: collocation), a CollocationLimit on Handler (+ wire in New), register POST /{id}/collocation. Mirrors voice, ~15 lines. Also extend normalizeType to accept the new type.
  • Frontend: suggestionMeta.ts collocation entry (💡 icon + its own warm color so cards read as friendly tips); client.ts collocation(docId); a gentle trigger like the existing "Check my voice" pill — "Make it sound natural 🌸" — rendering straight into SuggestionRail/SuggestionCard.
  • Verify via the millenia UI-test harness (real-browser editor check) + go/tsc/vite clean. Est. ~½ day (mostly the new prompt + the table-rebuild migration).

Phase 13 — Vocabulary garden (spaced repetition) 🔜 (planned 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 0005/0006vocab_words table: word, gloss, phonetic, example (sentence captured at lookup for context), doc_id, + SM-2-lite scheduling: due_at, interval_days, ease, reps, lapses, last_reviewed; UNIQUE(user_id, word).
  • Auto-capture: when WordCard calls lookupWord, also fire POST /api/vocab (upsert; new word → due_at = now + 1 day). Looking words up is the data source — zero extra effort from her.
  • Endpoints: POST /api/vocab (record/upsert), GET /api/vocab/due (cards due now), POST /api/vocab/{id}/review (grade again/good/easy → reschedule), GET /api/vocab (full garden), DELETE /api/vocab/{id}.
  • SR scheduler: gentle SM-2-lite / Leitner intervals (1d → 3d → 7d → 16d → 35d). No streak-shaming. This is the only genuinely new logic.
  • Frontend Garden panel (sibling to HistoryPanel/DocList): each learned word a blossom, more reps → more bloomed; the PetalCompanion kitten naps among them. Flashcard review: captured sentence with the word blanked → recall → EN↔中文 flip → again/good/easy (direction flips for recognition and production). A 🤍 "save to garden" on WordCard for explicit saves alongside auto-capture.
  • Verify via the millenia UI-test harness + go/tsc/vite clean. Est. ~1½2 days (new package + table + panel/review UI; reuses existing panel/companion patterns).

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)

  • Authentik OIDC auth + session middleware ← on hold: user doing foundational work first
  • Copyleaks Tier-2 + webhook HMAC
  • Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev

Next-up (post-v1 product, agreed with user 2026-06-26)

  • Phase 9 — ESL superpowers: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. (see Phase 9 above)
  • Phase 10 — organization & polish: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
  • Phase 12 — collocation coach: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. 🔜 (see Phase 12 above; build first)
  • Phase 13 — vocabulary garden: spaced-repetition review built from looked-up words, surfaced as a blooming garden. 🔜 (see Phase 13 above)
  • Phase 14 — companion warmth + bedtime nag + night mode: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. (see Phase 14 above)

Session log

  • 2026-06-26: 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).