- Accept confetti: CSS-only 4-dot burst spawned at the accepted card, trajectory via inline --dx/--dy, auto-cleared after 720ms. - Distraction-free mode: editor focus collapses the doc-list sidebar (width→0 slide), canvas re-centers full-width; Escape or a click outside the canvas (header/gutter/status bar) restores it. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
16 KiB
16 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)
Phase 7 — Spell check
- nspell browser-side (en-US), vendor dictionaries
Deferred (post-v1-local)
- Authentik OIDC auth + session middleware
- Copyleaks Tier-2 + webhook HMAC
- Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
Session log
- 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: 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 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).