Phase 6: design polish — accept confetti + distraction-free mode

- Accept confetti: CSS-only 4-dot burst spawned at the accepted card,
  trajectory via inline --dx/--dy, auto-cleared after 720ms.
- Distraction-free mode: editor focus collapses the doc-list sidebar
  (width→0 slide), canvas re-centers full-width; Escape or a click
  outside the canvas (header/gutter/status bar) restores it.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:21:46 -07:00
parent 0fa70979a0
commit 534f7262ab
5 changed files with 135 additions and 23 deletions

View File

@@ -60,11 +60,11 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- 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
- [ ] Full pastel tokens, Nunito + Lora + JetBrains Mono
- [ ] Shape language, shadows, transitions
- [ ] Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot)
- [ ] Distraction-free mode
### 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)
### Phase 7 — Spell check
- [ ] nspell browser-side (en-US), vendor dictionaries
@@ -81,4 +81,5 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- 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: **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 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).**

View File

@@ -12,6 +12,10 @@ export default function App() {
const [title, setTitle] = useState('')
const [wordCount, setWordCount] = useState(0)
const [ready, setReady] = useState(false)
// Distraction-free mode: entered on editor focus, collapses the doc-list
// sidebar. Escape or a click outside the editor canvas restores it.
const [focusMode, setFocusMode] = useState(false)
const canvasRef = useRef<HTMLDivElement>(null)
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
@@ -135,6 +139,22 @@ export default function App() {
[removeSuggestion],
)
// Escape always restores the sidebar while in distraction-free mode.
useEffect(() => {
if (!focusMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFocusMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [focusMode])
// A pointer-down outside the centered editor canvas (the cream gutters, the
// header, the status bar) also restores the sidebar.
const handleChromeDown = useCallback((e: React.MouseEvent) => {
if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false)
}, [])
const handleDismiss = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
@@ -150,6 +170,7 @@ export default function App() {
return (
<div className="flex h-full flex-col">
<header
onMouseDown={handleChromeDown}
className="flex h-12 shrink-0 items-center gap-2 px-5"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
@@ -158,19 +179,24 @@ export default function App() {
</header>
<div className="flex min-h-0 flex-1">
<DocList
docs={docs}
selectedId={currentDoc?.id ?? null}
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
/>
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
<DocList
docs={docs}
selectedId={currentDoc?.id ?? null}
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
/>
</div>
<main className="flex min-w-0 flex-1 flex-col">
{currentDoc ? (
<>
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<div
onMouseDown={handleChromeDown}
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
>
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
@@ -189,15 +215,18 @@ export default function App() {
onDismiss={handleDismiss}
onVoiceCheck={runVoice}
voicing={voicing}
onFocusMode={() => setFocusMode(true)}
/>
</div>
</div>
<StatusBar
wordCount={wordCount}
saveStatus={status}
checking={checking}
voicing={voicing}
/>
<div onMouseDown={handleChromeDown}>
<StatusBar
wordCount={wordCount}
saveStatus={status}
checking={checking}
voicing={voicing}
/>
</div>
</>
) : (
<div

View File

@@ -13,7 +13,7 @@ interface Props {
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
return (
<aside
className="flex w-[260px] shrink-0 flex-col gap-2 p-3"
className="flex h-full w-[260px] flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }}
>
<button

View File

@@ -30,6 +30,31 @@ interface Props {
// it runs (drives the toolbar button's loading state).
onVoiceCheck: () => void
voicing: boolean
// Fired when the editor gains focus, so the app can enter distraction-free mode.
onFocusMode?: () => void
}
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
// spray up-and-out from a point; each reads its direction from --dx/--dy.
const CONFETTI_DOTS = [
{ dx: -20, dy: -26, color: 'var(--color-accent)' },
{ dx: 18, dy: -30, color: 'var(--color-mint)' },
{ dx: -6, dy: -34, color: 'var(--color-peach)' },
{ dx: 24, dy: -18, color: 'var(--color-lavender)' },
]
function Confetti({ top, left }: { top: number; left: number }) {
return (
<div className="petal-confetti pointer-events-none absolute z-30" style={{ top, left }} aria-hidden>
{CONFETTI_DOTS.map((d, i) => (
<span
key={i}
className="petal-confetti-dot"
style={{ '--dx': `${d.dx}px`, '--dy': `${d.dy}px`, background: d.color } as React.CSSProperties}
/>
))}
</div>
)
}
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
@@ -63,9 +88,13 @@ export function EditorCore({
onDismiss,
onVoiceCheck,
voicing,
onFocusMode,
}: Props) {
const wrapperRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<HoverState | null>(null)
// Transient confetti burst played at the last accept location.
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// Delays card close so the pointer can travel from highlight to card.
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// While the Ask Petal panel is expanded the card is pinned: the hover-close
@@ -85,6 +114,7 @@ export function EditorCore({
editorProps: {
attributes: { class: 'petal-prose focus:outline-none' },
},
onFocus: () => onFocusMode?.(),
onUpdate: ({ editor }) => {
onChange({
content: JSON.stringify(editor.getJSON()),
@@ -170,7 +200,8 @@ export function EditorCore({
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// Accept applies the replacement to the document, then notifies the parent.
// Accept applies the replacement to the document, plays a little confetti
// burst where the card sat, then notifies the parent.
const handleAccept = useCallback(
(s: Suggestion) => {
if (editor && s.replacement.trim() !== '') {
@@ -179,6 +210,14 @@ export function EditorCore({
editor.chain().focus().insertContentAt(range, s.replacement).run()
}
}
setHover((h) => {
if (h) {
setConfetti({ top: h.top, left: h.left + 16 })
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
return h
})
closeCard()
onAccept(s)
},
@@ -193,7 +232,10 @@ export function EditorCore({
[onDismiss, closeCard],
)
useEffect(() => () => clearTimeout(closeTimer.current), [])
useEffect(() => () => {
clearTimeout(closeTimer.current)
clearTimeout(confettiTimer.current)
}, [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
// the only way to dismiss a pinned card without accept/dismiss.
@@ -216,6 +258,7 @@ export function EditorCore({
onMouseOut={handleMouseOut}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{hover && (
<SuggestionCard
suggestion={hover.suggestion}

View File

@@ -136,6 +136,45 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* --- Accept confetti --------------------------------------------------------
A tiny CSS-only burst played where a suggestion is accepted: four colored
dots spray up-and-out, then fade. No JS animation — each dot reads its
direction from --dx/--dy custom properties set inline. (Spec → Signature.) */
.petal-confetti {
width: 0;
height: 0;
}
.petal-confetti-dot {
position: absolute;
left: 0;
top: 0;
width: 7px;
height: 7px;
border-radius: var(--radius-pill);
animation: petal-confetti 680ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}
@keyframes petal-confetti {
0% { transform: translate(0, 0) scale(0.3); opacity: 0; }
18% { opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) scale(1); opacity: 0; }
}
/* --- Distraction-free mode ---------------------------------------------------
The doc-list sidebar slides left and collapses to zero width; the editor
canvas (centered, max-width) re-centers into the full pane. Width + transform
animate together for a smooth slide. (Spec → Distraction-free mode.) */
.petal-sidebar {
width: 260px;
overflow: hidden;
transition: width 280ms ease, transform 280ms ease, opacity 200ms ease;
}
.petal-sidebar-hidden {
width: 0;
transform: translateX(-24px);
opacity: 0;
pointer-events: none;
}
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
.petal-chat-caret {
animation: petal-breathe 1s ease-in-out infinite;