# 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}`. ### Phase 12 — Collocation coach ✅ (2026-06-26) **Why:** ESL writers nail grammar but miss *which words go together* — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't *wrong*, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. **Build this first** — it's small and de-risks the migration-rebuild pattern Phase 13 also needs. **Key insight:** the suggestion pipeline is already generic over a `pass` + a `pendingScope` "family" (`runPass` in `internal/suggestions/handlers.go`; grammar + voice already prove it). Collocation drops in as a **third family** and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer. - [x] `internal/llm/collocation.go` — `CollocationInterval` (25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). Whole-document (no `TruncateDoc`), tone passed through. - [x] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)` + `collocationSystemPrompt`. The prompt flags only non-wrong-but-non-native word pairings ("do a decision" → "make a decision"), explicitly defers grammar/spelling to the grammar family, and frames every explanation as warm "Natives usually say…" with a Mandarin gloss — never "error/wrong/mistake". - [x] `internal/db/models.go` (`SuggestionTypeCollocation`) + migration `0005_collocation_suggestion_type` — **rebuilds** the `suggestions` table (new table w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`), since SQLite can't `ALTER` a CHECK. Verified against a copy of the live DB (5 migrations apply cleanly, collocation insert accepted, rows preserved). - [x] `internal/suggestions/handlers.go` — `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), `CollocationLimit` on `Handler` (+ wired in `New`), `POST /{id}/collocation`, `normalizeType` extended. **Also fixed `grammarScope`** from `type != 'voice'` → `type NOT IN ('voice','collocation')` so a grammar checkpoint no longer wipes the collocation pending flags (the third family must survive like voice does). - [x] Frontend: `suggestionMeta.ts` collocation entry (`--color-blossom` warm pink, "Word pairing" label); `client.ts` `collocationDoc(docId)` + `SuggestionType` extended; `index.css` token + `.petal-suggestion-collocation` decoration; `useCheckpoint` `collocating`/`runCollocation` (mirrors `runVoice`, shares the run-token guard); `Toolbar` blossom **"Make it sound natural 🌸"** pill (→ "Reading…"); `StatusBar` breathing blossom dot "Finding natural phrasing…"; threaded through `EditorCore`/`App`. Renders straight into the existing `SuggestionRail`/`SuggestionCard` (Accept applies the native pairing). - [x] Verified: go build/vet/test (`TestCollocationPassCoexists` — three families coexist, grammar checkpoint doesn't wipe voice/collocation), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (dead LLM) → collocation route returns the warm 502 like check/voice. ### Phase 13 — Vocabulary garden (spaced repetition) ✅ (2026-06-26) **Why:** the lexicon (`internal/lexicon`) is a stateless static-dataset lookup — **nothing records which words she's looked up.** Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build **after** Phase 12. - [x] New `internal/vocab` package + migration `0006_vocab_garden` (0005 was taken by collocation) — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup), `doc_id` (`ON DELETE SET NULL` so a word outlives its source doc), + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)` + `idx_vocab_due`. - [x] Auto-capture: `EditorCore.openWordLookup` fires `POST /api/vocab` after a successful lookup — **only for words the dictionary actually knows** (a real gloss or definition), so typos/proper-noun lookups don't clutter the garden. Captures the surrounding sentence (`sentenceAround`) + `doc_id`. Idempotent upsert: re-looking-up a word refreshes its gloss/phonetic/example but never resets its schedule. - [x] Endpoints (`internal/vocab/handlers.go`): `POST /api/vocab` (upsert; new word → `due_at = datetime('now','+1 day')`), `GET /api/vocab/due` (due now, server-side `datetime('now')` comparison — avoids JS local-vs-UTC parsing bugs), `POST /api/vocab/{id}/review` (grade → reschedule via `datetime('now','+N days')`), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`. All owner-scoped. - [x] SR scheduler (`internal/vocab/scheduler.go`): gentle SM-2-lite / Leitner ladder (1d → 3d → 7d → 16d → 35d, then geometric by ease). "again" steps back to 1d + counts a lapse + nudges ease down (floored at 1.3) — no harsh wipe; "good" climbs one rung; "easy" climbs a rung and a bit more + raises ease. No streaks to break. - [x] Frontend `GardenPanel` (slide-over drawer, sibling to `HistoryPanel`): each word a blossom that opens further with reps (🌱→🌿→🌷→🌸→🌺); a "复习 N 个词 · Review N due" button; per-word detail (phonetic/example/source-doc/remove); footer "🐱💤 N 朵花在花园里" — the sleepy kitten napping among the blossoms. **Flashcard review**: due queue, the example sentence with the word blanked (`blankOut`), flip to reveal word+phonetic+gloss+sentence, again/good/easy grades; **direction alternates** by cursor parity for recognition (EN→中文) *and* production (中文→EN). A 🤍/💚 "save to garden" toggle on `WordCard` alongside the silent auto-capture. Opened from a global 🌷 词汇花园 button in the app header. All copy bilingual zh-first. - [x] Verified: go build/vet/test (`scheduler_test.go` — ladder/again-gentle/easy-further; `handlers_test.go` — capture/upsert/due/review/delete lifecycle + empty-word 400 + doc-delete SET NULL), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (throwaway DB) — full capture→list→due→review→delete flow + 400 on bad grade verified end-to-end. ### Phase 14 — Companion warmth + bedtime nag + night mode ✅ **Why:** the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of **encouraging phrases** so cheers don't repeat as quickly; (2) when she's still writing **late at night (≥11pm)**, the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm **night mode** — dark moonlit theme + the falling petals become **falling stars**. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only. - [x] `web/src/components/Companion/tips.ts` — `ENCOURAGEMENTS` grown from 5→10 bilingual zh-first lines so cheers rotate fresher. New `BEDTIME: Line[]` array — four warm/playful lines (user-supplied English wit: "I bet your bed is missing you right now", "A tired writer is a bad writer", "Sleep is a wondrous enabler", "Hear that? No… everyone is sleeping and you should be too") with gentle Mandarin leads. - [x] `web/src/components/Companion/useCompanion.ts` — bedtime check folded into the existing 10s heartbeat (after the idle-return + break check, before the generic tip): `isBedtime()` = local hour ≥ 23 or < 4 (`new Date().getHours()`, the writer's machine clock). Only fires while actively writing (idle branch returns first). Own `lastBedtime` ref + `BEDTIME_GAP` 30min cooldown; respects `PROACTIVE_GAP`. New `BubbleTone` `'bedtime'` paces `readBubbleMs` (BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobs `BEDTIME_FROM`/`BEDTIME_TO` so the 11pm–4am range is one edit to retune. - [x] No tone-based bubble styling exists, so no CSS needed; the tone is metadata for pacing only. Sound stays the rotating pop. - [x] **Night mode** — `web/src/lib/night.ts` centralizes `isBedtime()` + the `BEDTIME_FROM`/`BEDTIME_TO` window (shared with the companion nag so they always agree). `web/src/hooks/useNightMode.ts` re-checks every 60s and toggles a `petal-night` class on ``. `index.css` adds an `html.petal-night` block that **only re-points the palette tokens** (`--color-bg/surface/border/plum/muted/accent…`) to a dark moonlit set — every Tailwind color utility reads them via `var()`, so the whole UI flips with zero component changes (verified: `.text-plum{color:var(--color-plum)}`). Accent/type colors kept (they pop on dark); 600ms bg/color fade for a gentle dusk transition; print stays white (the `#fff` override is inside `@media print`). - [x] **Falling stars** — `PetalFall` gains a `night` prop. Particles are **chunky cartoon power stars** (`makeCartoonStar`, Mario/Kirby-style: fat 5-point shape, glossy radial fill, puffy round-join colored outline, corner shine + soft glow halo so they pop off the dark sky) in 5 candy colors (`CARTOON_COLORS`), mixed ~70/30 with small four-point twinkle sparkles (`makeStarSprite`/`STAR_PALETTE`) for depth. Every star clearly **spins** (random direction, ~0.5–2.2 rad/s so the quick ones really whirl while slow ones drift for contrast; guaranteed min speed since a 5-point star is symmetric every 72°), **falls straight down** (no sway — that's a petal thing), and shimmers via a shallow alpha pulse (not blink). Effect re-inits on the day↔night flip. App wires `const night = useNightMode()` → ``. All sprites are canvas-drawn (offline, no asset files) — `sprites[]` is an image array, so a real PNG/SVG star could drop in later without restructuring. - [x] Verified: tsc clean, vite build OK, companion vitest 45/45. **Real-browser screenshots** (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion). ### Deferred (post-v1-local) - [x] **Multi-user groundwork** (2026-07-26) — request-scoped identity. New `internal/auth`: `Middleware(Resolver)` resolves the caller once per API request and stores the id in the context; handlers read it via `auth.UserID(r.Context())` instead of naming `db.LocalUserID`. `StaticResolver(db.LocalUserID)` keeps Petal single-user today. **Auth itself is still deferred** — but every query is now scoped to whoever the resolver says is calling, so landing Authentik is a one-line change in `main.go` plus a `Resolver` implementation. - [ ] Copyleaks Tier-2 + webhook HMAC (still parked — needs a public webhook; revisit after Phase 15) - Authentik OIDC + deploy: **no longer deferred — expanded into Phases 15–17 below** (decisions ratified 2026-07-26; see `MULTIUSER_PLAN.md`). Deploy landed 2026-07-26 (Phase 15); Authentik itself already runs on the same VPS, so Phase 16 has its IdP waiting. ## Execution phases 15–22 (added 2026-07-26) Decisions behind these are ratified in `MULTIUSER_PLAN.md` (all OPENs settled) and `SUGGESTIONS.md` (the *why*; Q1–Q3 settled). **Standing rules for every phase below:** - **Isolation tests in the same commit** as any new user-scoped endpoint (the `docs/isolation_test.go` suites are the template — this discipline caught a real unscoped-query bug once already). - **LLM-minimalism** (SUGGESTIONS §6): the LLM never gates essential functionality; new essential features are code+data first. - **Aesthetic + bilingual-in-the-pair copy remain acceptance criteria** on every user-visible change. - Verify per project convention: go build/vet/test, tsc, vite build, vitest, live smoke on a throwaway DB/port. ### Phase 15 — Deploy plumbing (parodia.dev + headscale) ✅ (2026-07-26/27) Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth (Phase 16) needs the stable `BASE_URL`/redirect URI this phase creates. **Hostname: `petal.parodia.dev`** (DNS already pointed at the VPS). Runbook: `deploy/README.md`. - [x] Dockerfile (multi-stage: `npm run build` → `go build` → alpine runtime) + docker-compose. CGO stays off (modernc SQLite is pure Go), so the runtime layer exists only for **ffmpeg** (read-aloud transcode) and **tzdata** (the bedtime nag + night mode read the local clock). Non-root; `/data` is the single writable mount. **`.dockerignore`** keeps the live DB and a stale local `web/dist` out of the image. - [x] Traefik route + HTTPS on `petal.parodia.dev` — labels follow the host's existing convention (external `traefik` network, `web-secure` entrypoint, `default` cert resolver, `compression@file`) plus Petal's own header middleware. No host port is published; Traefik is the only way in. `BASE_URL=https://petal.parodia.dev` set for Phase 16's redirect URI. - [x] `LLM_ENDPOINT` → millenia's headscale address (`100.64.0.2:8000`); `LLM_TIMEOUT` **30s → 90s** for the WAN+VPN round trip (the voice/collocation passes send a whole document and the timeout is a hard deadline on `Complete`). **Exposed with a forwarder, not a rebind** (`deploy/vllm-headscale-proxy.service`, socat): `vllm-chat.service` is shared — Petal, **Gogobee** and Open WebUI all point at `127.0.0.1:8000`, and Open WebUI keeps its endpoint in its own database rather than in env, so rebinding meant editing three consumers and reloading a 35B AWQ model. The forwarder adds a second listener on `100.64.0.2` only (never `0.0.0.0` — the far end is a public host), zero downtime, zero consumer changes. Model is `qwen3.6-35b`. **Verified end to end: a grammar checkpoint from `petal.parodia.dev` returns real suggestions in ~3s over the VPN.** - [x] TTS — **deviation from the plan, deliberate**: Piper was *not* actually installed on parodia, and the `reala` account has no lingering session to keep user systemd units alive. Runs as **two sibling containers** (`piper-en`, `piper-zh`) off one image, models cached in a shared volume, on an internal network with no published ports. pt-PT in Phase 21 is a fourth service, not a new image. **Found + fixed while wiring**: piper-tts 1.6.0 moved synthesis from `POST /` to `POST /synthesize` (identical body); rather than pin both deployments to one release, the path is now config (`TTS_PATH`, default `/` so millenia is untouched). - [x] Backups — `db.Backup` uses **`VACUUM INTO`**, not a file copy: in WAL mode the newest committed pages may live in `petal.db-wal`, and copying the three files separately can capture a torn mid-checkpoint state. `VACUUM INTO` reads one coherent snapshot including the WAL, takes no write lock (safe against the live app), and emits a single file with no companions; it refuses an existing destination so a failed run can't destroy the last good backup. Driven by a `-backup` flag on the binary. - **On the VPS: folded into the host's existing `parodia-backup`** (age-encrypted, offsite to S3, 14-day retention, dead-man snitch) rather than a parallel cron — the user pointed out that layer already existed. **Found a real bug while doing it:** that script's `sqlite_dump` helper uses Python `iterdump`, which **does not reproduce an FTS5 virtual table** — it emits `documents_fts` as a raw `sqlite_master` row plus shadow tables, and replaying the result dies with `no such table`. Cross-document search would have been silently missing after any restore. Added a `sqlite_file_dump` helper using `VACUUM INTO` instead; round-trip verified (counts + a live FTS `MATCH`). - **On millenia: `petal-backup.timer`** — the canonical instance had **no scheduled backup at all** (newest snapshot a month old), which mattered far more than the staging one. Nightly 03:20, `Persistent=true` (the box isn't on 24/7), snapshot → gzip → **age-encrypt with the parodia public recipient** → push to the VPS over headscale with a size check → prune both ends. Verified the pushed archive is real age ciphertext and that neither box can decrypt it. - [x] Migration decision: **millenia stays canonical** (user's call). The VPS runs an empty staging DB so she moves accounts exactly once, when Phase 16/17 land. - [x] **Encryption at rest** (not in the original plan — the user raised it mid-session, correctly). VPS data dir is now a **LUKS2 volume** (`deploy/setup-encrypted-data.sh`), covering `petal.db`, `images/` **and the TTS cache** (synthesized audio of her sentences). LUKS-on-a-file rather than gocryptfs because SQLite in WAL mode needs a shared-memory index mapped consistently across processes and FUSE has a long history of mmap/locking differences. Key on the same box — a deliberate availability tradeoff, documented honestly: it stops a decommissioned disk or a raw block-device read, **not** anyone holding the whole VM image. Canary-verified: a marker written through the app is absent from the raw image and present through the mount. **Two bugs caught by rehearsing a reboot rather than trusting the clean run** — (1) mounting over a directory *hides* its contents rather than removing them, so the first pass left the original plaintext `petal.db` and WAL on the unencrypted root filesystem, invisible under the mount (now shredded pre-mount, with a refusal if the mountpoint won't come up empty); (2) **`systemd-cryptsetup` wasn't installed**, so `/etc/crypttab` was ignored entirely and the volume would never have unlocked at boot. Added a **mount-liveness guard** (`.volume-ok` bind-mounted with `create_host_path: false`) so an unmounted volume is a loud container start failure instead of Petal quietly serving a blank database. ⚠️ A true reboot is untested — the VPS also runs matrix/lemmy/akkoma/gitea/authentik, so that's the user's call. millenia remains unencrypted at rest (LVM, no LUKS). - [x] **Supervision** (also not in the original plan). millenia's Petal had been running as a bare `./petal` with **PPID 1** — no unit, no screen session — so a crash or reboot left it silently down; now `petal.service`, verified by `kill -9`. **Piper's silent-failure mode fixed**: with `RestartSec=3` against systemd's default 10s window the burst limit was never reached, so a dead service looped **26,800+ times over a day without entering `failed`**; both units now set `StartLimitIntervalSec=300`/`StartLimitBurst=5`. Still missing: an external probe (uptime-kuma monitors on `/api/health` and `/api/tts` — needs the UI, written up in `deploy/README.md` §7). - [x] **Interim edge gate** (not in the original plan; added once the instance was live). Petal authenticates nobody yet — `StaticResolver` hands every request the same `local` user — so on a public host the whole API was open to read/write and image upload. Traefik basic auth holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Deleted when OIDC lands. - [x] Acceptance — verified over public HTTPS **with the LLM link down** (it genuinely is): Hunspell dictionaries 200, gloss + word lookup (incl. phonetic) 200, doc create/save, FTS search on 春天, md + docx export, vocab capture/list, read-aloud EN + zh (real mp3 via ffmpeg, cache hit on repeat, 404 for an unconfigured language so the client falls back). `POST /check` → the warm 502 that renders as 小助手在休息. `/api/health` public; HTTP 301 → HTTPS with a valid cert. ### Phase 16 — Auth (in-app OIDC) + image-store ownership ✅ (2026-07-27) — live on petal.parodia.dev Option B ratified. `go-oidc` + `x/oauth2`; config fields already existed. The `Resolver` seam from Phase 0 was the only integration point — no handler or query moved. - [x] OIDC login flow (`internal/auth/oidc.go`): `/auth/login` → Authentik → `/auth/callback` → provision → session. **state** (cookie vs param, constant-time) + **nonce** (ID-token claim vs cookie, so a token minted for another attempt is refused) + **PKCE S256**. Discovery is **lazy and retried**: an Authentik outage blocks new logins but leaves every existing session working, since those need only Petal's own DB — the app must not fail to boot because the IdP is briefly down. - [x] `sessions` table (migration `0010`); opaque token in `petal_session` (`HttpOnly`, `SameSite=Lax`, `Secure` only when `BASE_URL` is https — flagging it on a plain-http dev server makes the browser silently drop it). **The table stores only the token's SHA-256**, so a DB copy yields no usable session. **30-day sliding expiry**, all time math in SQLite `datetime()` (canonical UTC), the extension throttled to one write per hour per session. `/auth/logout` deletes the row, not just the cookie; `RevokeAll` signs one writer out everywhere; expired rows pruned at startup. - [x] Allowlist: `PETAL_ALLOWED_SUBS`, comma-separated. **Matches a subject id *or* an email**, case-insensitively — a deliberate widening of the plan: a subject is an opaque uuid that doesn't exist until first login, so a subject-only list means letting someone in, reading a log line, and editing config. Empty = anyone Authentik authenticates. A rejected valid login gets the warm bilingual "这个 Petal 不是给你写的 · This Petal isn't yours to write in" page and **no provisioned account**. - [x] `main.go` picks the resolver from config: `SessionStore` (which is itself the `Resolver`) when `AUTHENTIK_URL`/id/secret are all set, `StaticResolver(local)` otherwise — so local dev and every pre-auth deployment behave exactly as before. `/auth/*` mounts on the root router, outside `/api`. - [x] Frontend: one 401 interceptor in `api/client.ts` (`UnauthorizedError` + an `onUnauthorized` hook covering `req`, the image upload and the SSE chat stream) → `useSession` → `SignInOverlay` (warm bilingual, editor still visible behind it — nothing has been taken away). **Draft rescue** (`lib/drafts.ts`): a save that 401s stashes its body in `localStorage` keyed by doc id *before* anything else, auto-save then stops (further attempts would only 401 and re-stash), and opening that doc after re-login merges it back and schedules a save. StatusBar says 已保存在本机 · Kept on this device — where the writing is, not what failed. Sidebar footer gains the account + 退出 · Sign out (hidden when the id is still `local`). - [x] **Image store ownership** (OPEN #5): `images` table (`PRIMARY KEY (name, user_id)`) — **one row per owner, not one owner per file**, so the same picture uploaded by two people is still stored once and dedup survives; the file is deleted only with its last row. Fetch joins on the caller and answers **404, not 403** (whether a hash exists is itself information). `Cache-Control` went `public` → `private` — a shared cache must never hand one writer's image to another. Files already on disk are claimed for the local user at startup (idempotent), because a row is now what makes an image fetchable and every picture already pasted into a document would otherwise 404. - [x] `users.pair_lang` (default `'zh'`) added in the same migration; login refreshes email/display name but never touches it — it's Petal's setting, not the IdP's. - [x] Tests: `session_test.go` (lifecycle, expiry + prune, sliding renewal, hash-not-token storage, cross-user non-interchangeability, `RevokeAll`, FK cascade, middleware wiring, user upsert, allowlist matrix) and `oidc_test.go` — **the whole round trip against a stub IdP** (RSA-signed ID tokens, real discovery + JWKS): PKCE challenge present, verifier reaches the token endpoint, state mismatch → 400, **replayed nonce from another attempt → 400**, allowlist refusal → the bilingual 403 with no account created, provider error → 403, already-signed-in login short-circuits home. `images/handler_test.go` gained two-user isolation, cross-user dedup + last-owner file deletion, and backfill idempotency. `web/src/lib/drafts.test.ts` covers the rescue (round-trip, per-doc, take-consumes, expiry, corrupted entry, storage that throws). - **A real bug the round-trip test caught:** the one-shot state/nonce/PKCE cookies were cleared with `defer o.clearTemp(w)` — which runs *after* the redirect has written the response header, so the `Set-Cookie` was silently dropped and they lingered in the browser for their full 10 minutes. Now cleared up front. - Verified: go build/vet/test, tsc, vite build, vitest 76/76 all clean. Migration `0010` applied to **a copy of the live millenia DB** (`VACUUM INTO` snapshot): 10 migrations apply, documents/vocab/versions counts unchanged, FTS search still returns hits, the one existing image claimed, `pair_lang` defaulted. Live smoke against the binary on a throwaway DB: auth-off → `/api/me` is `local` and everything 200s; auth-on → `/api/docs` and `/api/me` 401, `/api/health` still public, `/auth/login` with an unreachable IdP renders the warm 503 page, `/auth/logout` redirects home; a hand-inserted session row → 200 with the cookie, 401 without it, with a bad one, and once expired. - [x] **Deployed** (user: "do it! register it!"). Provider + application registered in Authentik (slug `petal`, confidential, strict redirect `https://petal.parodia.dev/auth/callback`, openid/profile/email, implicit-consent authorization flow), created through `ak shell` since the available API token is a limited invite-minter bot. `.env` filled in on the VPS, image rebuilt, container recreated. **The Traefik basic-auth gate is gone** along with the separate unauthenticated `/api/health` router that existed only to escape it — Petal 401s every `/api` route without a session, so an anonymous visitor gets the app shell and a redirect, and a second password in front of a real login is one more thing to lose. Verified over public HTTPS: `/api/health` 200, `/api/docs` **401 with no basic-auth challenge**, `/auth/login` → Authentik with state+nonce+PKCE in the URL, following it lands on the real sign-in page, `/petal.svg` served as the favicon. The final step — typing her password — is hers. - **Two bugs deploying caught that the whole test suite could not**, both fatal before the login page ever renders: (1) the code trimmed the issuer's **trailing slash**, and Authentik's issuer has one — OIDC requires a byte-for-byte match, so discovery failed every time while the stub IdP (which advertised a slashless issuer) kept passing. Fixed, and the stub's issuer is now a knob with a regression test that ends in a slash. (2) A provider created through `ak shell` rather than the admin UI comes up with **`grant_types = []`**, which authentik reads as "no grant type is permitted here" and answers with `invalid_request` / *The request is otherwise malformed*. Both are written up in `deploy/README.md` §4. - **Allowlist is currently `prosolis@proton.me` only.** That Authentik instance fronts ~40 accounts across several applications, so an empty list was not an option, and guessing which account is hers would either lock her out or let a stranger in. Adding her is one line in `.env` plus a restart. Note that an Authentik account with **no email set** (e.g. `akadmin`) can't match an email-based entry — use its subject id. ### Phase 17 — Migrate the `local` user ✅ (2026-07-27) — her writing now lives on her account Script, app stopped, backup first (OPEN #4). **The "she logs in once first" dependency turned out not to exist**: authentik's default `hashed_user_id` sub mode makes the subject `User.uid`, which is derived from her user id and the instance secret — stable, and readable before she has ever signed in (`ak shell -c "…User.objects.get(username='claire').uid"`). So the data can move *first*, and she signs in to find her writing already there rather than to an empty Petal that fills in later. - [x] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data - [x] `scripts/migrate_local_user.py` — dry-run by default, `VACUUM INTO` backup before touching anything, one transaction with `PRAGMA foreign_keys=OFF`, re-points `documents`/`tags`/`vocab_words`/`images`, deletes the old user row, and **verifies every expected row actually moved (and that the source is left owning nothing) before it commits**, rolling back otherwise. Refuses to merge into an account that already owns writing. Runbook in the script header. - **The "is the app stopped?" guard needed a second attempt.** `BEGIN EXCLUSIVE` — the obvious check — passes straight through against a *running but idle* Petal, because in WAL mode it only conflicts with another writer. That is exactly the case the guard exists to catch, and it would have failed silently. `PRAGMA locking_mode = EXCLUSIVE` conflicts with any connection at all, since it locks the shared-memory index every WAL reader maps; verified against a live server. - [x] **Run for real.** Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image moved from `local` onto `5f47d955…` (Claire, `clairew8@pm.me`). Sequence: `-backup` snapshot of millenia's live DB (taken while it kept running — `VACUUM INTO` needs no write lock), shipped to the VPS, installed over the throwaway staging database (kept as `petal.db.staging-*`), **started once so migration `0010` applied**, stopped, migrated, started. Verified over public HTTPS with a short-lived probe session, then removed: `/api/me` is her, 8 documents listed, her image 200s, vocabulary garden and version history intact, search returns hits — and 401 without the cookie. - **millenia is a frozen fallback, not a mirror** (user's call: "both, VPS first"). It was left running and completely untouched, still serving the same writing under the pre-auth `local` user. The two diverge the moment anything is written on either, so it wants retiring rather than syncing. - **A second bug in the guard, found by running it against production rather than a test file.** `PRAGMA locking_mode = EXCLUSIVE` keeps holding the lock after being set back to `NORMAL` — SQLite only releases it on that connection's next database access — so on a **WAL** database the script locked itself out of its own `VACUUM INTO` backup. It passed locally because the test database had come out of `VACUUM INTO` and so wasn't in WAL mode at all — the same shape of miss as the trailing-slash issuer: the fixture didn't look like production. The probe now runs on its own connection and closes it, and the fix was re-verified against a database that had genuinely been served in WAL mode. - **Startup crash averted while sequencing this**: the image backfill claims unowned files for `local`, which stops existing after the migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal. Petal would have entered a crash loop the first time it started on a migrated database. The backfill now skips a missing owner (there is nothing to claim in that case anyway; the migration moves the image rows itself). ### Phase 18 — Per-user, per-language client state ✅ (2026-07-27) - [x] **Preferences namespaced by account** (`web/src/lib/prefs.ts`) — `petal.sound`, `petal.petals` and `petal.companion` now read/write `.u.`. The wrinkle is timing: `sounds.ts` and `petals.ts` read their value at *import* time, long before `/api/me` answers, so rather than block startup on the network for a mute flag, a read before the answer sees the **legacy un-namespaced key** (on a single-writer browser, exactly the right value) and `setPrefsScope` — called from `useSession` the moment `/api/me` resolves — adopts it and fires `onPrefsScopeChange` so each module re-reads. `PetalCompanion` re-reads too, unless she's already swapped mascots in the meantime. - [x] **Legacy adoption is a move, not a copy** (user's call): the first account to sign in on a browser inherits whatever was set back when Petal had no accounts, and the key is then deleted so the *second* account starts from Petal's defaults rather than from a stranger's choices. An existing scoped value is never overwritten by the legacy one. - [x] **Personal spell dictionary promoted to a server table** (the plan's nice-to-have; user chose it) — new `internal/spell` package + migration `0011_personal_dictionary`: `personal_words (user_id, lang, word, created_at)`, `PRIMARY KEY (user_id, lang, word)`, cascading with the account. `GET/POST/DELETE /api/spell/words`; adds are idempotent, a delete of a word that was never there is a success (the caller's intent already holds), and **every response carries the full resulting list** so the client never has to merge two views of one set. Namespacing it in `localStorage` instead would have *fragmented* the list she already has across her laptop and tablet — strictly worse than before; a table means it follows her. - [x] `lang` is the **dictionary's** language, not the writer's — an en-US personal word must not silence a pt-PT flag once the second pair ships. Normalised (`trim`/lowercase, default `en`) so `EN`/`en`/absent can't split one list into three. - [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing. - [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety. - Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty. - [x] **Rehearsed, then deployed** (2026-07-27). The rehearsal the previous session couldn't run: `VACUUM INTO` snapshot of the live VPS database, pulled down, migrated by the Phase-18 binary — 11 migrations apply, every count unchanged (2 users, 8 documents, 33 versions, 103 suggestions, 3 vocab words, 1 image), FTS still matching, `integrity_check` and `foreign_key_check` both clean, `personal_words` present and empty. Then the deploy: off-box encrypted backup first, `git pull` + `docker compose up -d --build`, all three containers healthy, `0011` applied to the live DB with her writing untouched. Verified over public HTTPS: `/api/health` 200, `/api/docs` and the new `/api/spell/words` **401 without a session**. - ⚠️ The authenticated live probe of `/api/spell/words` (hand-inserted session row, as Phases 16/17 used) was **blocked by this session's permission classifier** — minting a session token reads as credential fabrication. Not worked around. The endpoint's full lifecycle is covered by `internal/spell/handlers_test.go` and was smoke-tested end to end on a throwaway DB when it was built; what remains unproven in production is only that it answers 200 for a real cookie, which the shared middleware already governs for every other route. ### Phase 19 — Langpack extraction (the copy chore) ✅ (2026-07-27) Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled). - [x] Every `中文 · English` string from the ~29 frontend files (plus `tips.ts`, `prose.ts`, `companions.ts`, `stats.ts`) now lives in `web/src/i18n`: `types.ts` (the `Pack` shape), `packs/zh.ts` (today's copy, **verbatim** — sentinel assertions in `i18n.test.ts` guard against a quiet rewording), `index.ts` (the accessor). - [x] Two access paths, matching where copy is built: `usePack()` for components (a `useSyncExternalStore` subscription, so a pack arriving after first paint re-renders), and `pack()` for the modules that compose a line when something *happens* rather than when something renders — the companion and the prose checker read it at call time, never at import time. - [x] **Anything with a value in it is a function on the pack**, not a template assembled at the call site (`reviewDue(n)`, `daysAgo(n)`, `duplicateTitle(title)`, every prose rule). Word order isn't universal; a pack author must be able to move the number. English pluralisation moved into the pack with it. - [x] `Line` renamed its Mandarin half `zh` → `native` throughout (companion bubbles, tone/style pills, history badges, stat rows). `gradeBand` now returns a band *name* rather than a label, and the roster constants (`TONES`, `REWRITE_STYLES`, export formats, companions) keep only value + emoji — the label is a pack lookup keyed by the same value, with a test asserting no roster entry is unlabelled. - [x] `internal/llm/lang.go`: the three prompts that *name* the writer's language — the collocation gloss, Ask Petal's "answer in her language", the explanation translator — take a `Lang` instead of saying "Simplified Chinese" outright. pt-PT is spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, since a model that has read far more pt-BR needs telling. `Why` carries her word for "why" (为什么 / porquê / …) so the tutor prompt still recognises the question. An unknown code falls back rather than erroring — a prompt is the wrong place to discover a config problem. - [x] Wired to `users.pair_lang` on both sides: `useSession` calls `setPackLang` the moment `/api/me` answers, and each LLM handler reads the column **in the row-scoped query it already ran** (the one that proves she owns the document) rather than in a second lookup that could disagree with it. - Tests: `internal/llm/lang_test.go` (fallback matrix; each prompt names the writer's language and *not* Chinese; the zh pair reads exactly as before), `internal/suggestions/pairlang_test.go` (the column reaches the model for collocation + translate, zh unchanged — **verified to fail when the join is removed**), `web/src/i18n/i18n.test.ts` (default before `/api/me`, fallback for an unshipped pair, no spurious notifications, verbatim sentinels, interpolation incl. plurals, no empty string anywhere in the pack, every companion/tone/style labelled). - Verified: go build/vet/test, tsc, vite build, vitest 90/90 clean; live smoke on a throwaway DB (:8074) — doc create/save, search, md export, spell add, warm 502 from the collocation pass with the LLM down; the built bundle still carries the zh strings. - [x] **Deployed 2026-07-27**, together with Phase 20. Pure refactor with no migration, so it was a rebuild; the langpack is live and reads exactly as before, which is the whole point of a verbatim `zh` pack. ### Phase 20 — DreamDict as a lexicon provider ✅ (2026-07-27) Option 3 ratified (import package, read-only `dict.db`). - [x] **The prerequisite was bigger than the plan thought.** Renaming the module was necessary but not sufficient: DreamDict's query layer lived in `internal/dictionary`, which no other module may import whatever the module is called. Both fixed upstream in one commit — `module github.com/prosolis/dreamdict`, `internal/dictionary` → `dictionary`, with a package comment saying why reading a built database is public API while building one stays internal. `internal/loader` is untouched, and DreamDict's own tests pass unchanged. - [x] **Provider seam** (`internal/lexicon/provider.go`): a `Provider` is the two questions the popover and the tooltip have always asked (`Lookup`, `Gloss`), which the embedded `*Lexicon` already satisfied unmodified. `Set.For(lang)` is the single place the choice is made. `OpenDreamDict` opens `dict.db` read-only beside `petal.db`; **a missing file returns `(nil, nil)`, not an error** — a laptop checkout has never had one — while a file that is *present but unimported* does error, because that one is somebody's half-finished deploy. - [x] **The absent-dictionary case degrades better than "no data".** A pt-PT writer with no `dict.db` falls back to the embedded datasets **with the gloss suppressed** (`glossless`), so she keeps English definitions, synonyms and phonetics — all compiled into the binary and all correct for her — and loses only the translation. Handing her the Chinese gloss would be worse than handing her nothing: empty reads as "not found", wrong-language reads as Petal being broken. - [x] pt-PT + fr + **es** wired to DreamDict; **zh stays on ECDICT**, and the routing test is the guard on that decision. The comparison the plan asked for was run against the real 452 MB database: DreamDict reaches a Chinese gloss for **53%** of the 2,000 commonest English words, against ECDICT's essentially total coverage of them. Quality did not hold, so nothing converged. es routes to DreamDict from day one and simply finds no rows in the April build — which is the same code path as any unglossed word. - [x] **The plan's central assumption was wrong, and measuring it is what found that.** `Gloss ← Translate(word, "en", L1)` was mapped 1:1 in `MULTIUSER_PLAN.md`; against real data that table answers for **17%** of common English words into pt-PT (16% into fr). Wiktionary's translation sections are thin in the en→X direction. Going through shared Princeton WordNet synset ids instead answers for **61%**, and it is where the words a learner wants live — "ephemeral", "think" and "quickly" have no en→pt-PT translation row at all. New `dictionary.Equivalents(word, from, to)` upstream does that, falling back to the translations table, for **62%** combined. A gloss absent five times in six is not a gloss. - [x] **Ranking, argued from a wrong answer.** Ordering equivalents by target-word frequency glosses "think" as *lembrar* — "remember" — because lembrar is the commoner Portuguese word even though pensar shares six of think's synsets to lembrar's one. Counting shared senses first, frequency second, asks which candidate means the same thing *most often*: think → pensar; achar; lembrar, write → escrever, garden → jardim, house → casa before firma. - [x] The de-inflection walk (`candidates`) is shared with the embedded path, because `dict.db` stores headwords — "running" has no row. The first candidate that *has definitions* becomes the headword every other field is read from, so one popover never mixes "running"'s frequency with "run"'s senses. The gloss walks separately, since a word can have an equivalent and no definition. - [x] **Surfaced where cheap**: a band chip beside the phonetic (`wordband.ts`) and an etymology line at the foot of the card. Following Phase 19, `wordBand` returns a band *name* and the langpack owns the wording. **Three bands, not five** — the difficulty score is a heuristic over length and corpus counts, good enough to separate "everyday" from "you will need to explain this" and not good enough to rank *obfuscate* against *serendipity*; a finer scale would be a confident-looking lie. Thresholds come from the real distribution (136k headwords bunch between 0.45 and 0.60; the words a writer reaches for sit under 0.40). An unscored word renders **no chip at all**. - [x] The pair language is read **per request** in `providerFor` — a word lookup has no row-scoped query to piggyback on, unlike Phase 19's handlers — and a failed read falls back to today's embedded behaviour rather than failing the lookup. `Cache-Control` dropped from `public` to `private`: the same URL now answers in a different language per writer. - Tests: `internal/lexicon/dreamdict_test.go` — fixture is a **real dict.db on disk**, so open/stat/seeded is the production path; missing vs. unseeded vs. unreadable, every field filled, gloss follows the writer not the word (pt-PT/fr/es/de), the synset path, de-inflection carrying *all* fields to one headword, miss-is-not-an-error, the NULL-difficulty sentinel, IPA chosen over CMU, and the handler tests (two writers/one URL/two languages, unknown caller, private caching). Upstream: `Equivalents` ordering, synset-over-translation, fallback. Frontend: `wordband.test.ts` (bands pinned to real scores; difficulty 0.0 is a score, not a missing value) and an i18n assertion that no band can be unlabelled. - **Two bugs the tests found before the browser did**: `trimEtymology` sliced by byte, which would put invalid UTF-8 in the JSON for exactly the etymologies that matter (ἐφήμερος, ephemerus), and its ellipsis path overran its own cap. - Verified: go build/vet/test, tsc, vite build, vitest 96/96 clean, both repos. Live smoke on a throwaway DB (:8075) against the real 452 MB `dict.db` — startup logs the languages it actually got, zh unchanged, then the same instance flipped to pt-PT and re-queried. - [x] Deploy documented (`deploy/README.md` §4b, `DICT_PATH` through Dockerfile/compose/.env.example): `dict.db` ships into the data dir, stays out of the backups because they name `petal.db` explicitly, and is rebuildable from public data. - [x] **Deployed 2026-07-27**, with Phase 19. The two dreamdict commits went to GitHub (`prosolis/dreamdict` main), the `replace` came out for a real pseudo-version, and Petal shipped as a rebuild — no migration in either phase, so `schema_migrations` is still 11. Order: off-box encrypted backup first (`✓ petal.db.age`), then `dict.db` copied into the LUKS volume and **SHA-256 verified end to end**, then pull + `docker compose up -d --build`. All three containers healthy; startup logs `dictionary: DreamDict open at /data/dict.db ([en fr pt-PT es zh])`, so the deployed binary opened the deployed file and found every language. Over public HTTPS: `/api/health` 200, `/api/docs`, `/api/word/…` and `/api/gloss/…` all **401 without a session**. Her writing untouched — 2 users, 8 documents, 33 versions, 103 suggestions, 3 vocabulary words, 1 image, FTS still matching, `integrity_check` ok. Both accounts are on the zh pair, so **nothing about her experience changed today**; what shipped is the capacity for the next pair. - [x] **`dict.db` rebuilt with Spanish and deployed** (2026-07-27, user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout turned out to carry ~490 lines of **uncommitted** changes; checking rather than pulling over them showed an *earlier draft* of the regional-variant work since committed upstream (main has the reviewed `wordListQuery` refactor, millenia the pre-refactor `Words`) — nothing unique at risk, but not mine to discard, so that checkout was left untouched and the build ran from a clean clone. Import: 6m15s, `es` 102,971 words / 71,680 definitions, **every other language byte-identical to April** — which is what says a language was added rather than the rest quietly shifted. Coverage of the 2,000 commonest English words: **es 68.6%** (best of the four), fr 63.1%, pt-PT 62.1% (both unchanged), **zh 53.2% — re-measured, still under ECDICT, so Chinese stays put**. Shipped millenia→parodia direct over headscale, SHA-256 verified both ends, April file kept as `dict.db.april-backup`. - **The startup log was lying, and the rebuild is what exposed it.** It printed `dictionary.Langs()` — a compile-time constant of the languages DreamDict *supports* — so it had been reporting a confident `[en fr pt-PT es zh]` over the April file that contained no Spanish at all: exactly the failure the line existed to catch, rendered as success. It now counts rows (`en=136615 es=102971 fr=56096 pt-PT=136300 zh=120883`), with a test asserting a fixture holding only two languages cannot name five. For a file somebody copies onto the box by hand, "what is in it" is the only question worth asking. - A second thing worth recording from the rebuild: the SUBTLEX-US download now fails (source moved behind a manual export), which looked like it would silently cost English frequency data. It doesn't — the loader falls back to `SUBTLEX-US.txt`. Chasing it down showed English "frequency" is mostly **SCOWL's commonness bucket** (1000/800/600/…/50), refined by SUBTLEX for ~1,600 words — which is the quantised distribution measured earlier, and independent confirmation that the band chip was right to read `difficulty` rather than `frequency`. - ⚠️ As in Phase 18, the authenticated live probe — seeing `/api/word` answer 200 for a real cookie — was **not performed**: minting a session row reads as credential fabrication to this session's classifier. The lookup path was smoke-tested end to end against this exact `dict.db` (same SHA-256) on a throwaway instance, including the zh→pt-PT flip, and the shared middleware governs that last step for every other route. ### Phase 21 — The pt-PT pair (first Latin pair, proves the model) SUGGESTIONS §1/§3/§3a. French and **Spanish** follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model. Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and pt-PT gloss coverage of common English words is 62%. **Code half built 2026-07-27** (user: "let's continue the build plan"; scope confirmed: code only, no VPS work; the pack written but flagged unreviewed). Not deployed — no migration, so it is a rebuild whenever the user wants it. - [x] **pt-PT spelling dictionary — and it could not be "vendored like en-US".** nspell expands affixes *eagerly on construction*: English's ~50k stems and small rule set are fine, European Portuguese's **1,340 affix rules over 44,257 stems** are not. Measured here: ~340 MB of heap for the first 12,000 entries and no return at all after three minutes on the whole file, i.e. comfortably over a gigabyte for a browser to load a spellchecker. So `scripts/build_ptpt_dictionary.py` runs Hunspell's expansion **once at build time** — 1,039,058 surface forms, 15 MB of text, **2.66 MB gzipped**, which nspell then reads with no affix machinery at all in **842 ms / ~120 MB**. The runtime path is byte-for-byte the English one, which is the real prize. The shipped `.aff` keeps only upstream's TRY/KEY/REP/MAP, which shape *corrections* rather than membership — so "telemovel" still corrects to "telemóvel" and "cao" still knows about "ção". - [x] **npm's `dictionary-pt` is not European Portuguese.** Both it and `dictionary-pt-br` package VERO (*Verificador Ortográfico Livre*, Brasil) — the obvious vendoring step would have shipped Brazilian spellings under a pt-PT label, which is the §3 drift risk arriving through the packaging rather than through the model. The real source is Projecto Natura's (Universidade do Minho), which LibreOffice ships and Debian packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`. The build script **asserts the fault lines before it writes anything**: accepts `receção`, `húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo`, `pensámos`, `escrevêssemos`; rejects `recepção`, `úmido`, `ônibus`, `óptimo`. A source that fails those is not the dictionary it is for. - [x] **Both-dictionaries spellcheck** (`useSpellChecker`): English always loads; her language loads when a pair ships one; a token is flagged only when **every** loaded dictionary rejects it. Correction pills **interleave** the two rather than concatenating — otherwise English fills all five and a misspelt Portuguese word gets no Portuguese suggestion, which is the one case the second dictionary was loaded for. No dictionary at all accepts everything: a failed fetch must not underline the whole document. - [x] **The tokenizer had to become a property of the checker, not a constant.** `[A-Za-z]` cuts "coração" into "cora" and "o", both short enough that `isCheckable` discards them — so the word was silently never checked *and* a right-click would have offered a definition of "cora". The wide alphabet is `[A-Za-zÀ-ÖØ-öø-ÿ]`, deliberately skipping × and ÷, which hide inside that Latin-1 range. It stays **off** for a writer with no Latin second language: widening it there can only find new words to underline (the *café* and *naïve* she borrows) and no mistake she actually made. `wordAt` takes the same flag, so the underline and every lookup agree. - [x] **Gloss/WordCard both directions** — new `lexicon.Reverse` on the lookup and a `reverse` line on the hover tip. A Latin pair has no script boundary: *data*, *sale*, *comum*, *tarde* and *ali* are real words on both sides, and there is no honest way to look at one in a mixed document and know which was meant. Petal asks both directions and shows whatever answers — no detector, so it cannot be wrong about her writing, and for a learner the collision is the interesting part. The English de-inflection walk is deliberately **not** applied in reverse: `candidates` knows -s/-ed/-ing, and running it over Portuguese would be right by accident and wrong by rule. - [x] Prompts pinned to **European Portuguese, never pt-BR** — already done in Phase 19 (`internal/llm/lang.go` spells it out inside the prompt, with *porquê* carried alongside so the tutor recognises her question). - [x] pt-PT langpack written (`web/src/i18n/packs/pt-PT.ts`), and pt-PT is now a real switch rather than a fallback. Post-Acordo spellings with the European lexicon (*ficheiro*, *ecrã*, *guardar*, *sinónimo*, *académico*, *Iniciar sessão*), *estás a escrever* rather than the gerund, and second-person *tu* — a companion in a private notebook, not a form. A test greps the built pack for Brazilian forms, because that is exactly the error nobody reviewing the diff can see. - [ ] ⚠️ **The pack is NOT reviewed by a pt-PT speaker** — SUGGESTIONS §3's own bar, and the one item here I cannot meet. Flagged at the top of the file and left unchecked deliberately; expect a speaker to change the register before the vocabulary. - [x] Companion tips/cheers/bedtime lines in the pt-PT pack. Not a translation of the zh pack: the bedtime proverbs are Portuguese ones and there is a false-friends tip the Mandarin pair had no use for. The English wit in the bedtime lines is the user's own and is kept word for word across packs. - [x] **Piper pt-PT voice on parodia** ✅ (2026-07-27) — `piper-pt` sidecar, fourth service off the one image. Two things had to change first. (1) **A language stopped being a code change**: the handler knew exactly two, named in the Config struct, so Petal now *discovers* its instances from the environment — English on the unsuffixed `TTS_ENDPOINT`/`TTS_VOICE_EN`, everything else on a `TTS_ENDPOINT_`/`TTS_VOICE_` pair, base tag only (an env var name can't hold pt-PT's hyphen, and one Portuguese model is loaded either way). Half a configuration is dropped rather than routed, so it reads to the client as "use Web Speech" rather than erroring on every tap. fr and es are now a compose service and two `.env` lines. (2) The startup line names the voices it *resolved* (`en=… pt=… zh=…`), the same lesson as the dictionary line. - [x] **`pt_PT-tugão-medium` is the only European voice Piper ships** — the other five `pt_*` models are Brazilian, so the default anyone reaches for is the wrong country: `dictionary-pt`'s trap again, arriving through the catalogue instead of the model. And it does not download: `piper.download_voices` pastes the voice name into the request line, `http.client` encodes that ASCII, and it dies on the *ã* before a byte leaves the container — precisely and only on the voice the pt-PT pair needs. The entrypoint now falls back to fetching the model and its config itself with the path percent-encoded, which is all the downloader was missing. - [x] **Slow replay** (SUGGESTIONS §5e) — `slow: true` on `/api/tts` raises `length_scale` to ~4/3 (≈0.75× pace); Piper stretches durations rather than resampling, so it stays a voice. The pace is **part of the cache key**: without it the slow replay of a word already heard at normal speed is served back at normal speed, which is the one request where the difference is the whole point. 🐢 beside 🔊 on the word card, the selection bubble and the garden flashcard; the Web Speech fallback slows too, so the button means the same thing when Piper is down. - [x] **L1 voice** — the `alsoIn` block speaks in the pair's locale, which the **pack names** (`locale`) rather than anything inferring it from the letters. "comum" is spelled identically in both halves; the component that knows it is rendering her language says so, exactly as the both-directions gloss avoids a detector. - [x] Acceptance ✅ (2026-07-27), with one part that cannot be met from here. **Verified on the box against the real 550 MB `dict.db`** (throwaway DB on :8091, auth off, real Piper sidecars): the pt-PT gloss path (*think* → **pensar**; achar; lembrar — Phase 20's sense-agreement ordering holding on real data, not just the fixture), and **the first real collision lookups** — *data* → "date / Indicação da época…", *comum* → "common; usual", *tarde* → "evening; afternoon", *ali* → "there", while *think*, *computer* and *garden* correctly carry **no** reverse block. Read-aloud: pt-PT/en-US × normal/slow all 200 with the slow clips ~27% longer and five distinct cache entries; zh unchanged; an unconfigured language (fr) still 404s. **zh-pair user sees zero change**: flipped back, the popover is byte-for-byte ECDICT again (gloss, phonetic, no reverse). Her live data untouched throughout — 8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok, `schema_migrations` still at 11. - [ ] ⚠️ **No pt-PT account exists yet.** Both accounts are on the zh pair, so nothing she sees changed today; what shipped is the capacity. The browser half of the pt-PT experience (the 2.66 MB dictionary inflating in a real tab, the wide alphabet, the pills interleaving) is covered by unit tests and by the assets being served — 577 B aff, 2,661,813 B gz over public HTTPS — but not by a human in a browser signed into a pt-PT account. That and the native-speaker review are what Phase 21 still owes. - Tests: `internal/lexicon/dreamdict_test.go` gains a real collision in the fixture (*data*: English facts, Portuguese date) — both readings on a collision, **no** reverse block for an English-only word, the tooltip carrying only the reverse gloss, and the embedded/glossless providers staying silent (a Chinese reading of an English word is worse than none). Frontend: `spellchecker.test.ts` (either-accepts, flag-only-if-both-reject, a Portuguese word never flagged for being unknown to English, no-dictionary-accepts-everything, a dictionary arriving *after* the checker was built, interleaved pills) and `SpellCheck.test.ts` (the narrow alphabet still cutting "coração", the wide one not, CJK never tokenized under either, × and ÷ excluded). The i18n suite now runs its shape assertions over *every* pack — a shape only the first author's pack satisfies is a coincidence, not a shape. - **A bug the test found, not the code review**: `extendedAlphabet` was a value computed when the checker was built while `correct`/`suggest` read live. Her dictionary arrives *after* English, so the underlines would have been right while every lookup was still resolving "cora". It is a getter now. - Verified: go build/vet/test, tsc, vite build, vitest 116/116 clean. The shipped asset loaded in a real nspell (842 ms, 139 MB, pt-PT variants correct both ways). Live smoke on a throwaway DB (:8091): both dictionary files served (577 B aff, 2,661,813 B gz), the gz inflating to 1,039,058 forms with `receção` present, and the zh word lookup unchanged. **Not verified against real data**: this laptop has no `dict.db`, so the reverse-lookup path is exercised by the fixture only — the first real pt-PT collision lookup happens on the VPS. ### Phase 22 — Learning loop + code-first layers ✅ (2026-07-27) — the last phase of the plan Each item independent and small; order within is free (SUGGESTIONS §5–§6). **First two built 2026-07-27** (user: "continue the build plan"; code only, no VPS work — not deployed, and there is no migration to undo, so it is a rebuild whenever the user wants it). **Remaining four built 2026-07-27** (user: "let's finish the last phase of the build plan"). With them the left-hand column of the SUGGESTIONS §6 table is complete: **spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need now works on a box with the tunnel down.** The model adds depth and conversation when it is reachable and holds nothing hostage when it isn't. Carries one migration (`0013_suggestion_source`), so unlike the earlier code-only sessions this is a deploy rather than a rebuild. - [x] **Growth journal** (Q3 settled) ✅ (2026-07-27) — `GET /api/suggestions/growth`, a read-side view of a table Petal already keeps: no new capture, no model call, nothing leaves the box. Three signals, and the work was in deciding which ones are *honest* rather than in computing them. - **Kept** — edits she took on board in the last 30 days, with the 30 before it offered flat beside it. That second number is the whole of the self-comparison rule: there is no target, no average and no other account anywhere in these queries. - **Stuck** — accepted phrasing that now appears in **two or more** of her own documents. One document is not evidence: it is the edit itself, still sitting where it was applied. The second is her reaching for the phrase on her own, which is the only thing the line actually claims. Candidate phrases are filtered through `vocab.PhraseKey`, the *same* definition of "a learnable chunk" the garden plants, so the journal and the garden can never disagree about what counts. - **Faded** — a pattern corrected ≥2× in the earlier window and not since. **Guarded by "has she written lately?"**: without that check, a month away from Petal is reported back to her as progress, which is the one way this feature could lie. Test named for the guard, not the query. - **The dates had to come from her decision, not the model's proposal** — migration `0012_suggestion_resolved_at`. `created_at` is when a checkpoint *offered* an edit; a suggestion offered in April and accepted in June is June's growth. Existing rows backfill to `created_at`, which is exactly the approximation the journal would otherwise have had to make (and is very nearly right — edits are settled minutes after a checkpoint); pending rows keep NULL, because nothing has been decided. Tested against a database rewound to before the column, since that is the only shape the live box will ever present. - **Surface**: a second tab *inside* the garden (🌷 Garden / 🌱 Growth) rather than new chrome — same idea seen twice, the garden as objects and the journal as change over time. A review session hides the tabs: mid-flashcard is no moment to be offered a different page. - **Feeds the companion**, which was the point: on an accept the kitten prefers a line that is true of *her* ("you're using 'make a decision' on your own now! 🌱") over one that would fit anybody — half the time, so it stays a surprise, once per line per session, so personal praise never becomes wallpaper. The journal is fetched on the first accept and **never awaited**: the cheer goes out now, personal or not. - Copy is bound by the same two rules as the SQL, and a test greps both packs for *error/mistake/wrong/streak/average/erro/errada/错误* — the framing is the feature, and it's the part a future edit would quietly undo. - [x] **Plant accepted collocations** in the vocabulary garden as phrase cards ✅ (2026-07-27) — scheduler untouched, as predicted: `vocab.Plant` writes the same row `capture` does, so a three-word chunk climbs the SM-2-lite ladder exactly like a looked-up word, blossoms with `reps`, and cloze-blanks in review. The garden now holds both halves of learning — what she sought out, and what she was gently given. - **Only collocations.** The other families fix *this* sentence (a comma, "their"→"there"); a collocation is the one that hands over something reusable, and reusable is the only thing worth reviewing in a week. - **What isn't a chunk**: `PhraseKey` rejects single words (that's word choice, and lookup already gardens it), anything over 6 words or 60 runes (a rewritten sentence wearing a collocation's label makes a miserable flashcard), and digit/symbol-only text. The cap counts **runes** — a byte cap would drop Portuguese chunks for being accented. - **The example is the *corrected* sentence.** The stored `content_text` is still the pre-accept draft (the client applies the replacement in the editor), so the sentence around `original` is extracted and swapped server-side. Otherwise the flashcard would quiz her on the phrasing she had just left behind. - **ON CONFLICT DO NOTHING**, unlike capture's refresh-the-context upsert. Accepting the same collocation again months later is evidence the chunk is still being learned; the worst possible response is to overwrite its first context and reset a schedule it has been climbing. Test asserts the card keeps `interval_days = 7`. - **Best-effort, always.** Planting runs after the status write and swallows its own errors: accepting an edit is what she asked for, and it must not fail — or feel slower — because a flashcard couldn't be made. A rewrite too long to plant still returns 204. - Verified live on a throwaway DB (:8099, no dictionary, no LLM): accept → card `make a decision` with example *"I had to make a decision about the job."* bounded to its own sentence, then the journal reporting `kept:1`, `stuck:[{make a decision, docs:2}]` once the phrase appeared in a second document, and a seeded two-month-old pattern surfacing under `faded`. - Tests: `internal/vocab/plant_test.go` (PhraseKey table incl. rune-vs-byte, plant-once, unplantable is a silent no-op), `internal/suggestions/plant_test.go` (corrected-sentence example, only-collocations, idempotent-and-never-resets, sentence-rewrite skipped without failing the accept), `internal/suggestions/growth_test.go` (both windows, stuck needs a second document, the wrote-recently guard, still-happening excluded, and a per-writer isolation test seeding bob), `internal/db/db_test.go` (the backfill). Frontend: `journalCheers.test.ts` (silent before the fetch lands, once per line, one fetch however often warmed, silent on failure, pack resolved at call time) plus journal assertions in `i18n.test.ts`. - Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 131/131. - ✅ **Deployed 2026-07-27** with the rest of Phase 22 (migrations 0012+0013). ⚠️ Still not seen in a browser, and the pt-PT journal copy is part of the pack a native speaker has not reviewed. - [x] **Daily writing invitation** from the companion ✅ (2026-07-27) — offered to a *blank page* about a minute into a session, at most once a day. Petal always has a document open, so "a session that starts with no doc open" became "the page in front of her is still empty", which is the state the invitation was actually for. - **The stored value is a date, and that is the entire mechanism.** No count, no run of days, nothing that degrades with absence: coming back after a month reads exactly like coming back tomorrow. That is the one property this feature could lose silently, so the rule lives in its own file (`invitation.ts`) rather than inside the heartbeat, and the test names it — *treats a month away the same as a day away*. - **Both answers spend the day's invitation.** Being asked again after "not today" would make no into a negotiation. Declining costs a sleepy `好吧,我继续睡 😴` and nothing else; letting the bubble time out is a third way of saying no. - **Accepting titles the blank page with the prompt**, so the question she agreed to answer is still in front of her once the bubble has gone. - Copy is bound the way the journal's is: a test greps both packs for *streak / in a row / every day / missed / 连续 / 打卡 / todos os dias* — the framing is the feature. - [x] **False-friend list** per pair ✅ (2026-07-27) — ~19 curated en↔pt entries in the pt-PT pack; **zh has none, and that is the honest answer**, not an unwritten one: the trap needs a shared script to spring. - **Never a correction.** Two surfaces, both heads-up only: a lavender block at the top of the WordCard (above the definition — it is the thing she would not think to check), and at most one companion note per pass. No `fix`, so it never becomes a card. *Actually* may well be the word she meant; the flag says what the English one means and stops. A test greps the entries for *wrong / mistake / errado* — this is the mistake that makes a learner feel foolish, and the tone is the whole point. - [x] **Embedded miscollocation list** ✅ (2026-07-27) — the do/make, say/tell, heavy-rain families as ten curated patterns, and **they file as `collocation`, not as a new family**. Same rail, same warm phrasing, and — the reason it matters — an accepted chunk plants in the vocabulary garden exactly as the coach's would. The writer never learns which engine spoke. - **That forced a schema change**: `type` had been doubling as the answer to "which engine found this" (`mechanics` meant offline). The moment an offline rule proposes a collocation that breaks — so migration `0013_suggestion_source` adds `source` (llm | local) and every pass now scopes its DELETE by engine. Without it the coach silently wiped every offline chunk on the page, and the offline pass left the coach's rows to accumulate. Both directions are tested; existing rows backfill by type, and a pre-0013 collocation row is correctly claimed as the coach's, since the offline list did not exist yet. - **The span tiebreak moved with it**: an exact offline card beats an overlapping LLM one by *source*, not by type — an offline miscollocation is as exact as an offline comma. - Replacements agree with the tense she wrote in (`did a mistake` → `made a mistake`), and a rule never proposes a phrase identical to what she already wrote. - [x] **Grammar lite** rule-pack ✅ (2026-07-27) — the deterministic `mechanics` family already *was* the fourth family (Phase 8), so this was the rule pack it had been waiting for rather than new plumbing: preposition pairs, doubled comparatives, `people is`, and per-pair L1 interference. All client-side, instant, no debounce, no rate limit, alive on a VPN-down box. - **Sourcing decision (SUGGESTIONS Q6): hand-curated, not mined.** LanguageTool's corpus is broad because it aims at recall; this pack aims at the opposite. Every entry here is a pairing that is wrong in essentially *all* contexts, and the ones that are only usually wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is tested in both directions, and the guard cases are the correct English sitting next to the mistake. - **L1 rules are gated by pair, and the gating is what lets them be confident**: a near-certainty for a Portuguese speaker is only a guess for anybody else. pt/fr/es get *ter 30 anos* → "I am 30 years old" (subject and tense carried into the correction), "I am agree", "since three years" → "for three years". zh gets 很喜欢 → "very like", 开灯 → "open the light", and 虽然…但是 → "although … but". - **The zh rules the plan named and this pack does not implement**: dropped articles and he/she slips. Neither is detectable from text alone — "She said he was late" is a perfect sentence whichever pronoun was meant — and flagging them would mean correcting correct writing, which is the one thing a rule pack running on every keystroke must not do. Said in a comment where the rules are, not only here. - Verified live on a throwaway DB (:8099, no dictionary, **no LLM configured at all**): an offline `did a mistake` → card → accept → garden card *made a mistake* with the example bounded to its own corrected sentence, and the journal reporting `kept:1`. - Tests: `grammarLite.test.ts` (30, every rule in both directions), `invitation.test.ts` (7), `offline_test.go` (the six engine-split cases), `db_test.go` (the 0013 backfill), plus false-friend shape/tone guards in `i18n.test.ts`. - ✅ **Deployed 2026-07-27.** ⚠️ Still not seen in a browser; the pt-PT copy added here is part of the pack a native speaker has not reviewed. ### Phase 23 — Choosing her own pair (2026-07-27) Raised by the user, not by the plan: *"I see no way to change my language in the mobile UI."* She was right, and the gap was total — `users.pair_lang` had been readable since Phase 19 and writable by nobody. `/api/me` was GET-only, `Upsert` deliberately skips the column, and no screen anywhere offered the choice. Phases 19–21 built the machinery for a second pair and then left the switch off the wall, which is why ⚠️ *"no pt-PT account exists yet"* stood unresolved through two phases: **nothing could create one.** - [x] **`PATCH /api/me`** (`auth.UpdateMeHandler`) — answers with the whole updated user rather than 204, so the client re-reads the pair from the server instead of assuming its own request took. One write reaches everything: the langpack, the Hunspell dictionary, the Piper voice, the lexicon provider and the prompt language all read `users.pair_lang` at use time. - [x] **The server refuses a pair it has no copy for.** `auth.shippedPairs` is deliberately *not* `internal/llm`'s language list — that one names every pair the **prompts** can talk about (cheap to add; fr and es have been in it since Phase 19), this one names every pair Petal can **render itself in**, which needs a langpack. Storing `fr` today would strand her on Chinese copy with no way back except a lucky guess at a button she cannot read. - [x] **The picker lives in the sidebar footer**, beside her name and the way out — because the sidebar *is* the mobile drawer, and it is the only chrome that is always one tap away on a phone. The status bar was the other candidate and is wrong: it exists only while a document is open, which is exactly the wrong moment to discover the app is speaking a language you can't read. - [x] **Each language names itself** — 中文, Português, and nothing else. The one place in Petal where bilingual copy would actively get in the way: a writer who has landed on the wrong pair cannot read "Portuguese" written in Chinese. The `aria-label` carries the English for a screen reader, which has no such problem. - [x] **No reload.** The pack was already a subscription (Phase 19), and `useSpellChecker` already reloads on `pack.code` while read-aloud already reads `pack().locale` — so the 2.66 MB pt-PT dictionary inflates, the wide alphabet turns on and the voice changes on the tap. Nothing here needed new plumbing; the switch is the only part that was missing. - [x] Tests: `internal/auth/pairlang_test.go` (round-trip and back again — a writer who tries a pair must be able to return; every unshipped code refused with the column unmoved; 400 vs 401 split so a lapsed session still becomes the sign-in overlay). `i18n.test.ts` asserts `shippedPacks()` offers exactly the pairs that have copy, and that every code it offers actually resolves. - Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 173/173. ⚠️ **Not seen in a browser** — no Chrome extension on this laptop, and the picker's appearance in a real mobile drawer is exactly the part unit tests cannot cover. **Deployed 2026-07-27** (`1f4ca47`), and it carried Phase 22 with it — the two could not be separated in the tree, so the user chose to ship both. Pre-deploy snapshot `data/backups/petal-pre-phase22-20260727T220410Z.db` (VACUUM INTO against the live app). On the box: migrations 0012 and 0013 applied, `source` backfilled **llm=100 / local=3** — the three are her pre-existing `mechanics` rows, claimed by type exactly as the migration intended. Her writing came through untouched: 9 documents, 33 versions, 103 suggestions, `integrity_check` ok. All four containers healthy, read-aloud still resolving `en/pt/zh`, dictionary still open on all five languages. `PATCH /api/me` answers **401 without a session** rather than 405, which is the only half of it a signed-out probe can prove — the route is mounted and behind auth. - **All three accounts are still on `zh`, deliberately.** Flipping her pair is hers to do now that the button exists, and doing it for her from a shell is precisely the change this phase was built to stop needing. ### Phase 24 — the fr pair ✅ (2026-07-27, code half) — and what the plan got wrong about it Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Then, this session: **French end to end, code only, deploy its own step** — es follows as a repeat of a proven groove rather than two half-finished pairs. Phase 21 was supposed to be the groove and mostly was; the exception is item 3, which the plan had recorded as solved and was not. 1. [x] **The langpack** (`web/src/i18n/packs/fr.ts`, 470 lines). Metropolitan French, tutoiement, *se connecter* rather than *login* — and the regional decision lives **entirely here**, unlike pt: Debian's `fr_FR`, `fr_CA`, `fr_BE`, `fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one word list, so there is no dictionary to get wrong and nothing but the copy to get right. A vitest greps the built pack for *courriel*, *clavarder*, *magasiner* and *fin de semaine*, exactly as the pt-PT one greps for Brazilian forms — the error nobody reviewing the diff can see. The pack punctuates the way French does (« guillemets », a space before ! ? : ;), which is *also* the habit `prose.spaceBeforePunct` warns her about in her English: the copy demonstrates the rule its own prose note tells her not to carry across. An ordinary space, not U+202F — a narrow no-break space is invisible in a diff and the next pack author would strip it by accident. 2. [~] **Reviewed by a quorum of models, not by a native speaker** (2026-07-27, user: "perhaps for now, we could leverage multiple LLMs to act as reviewers… accept the responses that have the most alignment amongst them" — explicitly an interim measure). Four models read each pack independently as native speakers, blind to one another, returning verbatim substrings so agreement could be counted mechanically rather than judged. **Threshold: a finding is applied only if ≥2 of 4 reached it on their own.** Eight reviews, 32 findings, 12 above threshold, 10 applied. - **fr, applied**: `Fatiguée, on écrit mal` (**4/4**), `Clique droit sur un mot anglais` → *Fais un clic droit* (3/4), `je me suis emmêlée` (3/4), `touche pour changer` → *appuie* (2/4), `laisse une espace` → *un espace* (2/4). - **pt-PT, applied**: `adjectivos` → *adjetivos* (3/4), `actualmente` → *atualmente* (3/4), `decepção` → *deceção* (2/4), `Cão abanão` → *Cão abana-rabo* (2/4), `Ouves? Pois não…` → *Pois não ouves…* (2/4). - **Where the reviewers agreed a line was wrong but not on the fix**, the wording is mine and the reasoning is written down rather than averaged: `Fatiguée, on écrit mal` split 2–2 between keeping *on* and switching to *tu*, and *both* camps' stated objection (feminine agreement with impersonal *on*) survives the *on* wording — so **Quand tu es fatiguée, tu écris mal** is the only candidate that answers every reviewer, and it matches the pack's own tutoiement. `je me suis emmêlée` drew three different fixes; *emmêlé les pinceaux* is the actual idiom and makes the participle invariable, which also settles the fourth reviewer's point that the companion is a *chat* and therefore masculine. - **The finding that justifies the exercise**: pt-PT was carrying **pre-Acordo spellings** — *adjectivos*, *actualmente* — in direct contradiction of its own header, plus Brazilian *decepção*. Phase 21's vitest greps the pack for Brazilian *vocabulary* and never checked the pack against its own stated *spelling policy*, so this had been shipped and reviewed and was still invisible. The grep now covers nine pre-Acordo forms, and was confirmed to fail on the old text before being kept. - **Below threshold, deliberately not applied** (1/4 each): *very* also modifies adverbs, so `veryBeforeVerb` is incomplete rather than false — and the rule that renders it only runs for the zh pair anyway; `aide à lire`; `et toi aussi tu devrais`; `Cansada não se escreve bem`; `Já vais em`; `está toda a gente a dormir`; the `longSentence` infinitive; `breaks[1]`. - ⚠️ **Still not a native speaker.** A quorum of models agreeing is agreement, not authority: it caught a *clique droit* that is not French and an adjective disagreeing with *on*; it cannot catch a line that is correct and lifeless. The ⚠️ at the top of both packs now says which review happened rather than none. 3. [x] **Hunspell dictionary — and "the pt-PT script generalizes" was wrong.** It handled single-character flags and plain PFX/SFX and *stopped* on anything else, which was the right call and not a generalization: `fr.aff` uses four of the things it stopped on, and every one changes which words are accepted. **`FLAG long`** — French flags are two characters (`S.`, `L'`, `Um`), so `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm; this is the one that fails silently. **Continuation flags** — French really does affix an affixed form (`PFX Um 0 0/S.`), which pt-PT dropped after asserting it was safe to. **`NEEDAFFIX`** on 68,075 of 84,140 stems, the bare form arriving through a zero-append rule. **`FULLSTRIP`**. `CIRCUMFIX` and `FORBIDDENWORD` are declared-but-unused and the script now *asserts* that rather than assuming it. Renamed `scripts/build_hunspell_dictionary.py` with a per-language profile; **the pt-PT rebuild is byte-identical to the shipped asset**, which is what says the generalization did not change the pair that already worked. - **Which of three, not which of six.** fr is packaged by how it treats the 1990 reform: `-classical`, `-revised`, `-comprehensive`. Petal ships **comprehensive**, because Petal never corrects her French — the only thing this dictionary can do is underline something, and *coût* and *cout* are both correct French taught in different decades. The MUST_ACCEPT list *proves* which package was used: classical rejects `cout`, revised rejects `coût`, only comprehensive accepts both. - **Elision is the size decision, and it moved out of the dictionary.** Both halves were built and measured: keeping the elided forms is **3,159,832 forms / 8.25 MB gzipped**; dropping them is **473,326 / 1.19 MB**. They are not new words — thirteen clitics glued to words already in the list — but the tokenizer keeps internal apostrophes, so `l'arbre` really does arrive whole and really would have been underlined. `withElision` splits at a *known* clitic and checks the remainder: `l'arbre` costs one extra hash probe instead of seven megabytes, `zzz'arbre` is still flagged because zzz is not a word French elides, and `l'zzzz` is still flagged because the remainder must itself be a word. Stems carrying their own apostrophe (`aujourd'hui`, `quelqu'un`, `presqu'île`) are kept verbatim and match directly; `entr'aide` is absent for the same reason Dicollecte omits it. - Loaded in a real nspell: **369 ms, 74 MB** for 473,326 forms — cheaper than pt-PT's 842 ms / 139 MB, on a bigger language. Suggestions do the thing an ESL writer needs most: `ecrire` → *écrire*, `francais` → *français*. 4. [x] **Piper voice** — `piper-fr`, a fourth sidecar off the same image, plus `TTS_ENDPOINT_FR` and `TTS_VOICE_FR`. No Go at all, which is Phase 21's discovery holding: a language is configuration now. The exact opposite of pt's trap — every `fr_*` voice in the catalogue is `fr_FR`, so there is no wrong country to default to, and `fr_FR-siwis-medium` is chosen to match the register of the other three rather than to avoid anything. ASCII, so the percent-encoded download fallback `tugão` needed never fires. 5. [x] **Lexicon coverage** — already measured, and better than the pair that shipped: the Phase 20 rebuild put fr at **63.1%** of the 2,000 commonest English words against pt-PT's 62.1%. Both directions answer with no code change; `lexicon.Set.For` routes every non-Chinese pair to DreamDict already. - Free, because Phase 19 and 22 did them: `internal/llm/lang.go` already carries fr, `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es, and the sidebar picker derives itself from the shipped packs — so the switch offering **Français** is not a line of new UI. - Tests: `i18n.test.ts` (the Québécois grep; French spacing and guillemets kept in the copy; agreement in the interpolated lines — *1 fleur* / *3 fleurs*, *1 chose retenue* / *5 choses retenues*; the fr false friends *attend* and *pass*, which pt-PT has no use for). `spellchecker.test.ts` gains seven elision cases including both directions of the flag-it/don't rule. `pairlang_test.go` now round-trips **every** shipped pair rather than the first one — the Go allowlist and the frontend's PACKS are two copies of one fact — and its unshipped examples moved to `es`/`fr-CA`. `config_test.go` discovers a fourth voice. - Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 190/190 (33 in the i18n suite after the review pass). **Not seen in a browser** — no Chrome extension on this laptop; the 1.19 MB dictionary inflating in a real tab and the picker's third entry in a real mobile drawer are what unit tests cannot cover. - **Not deployed.** No migration, so it is a rebuild whenever the user wants it; the Piper sidecar wants `docker compose up -d piper-fr` and a voice download on the box. ### Phase 25 ✅ (2026-07-28, code half) — the es pair, and the first regional decision made *for* a variety rather than against one Scope agreed with the user 2026-07-28: **Latin American neutral, quorum review, code only** — deploy is its own step, as with fr. The plan predicted this phase would be Phase 24 minus the surprises, and on the mechanical items it was exactly right. The surprise was the one thing the plan had already decided. 1. [x] **Latin American, not peninsular — and this is the first pack whose regional question had no default at all.** pt-PT's decision was forced by a packaging trap and fr's turned out not to exist; es had a genuine choice, and the user made it: *tú*, **ustedes**, no *vosotros*, and the pan-American half of every vocabulary split (*computadora*, *celular*, *carro*, *jugo*, *papa*, *departamento*, *lentes*, *boleto*). A vitest greps the pack for the peninsular twins the way fr is greped for québécismes — including **`coger`**, which is not merely regional but obscene through most of Latin America and is the one word a warm companion must never produce by accident. 2. [x] **The dictionary — and the phase's real mistake, caught by the user asking "should we pick a different Spanish dictionary?"** The first build of this pair shipped Debian's `hunspell-es` and this entry claimed, in some detail, that it was pan-Hispanic. It is not. `hunspell-es` installs twenty country codes all symlinked to one file, which reads like "one dictionary for all of Spanish" — but RLA (`sbosio/rla-es`) publishes **twenty-four** dictionaries per release: one per country, **plus a generic `es` that is the union of all of them**, and Debian ships the **peninsular `es_ES`** under the collapsed name. Petal now ships the generic build, taken from the upstream v2.9 release rather than from apt. | build | forms | voseo | vosotros | |---|---|---|---| | Debian `hunspell-es` (first shipped) | 659,085 | ✗ | ✓ | | upstream `es_ES` | 659,018 | ✗ | ✓ | | upstream `es_MX` | 554,923 | ✗ | ✗ | | upstream `es_AR` | 669,605 | ✓ | ✓ | | **upstream `es` (generic, shipped)** | **717,640** | **✓** | **✓** | - **The 58,622-form gap is essentially the voseo paradigm** — *vení*, *tenés*, *querés*, *sabés*, *andá* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America. Under the first build, a writer using it would have had her own verbs underlined as misspellings, and this document would have told her that was a considered decision. - **Why the wrong file passed a MUST_ACCEPT list designed to catch exactly this.** The profile asserted the pan-Hispanic *lexicon* — *computadora* and *ordenador*, *papa* and *patata* — and **every RLA variant carries the full pan-Hispanic vocabulary**; only the verb paradigms are localised. So the assertion was satisfiable by all twenty-four builds and discriminated nothing. The `REP` table was cited here as a second witness (`ll`↔`y`, `ás`↔`az` "are not Castilian confusions") and that was wrong too: it is shared by every build. **Two witnesses, both non-witnesses, agreeing.** - **The profile now demands the three things that do discriminate**, and each was verified to fail on the build it targets: **voseo** rejects `es_ES` and Debian's package; **vosotros** (`tenéis`, `escribid`) rejects `es_MX`; and **another region's everyday words** (`arepa`, `chévere`, `bacán`) reject `es_AR`, which has both paradigms and would otherwise have passed. All four neighbouring builds were run through it and confirmed refused. - **Accepting every variety is the same decision fr made**, arrived at from the other side: Petal ships `hunspell-fr-comprehensive` because *coût* and *cout* are both correct French, and it ships generic Spanish because *tienes* and *tenés* are both correct Spanish. The dictionary's only power is to underline, so it should hold the union; the *copy* chooses a register, because speaking requires one. The two pulling in opposite directions is not a contradiction — it is the difference between Petal speaking and Petal listening. - The expander needed **no changes at all**: `FLAG UTF-8` (already handled), and none of the directives it refuses — no compounding, no NEEDAFFIX, no FULLSTRIP, no CIRCUMFIX. **717,640 forms, 1.74 MB gzipped**, and in a real nspell **762 ms / 97 MB** (pt-PT: 842 ms / 139 MB). `jardin` → *jardín*, `corazon` → *corazón*, and `vení`/`tenés`/`andá` verified accepted in the shipped asset itself. - **Both existing dictionaries were rebuilt from their own upstream debs and are byte-identical to the shipped assets** — the check that makes "the shared script still means what it meant" a claim rather than a hope. 3. [~] **Reviewed by a quorum of four models, not by a native speaker** — the same interim measure and the same ≥2-of-4 threshold as Phase 24, on four *different* models reading blind and returning verbatim substrings so agreement could be counted mechanically. 27 findings, **5 above threshold, all applied**: - `el idioma no se movió` → **No se pudo cambiar el idioma — sigue igual** (3/4; a language does not "move", and the line still has to say that nothing changed). - `me hice bolas` → **me enredé** (3/4) — narrowly Mexican slang in a pack whose entire premise is pan-American neutrality. The reviewers caught the copy contradicting its own header. - `traes en repetición` → **no has parado de escuchar** (2/4), a calque of "on repeat". - `restoring: 'Restoring…'` → **Restaurando… · Restoring…** (2/4), the one user-facing string left untranslated. - `Quien duerme, cena.` → **Dormir es el mejor remedio.** (2/4). **This is the finding worth keeping**, because it caught me breaking the packs' own rule: that line was fr's *Qui dort dîne* calqued into Spanish, English gloss and all — precisely the "a pack is not a translation of another pack" the fr header states. Reviewers split on the replacement, so per Phase 24's rule the wording is mine and the reasoning is here rather than averaged: a real, pan-Hispanic saying with its own English gloss, and not a third proverb about haste beside the two already there. - **One below-threshold finding applied anyway, on stated grounds**: `Qué linda elección de palabra` opens an exclamative with no `¡` (1/4). It is not a matter of taste but a mechanical orthography rule the pack's own header commits to, and the reason only one reviewer saw it is that a missing *opening* mark has no closing `!` to look wrong against. **So it became an assertion instead of a judgment** — the suite now rejects any Line opening with `Qué/Cómo/Cuánto` without `¡`, plus the general rule that a native half closing `?`/`!` must open one. The lesson is Phase 24's, one level up: what a review found once, a test should find every time. - **Below threshold, deliberately not applied** (1/4 each): the fronted `Cansada, escribes mal` (the fr pack's equivalent line *was* changed on a 4/4, but this one drew one vote and reads fine); `precísalo`; `me regreso a dormir`; `Perro meneacola`; `¿Tienes una duda?` → *alguna*; `A quien madruga, Dios lo ayuda` → *le* (both are current, and *lo* is the American one); the `constipada` gloss; `Hace rato`; `Longitud promedio`; `¡Sigamos!`; and three about the file's *structure* (`daysAgo` using `n > 1`, `helperRestingEn` ending in Spanish, `matchCase` putting English first) which are **shared with the fr pack by design** and are not es's to change alone. - **A finding about the other packs, surfaced here**: `restoring` is untranslated in **fr and pt-PT too**. Only es was fixed, because changing shipped copy is not this phase's business — but it is now written down instead of re-discovered. - ⚠️ **Still not a native speaker.** Four models agreeing is agreement, not authority. 4. [x] **Piper voice — and the plan's own choice was the trap.** Phase 25 was written to use `es_ES-davefx-medium`; with the copy Latin American, that is the pt-PT mistake exactly, the wrong country arriving through the obvious default. Piper ships **nine** Spanish voices and **six are es_ES**; only `es_AR-daniela-high` and the `es_MX` pair are American. **`es_MX-ald-medium`** — Mexican is the neutral broadcast standard, and *medium* matches the register of the other four. No Go at all, again: `piper-es` is a compose service and two `.env` lines, which is Phase 21's discovery holding for the third pair running. 5. [x] **Lexicon: nothing to do, and it is the best-covered pair Petal has** — es reaches **68.6%** of the 2,000 commonest English words, against fr's 63.1%, pt-PT's 62.1% and zh's 53.2%. `lexicon.Set.For` already routes every non-Chinese pair to DreamDict. - Free, because Phases 19–24 did them: `internal/llm/lang.go` has carried es since Phase 19, `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+**es**, and the sidebar picker derives itself from the shipped packs — so offering **Español** is not a line of new UI. - Tests: `i18n.test.ts` (the peninsular grep incl. `coger`; the inverted-punctuation rule in both directions; `flor`/`flores`, which takes *-es* and is the agreement a translator gets wrong; *1 pétalo* / *2 pétalos*; the es false friends, asserted to be the **longest** list of the four because Spanish shares the most Latin with English — led by **`embarrassed`**, the false friend most likely to be said out loud to a room). `pairlang_test.go` round-trips all four pairs, and its unshipped examples moved to **`es-ES`** — the near-miss that now matters as much as `pt-BR`, since a peninsular code must not be quietly served American copy and a Mexican voice. `config_test.go` discovers a fifth voice. - Verified: go build/vet, `go test ./...` clean, tsc, vite build, **vitest 251/251**. ⚠️ **Not seen in a browser** — no Chrome extension on this laptop; the 1.59 MB dictionary inflating in a real tab and the picker's fourth entry in a real mobile drawer are what unit tests cannot cover. - **Not deployed.** No migration, so it is a rebuild whenever the user wants it; `piper-es` wants `docker compose up -d piper-es` and a voice download on the box. **No es account exists**, and all three accounts are still on `zh` — flipping a pair is hers to do from the picker. ### Phase 26 — the zh pair's other direction (2026-07-28, code half) — segmentation, and a rule pack that mostly says no Scope agreed with the user 2026-07-28: **a `direction` column, segmentation + hover pinyin/gloss, 错别字 detection, IME guards** — code only, and this one carries a migration, so deploy is its own step. SUGGESTIONS §4 called this "its own phase with its own spec" and "Petal's next big product bet"; it is also the first phase whose user is the *other* writer — the one learning Chinese rather than the one learning English. 1. [x] **`users.direction` (migration `0016`), and why it is a column rather than a pair code.** `pair_lang` has always answered "which two languages" and every surface built on it quietly assumed the answer to a second question nobody asked: that **English is the language being learned**. That assumption is load-bearing in a dozen places — CJK is deliberately never tokenized, never spell-checked, never glossed; the prompts explain English in her language; the garden captures English words. All correct for a Mandarin native practising English, all backwards for an English native practising Mandarin. A second pair code (`zh-learner`) was cheaper and the wrong shape: it makes two directions of one pair look like two unrelated languages to every query, and it would have to be repeated for fr, es and pt-PT before any of them could turn around. The backfill is the DEFAULT itself, and it is right rather than merely convenient — all three accounts today really are Mandarin natives writing English. - **`PATCH /api/me` validates the two fields as one decision.** Both are optional and each defaults to what the account has, which is what makes the picker able to send one without knowing the other — and it is exactly that convenience the endpoint has to protect against: a client sending only `pair_lang: "fr"` while the account sits on `learning_pair` is asking for French-with-segmentation, a state neither field names on its own. **Refused, not silently downgraded**: a downgrade leaves the writer looking at an editor that behaves like the one she just tried to leave, with nothing to read as an explanation. - **`auth.learnerPairs` is a third list, and deliberately not either of the two that exist.** `internal/llm`'s languages name every pair the *prompts* can discuss; `shippedPairs` names every pair Petal can *render itself in* (needs a langpack); this one names every pair Petal can be *learned toward*, which needs a word list and a dictionary reading out of that language. zh has both; fr, es and pt-PT have neither, and their failure mode is worse than a missing pack — a missing pack shows unreadable copy, a missing word list shows an editor that silently does nothing when you hover. - An empty `PATCH` body used to be a 400 and is now a 200 that changes nothing. That is a real contract change and it is the price of optional fields; it has its own test saying so. 2. [x] **Two assets, split so their coverage decisions come out opposite** (`scripts/build_cedict.py`, CC-CEDICT + jieba's `dict.txt`). Neither source has both halves: CC-CEDICT has headwords, pinyin and senses and *no frequencies*; jieba has 349k headwords with frequencies and *no definitions*. Segmentation needs the frequencies, because the algorithm is a shortest-path walk over log-probabilities and not longest-match. - **The browser gets the word list** (`words.txt.gz`, 188,522 words, **0.97 MB gzipped**) because segmentation runs on hover and a round-trip per hover is not a hover. **The server holds the whole dictionary** (`hanzi.json.gz`, 113,637 entries, **3.12 MB gzipped**, its own `sync.Once` so only a learner account pays for it). - **The client gate is a size decision and was measured as one.** Segmentation by the full 381,886-word union and by a frequency-gated list is **identical** on ordinary learner prose, including the textbook ambiguities (研究生命的起源, 乒乓球拍卖完了, 南京市长江大桥) — the long tail is rare proper nouns, and a rare word loses to two common ones every time. So the gate sits at freq ≥ 5, with **every CC-CEDICT headword unioned back in** so the segmenter can always see a word the server can explain. - **The dictionary gate is nothing at all, for the opposite reason.** The es phase settled that a *spelling* dictionary holds the union of every variety because its only power is to underline. This asset's only power is to **explain**, and the word a learner stops on is precisely the one they do not know — which is to say, the rare one. Trimming it by frequency would remove exactly the entries it exists for. - **Pinyin is tone-marked here, not at render time**, and `MAX_READINGS = 2` is not arbitrary: 得 is dé "to obtain" *and* de, the complement marker, and a learner who hovers 得 in 说得很好 and is told only "to obtain" has been actively misinformed about the sentence in front of them. The build script asserts all three of 的/地/得 carry their neutral-tone reading. - **Simplified only, said out loud.** Glossing traditional would be nearly free here and useless in the app: nothing would segment it, so nothing would ever ask. 3. [x] **The segmenter** (`web/src/lib/segment.ts`) — shortest-path over log-probabilities, `MAX_WORD_LEN` 6, unknown single characters scored at half an occurrence (positive, so every position has *some* path; below the rarest real word, so it never wins). **369 ms/74 MB was fr's Hunspell cost; this is 232 ms and 14 MB** for a bigger language, because a flat word list needs no affix machinery. `hanziWordAt` (`hanziWord.ts`) resolves it to ProseMirror positions through **the same `mapOffset`** the suggestion, spell and search layers anchor with, plus its inverse. - **Writing the tests found the boundary bug.** A position names a *gap* and a word covers *characters*, so a hover on a boundary was resolving to the word that **ended** there rather than the one that starts — index 6 of 我今天去公园跑步了 is the 跑 under the mouse. The step-back to the left-hand character is kept for exactly one case: the caret at the end of the text, which is where it sits the instant an IME commits a word. - Tested twice over: hand-built dictionaries pin the *algorithm* (they would pass with any word list), and a block at the bottom pins the **shipped asset** on the sentences a rebuild would plausibly break — including the minimal pair 研究生宿舍 / 他们正在研究生物, which is what says the 研究/生命 result was a decision and not a bias against long words. 4. [x] **Hover pinyin + English gloss, as an adapter rather than a second card.** `GET /api/hanzi/{word}` and one new prop each on `GlossTip` (a `lead` line above the meaning) and `WordCard` (`pinyin`, rendered **without** the slashes, because pinyin is not a phonetic transcription and the slashes would say something untrue in the one place a learner is looking for the truth about pronunciation). Everything else is reuse: same anchoring, same garden capture, same 🔊 — the zh pair already speaks Chinese, so reading 公园 aloud needed nothing. - **The character fallback.** The word list is a superset of the dictionary, so a hover really can land on a real word with no headword; Chinese compounds are usually transparent from their parts, which makes the per-character reading a real second answer. Returned in its own field so the surface can say which it is showing — and `hanziPinyin` stays empty in that case on purpose, since 不 is bù alone and bú before a fourth tone, and joining character readings would be inventing a pronunciation. 5. [x] **错别字 — and the pack's most interesting property is what it refuses.** Chinese has no misspellings in the Hunspell sense: every character an IME offers is a real character, correctly formed. The error is a **substituted character inside a correct-looking word**, so this is a rule pack over confusable pairs, filed as the existing `mechanics` family (same rail, same cards, no new colour) and gated on the segmenter's presence — which *is* the direction gate, so a writer practising English can never be told her quoted Chinese is wrong. - **Gate one: the pair must be decidable by the dictionary** — `wrong` absent from the 188k list, `right` present, checked against the shipped asset in the suite rather than asserted in a comment. This is what keeps out errors everyone knows are errors: **自已 for 自己 is among the commonest slips in written Chinese and 自已 is itself a headword**, so the pack does not flag it — exactly as Phase 22's English pack left out `married with`. Same fate for 好象, 倒底, 帐号 and 部份. 24 pairs survived out of ~50 screened. - **Gate two: the characters must not already belong to two different words,** and without it every rule is dangerous. 自己经常 contains 己经. 睡觉的时候 contains 觉的. 不知到底 contains 知到. A substring match corrupts all three — silently, into text still made of real characters. The segmenter already knows the difference: if the two characters land in different tokens and either is a real multi-character word, that is a word boundary; two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like. - **Where the gate costs a real catch, it pays.** 我不知到他在哪里 really is 知到 for 知道 and is left alone, because 不知 is itself a word — while 我不知到底该怎么办 is the same three characters and is correct. The test is named for that trade rather than for the rule. - Server-side, `TestOfflineHanziFindingStaysMechanics` pins the one rule a layer above that would plausibly claim it: `isTranslation` re-labels an edit whose original reads as her language and whose replacement reads as English. 己经 → 已经 looks like the first half of that and nothing like the second, and must stay a tidy-up in her own sentence. 6. [x] **The direction picker names each option in the language of the person who would choose it** — 英文 for the writer who is native in Chinese, "Chinese 中文" for the one who is native in English. The same self-naming principle the pair buttons follow, for the same reason: someone on the wrong side of this switch cannot read the side they are trying to reach. It renders only when the pack carries a `learner` block, which is the frontend's half of `auth.learnerPairs`. - **`@types/node` added as a devDependency**, which is a small thing with a real consequence: vitest can now read the *shipped* assets. Phases 21–25 all verified their dictionaries with throwaway scripts because the suite could not; `segment.test.ts` and `hanzi.test.ts` assert against the real files. - Verified: go build/vet, `go test ./...`, tsc, vite build, **vitest 284/284**; live smoke on a throwaway DB (:8071, LLM pointed at a dead port) — 公园 → gōngyuán, 得 → both readings, 猫书 → the character fallback, the word list served at 965,266 B, `PATCH {"direction":"learning_pair"}` accepted, `{"pair_lang":"fr"}` refused 400 while the account stayed put. The smoke also caught a cosmetic build bug: stripping CC-CEDICT's `CL:` field left "cat (" with an unbalanced paren, now fixed and the asset rebuilt. - ⚠️ **The IME guards were scoped into this phase and were NOT done** — no composition handling existed anywhere in the app. **Built 2026-07-28 as Phase 27 below.** - ⚠️ **Not deployed** (this one carries a migration, so it is a deploy and not a rebuild), **not seen in a browser**, and **no account has ever been in the learner direction** — every claim above about how this feels to use is inference from unit tests. The 错别字 pack has not been read by a native speaker either; unlike the Latin packs it is 24 mechanically-screened pairs rather than prose, which lowers the stakes without removing them. ### Phase 27 — IME composition guards (2026-07-28, code half) — the keystroke that isn't one Phase 26's own outstanding item, and the thing it named as most likely to be wrong the first time anyone types Chinese into Petal for real. Code only; no migration, no server change at all, so it is a rebuild whenever the user wants it. 1. [x] **One tracker, asked by everything** (`web/src/components/Editor/Composition.ts`). A composition is not a keystroke: the pinyin she types goes *into the document* as she types it, a candidate window sits over it, and only on choosing a candidate is the run replaced with hanzi. All three decoration layers recompute from the live document on every change, and recomputing rewrites the DOM around the node the browser is composing in — which is the classic bug that eats half-typed input. - **The layers hold their redraws, they do not skip them.** A rebuild that falls due mid-composition marks itself `stale` and the existing decorations are **mapped through the transaction**, so they travel with the text growing under them; the moment the composition ends, the held rebuild happens. Skipping instead of holding would leave every highlight a character behind for as long as she kept typing — the same wrongness, arriving quietly instead of loudly. - **The question is asked of the state *before* the transaction**, which is what makes the answer independent of plugin ordering: the flag was set by an earlier transaction (compositionstart), not by the one being applied. The end-of-composition transaction is the single exception — it releases rather than holds, or nothing ever would. - **The release is a macrotask late, and that is the one piece of timing that matters.** A custom `handleDOMEvents` handler runs *before* ProseMirror's own, and ProseMirror's `compositionend` queues the composition's final DOM changes as a **microtask**. Ending on `setTimeout(…, 0)` puts the release after both, so the rebuild sees the committed 公园 rather than the *gongyuan* it replaced. If a transaction from that flush lands first, it rebuilds anyway — by then the flag is already false. Both orders land, which is why it is not a race. - `blur` ends the composition too: clicking away mid-candidate doesn't always produce a `compositionend`, and without it the layers would stay held silently until she typed again. - **Clearing is never held.** Closing the Find bar removes decorations rather than adding them, so it goes through immediately. 2. [x] **Input rules needed no guard, and this was checked rather than assumed** — Tiptap's own input-rule plugin returns early while `view.composing`. It matters more here than in an English app: pinyin uses an apostrophe as a syllable separator (`xi'an` → 西安) and `Typography.ts` rewrites every `'` into a curly `’`, so an unguarded rule would corrupt the IME's buffer mid-word. 3. [x] **The save is deliberately *not* gated; the analysis is.** A tablet keyboard can hold one composition open for a whole sentence, and Petal never makes writing wait for anything — so `EditorChange` carries a `composing` flag and the auto-save ignores it. What it gates is the checkpoint, the rule pack and the companion: asking them to read half-typed pinyin can only produce advice about text that is about to stop existing. The editor emits **one more change the moment the composition commits**, so nothing is skipped, only deferred by the length of a word. The flag stays out of the document patch itself — it describes the keyboard, not the document, and the draft a signed-out save stashes should be the document alone. 4. [x] **Enter and Escape belong to the IME while a candidate window is open** (`web/src/lib/ime.ts`), and four places were taking them: the Find bar (Enter steps to the next match, Escape closes), the tag picker (Enter creates the tag — a field whose whole purpose is a name typed in Chinese), **Ask Petal's chat box** (the field she types Mandarin into, where Enter sends), and distraction-free mode's global Escape (pressed to fix a wrong candidate, it popped the sidebar back). `isComposing` plus the older `keyCode === 229`, which some Safari/IME combinations are still the only signal from. - Tests: `Composition.test.ts` (12) drives real plugin state through a real composition — pinyin typed in, candidate committed, composition ended — and asserts each layer holds, maps and then releases: no underline under half-typed pinyin, a suggestion list arriving mid-composition applied on end, matches held at two rather than briefly three, a checker that arrives mid-composition applied when it can be, and the release working on a transaction carrying **no document change at all**, which is what the timer-dispatched end signal usually is. - Verified: go build/vet/test (untouched, but the standing rule), tsc, vite build, **vitest 296/296**. - ⚠️ **Not verified with a real IME.** The tests exercise the layer where the decision is made — plugin state — and cannot exercise the layer where the bug bites, which is a browser deciding whether to abandon a composition after the DOM under it moved. This laptop has no Chrome extension and no IME. What is proven is that the redraws are held and released correctly; what is unproven is the browser's half of it. - **Not deployed.** No migration; a rebuild whenever the user wants it, along with Phases 24–26. ### Later / explicitly not now - Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model - ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it. - ~~Voseo for the es pair~~ **resolved 2026-07-28 before shipping** — the fix was not to generate the paradigm but to stop using Debian's package, which is peninsular. RLA's generic build has it. See Phase 25 item 2. - ~~**IME composition guards** — scoped into Phase 26 and not built~~ **built 2026-07-28, see Phase 27.** What it still owes is a real IME in a real browser. - **`restoring` is untranslated in the fr and pt-PT packs** — surfaced by the es review, fixed only in es. One line each, whenever those packs are next touched. - Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point - Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint ### 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) - [x] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. ✅ (see Phase 12 above) - [x] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. ✅ (see Phase 13 above) - [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above) ## Session log - 2026-07-28: **Phase 27 — the keystroke that isn't one** (user asked to continue the build plan; the plan's own next item was Phase 26's unbuilt IME guards, named there as the likeliest thing to be wrong the first time anyone types Chinese into Petal for real). **The bug is that a composition is not a keystroke**: the pinyin goes into the document as it is typed, a candidate window sits over it, and all three decoration layers recompute from the live document on every change — rewriting the DOM around the node the browser is composing in, which is what eats half-typed input. **The fix is to hold the redraws, not skip them**: a rebuild that falls due mid-composition marks itself stale and its decorations are *mapped through the transaction*, so they travel with the text growing under them and land correct the moment the composition ends. Skipping would have left every highlight a character behind for as long as she kept typing — the same wrongness, arriving quietly. **The composition flag is read from the state *before* the transaction**, which makes it independent of plugin ordering: the flag was set by compositionstart, not by the transaction being applied, and the end transaction is the one deliberate exception or nothing would ever release. **One piece of timing genuinely matters and is written down where it happens**: a custom `handleDOMEvents` handler runs *before* ProseMirror's own, and ProseMirror's compositionend queues the composition's last DOM changes as a microtask — so the release is a macrotask late, and the held rebuild sees the committed 公园 rather than the *gongyuan* it replaced. If the flush's transaction arrives first it rebuilds anyway; both orders land, which is what makes it not a race. **One thing was checked rather than assumed and turned out already handled**: Tiptap's input-rule plugin returns early while composing — which matters more here than in an English app, because pinyin uses an apostrophe as a syllable separator (xi'an → 西安) and Typography rewrites every `'` into a curly `’`. **The save is deliberately not gated and the analysis is**: a tablet keyboard can hold one composition open for a whole sentence, and Petal never makes writing wait for anything, so `EditorChange` carries the flag, auto-save ignores it, and the checkpoint/rule pack/companion wait — with one more change emitted the instant the composition commits, so nothing is skipped, only deferred by the length of a word. **And four places were quietly stealing keys from the IME**: the Find bar (Enter = next match, Escape = close), the tag picker (Enter = create, in a field whose purpose is a name typed in Chinese), Ask Petal's chat box (the field she types Mandarin into, where Enter sends), and distraction-free mode's global Escape — pressed to fix a wrong candidate, it popped the sidebar back. tsc, vite, go build/vet/test, **vitest 296/296** (12 new, driving real plugin state through a real composition: pinyin in, candidate committed, composition ended). ⚠️ **Not verified with a real IME** — the tests exercise the layer where the decision is made and cannot exercise the layer where the bug bites, which is a browser deciding whether to abandon a composition after the DOM under it moved. Held-and-released is proven; the browser's half is not. **Not deployed** (no migration — a rebuild, along with Phases 24–26). - 2026-07-28: **Phase 26 — the zh pair's other direction, and a rule pack whose best feature is what it refuses** (user asked to continue the build plan, then chose a new phase over deploying fr/es; scope chosen with the user: **direction column, segmentation + hover pinyin/gloss, 错别字, IME guards**, code only). SUGGESTIONS §4 had called this its own epic, and the reason turned out to be one sentence: **`pair_lang` had always been answering a second question nobody asked.** It says which two languages; every surface built on it assumed English was the one being *learned*, which is why CJK is deliberately never tokenized, never spell-checked and never glossed. All correct for the writer this app was built for, all backwards for the other one. A `direction` column rather than a `zh-learner` pair code, because the two are genuinely separate questions and the column is what lets fr/es/pt inherit the capacity later. **The phase has three decisions in it and they are all about coverage.** The browser gets a word list and the server keeps the dictionary, and their gates come out *opposite*: the client list is frequency-gated at 5 because segmentation by the full union and by the gated list is **identical** on ordinary prose (measured, including 研究生命的起源 and 乒乓球拍卖完了 — the long tail is rare proper nouns and the max-probability walk never picks one), while the dictionary is gated by **nothing**, because its only power is to *explain* and the word a learner stops on is precisely the rare one. That is the es dictionary decision arrived at from both sides in one phase. **Writing the segmenter tests found the boundary bug**: a position names a gap and a word covers characters, so a hover on a boundary was resolving to the word that ended there rather than the one that starts. **The 错别字 pack is the part worth reading.** Chinese has no misspellings — every character an IME offers is real — so the unit of error is a substituted character inside a correct-looking word, and the pack is 24 confusable pairs held by two mechanical gates. Gate one admits a pair only if the wrong form is *not* a dictionary word and the right form is, which is what makes it refuse **自已 for 自己** — one of the commonest slips in written Chinese, whose wrong form is itself a headword — exactly as Phase 22 refused `married with`. Gate two is the one that matters: **自己经常 contains 己经, 睡觉的时候 contains 觉的, 不知到底 contains 知到**, so a substring match would corrupt correct sentences silently, into text still made of real characters. The segmenter settles it — two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like — and where the gate costs a real catch (我不知到他在哪里 *is* 知到 for 知道, but 不知 is a word) it declines rather than risk the identical-looking correct sentence beside it. **`@types/node` went in as a devDependency and quietly fixes something older**: phases 21–25 each verified their shipped dictionary with a throwaway script because vitest could not read files; the suite now asserts against the real assets. go build/vet/test, tsc, vite, **vitest 284/284**, live smoke on a throwaway DB which itself caught a cosmetic build bug ("cat (" left by stripping CC-CEDICT's CL: field). ⚠️ **The IME guards were in scope and are not done** — no composition handling exists anywhere in the app, and a decoration rebuild mid-composition eating half-typed pinyin is the likeliest thing to be wrong the first time anyone types Chinese into Petal for real. Left untouched rather than half-built, and named here rather than buried. ⚠️ **Not deployed** (it carries a migration), **not seen in a browser**, and **no account has ever been in the learner direction**, so everything above about how it feels to use is inference from tests. - 2026-07-28: **Phase 25 — the es pair, and a plan that had quietly chosen the wrong Spanish** (user asked where Spanish support had gone, then "yes" to starting the phase; scope chosen with the user: **Latin American neutral**, quorum review, code only). The starting point was a misreading worth recording: the plan *reads* as though Spanish shipped, because the DreamDict rebuild, the LLM language entry, the L1 rule gating and the TTS env-discovery are all `[x]` — every piece of groundwork was done and the pair itself had never been built. `shippedPairs` was the honest answer all along: the server had been refusing `es` on purpose. **The regional question was the phase.** pt-PT's was forced by packaging and fr's turned out not to exist; es had a real choice with no default, and once the user chose Latin American, the plan's own two concrete decisions were both wrong. It warned that `hunspell-es` is "packaged per country — check what `es_ES` actually is": it ships twenty country codes and **every one is a symlink to one pan-Hispanic file**, so the trap was not there. And it named **`es_ES-davefx-medium`** for the voice, which *is* the trap — six of Piper's nine Spanish voices are peninsular, so the obvious pick would have read Latin American copy in a Castilian accent, the pt-PT mistake arriving through a different door. `es_MX-ald-medium` instead. **Then the user asked "should we pick a different Spanish dictionary?" and the answer was yes** — the phase had shipped the wrong one and written a confident justification for it. Debian's `hunspell-es` symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA actually publishes twenty-four builds per release, one per country **plus a generic `es` that is the union**, and Debian ships **peninsular `es_ES`**. The 58,622-form difference is essentially **voseo**: under the first build, *vení* and *tenés* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America — were underlined as misspellings, and this document called that a known gap handled on principle. **What makes it worth writing down is that the MUST_ACCEPT list was designed to catch exactly this and could not**: it asserted the pan-Hispanic *vocabulary*, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised — so it was satisfiable by all twenty-four. The `REP` table cited as the corroborating witness (yeísmo, seseo) is likewise shared by every build. Two independent-looking proofs, neither of which could distinguish anything, agreeing with each other. The profile now demands **voseo** (rejects es_ES and Debian), **vosotros** (rejects es_MX) and **another region's everyday words** — *arepa*, *chévere*, *bacán* (rejects es_AR, which has both paradigms and would otherwise pass); all four neighbours were run through it and confirmed refused. Shipping the union is the same call fr made between *coût* and *cout*: the dictionary's only power is to underline, so it holds every variety, while the *copy* picks a register because speaking requires one. 717,640 forms, 1.74 MB gzipped, **762 ms / 97 MB** in a real nspell, and **fr and pt-PT rebuild byte-identical** from their own upstream debs. **The quorum review earned its place twice**: four models, ≥2-of-4, 5 of 27 findings applied — one of which caught the pack's bedtime proverb being *Qui dort dîne* calqued into Spanish, English gloss and all, which is exactly the "a pack is not a translation of another pack" rule the fr header states and I had broken while writing it. And one below-threshold finding (a missing `¡` on an exclamative, seen by 1 of 4 because an absent *opening* mark has no closing `!` to look wrong against) was applied anyway and **turned into an assertion**: the suite now rejects any native line that closes `?`/`!` without opening one. That is Phase 24's lesson one level up — what a review finds once, a test should find every time. go build/vet/test, tsc, vite, **vitest 251/251**. ⚠️ **Not deployed, not seen in a browser, not read by a native speaker, and no es account exists** — all four accounts' worth of Spanish experience is still hypothetical, and the pack says so in its own header. - 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; it handled single-character flags and plain PFX/SFX and stopped on everything else, and `fr.aff` uses four of the things it stopped on. `FLAG long` is the dangerous one: French flags are two characters, so the pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and into `withElision`, which splits at a known clitic and still requires the remainder to be a word (`l'zzzz` stays flagged). Real nspell: 369 ms and 74 MB for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely. - 2026-07-27: **Phase 22 finished — the build plan's last four items, and the LLM stops holding anything hostage** (user: "let's finish the last phase of the build plan"; code only, no VPS work). The four remaining items shared one theme, and it only became visible while building them: **§6's left-hand column is now complete.** Spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need works with the tunnel down. **The plan asked for "grammar lite as a fourth suggestion family", and the fourth family already existed**: Phase 8's deterministic `mechanics` pass was the plumbing, so this was the rule pack it had been waiting for rather than new machinery — preposition pairs, doubled comparatives, `people is`, plus per-pair L1 interference. **Q6 answered by hand-curating rather than mining LanguageTool**: that corpus is broad because it aims at recall, and this pack aims at the exact opposite, so every entry is a pairing wrong in essentially *all* contexts and the ones only *usually* wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is pinned in both directions, the guard case being the correct English next to the mistake. **The L1 rules are gated by pair, and the gating is what earns them their confidence** — *ter 30 anos* → "I am 30 years old" is a near-certainty for a Portuguese writer and only a guess for anyone else. The two zh rules the plan itself named are the ones this pack **refuses** to implement: dropped articles and he/she slips are not detectable from text alone ("She said he was late" is perfect whichever pronoun was meant), and flagging them would mean correcting correct writing. **The miscollocation list forced the session's one real design change.** It had to file as `collocation` rather than as its own family — same rail, same phrasing, and an accepted chunk plants in the garden exactly as the coach's would — but `type` had been quietly doubling as the answer to *which engine found this*, and that breaks the instant an offline rule proposes a collocation. Migration `0013_suggestion_source` splits the two apart: each pass now scopes its DELETE by engine, and the span tiebreak moved with it (an exact offline card beats an overlapping LLM one by source, not by type — an offline miscollocation is as exact as an offline comma). Without it the coach silently wiped every offline chunk on the page and the offline pass left the coach's rows to pile up; both directions are now tested, and a pre-0013 collocation row correctly backfills to the coach, since the offline list did not exist yet. **The daily invitation's whole substance is one stored date** — no count, no run of days, nothing that gets worse for being away, so a month away reads exactly like a day away; it lives in its own file because that is the property this feature would lose silently, and the test is named for it rather than for the query. Both answers spend the day's invitation, because being asked again after "not today" would make no a negotiation. **False friends are the one thing here that never becomes a card**: ~19 curated en↔pt entries, shown as a lavender block above the WordCard's definition and as at most one companion note per pass, with no `fix` anywhere — *actually* may well be the word she meant, and this is the mistake that makes a learner feel foolish rather than merely corrected. zh has none, which is the honest answer and not an unwritten one: the trap needs a shared script. Copy for the invitation and the false friends is greped by tests the same way the journal's is (*streak / in a row / 连续 / todos os dias*; *wrong / mistake / errado*) — the framing is the feature, and it is the part a future edit would undo while meaning well. Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 172/172 (30 new rule cases, 7 invitation, plus false-friend shape/tone guards), and a live throwaway DB on :8099 with **no LLM configured at all** — offline `did a mistake` → card → accept → garden card *made a mistake*, example bounded to its own corrected sentence, journal `kept:1`. ⚠️ **Not deployed and not seen in a browser**, and this one carries a migration, so it is a deploy rather than a rebuild. The pt-PT copy added here joins the pack a native speaker still has not reviewed. - 2026-07-27: **Phase 21 deployed — the pt-PT pair has a voice** (user: "continue the build plan"; scope chosen: deploy Phase 21 to the VPS rather than start Phase 22). The plan's remaining line was "Piper pt-PT voice instance on parodia", and it hid two things. **A language was still a code change**: read-aloud knew exactly two, named in the Config struct as `TTSEndpointZH`/`TTSVoiceZH`, so adding Portuguese meant editing Go to add Portuguese. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair, everything else is `TTS_ENDPOINT_`/`TTS_VOICE_`, base tag only because an env var name cannot hold pt-PT's hyphen — and a language configured by halves is dropped rather than routed, so it reaches the client as "no voice, use Web Speech" instead of erroring on every tap. fr and es now cost a compose service and two `.env` lines. **And the voice itself repeated Phase 21's own lesson in a new place**: `pt_PT-tugão-medium` is the *only* European Portuguese voice in Piper's catalogue — the other five are Brazilian — so, exactly as with `dictionary-pt` packaging VERO, the default anyone reaches for ships the wrong country. Then it wouldn't download at all: `piper.download_voices` pastes the voice name into the HTTP request line and `http.client` encodes that as ASCII, so it dies with `UnicodeEncodeError` on the *ã* before a byte leaves the container — a failure that lands on precisely the one voice this pair needs and on no other. The entrypoint falls back to fetching the model and its config itself with the path percent-encoded, which is all the downloader was missing. **The slow replay** (§5e) went in while there: `slow: true` raises `length_scale` to ~4/3, and the pace is part of the **cache key** — without that, asking to hear slowly a word already heard at speed serves the fast clip back, which is the one request where the difference is the entire point. **The L1 voice asks the pack, not the letters**: a new `locale` field, because "comum" is spelled the same in both halves and a detector would have to guess — the same reason the gloss shows both directions. **Deploying is what finally ran the reverse lookup against real data**, the item the previous session left open because this laptop has no `dict.db`: *data* → "date", *comum* → "common; usual", *tarde* → "evening; afternoon", *ali* → "there", with *think*, *computer* and *garden* correctly silent; and *think* glossing to **pensar** first confirms Phase 20's sense-agreement ordering on the real 550 MB database rather than on a fixture. zh flipped back is byte-for-byte ECDICT again. go build/vet/test, tsc, vitest 125/125, vite; laptop smoke against two fake Pipers, then the real thing on the box. Her data untouched: 8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok, `schema_migrations` still at 11 (no migration in this phase). **Two things Phase 21 still owes, both said plainly**: the pack has not been read by a pt-PT speaker, and no pt-PT account exists — both writers are on the zh pair, so nothing she sees changed today and the browser half of the Portuguese experience has never had a human in front of it. - 2026-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS. - 2026-07-27: **dict.db rebuilt with Spanish, and a log line caught lying** (user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout held ~490 lines of uncommitted work; rather than pull over it, comparing file contents showed an earlier draft of the regional-variant work already committed upstream — nothing unique, but not mine to discard, so it was left alone and the rebuild ran from a clean clone pushed over from the laptop (millenia has no GitHub SSH). Import took 6m15s and added **es: 102,971 words**, leaving en/fr/pt-PT/zh byte-identical — the check that distinguishes "added a language" from "quietly changed everything". Coverage measured before shipping: **es 68.6%**, the best of the four; **zh re-measured at 53.2%**, so the ECDICT decision stands on fresh evidence rather than on the earlier number. Shipped direct millenia→parodia over headscale, hashed both ends, kept the April file for rollback. **The rebuild's real find was in Petal, not DreamDict**: the startup line reported `dictionary.Langs()`, a compile-time constant of *supported* languages, so it had been printing a cheerful `[en fr pt-PT es zh]` over a database with no Spanish in it — the exact failure it existed to catch, reported as success, and something I had already claimed as proof the deploy was good. It now counts rows. Chasing a failed SUBTLEX-US download (benign — the loader falls back to `.txt`) also confirmed English "frequency" is mostly SCOWL's commonness bucket, which independently vindicates the band chip reading `difficulty` instead. - 2026-07-27: **Phase 20 — DreamDict becomes the dictionary for every pair but Chinese** (user: "let's continue the build plan"; scope confirmed: build the seam against the existing April `dict.db`, rebuild it later, code + local verification only). The prerequisite was bigger than the plan recorded: renaming DreamDict's module path was necessary but useless on its own, because the query layer lived in `internal/dictionary` and no module may import another's `internal`. Both fixed upstream — the package is now `dictionary`, with a comment saying why *reading* a built database is public API while the loaders that build one stay internal. In Petal, `Provider` is the two questions the popover already asked, so the embedded `*Lexicon` satisfied it with no changes at all, and `Set.For(lang)` is the one place the choice is made. **The measurement is the story of the phase.** `MULTIUSER_PLAN.md` mapped `Gloss ← Translate(word, "en", L1)` 1:1; against the real 452 MB database that table answers for **17%** of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. The shared-synset path answers for **61%**, so a new upstream `Equivalents` queries that and falls back to translations for 62% combined. Then the *ordering* was wrong in an instructive way: sorting by target frequency glosses "think" as *lembrar* — "remember" — because lembrar is commoner in Portuguese, even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first fixes it (think → pensar; write → escrever; garden → jardim). The same measurement is what kept **zh on ECDICT**: DreamDict reaches a Chinese gloss for 53% of those words where ECDICT reaches nearly all — the plan said converge only if quality holds, and it didn't. Two other decisions worth keeping: a missing `dict.db` is **not an error** (a laptop has never had one) but a present-and-unimported one is; and a pt-PT writer without a dictionary falls back to the embedded datasets **with the gloss suppressed**, keeping the English half rather than blanking the popover — an empty field reads as "not found", the wrong language reads as broken. The new fields surface as **three** bands, not five, because the difficulty score can separate "everyday" from "you'll have to explain this" but cannot rank *obfuscate* against *serendipity*, and a finer scale would be a confident-looking lie. Writing the tests found two bugs first: `trimEtymology` sliced by byte, which would have emitted invalid UTF-8 for precisely the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean in both repos; live smoke on a throwaway DB against the real dictionary, one instance flipped from zh to pt-PT mid-run. **Then deployed, with Phase 19** (user: "do it"): dreamdict pushed to GitHub, the `replace` swapped for a real pseudo-version, encrypted off-box backup first, `dict.db` copied into the LUKS volume and SHA-256-verified, then a rebuild — no migration in either phase, so `schema_migrations` stayed at 11 and her writing came through untouched (8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok). Both accounts are on the zh pair, so **nothing she sees changed today**; what shipped is the capacity for the next pair. Outstanding: the deployed `dict.db` predates DreamDict's Spanish data and needs rebuilding before the es pair ships. - 2026-07-27: **Phase 18 deployed, and Phase 19 — the copy stops being hardcoded Mandarin** (user: "let's continue the build plan"; sequencing confirmed: rehearse + deploy 18, then start 19). The rehearsal the previous session was blocked from running went first: a `VACUUM INTO` snapshot of the live VPS database, migrated locally by the Phase-18 binary, every count unchanged and FTS/integrity/foreign keys clean, `personal_words` created empty — then the deploy itself (off-box encrypted backup, rebuild, all three containers healthy, `0011` applied to the live DB with her writing untouched, `/api/spell/words` 401 without a session over public HTTPS). **One check was refused and not worked around**: minting a probe session row to see the endpoint answer 200 for a real cookie reads as credential fabrication to this session's classifier; the endpoint's lifecycle is covered by tests and the shared middleware governs that last step for every other route. **Phase 19** then lifted every `中文 · English` literal out of ~29 files into `web/src/i18n` — one `Pack` type, a verbatim `zh` pack, and two access paths chosen by *when* copy is built: `usePack()` for components, `pack()` for the companion and prose checker, which compose a line when something happens rather than when something renders. The interesting decisions were about what a pack must be allowed to control: **every string with a value in it is a function** (`reviewDue(n)`, `daysAgo(n)`, even English pluralisation) because word order isn't universal; the roster constants keep only value + emoji so a label can never drift from its key; and `gradeBand` returns a band *name* rather than a label. On the server, `internal/llm/lang.go` replaces "Simplified Chinese" in the three prompts that name her language — with pt-PT spelled **"European Portuguese (pt-PT, never Brazilian Portuguese)"** in the prompt itself, and her word for "why" carried alongside so the tutor still recognises the question. `pair_lang` is read **in the row-scoped query each handler already ran**, not a second lookup that could disagree with it — and the test for that was checked by breaking the join and watching it fail. go build/vet/test, tsc, vite, vitest 90/90 clean; live smoke on a throwaway DB. **Phase 19 is not deployed** — no migration, so it's a rebuild whenever the user wants it. - 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason). - 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production. - 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart. - 2026-07-27: **Phase 15 finished off on the two boxes** (user granted millenia access mid-session: `ssh reala@192.168.1.212`, and parodia is `ssh reala@100.64.0.1` over headscale). **LLM link**: rather than rebinding vLLM as planned, `deploy/vllm-headscale-proxy.service` (socat) adds a listener on `100.64.0.2` only — `vllm-chat.service` is shared with **Gogobee** and **Open WebUI** (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. **Backups**: the user pointed out the VPS already has daily provider VM backups *and* an age-encrypted offsite `parodia-backup` job, so Petal was folded into the latter instead of running a parallel cron — and doing so **exposed a real bug in that job's `sqlite_dump` helper**: Python's `iterdump` does not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with a `VACUUM INTO`-based helper, round-trip verified). The **bigger** find: millenia, which holds her actual writing, had **no scheduled backup at all** — now `petal-backup.timer`, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. **Encryption at rest** (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images *and* the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs *under* the mount, and `systemd-cryptsetup` wasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a `.volume-ok` guard so an unmounted volume fails loudly instead of serving a blank DB. **Millenia hygiene**: Piper had been dead since the Jul 26 reboot — **26,800+ failed restarts**, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved `/usr/bin/python3` 3.13→3.14 and the venv's `site-packages` went invisible; venv recreated (lands Piper 1.6.0, which is what `TTS_PATH` exists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is now `petal.service` (verified by `kill -9`); the Piper units got `StartLimitIntervalSec`/`Burst` so a broken service enters `failed` instead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest. - 2026-07-26: **Phase 15 complete — Petal is deployed at https://petal.parodia.dev** (user: "let's start this build plan"; scope confirmed as artifacts **plus** the actual deploy, millenia stays canonical, hostname `petal.parodia.dev`). Stack: `Dockerfile` (node → go → alpine; CGO off, so the runtime layer carries only ffmpeg + tzdata), `docker-compose.yml` behind the host's existing Traefik, and **two Piper sidecars** instead of the planned host systemd units — Piper turned out never to have been installed on the VPS and the account has no lingering session, so containers on an internal network with no published ports are both simpler and tighter. **Three real problems found by deploying rather than by planning:** (1) the image's `petal` user (uid 10001) has no claim on a bind-mounted host directory → SQLite `unable to open database file (14)` and a restart loop; the container now runs as the stack directory's owner (still non-root, and the host account keeps write access the backup script needs); (2) piper-tts **1.6.0 moved synthesis from `POST /` to `POST /synthesize`** with an identical body → every read-aloud 405'd; rather than pin both deployments to one Piper release the path became config (`TTS_PATH`, default `/`, so millenia is untouched); (3) once the instance was live it was **a public, writable, unauthenticated API** — Petal authenticates nobody yet, so Traefik basic auth now holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Backups: `db.Backup` via **`VACUUM INTO`** (WAL-coherent, no write lock, single file, refuses to overwrite) behind a `-backup` flag so the nightly job snapshots the running container; `deploy/backup-petal.sh` compresses, pushes to millenia with a size check, prunes both sides; cron at 03:15; restore documented and verified by round-tripping an archive through the binary. Tests: `internal/db/backup_test.go` (WAL capture, seeded user survives, no `-wal`/`-shm` companions, refuses an existing destination, missing source), `internal/tts` path-normalisation + configured-path. go build/vet/test clean. **Acceptance verified over public HTTPS with the LLM link genuinely down**: dictionaries, gloss, word lookup + phonetic, doc create/save, CJK FTS search, md/docx export, vocab capture, read-aloud EN + zh (real mp3, cache hit, 404-fallback for an unconfigured language) — all fine; `/check` → the warm 502 that renders as 小助手在休息; health public, HTTP→HTTPS with a valid cert. **Two items outstanding, both needing millenia access I don't have**: vLLM isn't bound to its headscale interface (so no AI pass works yet), and parodia's ssh key isn't authorized on millenia (so backups are VPS-local only — not yet a real off-box backup). Both have one-command fixes in `deploy/README.md` §3 and §5. Also this session: **DreamDict gained Spanish**, so the es pair is no longer gated — folded into Phases 20/21 and the "Later" bucket. Next: **Phase 16 (auth)** — Authentik already runs on the same VPS. - 2026-07-26: **Product direction + execution plan ratified** (user: "make it so, number one"). New `SUGGESTIONS.md` (product rationale for the language-learning direction): the **pair model** — every user gets one (English + X) pair, X ∈ {zh, pt-PT, fr, maybe es}, bilingual UI in the pair, type in either language, direction inferred (no detector: both-dictionaries spellcheck, show-both gloss on collision); **langpacks** keyed by X; **LLM-minimalism** as a standing principle (LLM is garnish, never a gatekeeper — grammar-lite rule pack + embedded miscollocation list planned as code-first layers). Deployment settled: Petal on the **parodia.dev VPS**, vLLM on millenia over **headscale** (the only cross-VPN dependency; Piper is VPS-local). All `MULTIUSER_PLAN.md` OPENs ratified: in-app OIDC (B), 30-day sliding sessions, allowlist, migration script, image-store fix with auth, DreamDict via package import (Option 3, module rename prereq in the dreamdict repo), zh stays on ECDICT until compared. Everything expanded into **Phases 15–22** above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15. - 2026-07-26: **Multi-user groundwork** (user: "let's start preparing Petal for multi-user support"; scope agreed as plumbing-only, aimed at Authentik). New **`internal/auth`** package — context-carried identity (`WithUser`/`UserID`), a `Resolver` seam (`Resolve(*http.Request) (string, error)`), `StaticResolver` for today's single user, and `Middleware` that 401s anything unresolved. `main.go` splits `/api` into a **public group** (`/health`, `/version` — a monitoring probe must not need a session) and an **authenticated group** carrying everything else. All ~35 `db.LocalUserID` call sites across `docs`/`suggestions`/`vocab` now read the caller from the request; helpers that had no request in scope (`fetch`, `ownsDoc`, `ownsTag`, `tagsByDoc`, `fetchVersion`, `passportVersions`, `fetchPending`, vocab `fetch`) take an explicit `userID` param. `UserID` returns `""` rather than panicking when middleware is absent, so a mis-wired route **fails closed** (every query is `WHERE user_id = ?` → matches nothing). **Two real access-control gaps found and fixed while threading**: `setStatus` (accept/dismiss) updated a suggestion by bare id with **no ownership check at all**, and `fetchPending`/`listForDoc` read a document's suggestions by `doc_id` alone — a leak of the quoted source sentences. Both now scope through `documents.user_id`. **A third bug was caught by the new tests, not by the compiler**: `docs.fetch` gained a `userID` parameter but kept binding `db.LocalUserID` in the query — legal Go (unused params compile), silently unscoped, and it would have shipped. New tests: `internal/auth/auth_test.go` (round-trip, absent-context, both 401 paths) and **two-user isolation suites** (`docs/isolation_test.go`, `suggestions/isolation_test.go`) that mount the same routers twice behind two resolvers over one DB and assert a stranger gets 404 on get/update/delete/export/passport/snapshot/version-preview/restore/tag-assign/tag-rename/tag-delete/suggestion-accept/dismiss, sees nothing in list/search/version-list, and leaves the owner's data untouched. go build/vet/test all clean. **Still global, deliberately out of scope** (flagged for the auth phase): the image store is content-addressed with no per-user association or DB row — any authenticated user holding a hash can fetch any image (capability-URL security, needs a table + migration to fix); `export-all` is correctly scoped; frontend `localStorage` keys (`petal.spell.personal`, `petal.companion`, sound/petals prefs) are per-browser, not per-account, so they'd bleed across users sharing a device. - 2026-06-26: **Phases 12 + 13 complete** (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). **Phase 12**: collocation drops in as a third suggestion family reusing the whole `runPass`/`pendingScope` machinery — `llm/collocation.go` (`RunCollocation`, 25s floor, reuses `ParseCheckpoint`), `collocationSystemPrompt`/`CollocationMessages` (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration `0005` **rebuilds** the suggestions table to extend the `type` CHECK (SQLite can't ALTER a CHECK), `collocationScope` + `CollocationLimit` + `POST /{id}/collocation`. **Caught a latent bug**: `grammarScope` was `type != 'voice'` → would wipe collocation flags; fixed to `type NOT IN ('voice','collocation')`. Frontend: `--color-blossom` pink, "Make it sound natural 🌸" toolbar pill, `collocating`/`runCollocation` in `useCheckpoint`, StatusBar dot — all into the existing rail/card. **Phase 13**: new `internal/vocab` package — migration `0006_vocab_garden` (`vocab_words`, SM-2-lite columns, doc_id `ON DELETE SET NULL`, `UNIQUE(user_id,word)`), `scheduler.go` (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), `handlers.go` (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite `datetime()` so stored values stay canonical-UTC). Auto-capture wired into `EditorCore.openWordLookup` (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on `WordCard`. `GardenPanel` slide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests: `TestCollocationPassCoexists`, vocab `scheduler_test.go` + `handlers_test.go`, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. **Remaining: only the deferred infra bucket** — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik". - 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()` → ``. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune. - 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & 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).**