Item 7 said to confirm before building, and confirming is what mattered. The rail's 348px threshold is measured against a fixed 720px column centred in the pane. The doc-list sidebar is 280px, so at her 1517px viewport the right margin is 258 with it open and 406 without — either side of the threshold. What moves between them is distraction-free mode, which engages on its own when the editor takes focus. The rail therefore appears when she starts writing and disappears when she stops; items 4 and 5 disagreed about whether it exists at 1517px only because they caught it in different states. Re-centring a fixed-width column changes its position and not its size, so the wrapper's ResizeObserver reported nothing and no window resize fired. railEnabled kept whatever value it last had. Leaving distraction-free with the rail up left a 300px column in a 266px margin: overhanging the viewport by 66px, cards clipped mid-sentence, the page scrolling sideways. Entering it with the rail down opened 406px of margin and put nothing in it. Both persisted until something else happened to resize the window. Observe the scrollport too — it spans the pane, so it resizes whenever the chrome around the editor does. That covers any future chrome that moves the editor, which threading focusMode down as a prop would not. Clicking a highlight now opens the anchored card even when the rail is up. That is the item's own acceptance criterion and was previously false by design; the measured distance from the first flagged span to its rail card is 651px, not the ~400 the review guessed. Hover still defers to the rail, since the reasoning against an unbidden second card was about hover and still holds — but a click is her asking to deal with that word. The rail card glows instead of expanding, so nothing is ever open twice. Verified in Chrome at the review's own 1517x810, driving the rule pack from item 3b so no model was involved: the rail follows the mode in both directions with no resize event anywhere; the popover lands 6px under the word with the full explanation, Ask Petal, Accept and Dismiss; accepting from it applied the edit and took the rail 6 cards to 5, leaving the rest with their ids, positions and wording intact. railFit.test.ts pins the threshold to the margins actually measured. The observer wiring has no unit test and can't have a useful one: jsdom has no layout, so every rect is zero and the rail branch is unreachable there. That half is browser-verified only, and the doc says so.
🌸 Petal
A self-hosted, privacy-first writing editor with warm bubbly design, auto-save, and
local-LLM grammar/ESL suggestions. See petal-spec.md for the full
design spec and BUILD_PLAN.md for build progress.
Stack
Go + chi backend · SQLite (modernc, pure Go) · React + Vite + Tiptap + Tailwind v4 frontend ·
local vLLM/Ollama for AI suggestions. Single-binary deployment (frontend embedded via go:embed).
Local development
Two processes during development:
# 1. Backend (serves /api on :8080)
go run ./cmd/server
# 2. Frontend dev server (HMR on :5173, proxies /api → :8080)
cd web && npm install && npm run dev
Open http://localhost:5173 while developing.
Production build (single binary)
cd web && npm run build # emits web/dist (embedded by the Go binary)
cd .. && go build -o petal ./cmd/server
./petal # serves UI + API on :8080
Configuration is via environment variables — copy .env.example to .env.
Deployment
docker compose up -d --build # petal + the two Piper read-aloud sidecars
Behind Traefik on the parodia.dev VPS; vLLM stays on millenia over headscale.
Full runbook — first deploy, the LLM link, backups and restore — in
deploy/README.md.
Status
Early build, multi-session. Auth (Authentik OIDC) is next; Copyleaks plagiarism is
still parked — see BUILD_PLAN.md.