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

@@ -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;