Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.
- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
loads one voice, so language routes to its own endpoint:{voice}. Unknown
language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.
English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:
- replacePending now suppresses any freshly-generated suggestion whose
original->replacement matches one the user already accepted or
dismissed for that doc. The model has no memory between passes, so
without this it re-proposes the identical edit on the next checkpoint.
- findRange anchored suggestions by exact string match, which missed
whenever the model echoed an `original` with plain ASCII (straight
quotes, --, ...) while the document held the Typography-converted
glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
no highlight AND a silent no-op on accept, which then fed the re-nag
above. foldTypography canonicalizes those variants (with a source
index map for length changes) so matching survives the mismatch.
Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
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
Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
API, EditorCore wiring, and md/html/docx export support for images
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.
Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.
Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.
Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).
Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.
Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.
Rewrite: selecting text pops a SelectionBubble (✨更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.
Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Version history: new document_versions table (migration 0003) holding
full-body snapshots that cascade with the doc. Throttled auto-snapshots
on save (>=3min apart, max 40/doc, pruned), explicit manual restore
points, and a pre_restore safety copy taken before each restore so
restoring is itself undoable. Endpoints under /api/docs/:id/versions,
all owner-scoped. Empties and bare renames never snapshot.
Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx
(no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987
filenames. docx is a hand-built OOXML zip. PDF is handled client-side
via the browser print dialog + an @media print stylesheet so CJK
renders with the reader's own fonts.
Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel
(snapshot list, preview, restore) wired into the title row; bilingual
zh-first to match chrome. Restore remounts the editor via editorEpoch.
Stop saving empty docs: blank Untitled drafts now reuse-on-create and
self-discard when navigated away from (refs avoid stale closures).
Tests: versions_test.go, export_test.go (incl. valid-zip docx).
go build/vet/test, tsc, vite build all clean; live end-to-end smoke
verified snapshot/throttle/restore/export.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Four enhancements to make the editor fit real school usage:
- Per-document tone (academic/professional/casual/humorous/creative/
persuasive/general): new documents.tone column (migration 0002), threaded
through the docs API, a bilingual ToneSelect dropdown on the title row, and
injected into the grammar-checkpoint LLM prompt so advice fits the register.
The voice pass stays tone-agnostic.
- Right-click word lookup: a new offline `lexicon` package serves definitions
(Wordset, modern ESL-friendly glosses) and synonyms (WordNet synsets first,
then frequency+stopword-ranked Moby for breadth) from gzipped embedded data,
behind /api/word/{word} with light morphology. The WordCard popover shows the
definition and tappable synonym pills that swap the word in place.
- Expanded writing stats: clicking the word count opens a StatsPanel with page
count, sentences, paragraphs, reading time, average word length, word variety,
and Flesch-Kincaid reading level — all computed client-side.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages
that read tonally out of place (formal/over-polished/paraphrased-too-closely),
as honey-decorated `voice` flags with no correction (awareness-only).
- internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc),
MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt
in prompts.go (not bundled with the grammar checkpoint, per spec).
- internal/suggestions: POST /api/docs/:id/voice. replacePending is now
family-scoped (pendingScope) so grammar and voice never clobber each other's
pending flags; both passes return the unified pending set. check/voice share
one runPass helper. TestVoicePassCoexists covers both directions.
- Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my
voice" toolbar pill, breathing honey dot in StatusBar.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Conversational follow-up on a suggestion, streamed token-by-token.
Backend (interface-only; handlers never touch a concrete LLM client):
- internal/llm/chat.go: StreamAskPetal with conversational sampling
(max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"),
reusing 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
(whole-doc fallback when unlocated) and injects it server-side.
Streams event: token / event: done SSE frames with JSON-encoded data
so token newlines can't break framing; real http.Flusher per chunk.
LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404.
Frontend:
- streamSuggestionChat: fetch + ReadableStream SSE parser (not
EventSource, needs POST), abortable.
- AskPetal.tsx: whole conversation in component state (no persistence,
cleared on close), Petal's first bubble pre-seeded with the
explanation, rose/lavender bubbles, CJK font stack on the bubbles
only (Note #17), streaming caret.
- SuggestionCard "Ask Petal" pill pins the card open while chatting
(hover-close suppressed, click-away closes) and widens it to 340px.
Tests: chat_test.go covers streamed-text concat + done event,
server-side context injection on the system message, sampling params,
404, and surroundingParagraph. go build/vet/test clean, tsc clean,
vite build OK. Live SSE smoke-tested against a fake streaming vLLM:
tokens flushed individually through the chi middleware stack, done
terminator, 502 on LLM-down, 404 on unknown suggestion.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Qwen 3.5 — the spec's recommended model — is a reasoning model. With
thinking on, Ollama streams its chain-of-thought into a separate
`thinking` field and hits num_predict before emitting any answer into
`content`, so Complete() got an empty string and the checkpoint failed
with "no JSON object in model output". Sending `"think": false` on every
/api/chat request fixes it; non-thinking models (qwen2.5) ignore the flag.
Validated end-to-end on deployment hardware (Ollama, qwen3.5:9b): the
grammar checkpoint now caught all five ESL errors in a 3-sentence sample
with correct JSON and string-anchoring, ~8.5s warm.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.
internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.
Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.
Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Backend internal/docs: chi sub-router (list/create/get/update/delete)
mounted at /api/docs, scoped to the local user. Create uses RETURNING;
update is a COALESCE partial-update so rename and full editor save share
one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle.
Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush
before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/
Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar,
and an App.tsx that orchestrates load/select/create/delete with
optimistic sidebar patching. content + content_text + word_count are
emitted together on every edit. .petal-prose styling (Lora body, Nunito
headings).
Verified: tsc clean, vite build, go build, full CRUD smoke test incl.
CJK title round-trip and SPA serve.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
- Go module + chi server with embedded SPA serving and /api/health
- internal/config env loader (local-dev defaults; auth/copyleaks deferred)
- React 19 + Vite 6 + Tailwind v4 frontend with full Petal design tokens
- Frontend embedded into the binary via web/embed.go (go:embed all:dist)
- README dev workflow, .env.example, BUILD_PLAN progress tracker
- Verified end-to-end: binary serves health, embedded SPA, and SPA fallback