Surfc UI/UX Overhaul — Branch 2 Implementation Plan
Surfc UI/UX Overhaul — Branch 2 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Layer three CSS animations on top of the Branch 1 structure: a card slide-down on photo capture, an ink bleed reveal of transcribed text, and a staggered fade-in for idea chips.
Architecture: All animations are pure CSS @keyframes — no JS animation loops. The card slide requires a small wiring change: useNoteForm.handleImageSelected calls nav.onCaptureReady(image) instead of nav.goToNote() so App.jsx can control when navigation fires (after animationend). Ink bleed and chip fade-in are self-contained inside NoteForm.jsx. No data, sync, or navigation logic changes.
Tech Stack: React 18, Vite, Vitest + @testing-library/react, CSS @keyframes, existing tokens.css variables throughout.
Spec: docs/ui-ux/user-journey-v2.md §5, FUNCTIONAL.md Rule 8.
Branch: design/capture-animations (create from merged design/ui-structure-overhaul)
File Map
| File | Change |
|---|---|
src/styles.css | Append 3 keyframes + supporting utility classes |
src/hooks/useNoteForm.js | handleImageSelected calls nav.onCaptureReady(img) instead of nav.goToNote() |
src/components/CaptureScreen.jsx | Accept captureAnimImg + onCardSlideEnd; render animated preview card |
src/App.jsx | Add captureAnimImg state; wire onCaptureReady + onCardSlideEnd |
src/components/NoteForm.jsx | Ink bleed display via local state; chip stagger via animationDelay style |
src/test/capture.test.jsx | Add animation behaviour tests |
Pre-flight
- Checkout branch (create from merged Branch 1)
git checkout design/ui-structure-overhaulgit pullgit checkout -b design/capture-animations- Confirm tests pass before touching anything
npm run test:runExpected: all green.
Task 1: CSS keyframes
Files:
- Modify:
src/styles.css(append only — no existing rules removed)
Three keyframes plus the utility classes that use them. Write and verify all CSS before wiring any React.
- Write a failing test that checks the keyframe class names are present in the DOM on relevant elements
Create a new file src/test/animations.test.jsx:
/** * Tests for Branch 2 animation behaviour. * * CSS @keyframes are not evaluated by jsdom, so these tests check: * - The correct CSS class names are applied to the right elements. * - The correct inline `animationDelay` style is applied to chips. * - The ink bleed display renders word groups when transcription completes. * - The captured card preview renders in CaptureScreen when `captureAnimImg` is set. */import { describe, it, expect, beforeEach, vi } from 'vitest'import { render, screen, waitFor, fireEvent } from '@testing-library/react'import CaptureScreen from '../components/CaptureScreen.jsx'import NoteForm from '../components/NoteForm.jsx'
// ── CaptureScreen card preview ─────────────────────────────────────────────
describe('CaptureScreen — card slide preview', () => { it('does not render .capture-card-preview when captureAnimImg is null', () => { render( <CaptureScreen onTakePhoto={vi.fn()} onWriteNote={vi.fn()} onOpenSettings={vi.fn()} captureAnimImg={null} onCardSlideEnd={vi.fn()} /> ) expect(document.querySelector('.capture-card-preview')).toBeNull() })
it('renders .capture-card-preview with an img tag when captureAnimImg is set', () => { const img = { dataUrl: 'data:image/jpeg;base64,abc123' } render( <CaptureScreen onTakePhoto={vi.fn()} onWriteNote={vi.fn()} onOpenSettings={vi.fn()} captureAnimImg={img} onCardSlideEnd={vi.fn()} /> ) const card = document.querySelector('.capture-card-preview') expect(card).not.toBeNull() expect(card.querySelector('img').src).toContain('base64,abc123') })
it('calls onCardSlideEnd when animationend fires on the preview card', () => { const img = { dataUrl: 'data:image/jpeg;base64,abc123' } const onCardSlideEnd = vi.fn() render( <CaptureScreen onTakePhoto={vi.fn()} onWriteNote={vi.fn()} onOpenSettings={vi.fn()} captureAnimImg={img} onCardSlideEnd={onCardSlideEnd} /> ) const card = document.querySelector('.capture-card-preview') fireEvent.animationEnd(card) expect(onCardSlideEnd).toHaveBeenCalledTimes(1) })})
// ── NoteForm ink bleed ─────────────────────────────────────────────────────
const BASE_NOTE_PROPS = { books: [], noteText: '', onNoteTextChange: vi.fn(), noteBook: '', onNoteBookChange: vi.fn(), notePage: '', onNotePageChange: vi.fn(), pendingTags: [], onRemoveTag: vi.fn(), capturedImage: null, transcribeLoading: false, transcribeError: '', onRetranscribe: vi.fn(), onClearCapture: vi.fn(), onRetakeCapture: vi.fn(), onDismissTranscribeError: vi.fn(), detectedAttribution: null, onAddDetectedBook: vi.fn(), onDismissAttribution: vi.fn(), aiLoading: false, aiError: '', hasAutoDiscovered: false, onManualDiscover: vi.fn(), onSave: vi.fn(), onNavigateSources: vi.fn(), onClose: vi.fn()}
describe('NoteForm — ink bleed', () => { it('does not render .ink-bleed-display when transcribeLoading stays false', () => { render(<NoteForm {...BASE_NOTE_PROPS} transcribeLoading={false} noteText="Some text." />) expect(document.querySelector('.ink-bleed-display')).toBeNull() })
it('renders .ink-bleed-display when transcribeLoading transitions from true to false with text', () => { const { rerender } = render( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={true} noteText="" /> ) rerender( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={false} noteText="The world is the totality of facts." /> ) expect(document.querySelector('.ink-bleed-display')).not.toBeNull() })
it('does not render .ink-bleed-display when transcribeLoading transitions false→false', () => { const { rerender } = render( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={false} noteText="Already had text." /> ) rerender( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={false} noteText="Updated text." /> ) expect(document.querySelector('.ink-bleed-display')).toBeNull() })
it('renders word groups as .ink-bleed-group spans with staggered animationDelay', () => { const { rerender } = render( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={true} noteText="" /> ) // 8 words → 2 groups of 4 rerender( <NoteForm {...BASE_NOTE_PROPS} transcribeLoading={false} noteText="one two three four five six seven eight" /> ) const groups = document.querySelectorAll('.ink-bleed-group') expect(groups.length).toBe(2) expect(groups[0].style.animationDelay).toBe('0ms') expect(groups[1].style.animationDelay).toBe('50ms') })})
// ── NoteForm chip stagger ──────────────────────────────────────────────────
describe('NoteForm — chip fade-in stagger', () => { it('applies staggered animationDelay to each idea chip', () => { render( <NoteForm {...BASE_NOTE_PROPS} pendingTags={['Justice', 'Knowledge', 'Truth']} hasAutoDiscovered={true} /> ) const chips = document.querySelectorAll('.idea-chip') expect(chips[0].style.animationDelay).toBe('0ms') expect(chips[1].style.animationDelay).toBe('50ms') expect(chips[2].style.animationDelay).toBe('100ms') })})- Run the test to confirm it fails
npm run test:run -- --reporter=verbose src/test/animations.test.jsxExpected: all tests FAIL (components don’t yet have the new props/classes).
- Append the CSS blocks to
src/styles.css
Append these rules after the existing @keyframes grain block at line 2491:
/* ============================================================ Branch 2 Animations ============================================================ */
/* ── 1. Card slide-down ─────────────────────────────────────── */
@keyframes card-slide-down { from { transform: scale(1) translateY(0); opacity: 1; } to { transform: scale(0.3) translateY(calc(100vh - 80px)); opacity: 0.8; }}
.capture-card-preview { position: absolute; /* Start centred inside the viewfinder */ inset: 0; z-index: 20; pointer-events: none; overflow: hidden; border-radius: var(--radius-card); animation: card-slide-down 400ms cubic-bezier(0.4, 0, 0.8, 0.6) forwards;}
.capture-card-preview img { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-card);}
/* ── 2. Ink bleed (text reveal) ─────────────────────────────── */
@keyframes ink-bleed-in { from { opacity: 0; filter: blur(2px); } to { opacity: 1; filter: blur(0); }}
.note-text-field { position: relative;}
.note-text-field textarea { transition: opacity 150ms ease;}
.note-text-field textarea.text-hidden { opacity: 0; /* Remains in DOM and focusable — Save button can still fire */ pointer-events: none;}
.ink-bleed-display { position: absolute; inset: 0; padding: 10px 12px; /* Match .inp padding */ font-size: var(--text-body-size); line-height: var(--text-body-height); color: var(--color-text-primary); font-family: var(--font-base); pointer-events: none; /* Tap passes through to textarea below */ white-space: pre-wrap; overflow: hidden;}
.ink-bleed-group { display: inline; opacity: 0; /* Start invisible; animation fills to 1 */ animation: ink-bleed-in 300ms ease-out both;}
.ink-bleed-group::after { content: ' '; /* Preserve inter-group spacing */}
/* ── 3. Idea chip fade-in ────────────────────────────────────── */
@keyframes chip-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); }}
.detected-tags .idea-chip { animation: chip-fade-in 200ms ease-out both; /* animation-delay is applied via inline style for stagger */}- Commit
git add src/styles.css src/test/animations.test.jsxgit commit -m "style: add card-slide, ink-bleed, chip-fade-in keyframes; add failing animation tests"Task 2: Card slide animation — useNoteForm + CaptureScreen + App
Files:
- Modify:
src/hooks/useNoteForm.js - Modify:
src/components/CaptureScreen.jsx - Modify:
src/App.jsx
The navigation flow changes so App.jsx controls when goToNote fires (after animationend), not useNoteForm directly.
2a: Update useNoteForm.js
- In
src/hooks/useNoteForm.js, destructureonCaptureReadyfromnav
Change line 18:
const { goToNote, onSaveSuccess } = navto:
const { goToNote, onSaveSuccess, onCaptureReady } = nav- Replace all three
if (goToNote) goToNote()calls inhandleImageSelected
There are three sites in handleImageSelected (around lines 119, 126, and 156). Replace each with the fallback pattern:
if (onCaptureReady) onCaptureReady(compressedImage)else if (goToNote) goToNote()The full updated section of handleImageSelected — the three transition-to-note points — looks like:
// ── Early return: no marked text (imageCase 2) ──if (imageCase === 2) { const attr = [title, author].filter(Boolean).join(' - ') setTranscribeError(`No marked-up text found${attr ? ` (detected: ${attr})` : ''}. Highlight or underline passages before photographing.`) if (page) setNotePage(page) if (title || author) setDetectedAttribution({ title: title || '', author: author || '' }) setTranscribeLoading(false) if (onCaptureReady) onCaptureReady(compressedImage) else if (goToNote) goToNote() return}
// ── Early return: no text at all ──if (!text) { setTranscribeError('No readable text found in image.') setTranscribeLoading(false) if (onCaptureReady) onCaptureReady(compressedImage) else if (goToNote) goToNote() return}And at the end of the try/catch block (before autoDiscover):
setTranscribeLoading(false)if (onCaptureReady) onCaptureReady(compressedImage)else if (goToNote) goToNote()
if (transcribedText) { autoDiscover(transcribedText)}- Commit
git add src/hooks/useNoteForm.jsgit commit -m "feat(noteForm): call onCaptureReady(img) instead of goToNote — lets App control animation timing"2b: Update CaptureScreen.jsx
- Replace the file
import { Camera, PencilSimpleLine, Gear } from '@phosphor-icons/react'
export default function CaptureScreen({ onTakePhoto, onWriteNote, onOpenSettings, captureAnimImg, onCardSlideEnd}) { return ( <section className="capture-screen" aria-label="Capture a marked passage"> <div className="capture-film-grain" aria-hidden="true" />
<header className="capture-header"> <div> <p className="capture-kicker">Capture Mode</p> <h1>Record your highlights and annotations</h1> </div> <button className="icon-btn" onClick={onOpenSettings} aria-label="Open settings"> <Gear size={22} weight="light" /> </button> </header>
<div className="capture-viewfinder" role="img" aria-label="Camera viewfinder"> <div className="capture-viewfinder-overlay" aria-hidden="true" /> <div className="capture-bracket tl" aria-hidden="true" /> <div className="capture-bracket tr" aria-hidden="true" /> <div className="capture-bracket bl" aria-hidden="true" /> <div className="capture-bracket br" aria-hidden="true" /> <div className="capture-focus-glow" aria-hidden="true" /> <p className="capture-hint"> Mark up the passage first. Capture will ignore unmarked text. </p>
{captureAnimImg && ( <div className="capture-card-preview" onAnimationEnd={onCardSlideEnd} aria-hidden="true" > <img src={captureAnimImg.dataUrl} alt="" /> </div> )} </div>
<div className="capture-controls"> <button type="button" className="capture-shutter" onClick={onTakePhoto}> <span className="capture-shutter-ring" aria-hidden="true" /> <Camera size={28} weight="light" aria-hidden="true" /> <span>Capture Text</span> </button> <button type="button" className="capture-secondary" onClick={onWriteNote}> <PencilSimpleLine size={22} weight="light" aria-hidden="true" /> <span>Write Manually</span> </button> </div> </section> )}- Commit
git add src/components/CaptureScreen.jsxgit commit -m "feat(capture): render animated card preview on captureAnimImg prop"2c: Update App.jsx
- Add
captureAnimImgstate and two handler functions after thehandleRetakeCapturedefinition (around line 97)
const [captureAnimImg, setCaptureAnimImg] = useState(null)
function handleCaptureReady(img) { setCaptureAnimImg(img)}
function handleCardSlideEnd() { setCaptureAnimImg(null) ui.goToNote()}- Pass
onCaptureReadytouseNoteForm
Change the useNoteForm call from:
const noteForm = useNoteForm( { books, setBooks, notes, setNotes, apiKey, session, customIdeas }, { cloudWrite, showToast }, { goToNote: ui.goToNote, onSaveSuccess: ui.goToIndex })to:
const noteForm = useNoteForm( { books, setBooks, notes, setNotes, apiKey, session, customIdeas }, { cloudWrite, showToast }, { goToNote: ui.goToNote, onSaveSuccess: ui.goToIndex, onCaptureReady: handleCaptureReady })- Pass
captureAnimImgandonCardSlideEndtoCaptureScreen
Change the CaptureScreen usage from:
<CaptureScreen onTakePhoto={() => noteForm.cameraRef.current?.click()} onWriteNote={ui.goToNote} onOpenSettings={ui.openSettings}/>to:
<CaptureScreen onTakePhoto={() => noteForm.cameraRef.current?.click()} onWriteNote={ui.goToNote} onOpenSettings={ui.openSettings} captureAnimImg={captureAnimImg} onCardSlideEnd={handleCardSlideEnd}/>- Commit
git add src/App.jsxgit commit -m "feat(app): wire captureAnimImg state — delay goToNote until card-slide animationend"2d: Run animation tests — card slide
- Run only the animation test file
npm run test:run -- --reporter=verbose src/test/animations.test.jsxExpected: the three CaptureScreen — card slide preview tests PASS. The NoteForm tests still FAIL (ink bleed not wired yet).
Task 3: Ink bleed animation — NoteForm.jsx
Files:
- Modify:
src/components/NoteForm.jsx
When transcribeLoading transitions from true → false with non-empty noteText, render word-group spans instead of the visible textarea. The textarea stays in the DOM (hidden) so the Save button logic (noteText.trim()) is unaffected.
- Replace the file
import { useEffect, useRef, useState } from 'react'import { X, Plus, ArrowCounterClockwise, Lightbulb, Tag} from '@phosphor-icons/react'
// Split a string into groups of `size` words, return array of strings.function toWordGroups(text, size = 4) { const words = text.trim().split(/\s+/) const groups = [] for (let i = 0; i < words.length; i += size) { groups.push(words.slice(i, i + size).join(' ')) } return groups}
export default function NoteForm({ books, noteText, onNoteTextChange, noteBook, onNoteBookChange, notePage, onNotePageChange, pendingTags, onRemoveTag, capturedImage, transcribeLoading, transcribeError, onRetranscribe, onClearCapture, onRetakeCapture, onDismissTranscribeError, detectedAttribution, onAddDetectedBook, onDismissAttribution, aiLoading, aiError, hasAutoDiscovered, onManualDiscover, onSave, onNavigateSources, onClose}) { const canSave = Boolean(noteText.trim()) && !transcribeLoading && !aiLoading const showManualDiscover = !hasAutoDiscovered && !aiLoading && noteText.trim().length > 0
// ── Ink bleed state ──────────────────────────────────────────────────────── // Tracks the previous value of transcribeLoading to detect the true→false transition. const prevTranscribeLoadingRef = useRef(transcribeLoading) const noteTextRef = useRef(noteText) const inkBleedTimerRef = useRef(null) const [isInkBleeding, setIsInkBleeding] = useState(false) const [inkBleedGroups, setInkBleedGroups] = useState([])
// Keep noteTextRef current so the effect below can read it without a stale closure. useEffect(() => { noteTextRef.current = noteText }, [noteText])
useEffect(() => { const wasLoading = prevTranscribeLoadingRef.current prevTranscribeLoadingRef.current = transcribeLoading
// Only fire on the true→false transition with non-empty text. if (wasLoading && !transcribeLoading && noteTextRef.current.trim()) { const groups = toWordGroups(noteTextRef.current) setInkBleedGroups(groups) setIsInkBleeding(true)
// Auto-dismiss: last group's delay (groups.length - 1) * 50ms + 300ms animation + 100ms buffer const totalMs = (groups.length - 1) * 50 + 300 + 100 clearTimeout(inkBleedTimerRef.current) inkBleedTimerRef.current = setTimeout(() => setIsInkBleeding(false), totalMs) } }, [transcribeLoading])
// Clean up timer on unmount. useEffect(() => () => clearTimeout(inkBleedTimerRef.current), [])
// ── Render ─────────────────────────────────────────────────────────────────
return ( <div className="note-form-screen"> <header className="note-form-header"> <div> <p className="note-form-kicker">New Note</p> <h1>Describe the idea in your own words</h1> </div> <button className="icon-btn" onClick={onClose} aria-label="Close note editor"> <X size={22} weight="light" /> </button> </header>
<div className="note-form-body"> <label className="field-label" htmlFor="note-source">Source</label> <select id="note-source" className="inp source-select" value={noteBook} onChange={e => onNoteBookChange(e.target.value)} > <option value="">-- Select source --</option> {books.map(b => ( <option key={b.id} value={b.id}> {b.title}{b.author ? ` - ${b.author}` : ''} </option> ))} </select> <button type="button" className="text-link" onClick={onNavigateSources}> <Plus size={14} weight="light" /> Add new source </button>
<div className="note-form-row"> <label className="field-label" htmlFor="note-page">Page / Section</label> <input id="note-page" className="inp" placeholder="Optional" value={notePage} onChange={e => onNotePageChange(e.target.value)} /> </div>
{capturedImage && ( <div className="capture-preview-card"> <img src={capturedImage.dataUrl} alt="Captured source" /> <div className="capture-preview-actions"> <button className="text-link" onClick={onRetranscribe}>Re-run transcription</button> <button className="text-link" onClick={() => { onClearCapture(); onRetakeCapture?.() }}> Retake photo </button> </div> </div> )}
{detectedAttribution && ( <div className="attribution-banner"> <div> <div className="attribution-label">Source detected</div> <div className="attribution-value"> {detectedAttribution.title} {detectedAttribution.author ? ` - ${detectedAttribution.author}` : ''} </div> </div> <div className="attribution-actions"> <button className="btn btn-teal btn-sm" onClick={onAddDetectedBook}>Add and select</button> <button className="text-link" onClick={onDismissAttribution}>Dismiss</button> </div> </div> )}
{transcribeError && ( <div className="transcribe-error-banner"> <p>We couldn't quite read that passage. Would you like to try again or type it manually?</p> <div className="transcribe-error-actions"> <button className="btn btn-outline" onClick={onRetakeCapture}> <ArrowCounterClockwise size={16} weight="light" /> Retake </button> <button className="text-link" onClick={onDismissTranscribeError}>Type manually</button> </div> </div> )}
<label className="field-label" htmlFor="note-text">Note text</label>
{/* ── Ink bleed wrapper ── */} <div className="note-text-field"> <textarea id="note-text" className={`inp textarea${isInkBleeding ? ' text-hidden' : ''}`} placeholder="Transcribed text appears here - or type directly..." value={noteText} onChange={e => onNoteTextChange(e.target.value)} /> {isInkBleeding && ( <div className="ink-bleed-display" aria-hidden="true"> {inkBleedGroups.map((group, i) => ( <span key={i} className="ink-bleed-group" style={{ animationDelay: `${i * 50}ms` }} > {group} </span> ))} </div> )} </div>
{transcribeLoading && ( <div className="inline-status">Transcribing...</div> )} {aiLoading && ( <div className="inline-status"> <Lightbulb size={14} weight="light" /> Discovering ideas... </div> )}
{showManualDiscover && ( <button type="button" className="btn btn-ghost" onClick={onManualDiscover}> <Lightbulb size={16} weight="light" /> Discover Ideas </button> )}
{aiError && <div className="ai-error">{aiError}</div>}
{pendingTags.length > 0 && ( <div className="detected-tags"> <div className="field-label">Ideas detected</div> <div className="tags-row"> {pendingTags.map((tag, i) => ( <button key={tag} className="idea-chip" style={{ animationDelay: `${i * 50}ms` }} onClick={() => onRemoveTag(tag)} > <Tag size={12} weight="light" /> {tag} </button> ))} </div> </div> )} </div>
<div className="note-form-footer"> <button className="btn btn-accent" onClick={onSave} disabled={!canSave}> Save Note </button> </div> </div> )}- Commit
git add src/components/NoteForm.jsxgit commit -m "feat(noteForm): ink bleed reveal on transcription success; chip stagger animationDelay"Task 4: Run and fix all animation tests
- Run the full animation test file
npm run test:run -- --reporter=verbose src/test/animations.test.jsxExpected: all tests PASS.
If any fail, diagnose carefully:
-
ink-bleed-displaynot rendering: Check theuseEffectdependencies — it must depend on[transcribeLoading]only and readnoteTextRef.currentvia ref. -
chip animationDelaywrong: Verify thependingTags.map((tag, i) => ...)usesicorrectly. -
CaptureScreencard: VerifycaptureAnimImgprop is being destructured in the component. -
Run the full test suite
npm run test:runExpected: all tests pass. If capture.test.jsx tests for getByDisplayValue fail during the ink-bleed phase (textarea is opacity:0 but still present in DOM), confirm that getByDisplayValue matches by value, not visibility — it should still pass. If a test fails due to timing, use vi.useFakeTimers() to advance past the auto-dismiss setTimeout:
If needed, add to the relevant test:
import { act } from '@testing-library/react'// ...vi.useFakeTimers()// ... fire event ...await act(async () => { vi.runAllTimers() })- Commit any fixes
git add -Agit commit -m "test(animations): fix any timing issues with vi.useFakeTimers"Task 5: Verify FUNCTIONAL.md Rule 8 compliance
Read each constraint from FUNCTIONAL.md Rule 8 and confirm each is met before the build step:
- Confirm: no JS animation loops
Grep for requestAnimationFrame, setInterval, and animation loop patterns:
grep -n "requestAnimationFrame\|setInterval" src/components/CaptureScreen.jsx src/components/NoteForm.jsx src/App.jsx src/hooks/useNoteForm.jsExpected: no matches.
- Confirm: no animation blocks during active API call
The ink bleed display uses opacity:0 on the textarea while isInkBleeding = true. The Save button is enabled as soon as !transcribeLoading && noteText.trim() — it does NOT wait for isInkBleeding to be false. The canSave check in NoteForm is:
const canSave = Boolean(noteText.trim()) && !transcribeLoading && !aiLoadingisInkBleeding is not in canSave. Confirm this in the NoteForm source.
- Confirm: no transform on the video element
The .capture-card-preview is positioned inside .capture-viewfinder (a div), not on the <video> element itself. The @keyframes card-slide-down targets only .capture-card-preview. Confirm:
grep -n "video" src/components/CaptureScreen.jsxExpected: no <video> element present in CaptureScreen (the camera is triggered via the file input ref, not a live video preview). Rule 8 constraint is satisfied.
- Confirm: ink bleed filter is on individual spans, not the textarea or its parent
In NoteForm.jsx, filter: blur() is in the @keyframes ink-bleed-in rule, applied to .ink-bleed-group spans inside .ink-bleed-display, which is pointer-events: none and aria-hidden. The textarea sits behind it with no filter applied. Confirm by reading the CSS block from Task 1.
Task 6: Build verification + PR
- Run the production build
npm run buildExpected: exits with 0 errors.
- Run full test suite one final time
npm run test:runExpected: all green.
- Push branch
git push -u origin design/capture-animations- Open PR
gh pr create \ --title "feat: capture animations — card slide, ink bleed, chip fade-in" \ --body "$(cat <<'EOF'## Summary- Card slide-down: captured image animates as a filing card before navigating to NoteForm (~400ms, CSS-only)- Ink bleed: transcribed text reveals word-group by word-group with blur→focus fade; textarea stays active so Save is never blocked- Idea chip fade-in: each chip staggered 50ms apart using inline `animationDelay`- All animations are CSS `@keyframes` — no JS loops, no `requestAnimationFrame`, no blocking during API calls (FUNCTIONAL.md Rule 8)- `useNoteForm` now calls `nav.onCaptureReady(img)` instead of `nav.goToNote()` so App controls the animation→navigate handoff via `onAnimationEnd`
## Spec`docs/ui-ux/user-journey-v2.md` §5
## Test plan- [ ] `npm run test:run` — all tests green- [ ] `npm run build` — zero errors- [ ] Manual: capture a photo — confirm card slides down before Note form appears- [ ] Manual: confirm transcribed text appears word-by-word with blur effect- [ ] Manual: confirm Save Note is enabled immediately after text lands (does not wait for animation)- [ ] Manual: trigger discover — confirm chips fade in with stagger- [ ] Manual: confirm no animation when transcription fails (ink bleed skipped)- [ ] Manual: confirm capture screen viewfinder background/video stream is not filtered or transformed
🤖 Generated with [Claude Code](https://claude.com/claude-code)EOF)"Self-Review
Spec coverage check
| Spec requirement | Task |
|---|---|
Card slide: transform: scale(1) translateY(0) → scale(0.3) translateY(calc(100vh - 80px)), 400ms, cubic-bezier(0.4, 0, 0.8, 0.6) | Task 1 CSS, Task 2 wiring |
| Card slide fires on every shutter tap regardless of transcription outcome | Task 2a: all three goToNote call sites replaced |
| Ink bleed fires only on transcription success; skipped on failure | Task 3: wasLoading && !transcribeLoading && noteTextRef.current.trim() guards it |
Ink bleed: word groups of 3–5 words, 50ms stagger, 300ms per group, opacity/blur | Task 1 CSS + Task 3 toWordGroups(text, 4) |
Save button active as soon as transcribeLoading = false && noteText.trim() — does not wait for ink bleed | Task 3: canSave does not include isInkBleeding |
Chip fade-in: opacity: 0→1, translateY(4px)→0, 200ms, ease-out | Task 1 CSS |
| Chip stagger: 50ms per chip | Task 3 chip render: animationDelay: ${i * 50}ms |
No JS animation loops, no requestAnimationFrame | Task 5 Rule 8 compliance check |
No animation on raw <video> element | Task 5 — no <video> in CaptureScreen; card preview is a div |
| No animation blocks UI during active API call | Task 5 — isInkBleeding not in canSave |
Placeholder scan
No TBDs, no “add appropriate handling”, no forward references to undefined types.
Type consistency
captureAnimImg— shape{ dataUrl: string }— set inhandleCaptureReady(img)(Task 2c), read inCaptureScreenascaptureAnimImg.dataUrl(Task 2b), tested inanimations.test.jsx(Task 1). ✓onCaptureReady— added tonavdestructuring inuseNoteForm(Task 2a), passed fromApp.jsx(Task 2c). ✓inkBleedGroups—string[], produced bytoWordGroups(text, 4)(Task 3), rendered as.ink-bleed-groupspans. ✓isInkBleeding—boolean, controlled byuseEffectwatchingtranscribeLoading(Task 3). ✓