# 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 ✅ - [x] `git init`, `.gitignore`, remote → gitea.parodia.dev/drwily/petal - [x] Go module (`go.mod`), directory skeleton per spec - [x] `internal/config` env loading (local-dev defaults; auth/copyleaks fields kept for later) - [x] Vite + React 19 + TS + Tailwind v4 scaffold in `web/` (design tokens in `@theme`, Google fonts) - [x] Frontend embedded via `web/embed.go` (`go:embed all:dist`) + SPA handler in `cmd/server/main.go` - [x] Dev workflow documented in README; `.env.example` added - [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback ### Phase 1 — Data layer ✅ - [x] SQLite (modernc) init + migrations (`internal/db/db.go`) — versioned `schema_migrations` runner, WAL + foreign keys, single writer conn - [x] Models: User, Document, Suggestion (`internal/db/models.go`) — + type/status constants - [x] Seed hardcoded `local` user (idempotent on startup) - [x] Schema includes `voice` in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration) - [x] `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 ✅ - [x] 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 - [x] Frontend DocList sidebar (create/rename/delete) — `DocList`/`DocListItem`, optimistic title/word-count patching - [x] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline `Toolbar` (B/I/U, H1/H2, bullets, align) - [x] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id, with `saveNow()` flush before doc switch/create - [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s) - [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together ### Phase 3 — LLM grammar checkpoint ✅ - [x] `LLMClient` interface + factory (`internal/llm/client.go`) — chat-model fallback in factory; doc/history truncation helpers - [x] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE) - [x] `checkpoint.go` (30s/doc `RateLimiter`), `prompts.go` — brace-matched JSON salvage from model output, empty-original drop, type normalization - [x] `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 - [x] `useCheckpoint` (4s debounce) + breathing rose checkpoint dot in StatusBar - [x] `SuggestionHighlight` (ProseMirror **decorations**, re-anchored by `original` string on every doc change — not stored marks) + `SuggestionCard` (accept applies replacement in-editor then PATCHes; dismiss) - [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice) ### Phase 4 — Ask Petal (conversational follow-up) ✅ - [x] `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. - [x] 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. - [x] 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) ✅ - [x] `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) - [x] `voice` suggestion type, honey decoration — type already in schema/CSS; voice flags carry `replacement: null` → stored `""`, `SuggestionCard` hides the diff row + Accept (Dismiss only) - [x] **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). - [x] 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 ✅ - [x] Full pastel tokens, Nunito + Lora + JetBrains Mono — `@theme` tokens + Google Fonts (landed in Phase 0, in use throughout) - [x] Shape language, shadows, transitions — `--radius-*`, `--shadow-soft`, global `200ms ease` on interactive elements - [x] 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 - [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus` → `App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check) - [x] **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 ✅ - [x] 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. - [x] `useSpellChecker` hook (App-level, loads **once per session** not per doc) — `fetch`es 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). - [x] `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). - [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` 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) ✅ - [x] **Version history** — `document_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. - [x] **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. - [x] **Frontend** — `ExportMenu` (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. - [x] **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 ✅ - [x] **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. - [x] **"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 ✅ - [x] **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. - [x] **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`. - [x] **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). - [x] **Warm LLM-down failure states** — `useCheckpoint` 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 ✅ - [x] **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. - [x] **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. - [x] **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.) - [x] **Whole-corpus backup** — `GET /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. - [x] **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. - [x] **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). - [x] **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 (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`. ### 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) - [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above) - [x] **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) ## Session log - 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` 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 (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user. - 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.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`, `useCheckpoint` → `voicing`/`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 `onFocus` → `App` `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.dic` → `web/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 (0–7) done.** Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion. - 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, 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).**