Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix. - Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension + FindReplace bar (match-case, replace-all back-to-front, scroll without popping the selection bubble). - Read-aloud (Web Speech, offline) on the word card and selection bubble. - Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret, Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the right-click handler into a shared openWordLookup(pos). - Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx), de-dupes filenames, dated name; sidebar download links. TestExportAll. - Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so CJK is untouched. - Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover. - English phonetic (chosen over pinyin for an English learner): ECDICT-built phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line; scripts/build_phonetic.py (full build + --seed fallback). - Selection bubble no longer blocks copy/paste: deferred to pointer-up and made click-through except on its buttons. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
40 KiB
40 KiB
Petal — Build Plan & Progress
Multi-session build. Source of truth for what's done and what's next. Update the checkboxes as work completes. petal-spec.md is the design spec; this file tracks execution.
Decisions locked in (see also memory: petal-design-north-star)
- Auth deferred — no Authentik yet. Seed a single hardcoded local user (
id = "local"); keep theuser_idcolumn so auth drops in later without a schema migration. - Copyleaks deferred — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
- Traefik/deploy deferred — local dev first.
- LLM: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
- Reasoning models: the Ollama client sends
"think": falseon every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separatethinkingfield and exhaustsnum_predictbefore emitting any answer incontent(empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
- Reasoning models: the Ollama client sends
- Suggestion anchoring: resolve by
originalstring in ProseMirror coords at render time; storedfrom_pos/to_posare plaintext offsets for server-side use only. (Spec Note #6.) - Aesthetic is an acceptance criterion: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.
Phases
Phase 0 — Foundation / scaffold ✅
git init,.gitignore, remote → gitea.parodia.dev/drwily/petal- Go module (
go.mod), directory skeleton per spec internal/configenv loading (local-dev defaults; auth/copyleaks fields kept for later)- Vite + React 19 + TS + Tailwind v4 scaffold in
web/(design tokens in@theme, Google fonts) - Frontend embedded via
web/embed.go(go:embed all:dist) + SPA handler incmd/server/main.go - Dev workflow documented in README;
.env.exampleadded - Verified end-to-end: binary serves
/api/health+ embedded SPA + SPA fallback
Phase 1 — Data layer ✅
- SQLite (modernc) init + migrations (
internal/db/db.go) — versionedschema_migrationsrunner, WAL + foreign keys, single writer conn - Models: User, Document, Suggestion (
internal/db/models.go) — + type/status constants - Seed hardcoded
localuser (idempotent on startup) - Schema includes
voicein suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration) db.Openwired intocmd/server/main.go;db_test.gocovers migrate/seed idempotency, CHECK constraint, FK cascade
Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅
- Doc handlers: list/create/get/update/delete (
internal/docs/handlers.go) — chi sub-router mounted at/api/docs, all scoped tolocaluser, partial-update via COALESCE so rename and full save share one PUT;handlers_test.gocovers the lifecycle - Frontend DocList sidebar (create/rename/delete) —
DocList/DocListItem, optimistic title/word-count patching - Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline
Toolbar(B/I/U, H1/H2, bullets, align) useAutoSave(1.5s debounce) → PUT /api/docs/:id, withsaveNow()flush before doc switch/create- StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
- Keep
content(Tiptap JSON) andcontent_text(plain) in sync on save — editor emits both + word_count together
Phase 3 — LLM grammar checkpoint ✅
LLMClientinterface + factory (internal/llm/client.go) — chat-model fallback in factory; doc/history truncation helpersvllm.go(OpenAI-compat),ollama.go(native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE)checkpoint.go(30s/docRateLimiter),prompts.go— brace-matched JSON salvage from model output, empty-original drop, type normalizationPOST /api/docs/:id/check(+GET /api/docs/:id/suggestions,POST /api/suggestions/:id/{accept,dismiss}) ininternal/suggestions; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current setuseCheckpoint(4s debounce) + breathing rose checkpoint dot in StatusBarSuggestionHighlight(ProseMirror decorations, re-anchored byoriginalstring on every doc change — not stored marks) +SuggestionCard(accept applies replacement in-editor then PATCHes; dismiss)- Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
Phase 4 — Ask Petal (conversational follow-up) ✅
POST /api/suggestions/:id/chatSSE streaming; server-side context injection —internal/suggestions/chat.goloads the suggestion + parent doc in one user-scoped query, extracts the\n\n-bounded paragraph aroundfrom_pos(falls back to truncated doc whenfrom_pos == -1), injects it viaAskPetalSystemPrompt, streamsevent: token/event: doneSSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.- AskPetal component, token-by-token render, no persistence —
AskPetal.tsxholds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams viastreamSuggestionChat(fetch + ReadableStream, not EventSource).SuggestionCardgains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded. - CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the
'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body. internal/llm/chat.go:StreamAskPetal(max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop\n\n\n) reusing the existingAskPetalSystemPrompt+TrimHistory. Backend stays interface-only; the SSE handler never touches a concrete client.
Phase 5 — Voice consistency pass (Tier 1) ✅
POST /api/docs/:id/voice, whole-document (noTruncateDoc), explicit "Check my voice 🍯" toolbar action —internal/llm/voice.go(RunVoice,VoiceInterval20s floor, MaxTokens 2048),voiceSystemPrompt/VoiceMessagesinprompts.go(standalone — not bundled with the grammar checkpoint per spec)voicesuggestion type, honey decoration — type already in schema/CSS; voice flags carryreplacement: null→ stored"",SuggestionCardhides the diff row + Accept (Dismiss only)- Family-scoped pending sets: grammar and voice are independent passes sharing the suggestions table.
replacePendingnow scopes its DELETE by family (pendingScope: grammar =type != 'voice', voice =type = 'voice'), so neither pass wipes the other's pending flags. Both/checkand/voicereturn the unified pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency). - Frontend:
api.voiceDoc,useCheckpointgainsvoicing/runVoice(shares the run-token guard),Toolbarhoney "Check my voice 🍯" pill (loading→"Reading…"),StatusBarbreathing honey dot "Reading your voice…".check/voicecollapsed into a sharedrunPassserver-side. - Tests:
TestVoicePassCoexists(grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list[grammar, voice]). - Known limitation (carried from Phase 3):
findRangeanchors within a single textblock, so a voice passage spanning a paragraph break (\n\n) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.
Phase 6 — Design system & polish ✅
- Full pastel tokens, Nunito + Lora + JetBrains Mono —
@themetokens + Google Fonts (landed in Phase 0, in use throughout) - Shape language, shadows, transitions —
--radius-*,--shadow-soft, global200ms easeon interactive elements - Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot) — fade-float + breathing dot already live; accept confetti added this phase: CSS-only 4-dot burst (
petal-confetti/@keyframes petal-confetti, direction via--dx/--dyinline), spawned inEditorCore.handleAcceptat the card position, auto-cleared after 720ms - Distraction-free mode — entered on editor focus (
EditorCoreonFocus→App.setFocusMode), the doc-list sidebar slides left + collapses to 0 width (.petal-sidebar/.petal-sidebar-hidden, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar viahandleChromeDown+canvasRefcontainment check) - Companion mascot (
web/src/components/Companion/) — cozy corner mascot that reacts to the writing session.useCompanionis the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back);PetalCompanionrenders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation vialottie-web/build/player/lottie_light(offline, no eval/CDN) behind aLottiePlayerwrapper that auto-crops the asset to its content bbox (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. Selectable companions (companions.tsroster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between 瞌睡猫 Sleepy Cat (sleeping-cat.json,alwaysAsleep→ every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and 开心狗 Happy Dog (happy-dog.json, awake/bouncy; naps via 😴 emoji). Choice persists inlocalStorage(petal.companion). Add a companion = drop a pure-vector Lottie JSON inanimations/+ append aCOMPANIONSentry.napping = companion.alwaysAsleep || mood === 'sleeping'drives the sway/zzz. Each asset is auto-cropped to its content bbox byLottiePlayer. App feeds iteditTick/acceptTick+wordCount/saveStatus. All copy bilingual intips.ts. (resolveJsonModuleenabled in tsconfig for the JSON import.)
Phase 7 — Spell check ✅
- nspell browser-side (en-US), vendor dictionaries — Hunspell
en.aff/en.dic(fromdictionary-en, now a devDep) vendored intoweb/public/dictionaries/en/(+ upstreamLICENSE); Vite copies them todist/, the Go binary embeds them. ~550KB.dicstays out of the JS bundle, fetched as a static asset. useSpellCheckerhook (App-level, loads once per session not per doc) —fetches aff+dic, builds annspellinstance, replays a personal word list fromlocalStorage(petal.spell.personal);addWordpersists + bumps aversionso the checker's identity changes and consumers re-decorate. Exposes a minimalSpellChecker({correct,suggest}). Ambient types insrc/types/nspell.d.ts(package ships none).SpellCheckTiptap extension — ProseMirror decorations (no stored marks, same as the AI-suggestion layer), recomputed on doc edit / caret move / checker swap. English-only tokenizer (/[A-Za-z][A-Za-z']*/) so CJK is never tokenized → never flagged (north-star: the user writes Mandarin + English); skips <2-char tokens and all-caps acronyms, trims edge apostrophes. Exempts the word under the caret (no jitter mid-typing). ReusesmapOffset(now exported fromSuggestionHighlight) for atom-aware offset→PM-pos mapping.wordAt(doc, pos)resolves the exact span under a click (robust to duplicate misspellings).MisspellCardpopover + EditorCore wiring — soft rose wavy underline (.petal-misspelling, pastel take on the red squiggle, not classic red). Click a flagged word →posAtCoords→wordAtopens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace viainsertContentAt) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.- Verified: tsc clean, vite build OK (dict in
dist/dictionaries/en/), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (helllo→hello,recieve→receive,写作untokenized,NASAok,add()persists).
Phase 8 — Trust foundation (version history + export) ✅
- Version history —
document_versionstable (migration0003), full-body snapshots that cascade with the doc. Kinds:auto(throttled background, ≥3min apart, max 40/doc, pruned),manual(explicit restore point),pre_restore(safety copy taken before a restore, so restore is undoable). Snapshot taken post-save inupdateonly when a real body came through and content changed (empties + bare renames never snapshot). Endpoints:GET/POST /api/docs/:id/versions,GET /api/docs/:id/versions/:vid,POST /api/docs/:id/versions/:vid/restore. All scoped to the owner via a join ondocuments.versions_test.gocovers lifecycle/throttle/restore/pre_restore/404. - Export — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / docx (
export.go), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987filename*=UTF-8''so Chinese titles download cleanly.GET /api/docs/:id/export?format=. PDF is client-side via the browser print dialog + a@media printstylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat).export_test.goasserts every format incl. valid-zip docx with CJK. - Frontend —
ExportMenu(download links + Print/PDF) andHistoryPanel(slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via aneditorEpochbump). Both bilingual zh-first, matching chrome. Wired into the title row;.petal-no-printstrips all chrome for print. - Stop saving empty docs — blank
Untitleddrafts now self-discard:handleCreatereuses an existing blank instead of stacking another;openDocdeletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.) - Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a
pre_restore, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
Phase 9 — ESL superpowers ✅
- Inline Chinese gloss (offline) — embedded English→Chinese dictionary (
internal/lexicon/data/gloss.json.gz, ~1.3MB, 57k common words built from ECDICT viascripts/build_gloss.py: frequency-gated to rank ≤50k,[网络]/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars).Lexicongains aglossmap +Gloss(word)(samecandidates()de-inflection as defs/syns);Resultgains aGlossfield. Two surfaces: the right-click WordCard now leads with the 中文 gloss, and a new lightweightGET /api/gloss/{word}(→{word, gloss}, cached) backs the hover tooltip. Offline + instant, works with the LLM down (north-star reliability). Frontend:GlossTip(dark pointer-events-none bubble under the resting word; 350ms hover delay; reuseswordAtso CJK is never glossed — it's the source language), wired intoEditorCore'sonMouseMove/onMouseLeavewith a request-token guard, suppressed during selection/preview/other popovers. - "Say it more naturally" / tone-rewrite — selecting text pops a
SelectionBubble(✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored fromToneSelect/styleGuidance). Picking a style callsPOST /api/docs/:id/rewrite({text, style}→{rewrite}), shown in aRewritePreview(original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor viainsertContentAtover the captured PM range. Backend:llm.RunRewrite(one-shot Complete,RewriteMaxRunes2000 cap,cleanRewritestrips stray wrapping quotes) +rewriteSystemTemplate/styleGuidanceinprompts.go; handler ininternal/suggestions/rewrite.go(owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). Stateless — not persisted as a suggestion; the version history captures the resulting doc change. - Tests:
lexicongloss + Lookup-includes-gloss + inflection/miss;suggestionsrewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss forriver/inflected/CJK-empty/nonsense-empty,word/happycarries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle. - Known limitation:
Glosstries the literal form first (matching defs/syns ordering), so an inflected word that is itself a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g.rivers→ the proper-noun "Rivers" sense, notriver). The base form always glosses correctly; acceptable.
Phase 10 — Organization & polish ✅
- Cross-document search (FTS5) — migration
0004adds adocuments_ftsvirtual table overtitle+content_textusing thetrigramtokenizer (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync byAFTER INSERT/UPDATE/DELETEtriggers ondocuments, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately).GET /api/search?q=(internal/docs/search.go): queries of ≥3 runes use the FTS index (fast,ORDER BY rank); shorter queries fall back to aLIKEscan so 2-character Chinese words (e.g. 公园) still resolve. Snippets are built in Go from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in\x01…\x02sentinels; the client splits on these to highlight withoutinnerHTML. Owner-scoped, capped at 50 hits. FrontendSearchBoxin the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open. - Tags (organize) — migration
0004addstags(user-scoped,UNIQUE(user_id, name),color= palette key) +document_tagsjoin (both sides cascade).internal/docs/tags.go:GET/POST/PATCH/DELETE /api/tags(create is idempotent on name; unknown colors coerced to rose) +POST /api/docs/:id/tags/DELETE /api/docs/:id/tags/:tagId(owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in onetagsByDocquery, no N+1). Frontend:useTags(roster + counts),TagChip,TagPicker(assign existing / create-and-attach with a color swatch), tag chips on each doc row, a filter bar (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens viatagColorVar. - Tablet / touch polish — responsive sidebar: below 768px it becomes an overlay drawer toggled by a header hamburger, with a scrim (auto-closes on doc select).
@media (pointer: coarse)enlarges tap targets (.petal-tap≥44px,.petal-tap-sm≥36px) and reveals the hover-only row actions (tag/delete). Tap-to-open for AI-suggestion cards (no hover on touch): a tap on a.petal-suggestionopens its card via the editor click handler, and apointerdownoutside the card/highlight dismisses it (mouse users keep the hover bridge). - Warm LLM-down failure states —
useCheckpointnow tracks anllmDownflag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). TheStatusBarshows a gentle bilingual note — 🌙 小助手在休息 · Petal's helper is resting · 文字已保存 — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9. - Tests:
tags_test.go(full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s),search_test.go(EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, edit re-indexes via the update trigger). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (petal-tag-chip/petal-scrim/petal-drawer-open/pointer:coarse) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately. - Known limitations: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
Phase 11 — Writer power-ups ✅
- In-document Find & Replace (Ctrl/Cmd+F) —
SearchHighlightProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded).FindReplacebar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one. - Read-aloud / TTS (
web/src/audio/speech.ts) — Web Speech API, offline, feature-detected. 🔊 in theWordCard(pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line. - Keyboard + touch access to the ESL helpers — refactored the right-click word-lookup into a position-based
openWordLookup(pos); now also driven by Ctrl/Cmd+D (look up the word at the caret) and a touch long-press (~500ms, the touch equivalent of right-click). Ctrl/Cmd+J rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.) - Whole-corpus backup —
GET /api/docs/export-all?format=md|docx|…zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; datedpetal-backup-YYYY-MM-DD.zip). Static route takes priority over/{id}in chi — covered byTestExportAll. Sidebar footer "备份 · Back up all: Word / Markdown" download links. - Smart typography (
Typography.ts) — dependency-free input rules: curly quotes, em-dash (--), ellipsis (...). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able. - Org niceties — duplicate-a-document (App
handleDuplicate→ "… (副本)", copies body/tone, not tags), sidebar sort (Recent / Title / Longest), and a document outline popover in the toolbar (headings → click to scroll, indented by level). - English phonetic (pivot from pinyin) — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently).
scripts/build_phonetic.pyextracts ECDICT'sphoneticcolumn (same source/freq-gate as the gloss);phonetic.json.gzembedded + lazily loaded;Result.Phoneticresolved via the same de-inflecting candidate walk; shown as/ˈrɪvər/in the WordCard. Full dataset built from ECDICT: 46,579 words (361KB gz), in line with the other lexicon assets. The script also has a--seedmode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist. - Verified: go build/vet/test (incl. new
TestExportAll), tsc, vite build all clean; live smoke vs throwaway binaries —/word/river|running|serendipity|riversall return phonetic (rivers→riverde-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with/{id}.
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)
Session log
- 2026-06-26: Phase 11 complete (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc Find & Replace —
SearchHighlightdecoration extension +FindReplacebar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) read-aloud Web Speech util + 🔊 in WordCard & selection bubble; (3) keyboard/touch access — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactoredhandleContextMenu→ sharedopenWordLookup(pos)); (4) export-all backup zip (GET /api/docs/export-all,TestExportAll, sidebar download links); (5) smart typography input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) duplicate doc + sidebar sort + outline popover; (7) English phonetic (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) viascripts/build_phonetic.py+ embeddedphonetic.json.gz+Result.Phonetic+ WordCard/ˈrɪvər/line — full 46,579-word dataset built from ECDICT (the csv re-download worked;--seedmode kept as a csv-free fallback). Also folded in this session: the selection-bubble vs copy/paste fix (bubble deferred to pointer-up + containerpointer-events:noneso it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user. - 2026-06-26: Phase 10 complete (organization & polish). Scope confirmed with user: all four areas, tags (not folders), FTS5 search. Backend: migration
0004_tags_and_search(tags + document_tags +documents_ftstrigram virtual table with sync triggers + back-fill);db.Tagmodel + color constants;internal/docs/tags.go(tag CRUD + idempotent assignment +tagsByDochelper, doc list now carries tags);internal/docs/search.go(GET /api/search, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted/api/tags+/api/searchin main.go. Frontend:useTags,TagChip/TagPicker/SearchBox, rewrittenDocList/DocListItem(chips + filter bar + search),api.search/tag methods +splitSnippet/tagColorVar; responsive sidebar drawer (hamburger + scrim, <768px) +pointer:coarsetap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch);useCheckpointllmDownflag → warm bilingual "小助手在休息" StatusBar note. Tests:tags_test.go,search_test.go(incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. All v1 phases (0–7) + post-v1 product (8–10) done. Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user. - 2026-06-26: Phase 9 complete (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an offline EC dictionary (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a selection bubble. Data:
scripts/build_gloss.pybuildsinternal/lexicon/data/gloss.json.gzfrom ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend:lexicongloss map +Gloss()/Result.Gloss+GET /api/gloss/{word};llm.RunRewrite+ rewrite prompt/styleGuidance;internal/suggestions/rewrite.go(POST /api/docs/:id/rewrite, stateless, owner-scoped). Frontend:GlossTiphover tooltip (350ms delay, reuseswordAt, CJK-safe) + gloss line inWordCard;SelectionBubble+RewritePreviewwired throughEditorCore(onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch);api.glossWord/api.rewriteSelection; CSS for the three new surfaces (+ print-hidden). Tests added inlexiconandsuggestions. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: Phase 10 (organization & polish). - 2026-06-26: Phase 8 complete (Trust foundation: version history + export) + empty-doc fix. Backend: migration
0003_document_versions;internal/docs/versions.go(throttled auto-snapshot wired intoupdate, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) andinternal/docs/export.go(pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987).DocumentVersionmodel + kind constants. Tests:versions_test.go,export_test.go(incl. valid-zip docx assertion). Frontend:api.clientversion/export methods;ExportMenu+HistoryPanelcomponents wired into the title row;@media printstylesheet +.petal-no-printfor the browser PDF path;editorEpochremount on restore. Empty-doc fix inApp.tsx(blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; Phase 9 (ESL gloss + tone-rewrite) next, then Phase 10 (search/folders/polish); auth/deploy (was Phase 11) shelved until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: Phase 9. - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
- 2026-06-25: Phase 0 complete. Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: Phase 1 (data layer) — SQLite via modernc, models, seed
localuser. - 2026-06-25: Phase 1 complete.
internal/dbpackage: modernc.org/sqlite (pulled go toolchain → 1.25),Open()does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired intomain.go; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writespetal.db. Next: Phase 2 (document CRUD + auto-save) — first "it works" milestone. - 2026-06-25: Phase 2 complete. Backend
internal/docs: chi sub-router (list/create/get/update/delete) mounted at/api/docs, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors;handlers_test.gowalks the full lifecycle. Frontend:api/client.ts,useAutoSave(1.5s debounce +saveNowflush),EditorCore(Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) +Toolbar,DocList/DocListItem,StatusBar, rewrittenApp.tsxorchestrating load/select/create/delete with optimistic sidebar patching..petal-prosestyles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: Phase 3 (LLM grammar checkpoint). - 2026-06-25: Phase 3 complete. Backend
internal/llm:LLMClientinterface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream),prompts.go(checkpoint + Ask Petal templates),checkpoint.go(brace-matched JSON salvage, per-doc 30sRateLimiter, doc/history truncation).internal/suggestions:/api/docs/:id/check+:id/suggestions+/api/suggestions/:id/{accept,dismiss}; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located bystrings.Index(advisory only). Frontend:useCheckpoint(4s debounce, loads existing on doc open, run-token guards stale responses),SuggestionHighlightTiptap extension rendering ProseMirror decorations re-anchored byoriginalstring on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms),SuggestionCard(type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified:I has→0:5,two apple→6:15) and 502 path when LLM unreachable. Next: Phase 4 (Ask Petal SSE chat). - 2026-06-25: Phase 5 complete. Tier-1 voice-consistency pass. Backend:
internal/llm/voice.go(RunVoice— whole document, noTruncateDoc, MaxTokens 2048,VoiceInterval20s per-doc floor), standalonevoiceSystemPrompt/VoiceMessages(not bundled with the grammar checkpoint).internal/suggestions:POST /api/docs/:id/voiceroute;check/voicecollapsed into a sharedrunPass(limiter, pass, scope);pendingScopemakesreplacePendingfamily-aware (grammar deletestype != 'voice', voice deletestype = 'voice'), so the two passes never clobber each other's pending flags; both endpoints now return the unified pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend:api.voiceDoc,useCheckpoint→voicing/runVoice(shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill inToolbar(→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" inStatusBar. Voice flags'replacement: nullround-trips to"";SuggestionCardalready hides the diff row + Accept for those. Tests:TestVoicePassCoexists(coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified[grammar@0, voice@62 (empty replacement)], grammar preserved. Known limitation:findRangeis single-textblock, so a voice passage crossing a\n\nparagraph break won't decorate (deferred). Next: Phase 6 (design system & polish). - 2026-06-25: Companion kitten added (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges.
web/src/components/Companion/:useCompanion(behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags),tips.ts(all copy bilingual, zh-first),LottiePlayer(wrapslottie-weblight build — offline, no eval/CDN fetch, so it bundles into the Go binary),PetalCompanion(kitten + CJK-first speech bubble). Ships working today with an emoji-kitten placeholder (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON intoanimations/index.tsis the only change to upgrade to real animation. Library decision: Lottie vialottie-web(not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wireseditTick/acceptTick/wordCount/saveStatus. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. TODO (user): source a Lottie cat asset to replace the emoji placeholder. - 2026-06-25: Phase 6 complete. Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. Accept confetti: CSS-only burst (
.petal-confetti-dot+@keyframes petal-confetti, each dot's trajectory from inline--dx/--dy), aConfetticomponent inEditorCorespawned at the accepted card's position onhandleAcceptand cleared after 720ms (timer cleaned up on unmount). Distraction-free mode:EditorCoregains anonFocus→AppfocusModestate; the doc-list sidebar is wrapped in.petal-sidebarand collapses via.petal-sidebar-hidden(width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (handleChromeDowncheckscanvasRefcontainment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: Phase 7 (browser-side spell check, nspell en-US). - 2026-06-25: Phase 7 complete. Browser-side spell check (nspell, en-US). Vendored Hunspell
en.aff/en.dic→web/public/dictionaries/en/(dictionary-enmoved to devDep; dict served as a static asset + embedded in the binary, kept out of the JS bundle).useSpellChecker(App-level, loads once/session) builds the nspell instance, replays alocalStoragepersonal word list,addWordpersists + bumps a version to re-decorate;src/types/nspell.d.tssupplies the missing types.SpellCheckextension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exportedmapOffset;wordAtfor click→span).MisspellCard: rose wavy underline, bilingual card with correction pills + add-to-dictionary. tsc/vite/go all clean; live server serves both dict files; nspell behavior smoke-tested. All v1 phases (0–7) done. Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion. - 2026-06-25: Phase 4 complete. Backend:
internal/llm/chat.go(StreamAskPetal— conversational sampling params, reusesAskPetalSystemPrompt/TrimHistory),internal/suggestions/chat.go(POST /api/suggestions/:id/chat— one user-scoped join loads the suggestion + parentcontent_text,surroundingParagraphextracts the\n\n-bounded paragraph atfrom_poswith whole-doc fallback, streamsevent: token/event: doneSSE frames with JSON-encoded data,X-Accel-Buffering: no, realhttp.Flusherper chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend:streamSuggestionChat(fetch + ReadableStream SSE parser, abortable),AskPetal.tsx(in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret),SuggestionCard"Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests:chat_test.go(streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404,surroundingParagraphunit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack,doneterminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: Phase 5 (voice consistency pass, Tier 1).