diff --git a/UX_REVIEW_2026-07-27.md b/UX_REVIEW_2026-07-27.md new file mode 100644 index 0000000..9907544 --- /dev/null +++ b/UX_REVIEW_2026-07-27.md @@ -0,0 +1,212 @@ +# 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. diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index fb6ec7a..c3f767b 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave' import { useCompanion, type Mood } from './useCompanion' import { LottiePlayer } from './LottiePlayer' import { COMPANIONS, DEFAULT_COMPANION } from './companions' +import { useCardOverlap } from './useCardOverlap' import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs' import { usePack } from '../../i18n' @@ -86,6 +87,14 @@ export function PetalCompanion({ const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0] const [pickerOpen, setPickerOpen] = useState(false) const rootRef = useRef(null) + const badgeRef = useRef(null) + + // When suggestion cards stack down into the corner, the kitten fades to + // translucent and shrinks a step so the card stays readable and clickable. + // It wakes back up whenever it has something to say (bubble) or is being + // interacted with (picker open). + const crowded = useCardOverlap(badgeRef) + const faded = crowded && !pickerOpen && !bubble // Awake companions (no sleeping clip) don't visibly nap — when the engine // dozes them, keep their normal idle pose instead of a sleepy face. Only a @@ -239,11 +248,14 @@ export function PetalCompanion({ )}