Skip to content

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

FileChange
src/styles.cssAppend 3 keyframes + supporting utility classes
src/hooks/useNoteForm.jshandleImageSelected calls nav.onCaptureReady(img) instead of nav.goToNote()
src/components/CaptureScreen.jsxAccept captureAnimImg + onCardSlideEnd; render animated preview card
src/App.jsxAdd captureAnimImg state; wire onCaptureReady + onCardSlideEnd
src/components/NoteForm.jsxInk bleed display via local state; chip stagger via animationDelay style
src/test/capture.test.jsxAdd animation behaviour tests

Pre-flight

  • Checkout branch (create from merged Branch 1)
Terminal window
git checkout design/ui-structure-overhaul
git pull
git checkout -b design/capture-animations
  • Confirm tests pass before touching anything
Terminal window
npm run test:run

Expected: 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
Terminal window
npm run test:run -- --reporter=verbose src/test/animations.test.jsx

Expected: 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
Terminal window
git add src/styles.css src/test/animations.test.jsx
git 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, destructure onCaptureReady from nav

Change line 18:

const { goToNote, onSaveSuccess } = nav

to:

const { goToNote, onSaveSuccess, onCaptureReady } = nav
  • Replace all three if (goToNote) goToNote() calls in handleImageSelected

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
Terminal window
git add src/hooks/useNoteForm.js
git 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
Terminal window
git add src/components/CaptureScreen.jsx
git commit -m "feat(capture): render animated card preview on captureAnimImg prop"

2c: Update App.jsx

  • Add captureAnimImg state and two handler functions after the handleRetakeCapture definition (around line 97)
const [captureAnimImg, setCaptureAnimImg] = useState(null)
function handleCaptureReady(img) {
setCaptureAnimImg(img)
}
function handleCardSlideEnd() {
setCaptureAnimImg(null)
ui.goToNote()
}
  • Pass onCaptureReady to useNoteForm

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 captureAnimImg and onCardSlideEnd to CaptureScreen

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
Terminal window
git add src/App.jsx
git 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
Terminal window
npm run test:run -- --reporter=verbose src/test/animations.test.jsx

Expected: 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
Terminal window
git add src/components/NoteForm.jsx
git 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
Terminal window
npm run test:run -- --reporter=verbose src/test/animations.test.jsx

Expected: all tests PASS.

If any fail, diagnose carefully:

  • ink-bleed-display not rendering: Check the useEffect dependencies — it must depend on [transcribeLoading] only and read noteTextRef.current via ref.

  • chip animationDelay wrong: Verify the pendingTags.map((tag, i) => ...) uses i correctly.

  • CaptureScreen card: Verify captureAnimImg prop is being destructured in the component.

  • Run the full test suite

Terminal window
npm run test:run

Expected: 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
Terminal window
git add -A
git 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:

Terminal window
grep -n "requestAnimationFrame\|setInterval" src/components/CaptureScreen.jsx src/components/NoteForm.jsx src/App.jsx src/hooks/useNoteForm.js

Expected: 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 && !aiLoading

isInkBleeding 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:

Terminal window
grep -n "video" src/components/CaptureScreen.jsx

Expected: 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
Terminal window
npm run build

Expected: exits with 0 errors.

  • Run full test suite one final time
Terminal window
npm run test:run

Expected: all green.

  • Push branch
Terminal window
git push -u origin design/capture-animations
  • Open PR
Terminal window
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 requirementTask
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 outcomeTask 2a: all three goToNote call sites replaced
Ink bleed fires only on transcription success; skipped on failureTask 3: wasLoading && !transcribeLoading && noteTextRef.current.trim() guards it
Ink bleed: word groups of 3–5 words, 50ms stagger, 300ms per group, opacity/blurTask 1 CSS + Task 3 toWordGroups(text, 4)
Save button active as soon as transcribeLoading = false && noteText.trim() — does not wait for ink bleedTask 3: canSave does not include isInkBleeding
Chip fade-in: opacity: 0→1, translateY(4px)→0, 200ms, ease-outTask 1 CSS
Chip stagger: 50ms per chipTask 3 chip render: animationDelay: ${i * 50}ms
No JS animation loops, no requestAnimationFrameTask 5 Rule 8 compliance check
No animation on raw <video> elementTask 5 — no <video> in CaptureScreen; card preview is a div
No animation blocks UI during active API callTask 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 in handleCaptureReady(img) (Task 2c), read in CaptureScreen as captureAnimImg.dataUrl (Task 2b), tested in animations.test.jsx (Task 1). ✓
  • onCaptureReady — added to nav destructuring in useNoteForm (Task 2a), passed from App.jsx (Task 2c). ✓
  • inkBleedGroupsstring[], produced by toWordGroups(text, 4) (Task 3), rendered as .ink-bleed-group spans. ✓
  • isInkBleedingboolean, controlled by useEffect watching transcribeLoading (Task 3). ✓