# Petal UX review — 2026-07-27 (implementation doc) **Origin:** a hands-on browser session against the live VPS deploy (petal.parodia.dev) on a 1517×810 desktop viewport, deliberately writing ESL-style English, accepting/undoing suggestions, using Ask Petal, the right-click dictionary, the Garden, History, and the doc-type menu. Goal: close the gap to Grammarly's *feel* (latency, stability, proximity of feedback) without its cost or surveillance. **How to use this doc:** each item has symptom/repro, likely code location, proposed fix, and acceptance criteria. Items are ordered by value-for-effort. Where the session couldn't confirm root cause, that is said explicitly — verify before building. The product *why* behind Petal lives in `SUGGESTIONS.md`; execution phases in `BUILD_PLAN.md`. Nothing here contradicts them; this is polish on the existing loop. **What was verified working well (don't regress):** error coverage and explanation quality across two check rounds; bilingual (en+zh) card explanations; Ask Petal follow-up answers; instant right-click dictionary with TTS + slow-replay; Garden sprouting; autosave + History snapshots; kitten cheers on accept; clean console throughout. --- ## 0. DONE this session — kitten yields to cards The corner mascot's halo sat on top of the bottom rail cards, History-panel footer controls, and the Garden counter, blocking reading and clicks. **Implemented (this commit):** when any `.petal-rail-card` overlaps the mascot badge, the kitten fades to 15% opacity, shrinks 10% toward its corner (standalone `scale` property, so it composes with the bob animation's `transform`), and goes `pointer-events: none` so clicks pass through. It wakes (full size/opacity) while its speech bubble or the companion picker is open, or when cards no longer overlap. - `web/src/components/Companion/useCardOverlap.ts` — rect-intersection hook, rAF-throttled on scroll/resize + 500 ms poll. - `web/src/components/Companion/PetalCompanion.tsx` — `faded` wiring. - `web/src/index.css` — `.petal-companion-faded`, transition. **Follow-up for Opus:** the overlap check only watches `.petal-rail-card`. The History panel footer and Garden counter can still sit under the kitten. Either extend the hook's selector list (`.petal-rail-card, [data-panel]`…) or give those panels a bottom padding of `calc(var(--petal-companion-size) * 0.5)`. Acceptance: with History open and with the Garden footer visible, no interactive control is under the mascot, or the mascot is faded + click-through. --- ## 1. Bug: redo does not re-apply an accepted suggestion **Repro:** accept a suggestion (text updates), press Ctrl+Z (text reverts — correct), press Ctrl+Shift+Z → nothing happens. Observed on the Idiom card "by foots → on foot". **Where to look:** `EditorCore.handleAccept` (`web/src/components/Editor/EditorCore.tsx` ~line 556) applies via `editor.chain().focus().insertContentAt(range, s.replacement).run()`, which *is* a normal history transaction — so the break is probably downstream: after the undo, the parent `onAccept`/re-check flow may dispatch a transaction that clears the redo stack (any doc-touching tr wipes redo), or the accepted suggestion's server-side state makes the recheck rewrite content. Root cause was **not** confirmed in the session — instrument first. **Acceptance:** accept → undo → redo restores the replacement; the suggestion card state stays consistent with whichever text is showing. ## 2. Suggestion stability: stop regenerating the world on every accept The biggest feel gap vs Grammarly. Today every accept (and every edit) triggers a full-document `POST /api/docs/:id/check`; all remaining cards vanish and re-arrive seconds later, spans re-merge into different shapes, and the LLM re-words every explanation each round (the "weather were" card carried three different explanations in one session). It reads as instability, doubles the pause after each accept, and burns qwen3.5 tokens. **Proposed fix (server + client):** - Split the doc into sentences (or paragraphs) and hash each. On re-check, send only chunks whose hash changed since the last check; suggestions on unchanged chunks are returned from cache byte-identical, including the explanation text. `internal/suggestions/handlers.go` is the entry point. - Give suggestions stable identity across checks: key on (chunk hash, original span, replacement) so an untouched suggestion keeps its `id`, and the client keeps the existing card DOM instead of remounting (no vanish/reappear). - Client: on accept, remove that one card optimistically and leave the rest untouched while the changed-chunk recheck runs. **Acceptance:** accepting one suggestion never changes the text, wording, or position of any other card; re-check traffic after a one-sentence edit contains only that sentence's chunk; explanations are stable across rounds. ## 3. Perceived latency: mask the LLM round-trip Measured ~8–15 s from typing-stop to cards, with only a small "Checking…" in the status bar. Two independent levers, both worth doing: - **Incremental surfacing.** Stream/deliver per-chunk results as each sentence finishes checking instead of one batch at the end (pairs naturally with item 2's chunking). Status bar shows a live count: "Found 3 so far…". - **Instant local rules layer.** A tiny deterministic pass that underlines the classics with zero network: a/an before vowel sound, plural after some/many/three…, he/she/it + verb-s, common mass nouns ("an information"). Petal's ethos (see `SUGGESTIONS.md`: LLM is garnish, plain code essential) fits this exactly. Note `grammarLite.test.ts` already exists under `web/src/components/Companion/` — check whether a rules engine is already half-built before writing a new one. Local hits render immediately with a modest style, then get confirmed/enriched (or withdrawn) when the LLM pass lands. **Acceptance:** an obvious error like "a apple" underlines in <100 ms offline; during a full check, at least one card appears before the last chunk finishes; the status bar shows a running count. ## 4. Rail scrolls away from the text With ~7 cards the rail is taller than the viewport; scrolling to reach lower cards scrolls the document text fully off-screen, severing the card↔sentence connection. **Proposed fix:** keep the editor column sticky/pinned while the rail (`web/src/components/Editor/SuggestionRail.tsx`, `.petal-rail` in `index.css`) scrolls in its own `overflow-y: auto` container. Preserve the existing anchor-to-highlight layout for cards that fit; the container only takes over when the stack exceeds the viewport. **Acceptance:** with 10+ suggestions, the flagged text stays visible while scrolling the card list; hover-linking still highlights the right span. ## 5. Mixed-language spans: offer translation, don't ignore Typed mid-document: 我想说这句话但是不知道用英语怎么说。 ("I want to say this but don't know how in English") — Petal produced **no card at all**. The pair model (`SUGGESTIONS.md` §1: user may type in either language, Petal infers direction) says this should be the flagship moment. **Proposed fix:** during chunking (item 2), detect spans in the pair's X language inside an English context (CJK detection already exists for spellcheck exclusion — see `web/src/components/Editor/SpellCheck.ts`). Emit a new suggestion type `translate` whose replacement is the English rendering, card labeled 翻译 · Translate, with the usual Accept / Ask Petal. Accept replaces the span (keep the original as the card's strikethrough line so she can still see what she wrote). **Acceptance:** a Chinese sentence inside an English doc yields a Translate card within one check cycle; accepting swaps in the English; an English-only doc and a Chinese-only doc are unaffected. ## 6. Ask Petal answers: bilingual, and room to read The card's *explanation* is bilingual, but the Ask Petal *answer* came back English-only, rendered in a small scrollable box inside the card. **Proposed fix:** prompt the answer path (`internal/suggestions/chat*.go` / `AskPetal.tsx`) to reply in both pair languages (native first, mirroring the bubble pattern in `PetalCompanion.tsx`); let the answer area grow to the card's width with a sane max-height (~50vh) before scrolling. **Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph answer is readable without scrolling a ~100 px box. ## 7. Inline popover at the underline (verify, then strengthen) Grammarly's core gesture is click-the-word → popup at the word. `EditorCore.tsx` already has a `hover` card anchored to highlights (see `handleAccept`'s fallback burst position), and the rail wires `activeId`/`onHover` both ways — so part of this exists. The session experience on a wide (1517 px) screen was still: click underline → the *far-right* rail card expands, ~400 px of eye travel. **Task:** confirm the hover card appears on click as well as hover, that it offers Accept + a one-line reason + "more" (expanding the rail card), and that hover-linking (card ↔ span glow) works in both directions. Fix whichever half is missing. **Acceptance:** clicking an underline shows an anchored mini-popover with Accept, without needing the rail; hovering a rail card glows its span and vice versa. ## 8. Smaller items (each small, do opportunistically) - **Accept All per category.** Five tense fixes = five clicks today. Add "Accept all Grammar (5)" per category header in the rail, one undo step for the batch. Acceptance: batch-accept applies all, single Ctrl+Z reverts the batch. - **Keyboard flow.** Tab/Shift+Tab (or n/p) cycles underlines with the popover open; Enter accepts, Esc dismisses popover. Acceptance: a doc can be fully triaged without the mouse. - **Dismissal persistence.** Untested in session: does a dismissed (✕) suggestion stay dismissed after the next full re-check? With item 2's stable identity, store dismissed keys per doc and filter server-side. Acceptance: dismiss → edit elsewhere → recheck → the dismissed card does not return. - **Status-bar summary.** "3 petals to polish 🌸 · 三片花瓣待打磨" next to the word count — the gentle version of Grammarly's score. No numeric grade, per the north star. Acceptance: count updates live with the rail. --- ## Explicit non-goals (from this review) - No document score/grade, no streaks-pressure — the Garden and kitten already carry motivation warmly. - No browser-extension-style everywhere-checking; Petal is the writing place. - The doc-type dropdown's translucent look during open was **animation mid-fade, not a bug** — leave it.