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

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