Surfc UI/UX Overhaul — Branch 1 Implementation Plan
Surfc UI/UX Overhaul — Branch 1 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: Replace the current Home→Review→Note flow with a Capture-First architecture: default launch on the camera, remove ReviewScreen, auto-chain transcription→discover-ideas, and add Sources and Active Ideas screens.
Architecture: Navigation state lives in useUI.js (default 'capture'). Auto-discover chaining lives in useNoteForm.js behind a hasAutoDiscovered flag. Two new screens (SourcesScreen, IdeasScreen) are pure UI extractions — all data operations go through existing db.js / cloudWrite paths. The NoteForm panel stays permanently mounted in the DOM so React state survives navigation to sources/active-ideas.
Tech Stack: React 18, Vite, Dexie.js, Vitest + @testing-library/react, Phosphor React (@phosphor-icons/react, weight "light" always), existing tokens.css variables throughout.
Spec: docs/ui-ux/user-journey-v2.md
Branch: design/ui-structure-overhaul (create from main)
Pre-flight
- Create feature branch
git checkout main && git pullgit checkout -b design/ui-structure-overhaul- Confirm tests pass on main before touching anything
npm run test:runExpected: all green.
Task 1: Update FUNCTIONAL.md
Files:
-
Modify:
FUNCTIONAL.md -
Replace the file contents
# Surfc Functional Contract (DO NOT BREAK)
## 1. Data Persistence (Dexie Heartbeat)- Every UI action MUST persist to Dexie first.- The UI must boot from IndexedDB via `loadAll`.- **Rule:** Never replace `useAuth` or `db.js` logic for the sake of UI speed.
## 2. Sync Choreography (Outbox-First)- All writes must enqueue through the Dexie outbox.- Redesigned buttons must call existing sync hooks (`syncFromCloud`) rather than direct API calls.
## 3. The Ingestion Pipeline- The Capture UI feeds the raw photo into `ingest(photoAdapter)`.- `ReviewScreen` is removed — on capture, the app transitions directly to the Note form.- Transcription state (`transcribeLoading`, `transcribeError`) is managed inline on the Note form.- **Constraint:** The film grain overlay must not block access to the raw video stream required by `api.js`.
## 4. Idea Mapping- AI output must always be funnelled through `callDiscoverIdeas` to ensure it maps back to the 102 "Great Ideas."
## 5. Auto-Discover Chaining- When transcription succeeds, `discoverIdeas` must be triggered automatically, exactly once per capture event, without user input.- A `hasAutoDiscovered` boolean flag on `useNoteForm` prevents re-triggering on subsequent text edits.- If transcription fails or produces no text, `discoverIdeas` must not fire.- On the manual entry path (no photo taken), a visible "Discover Ideas" button remains available as a fallback and is shown when `!hasAutoDiscovered && !aiLoading && noteText.trim()`.
## 6. Sources Screen Contract- All book/source CRUD is performed through `db.js` (`saveBook`, `deleteBook`) and `cloudWrite`, identical to the existing flow.- `SourcesScreen` is a UI extraction only — no new data operations or Dexie stores.- The source `<select>` dropdown on the Note form and the Sources screen both read from the shared `books` state provided by `useAuth`.
## 7. Navigation Contract- The default `mobileView` on app launch is `'capture'`.- Valid view states: `home`, `capture`, `note`, `index`, `sources`, `active-ideas`, `related-notes`.- The state `'review'` is removed.- The Home Screen is the only screen that does not render the shared 3-button bottom nav.
## 8. Animation Contract- All animations are CSS `transition` or `@keyframes` only — no JS animation loops.- No animation may block the UI thread during an active API call.- Animations must not apply `filter` or `transform` to the raw `<video>` element used by the camera.
## 9. Error Copy Contract- Transcription errors use `--color-warning-bg` and `--color-warning` tokens (warm amber).- `--color-destructive` (red) is reserved for delete actions only.- Approved copy for transcription failure: "We couldn't quite read that passage. Would you like to try again or type it manually?"- Commit
git add FUNCTIONAL.mdgit commit -m "docs(functional): add rules 5-9, update rule 3 for capture-first flow"Task 2: Update useUI.js — Navigation state machine
Files:
-
Modify:
src/hooks/useUI.js -
Replace the file
import { useState } from 'react'
export function useUI(notes, customIdeas) { const [mobileView, setMobileView] = useState('capture') // ← was 'home' const [selectedIdea, setSelectedIdea] = useState(null) const [ideaSearch, setIdeaSearch] = useState('') const [showSettings, setShowSettings] = useState(false) const [lightboxImg, setLightboxImg] = useState(null) const [relatedNotesNote, setRelatedNotesNote] = useState(null) const [previousMobileView, setPreviousMobileView] = useState('index') const [previousNoteView, setPreviousNoteView] = useState('capture') // tracks where note was opened from
// ── COMPUTED ───────────────────────────────────────────────────────────────
const ideaCounts = {} notes.forEach(n => (n.tags || []).forEach(t => { ideaCounts[t] = (ideaCounts[t] || 0) + 1 }))
const customSet = new Set(customIdeas.map(c => c.name)) function isCustom(name) { return customSet.has(name) }
const notesForIdea = selectedIdea ? notes.filter(n => (n.tags || []).includes(selectedIdea)) : []
// ── NAMED NAVIGATION ACTIONS ───────────────────────────────────────────────
function goToHome() { setMobileView('home') } function goToIdeas() { setMobileView('ideas') } function goToNote() { setPreviousNoteView(mobileView) setMobileView('note') } function goToIndex() { setMobileView('index') } function goToCapture() { setMobileView('capture') } function goToSources() { setMobileView('sources') } function goToActiveIdeas() { setMobileView('active-ideas') }
function goBackFromNote() { setMobileView(previousNoteView || 'capture') }
function goToRelatedNotes(note) { if (mobileView !== 'related-notes') setPreviousMobileView(mobileView) setRelatedNotesNote(note) setMobileView('related-notes') }
function goBackFromRelatedNotes() { setMobileView(previousMobileView || 'index') }
function selectIdea(idea) { setSelectedIdea(prev => prev === idea ? null : idea) goToIdeas() }
function syncSelectedIdea(idea) { setSelectedIdea(idea) }
function filterByIdea(idea) { setSelectedIdea(prev => prev === idea ? null : idea) }
function clearSelectedIdea() { setSelectedIdea(null) }
function openSettings() { setShowSettings(true) } function closeSettings() { setShowSettings(false) }
function openLightbox(img) { setLightboxImg(img) } function closeLightbox() { setLightboxImg(null) }
return { mobileView, setMobileView, selectedIdea, ideaSearch, setIdeaSearch, showSettings, lightboxImg, // computed ideaCounts, isCustom, notesForIdea, // named actions relatedNotesNote, goToHome, goToIdeas, goToNote, goToIndex, goToCapture, goToSources, goToActiveIdeas, goBackFromNote, goToRelatedNotes, goBackFromRelatedNotes, selectIdea, syncSelectedIdea, filterByIdea, clearSelectedIdea, openSettings, closeSettings, openLightbox, closeLightbox }}- Commit
git add src/hooks/useUI.jsgit commit -m "feat(ui): default launch on capture, add sources/active-ideas navigation"Task 3: Update useNoteForm.js — Auto-discover chaining
Files:
- Modify:
src/hooks/useNoteForm.js
The changes: add hasAutoDiscovered state, auto-trigger discoverIdeas after successful transcription, expose dismissTranscribeError and addBookDirectly, reset flag on clearCapture and saveNoteForm, remove bookTitle/bookAuthor state (no longer needed — those fields move to SourcesScreen).
- Replace the file
import { useState, useRef } from 'react'import { saveBook, deleteBook as dbDeleteBook, saveNote, deleteNote as dbDeleteNote, setNoteImagePath} from '../db.js'import { uploadImage } from '../supabase.js'import { callDiscoverIdeas } from '../api.js'import { uid, fileToBase64, compressImage } from '../utils.js'import { ingest } from '../ingest/index.js'import { photoAdapter } from '../ingest/photoAdapter.js'
export function useNoteForm(data, fns, nav) { const { books, setBooks, notes, setNotes, apiKey, session } = data const { cloudWrite, showToast } = fns const { goToNote, onSaveSuccess } = nav
// ── NOTE FORM STATE ────────────────────────────────────────────────────────
const [noteText, setNoteText] = useState('') const [noteBook, setNoteBook] = useState('') const [notePage, setNotePage] = useState('') const [pendingTags, setPendingTags] = useState([]) const [aiLoading, setAiLoading] = useState(false) const [aiError, setAiError] = useState('') const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false)
// ── IMAGE CAPTURE STATE ────────────────────────────────────────────────────
const [capturedImage, setCapturedImage] = useState(null) const [capturedMeta, setCapturedMeta] = useState(null) const [capturedSourceId, setCapturedSourceId] = useState(null) const [capturedSource, setCapturedSource] = useState(null) const [transcribeLoading, setTranscribeLoading] = useState(false) const [transcribeError, setTranscribeError] = useState('') const [detectedAttribution, setDetectedAttribution] = useState(null) const cameraRef = useRef(null) const fileRef = useRef(null)
// ── BOOKS ──────────────────────────────────────────────────────────────────
// Used by SourcesScreen — takes title/author directly (no shared input state). async function addBookDirectly(title, author) { if (!title.trim()) return const book = { id: uid(), title: title.trim(), author: (author || '').trim(), createdAt: Date.now() } const saved = await saveBook(book) await cloudWrite('books', saved) setBooks(b => [...b, saved]) showToast(`"${book.title}" added`) }
async function handleDeleteBook(id) { await dbDeleteBook(id) const book = books.find(b => b.id === id) if (book) await cloudWrite('books', { ...book, deleted: 1, updatedAt: Date.now() }) setBooks(b => b.filter(x => x.id !== id)) setNotes(n => n.filter(x => x.bookId !== id)) }
async function addDetectedBook() { if (!detectedAttribution) return const book = { id: uid(), title: detectedAttribution.title || 'Unknown Title', author: detectedAttribution.author || '', createdAt: Date.now() } const saved = await saveBook(book) await cloudWrite('books', saved) setBooks(b => [...b, saved]) setNoteBook(saved.id) setDetectedAttribution(null) showToast(`"${book.title}" added and selected`) }
// ── IMAGE CAPTURE ──────────────────────────────────────────────────────────
async function handleImageSelected(e) { const file = e.target.files?.[0] if (!file) return e.target.value = '' setTranscribeLoading(true) setTranscribeError('')
const compressedImage = { dataUrl: await compressImage(await fileToBase64(file)), mimeType: 'image/jpeg' } setCapturedImage(compressedImage)
let transcribedText = null
try { const [note] = await ingest(photoAdapter, compressedImage, { apiKey }) const { text, source, sourceId, sourceMeta } = note const { case: imageCase, title, author, page } = sourceMeta
setCapturedMeta(sourceMeta) setCapturedSourceId(sourceId) setCapturedSource(source)
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 (goToNote) goToNote() return }
if (!text) { setTranscribeError('No readable text found in image.') setTranscribeLoading(false) if (goToNote) goToNote() return }
transcribedText = text setNoteText(prev => prev ? `${prev}\n\n${text}` : text) if (page) setNotePage(page)
if (title || author) { const normTitle = (title || '').toLowerCase().trim() const normAuthor = (author || '').toLowerCase().trim() const matched = books.find(b => (normTitle && b.title.toLowerCase().includes(normTitle)) || (normAuthor && b.author.toLowerCase().includes(normAuthor)) ) if (matched) { setNoteBook(matched.id); showToast(`Transcribed · matched: ${matched.title}`) } else { setDetectedAttribution({ title: title || '', author: author || '' }); showToast('Transcribed · unrecognised source — see below') } } else { showToast(imageCase === 3 ? 'Handwritten notes transcribed' : 'Marked passages transcribed') } } catch (err) { setTranscribeError(err.isRateLimit ? err.message : `Transcription failed: ${err.message}`) }
setTranscribeLoading(false) if (goToNote) goToNote()
// Auto-trigger discover — only on transcription success, only once per capture. if (transcribedText && !hasAutoDiscovered) { setHasAutoDiscovered(true) setAiLoading(true) setAiError('') try { const { customIdeas } = data const valid = await callDiscoverIdeas(apiKey, transcribedText, customIdeas) setPendingTags(valid) if (!valid.length) setAiError('No matching ideas found — try a longer or more conceptual note.') } catch (err) { setAiError(err.isRateLimit ? err.message : `Discover Ideas failed: ${err.message}`) } setAiLoading(false) } }
async function retranscribeImage() { if (!capturedImage) { setTranscribeError('No image to retranscribe.'); return } setTranscribeLoading(true); setTranscribeError('') try { const { callTranscribeImage } = await import('../api.js') const { case: imageCase, text, title, author, page } = await callTranscribeImage(apiKey, capturedImage) const meta = { case: imageCase, title: title || null, author: author || null, page: page || null } setCapturedMeta(meta)
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); return }
if (!text?.trim()) { setTranscribeError('No readable text found in image.'); setTranscribeLoading(false); return }
setNoteText(prev => prev ? `${prev}\n\n${text.trim()}` : text.trim()) if (page) setNotePage(page)
if (title || author) { const normTitle = (title || '').toLowerCase().trim() const normAuthor = (author || '').toLowerCase().trim() const matched = books.find(b => (normTitle && b.title.toLowerCase().includes(normTitle)) || (normAuthor && b.author.toLowerCase().includes(normAuthor)) ) if (matched) { setNoteBook(matched.id); showToast(`Transcribed · matched: ${matched.title}`) } else { setDetectedAttribution({ title: title || '', author: author || '' }); showToast('Transcribed · unrecognised source — see below') } } else { showToast(imageCase === 3 ? 'Handwritten notes transcribed' : 'Marked passages transcribed') } } catch (err) { setTranscribeError(err.isRateLimit ? err.message : `Transcription failed: ${err.message}`) } setTranscribeLoading(false) }
// ── AI TAGGING ─────────────────────────────────────────────────────────────
// Manual fallback — used only when no auto-discover has run (manual entry path). async function discoverIdeas() { if (!noteText.trim()) return setAiLoading(true); setAiError(''); setPendingTags([]) try { const { customIdeas } = data const valid = await callDiscoverIdeas(apiKey, noteText, customIdeas) setPendingTags(valid) if (!valid.length) setAiError('No matching ideas found — try a longer or more conceptual note.') } catch (err) { setAiError(err.isRateLimit ? err.message : `Discover Ideas failed: ${err.message}`) } setAiLoading(false) setHasAutoDiscovered(true) // prevent double-fire even on manual trigger }
// ── NOTES ──────────────────────────────────────────────────────────────────
async function saveNoteForm() { if (!noteText.trim()) return const note = { id: uid(), bookId: noteBook || null, text: noteText.trim(), page: notePage.trim(), tags: pendingTags, imageDataUrl: capturedImage?.dataUrl || null, imagePath: null, createdAt: Date.now(), source: capturedSource || (capturedImage ? 'image' : 'manual'), sourceId: capturedSourceId || null, sourceMeta: capturedMeta || {} } const saved = await saveNote(note)
if (capturedImage?.dataUrl && session) { try { const path = await uploadImage(saved.id, capturedImage.dataUrl, session.user.id) await setNoteImagePath(saved.id, path) saved.imagePath = path } catch (err) { console.warn('Image upload failed, stored locally only:', err) } }
await cloudWrite('notes', saved) setNotes(n => [saved, ...n]) setNoteText(''); setNotePage(''); setPendingTags([]); setAiError('') setCapturedImage(null); setCapturedMeta(null); setCapturedSourceId(null); setCapturedSource(null) setTranscribeError(''); setDetectedAttribution(null) setHasAutoDiscovered(false) // reset for next capture showToast('Note saved' + (pendingTags.length ? ` · ${pendingTags.length} idea${pendingTags.length !== 1 ? 's' : ''} indexed` : '')) if (onSaveSuccess) onSaveSuccess() }
async function handleDeleteNote(id) { await dbDeleteNote(id) const note = notes.find(n => n.id === id) if (note) await cloudWrite('notes', { ...note, deleted: 1, updatedAt: Date.now() }) setNotes(n => n.filter(x => x.id !== id)) }
function syncPendingTagRename(oldName, newName) { setPendingTags(tags => tags.map(tag => tag === oldName ? newName : tag)) }
return { // state noteText, setNoteText, noteBook, setNoteBook, notePage, setNotePage, pendingTags, setPendingTags, aiLoading, aiError, hasAutoDiscovered, capturedImage, transcribeLoading, transcribeError, detectedAttribution, // refs cameraRef, fileRef, // named actions clearCapture() { setCapturedImage(null); setCapturedMeta(null) setCapturedSourceId(null); setCapturedSource(null) setTranscribeError(''); setDetectedAttribution(null) setHasAutoDiscovered(false) // reset so next capture can auto-discover }, dismissAttribution() { setDetectedAttribution(null) }, dismissTranscribeError() { setTranscribeError('') }, syncPendingTagRename, // handlers addBookDirectly, handleDeleteBook, addDetectedBook, handleImageSelected, retranscribeImage, discoverIdeas, saveNoteForm, handleDeleteNote }}- Commit
git add src/hooks/useNoteForm.jsgit commit -m "feat(noteForm): add hasAutoDiscovered flag, auto-chain discoverIdeas after transcription"Task 4: Create SourcesScreen.jsx
Files:
-
Create:
src/components/SourcesScreen.jsx -
Write the file
import { useState } from 'react'import { Gear } from '@phosphor-icons/react'
export default function SourcesScreen({ books, onAddBook, onDeleteBook, onOpenSettings }) { const [bookTitle, setBookTitle] = useState('') const [bookAuthor, setBookAuthor] = useState('')
function handleAdd() { if (!bookTitle.trim()) return onAddBook(bookTitle.trim(), bookAuthor.trim()) setBookTitle('') setBookAuthor('') }
return ( <div className="sources-screen"> <div className="screen-header"> <div /> <button className="settings-btn" onClick={onOpenSettings} aria-label="Settings"> <Gear size={22} weight="light" /> </button> </div>
<div className="screen-content"> <h1 className="screen-title">Sources</h1> <p className="screen-subtitle">Your reading library</p>
<div className="sources-add-form section-card"> <p className="section-title">Add source</p> <input className="inp" placeholder="Title" value={bookTitle} onChange={e => setBookTitle(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} /> <div className="sources-add-row"> <input className="inp" placeholder="Author" value={bookAuthor} onChange={e => setBookAuthor(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} /> <button className="btn btn-gold" onClick={handleAdd}>Add</button> </div> </div>
{books.length === 0 ? <p className="no-items-msg">No sources yet.</p> : ( <ul className="sources-list"> {books.map(b => ( <li key={b.id} className="sources-list-item"> <div> <div className="sources-item-title">{b.title}</div> {b.author && <div className="sources-item-author">{b.author}</div>} </div> <button className="btn-text-destructive" onClick={() => onDeleteBook(b.id)} > remove </button> </li> ))} </ul> ) } </div> </div> )}- Commit
git add src/components/SourcesScreen.jsxgit commit -m "feat: add SourcesScreen component"Task 5: Create IdeasScreen.jsx
Files:
-
Create:
src/components/IdeasScreen.jsx -
Write the file
import { Gear } from '@phosphor-icons/react'
export default function IdeasScreen({ ideaCounts, isCustom, onSelectIdea, onGoToIndex, onOpenSettings }) { const activeIdeas = Object.keys(ideaCounts) .filter(idea => ideaCounts[idea] > 0) .sort((a, b) => ideaCounts[b] - ideaCounts[a])
function handleIdeaTap(idea) { onSelectIdea(idea) onGoToIndex() }
return ( <div className="ideas-screen"> <div className="screen-header"> <div /> <button className="settings-btn" onClick={onOpenSettings} aria-label="Settings"> <Gear size={22} weight="light" /> </button> </div>
<div className="screen-content"> <h1 className="screen-title">Ideas</h1> <p className="screen-subtitle"> {activeIdeas.length} active · sorted by note volume </p>
{activeIdeas.length === 0 ? <p className="empty-state-msg">Capture your first note to see ideas here.</p> : ( <ul className="ideas-screen-list"> {activeIdeas.map(idea => ( <li key={idea} className="ideas-screen-item" onClick={() => handleIdeaTap(idea)} > <div className="ideas-screen-item-left"> <span className="idea-filter-dot" style={{ background: isCustom(idea) ? 'var(--color-custom)' : 'var(--color-accent)' }} /> <span className="ideas-screen-item-name">{idea}</span> </div> <span className="ideas-screen-badge" style={{ background: isCustom(idea) ? 'var(--color-custom-bg)' : 'var(--color-accent-light)', color: isCustom(idea) ? 'var(--color-custom-text)' : 'var(--color-accent-text)' }} > {ideaCounts[idea]} </span> </li> ))} </ul> ) } </div> </div> )}- Commit
git add src/components/IdeasScreen.jsxgit commit -m "feat: add IdeasScreen component — active ideas sorted by note volume"Task 6: Rewrite HomeScreen.jsx
Files:
-
Modify:
src/components/HomeScreen.jsx -
Replace the file
import { Gear, CaretRight } from '@phosphor-icons/react'
export default function HomeScreen({ notes, books, ideaCounts, onStartCapturing, onGoToIndex, onGoToActiveIdeas, onGoToSources, onOpenSettings}) { const activeIdeasCount = Object.keys(ideaCounts).filter(k => ideaCounts[k] > 0).length
return ( <div className="home-screen"> <div className="home-header"> <div /> <button className="settings-btn" onClick={onOpenSettings} aria-label="Settings"> <Gear size={22} weight="light" /> </button> </div>
<div className="home-stats-row"> <button className="home-stat-box" onClick={onGoToIndex}> <span className="home-stat-number">{notes.length}</span> <span className="home-stat-label">Notes</span> <CaretRight size={14} weight="light" className="home-stat-caret" /> </button> <button className="home-stat-box" onClick={onGoToActiveIdeas}> <span className="home-stat-number">{activeIdeasCount}</span> <span className="home-stat-label">Ideas</span> <CaretRight size={14} weight="light" className="home-stat-caret" /> </button> <button className="home-stat-box" onClick={onGoToSources}> <span className="home-stat-number">{books.length}</span> <span className="home-stat-label">Sources</span> <CaretRight size={14} weight="light" className="home-stat-caret" /> </button> </div>
<div className="home-logo-area"> <div className="home-logo">Surfc</div> <div className="home-tagline">A personal index of great ideas</div> </div>
<div className="home-cta-area"> <button className="btn btn-gold home-cta-primary" onClick={onStartCapturing}> Start Capturing </button> </div> </div> )}- Commit
git add src/components/HomeScreen.jsxgit commit -m "feat(home): stat boxes with CaretRight, logo placeholder, single CTA"Task 7: Update CaptureScreen.jsx — Remove internal nav
Files:
- Modify:
src/components/CaptureScreen.jsx
The only change is removing the NAV_ITEMS constant and the <nav className="capture-bottom-nav"> block. The shared nav in App.jsx takes over.
- Replace the file
import { Camera, PencilSimpleLine } from '@phosphor-icons/react'
export default function CaptureScreen({ onTakePhoto, onWriteNote }) { return ( <section className="capture-screen" aria-label="Capture a marked passage"> <div className="capture-film-grain" aria-hidden="true" />
<header className="capture-header"> <p className="capture-kicker">Capture Mode</p> <h1>Record your highlights and annotations</h1> </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> </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): remove internal nav — shared bottom nav takes over"Task 8: Rewrite NoteForm.jsx
Files:
- Modify:
src/components/NoteForm.jsx
Removes: Add Source section, Library tab, hideCapture zone, manual Discover Ideas button (default path), onOpenSettings gear icon.
Adds: X cancel (Phosphor), source <select>, + Add new source link, inline transcription error banner with ArrowCounterClockwise, auto-discover status indicator, conditional manual discover fallback, Deep Walnut text on Save button.
- Replace the file
import { X, ArrowCounterClockwise } from '@phosphor-icons/react'
export default function NoteForm({ // data books, // note state noteText, onNoteTextChange, noteBook, onNoteBookChange, notePage, onNotePageChange, pendingTags, onRemoveTag, // transcription transcribeLoading, transcribeError, onDismissTranscribeError, // attribution detectedAttribution, onAddDetectedBook, onDismissAttribution, // AI tagging aiLoading, aiError, hasAutoDiscovered, onDiscoverIdeas, // actions onSave, onCancel, onRetake, onGoToSources}) { const canSave = noteText.trim() && !transcribeLoading
return ( <div className="note-form-panel"> <div className="note-form-header"> <span className="note-form-title">New Note</span> <button className="note-form-cancel" onClick={onCancel} aria-label="Cancel"> <X size={22} weight="light" /> </button> </div>
<div className="section-card"> {/* Source dropdown */} <div className="form-field"> <label className="form-label">Source</label> <select className="inp" 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 className="link-btn" type="button" onClick={onGoToSources}> + Add new source </button> </div>
{/* Page */} <div className="form-field"> <label className="form-label">Page / §</label> <input className="inp" placeholder="Page / §" value={notePage} onChange={e => onNotePageChange(e.target.value)} style={{ maxWidth: 80 }} /> </div>
{/* Transcription error banner — warm amber, not red */} {transcribeError && ( <div className="transcribe-error-banner"> <p className="transcribe-error-msg"> 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-ghost btn-sm" type="button" onClick={onRetake}> <ArrowCounterClockwise size={16} weight="light" aria-hidden="true" /> Retake </button> <button className="btn-text" type="button" onClick={onDismissTranscribeError}> Type manually </button> </div> </div> )}
{/* Detected attribution banner — unchanged */} {detectedAttribution && ( <div className="attribution-banner"> <div className="attribution-banner-text"> <span className="attribution-banner-label">Source detected</span> <span className="attribution-banner-book"> {detectedAttribution.title} {detectedAttribution.author ? ` — ${detectedAttribution.author}` : ''} </span> </div> <div className="attribution-banner-actions"> <button className="btn btn-teal btn-sm" onClick={onAddDetectedBook}> + Add to library & select </button> <button className="btn btn-ghost btn-sm" onClick={onDismissAttribution}> Dismiss </button> </div> </div> )}
{/* Note textarea */} <textarea className="inp textarea" placeholder="Transcribed text appears here — or type / paste directly…" value={noteText} onChange={e => onNoteTextChange(e.target.value)} />
{/* Auto-discover in-flight indicator */} {aiLoading && ( <div className="status-row"> <span className="thinking">✦ Discovering ideas…</span> </div> )}
{/* Manual discover fallback — only shown when auto-discover hasn't run */} {!hasAutoDiscovered && !aiLoading && noteText.trim() && ( <button className="btn btn-ghost" type="button" onClick={onDiscoverIdeas}> ✦ Discover Ideas </button> )}
{aiError && <p className="ai-error">{aiError}</p>}
{pendingTags.length > 0 && ( <div className="tags-detected"> <p className="tags-detected-label">Ideas detected — tap to remove:</p> <div className="tags-row"> {pendingTags.map(tag => ( <button key={tag} className="idea-tag remove-mode" type="button" onClick={() => onRemoveTag(tag)} > {tag} ✕ </button> ))} </div> </div> )}
<div className="action-row"> {/* Deep Walnut text on amber — active as soon as text is ready */} <button className="btn btn-gold" type="button" onClick={onSave} disabled={!canSave} > Save Note </button> </div>
<p className="capture-hint">AI features send note text and images to Anthropic for processing.</p> </div> </div> )}- Commit
git add src/components/NoteForm.jsxgit commit -m "feat(noteForm): source dropdown, inline error, auto-discover status, Phosphor icons"Task 9: Update IndexScreen.jsx — Add Gear icon
Files:
- Modify:
src/components/IndexScreen.jsx
Add Gear import and onOpenSettings prop. Wrap the existing title div and subtitle div in a flex row with the gear button on the right.
- Add Gear import at the top
In src/components/IndexScreen.jsx, change line 1 from:
import { useState } from 'react'import { useLongPress } from '../hooks/useLongPress.js'to:
import { useState } from 'react'import { Gear } from '@phosphor-icons/react'import { useLongPress } from '../hooks/useLongPress.js'- Add
onOpenSettingsto the component signature
Change:
export default function IndexScreen({ notes, books, customIdeas, selectedIdea, onSelectIdea, onLongPress, rediscoveringId, onSetLightboxImg, isCustom}) {to:
export default function IndexScreen({ notes, books, customIdeas, selectedIdea, onSelectIdea, onLongPress, rediscoveringId, onSetLightboxImg, isCustom, onOpenSettings}) {- Replace the header area inside the component (currently the two bare
divelements for title and subtitle at line ~132):
Change:
<div style={{ padding: '16px 16px 0', flexShrink: 0 }}> <div style={{ fontSize: 'var(--text-display-size)', fontWeight: 'var(--text-display-weight)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-base)' }}> Your Index </div> <div style={{ fontSize: 'var(--text-caption-size)', color: 'var(--color-text-muted)', marginTop: 4 }}> {notes.length} notes across {activeIdeasCount} idea{activeIdeasCount !== 1 ? 's' : ''} </div>to:
<div style={{ padding: '16px 16px 0', flexShrink: 0 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ fontSize: 'var(--text-display-size)', fontWeight: 'var(--text-display-weight)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-base)' }}> Your Index </div> <button className="settings-btn" onClick={onOpenSettings} aria-label="Settings"> <Gear size={22} weight="light" /> </button> </div> <div style={{ fontSize: 'var(--text-caption-size)', color: 'var(--color-text-muted)', marginTop: 4 }}> {notes.length} notes across {activeIdeasCount} idea{activeIdeasCount !== 1 ? 's' : ''} </div>- Commit
git add src/components/IndexScreen.jsxgit commit -m "feat(index): add settings gear icon to header"Task 10: Rewrite App.jsx — Wire everything together
Files:
- Modify:
src/App.jsx
This is the largest task. Changes:
- Add imports for
SourcesScreen,IdeasScreen. RemoveReviewScreen,Library(from imports and render). - Update
useNoteFormnavarg — passgoToNoteinstead ofgoToLibrary/goToReview. - Remove the global
<header>block. - Render
home,capture,index,sources,active-ideas,related-notespanels. - NoteForm panel: always mounted, CSS-controlled visibility.
- Remove
reviewpanel entirely. - Bottom nav: 3 buttons, hidden on
homeview. - Pass new props to all updated components.
- Replace the file
import { useState } from 'react'import AuthScreen from './AuthScreen.jsx'import HomeScreen from './components/HomeScreen.jsx'import IndexScreen from './components/IndexScreen.jsx'import CaptureScreen from './components/CaptureScreen.jsx'import IdeasScreen from './components/IdeasScreen.jsx'import SourcesScreen from './components/SourcesScreen.jsx'import IdeasSidebar from './components/IdeasSidebar.jsx'import IdeaDetail from './components/IdeaDetail.jsx'import NoteForm from './components/NoteForm.jsx'import SettingsModal from './components/SettingsModal.jsx'import NoteActionSheet from './components/NoteActionSheet.jsx'import RelatedNotesScreen from './components/RelatedNotesScreen.jsx'import NoteEditForm from './components/NoteEditForm.jsx'import IdeaActionSheet from './components/IdeaActionSheet.jsx'import IdeaEditForm from './components/IdeaEditForm.jsx'import { useNoteActions } from './hooks/useNoteActions.js'import { useToast } from './hooks/useToast.js'import { useAuth } from './hooks/useAuth.js'import { useUI } from './hooks/useUI.js'import { useNoteForm } from './hooks/useNoteForm.js'import { useSettings } from './hooks/useSettings.js'
export default function App() { const { toast, showToast } = useToast()
const { session, setSession, online, syncing, syncStatus, ready, books, setBooks, notes, setNotes, customIdeas, setCustomIdeas, apiKey, setApiKeyState, syncFromCloud, cloudWrite, handleSignOut } = useAuth(showToast)
const ui = useUI(notes, customIdeas)
const noteForm = useNoteForm( { books, setBooks, notes, setNotes, apiKey, session, customIdeas }, { cloudWrite, showToast }, { goToNote: ui.goToNote, onSaveSuccess: ui.goToIndex } )
const noteActions = useNoteActions( { notes, setNotes, apiKey, customIdeas, session }, { cloudWrite, showToast } )
const [activeNote, setActiveNote] = useState(null) const [activeAction, setActiveAction] = useState(null)
function handleLongPress(note) { setActiveNote(note); setActiveAction('sheet') } function closeAction() { setActiveNote(null); setActiveAction(null) }
const [activeIdea, setActiveIdea] = useState(null) const [activeIdeaAction, setActiveIdeaAction] = useState(null)
function handleLongPressIdea(idea) { setActiveIdea(idea); setActiveIdeaAction('sheet') } function closeIdeaAction() { setActiveIdea(null); setActiveIdeaAction(null) }
async function handleSaveIdeaEdit({ name, description }) { const ok = await settings.handleEditCustomIdea(activeIdea.id, name, description) if (ok) closeIdeaAction() }
const settings = useSettings( { customIdeas, setCustomIdeas, books, setBooks, notes, setNotes, apiKey, setApiKeyState }, { session, cloudWrite, showToast }, { clearSelectedIdea: ui.clearSelectedIdea, selectedIdea: ui.selectedIdea, syncSelectedIdea: ui.syncSelectedIdea, syncPendingTagRename: noteForm.syncPendingTagRename } )
if (session === undefined) return <div className="loading">Loading…</div> if (session === null) return <AuthScreen onAuth={s => setSession(s)} /> if (!ready) return <div className="loading">Loading your library…</div>
const ideaDetailProps = { selectedIdea: ui.selectedIdea, customIdeas, notesForIdea: ui.notesForIdea, books, isCustom: ui.isCustom, onBack: ui.clearSelectedIdea, onSelectIdea: ui.selectIdea, onLightbox: ui.openLightbox, onLongPress: handleLongPress, rediscoveringId: noteActions.rediscoveringId }
return ( <> <input ref={noteForm.cameraRef} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={noteForm.handleImageSelected} /> <input ref={noteForm.fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={noteForm.handleImageSelected} />
<div className="app"> <div className="layout">
{/* ── HOME ── */} {ui.mobileView === 'home' && ( <div className="mobile-only-panel"> <HomeScreen notes={notes} books={books} ideaCounts={ui.ideaCounts} onStartCapturing={ui.goToCapture} onGoToIndex={ui.goToIndex} onGoToActiveIdeas={ui.goToActiveIdeas} onGoToSources={ui.goToSources} onOpenSettings={ui.openSettings} /> </div> )}
{/* ── CAPTURE ── */} {ui.mobileView === 'capture' && ( <div className="mobile-only-panel"> <CaptureScreen onTakePhoto={() => noteForm.cameraRef.current.click()} onWriteNote={ui.goToNote} /> </div> )}
{/* ── INDEX ── */} {ui.mobileView === 'index' && ( <div className="mobile-only-panel"> <IndexScreen notes={notes} books={books} customIdeas={customIdeas} selectedIdea={ui.selectedIdea} onSelectIdea={ui.filterByIdea} onLongPress={handleLongPress} rediscoveringId={noteActions.rediscoveringId} onSetLightboxImg={ui.openLightbox} isCustom={ui.isCustom} onOpenSettings={ui.openSettings} /> </div> )}
{/* ── SOURCES ── */} {ui.mobileView === 'sources' && ( <div className="mobile-only-panel"> <SourcesScreen books={books} onAddBook={noteForm.addBookDirectly} onDeleteBook={noteForm.handleDeleteBook} onOpenSettings={ui.openSettings} /> </div> )}
{/* ── ACTIVE IDEAS ── */} {ui.mobileView === 'active-ideas' && ( <div className="mobile-only-panel"> <IdeasScreen ideaCounts={ui.ideaCounts} isCustom={ui.isCustom} onSelectIdea={ui.filterByIdea} onGoToIndex={ui.goToIndex} onOpenSettings={ui.openSettings} /> </div> )}
{/* ── RELATED NOTES ── */} {ui.mobileView === 'related-notes' && ui.relatedNotesNote && ( <div className="mobile-only-panel"> <RelatedNotesScreen note={ui.relatedNotesNote} notes={notes} books={books} onBack={ui.goBackFromRelatedNotes} onLongPress={handleLongPress} onLightbox={ui.openLightbox} /> </div> )}
{/* ── NOTE FORM — always mounted, CSS controls visibility ── */} {/* Stays in DOM when navigating to sources/active-ideas so form state is preserved */} <div className={`mobile-only-panel${ui.mobileView === 'note' ? '' : ' mobile-hidden'}`}> <NoteForm books={books} noteText={noteForm.noteText} onNoteTextChange={noteForm.setNoteText} noteBook={noteForm.noteBook} onNoteBookChange={noteForm.setNoteBook} notePage={noteForm.notePage} onNotePageChange={noteForm.setNotePage} pendingTags={noteForm.pendingTags} onRemoveTag={tag => noteForm.setPendingTags(t => t.filter(x => x !== tag))} transcribeLoading={noteForm.transcribeLoading} transcribeError={noteForm.transcribeError} onDismissTranscribeError={noteForm.dismissTranscribeError} detectedAttribution={noteForm.detectedAttribution} onAddDetectedBook={noteForm.addDetectedBook} onDismissAttribution={noteForm.dismissAttribution} aiLoading={noteForm.aiLoading} aiError={noteForm.aiError} hasAutoDiscovered={noteForm.hasAutoDiscovered} onDiscoverIdeas={noteForm.discoverIdeas} onSave={noteForm.saveNoteForm} onCancel={ui.goBackFromNote} onRetake={() => { noteForm.clearCapture(); ui.goToCapture() }} onGoToSources={ui.goToSources} /> </div>
{/* ── DESKTOP: Ideas sidebar + detail (unchanged) ── */} <div className={`sidebar-col${ui.mobileView === 'ideas' ? ' mobile-visible' : ' mobile-hidden'}`}> <IdeasSidebar customIdeas={customIdeas} selectedIdea={ui.selectedIdea} onSelectIdea={ui.selectIdea} onLongPressIdea={handleLongPressIdea} activeIdeaId={activeIdea?.id ?? null} activeIdeaAction={activeIdeaAction} ideaCounts={ui.ideaCounts} ideaSearch={ui.ideaSearch} onIdeaSearchChange={ui.setIdeaSearch} /> {ui.selectedIdea && ( <div className="mobile-idea-detail"> <IdeaDetail {...ideaDetailProps} /> </div> )} </div>
<div className="detail-col"> <IdeaDetail {...ideaDetailProps} /> </div>
</div>
{/* ── BOTTOM NAV — hidden on home screen ── */} {ui.mobileView !== 'home' && ( <nav className="bottom-nav"> <button className={`nav-btn${['home', 'sources', 'active-ideas'].includes(ui.mobileView) ? ' active' : ''}`} onClick={ui.goToHome} > <span className="nav-icon">🏠</span> <span className="nav-label">Home</span> </button> <button className={`nav-btn${['capture'].includes(ui.mobileView) ? ' active' : ''}`} onClick={ui.goToCapture} > <span className="nav-icon">📷</span> <span className="nav-label">Capture</span> </button> <button className={`nav-btn${['index', 'related-notes'].includes(ui.mobileView) ? ' active' : ''}`} onClick={ui.goToIndex} > <span className="nav-icon">≡</span> <span className="nav-label">Index</span> </button> </nav> )} </div>
<SettingsModal show={ui.showSettings} onClose={ui.closeSettings} session={session} apiKey={apiKey} online={online} syncing={syncing} syncStatus={syncStatus} onSync={() => syncFromCloud(session)} onSignOut={handleSignOut} apiKeyDraft={settings.apiKeyDraft} onApiKeyDraftChange={settings.setApiKeyDraft} onSaveApiKey={settings.handleSaveApiKey} apiKeySaved={settings.apiKeySaved} customIdeas={customIdeas} newIdeaName={settings.newIdeaName} onNewIdeaNameChange={settings.setNewIdeaName} newIdeaDesc={settings.newIdeaDesc} onNewIdeaDescChange={settings.setNewIdeaDesc} onAddCustomIdea={settings.addCustomIdea} onLongPressIdea={handleLongPressIdea} books={books} notes={notes} importRef={settings.importRef} onExport={settings.handleExport} onImportFile={settings.handleImportFile} importResult={settings.importResult} pendingImport={settings.pendingImport} onMerge={settings.confirmMerge} onReplace={settings.confirmReplace} onCancelImport={settings.cancelImport} />
{activeAction === 'sheet' && activeNote && ( <NoteActionSheet note={activeNote} onEdit={() => { setActiveAction('edit-full') }} onAddSource={() => { setActiveAction('edit-source') }} onRediscover={() => { noteActions.rediscoverIdeas(activeNote); closeAction() }} onRelatedNotes={() => { ui.goToRelatedNotes(activeNote); closeAction() }} onDelete={() => { noteActions.deleteNote(activeNote.id); closeAction() }} onClose={closeAction} /> )} {(activeAction === 'edit-full' || activeAction === 'edit-source') && activeNote && ( <NoteEditForm note={activeNote} mode={activeAction === 'edit-source' ? 'source' : 'full'} books={books} onSave={updates => { noteActions.editNote(activeNote.id, updates); showToast('Note updated'); closeAction() }} onClose={closeAction} /> )}
{activeIdeaAction === 'sheet' && activeIdea && ( <IdeaActionSheet idea={activeIdea} onEdit={() => setActiveIdeaAction('edit')} onDelete={() => { settings.handleDeleteCustomIdea(activeIdea.id); closeIdeaAction() }} onClose={closeIdeaAction} /> )} {activeIdeaAction === 'edit' && activeIdea && ( <IdeaEditForm idea={activeIdea} onSave={handleSaveIdeaEdit} onClose={closeIdeaAction} /> )}
{ui.lightboxImg && <div className="lightbox" onClick={ui.closeLightbox}><img src={ui.lightboxImg} alt="Source" /></div>} {toast && <div className="toast">{toast}</div>} </> )}- Commit
git add src/App.jsxgit commit -m "feat(app): capture-first launch, new screens, remove ReviewScreen, 3-button nav"Task 11: Add styles to styles.css
Files:
- Modify:
src/styles.css
Append these blocks to the end of the existing file. Do not remove existing styles.
- Append to
src/styles.css
/* ============================================================ Home Screen v2 ============================================================ */
.home-screen { display: flex; flex-direction: column; height: 100%; background: var(--color-bg-primary);}
.home-header { display: flex; justify-content: space-between; align-items: center; padding: max(var(--safe-top), var(--space-2)) var(--space-2) 0; flex-shrink: 0;}
.home-stats-row { display: flex; gap: var(--space-1); padding: var(--space-2) var(--space-2) 0; flex-shrink: 0;}
.home-stat-box { flex: 1; background: var(--color-bg-input); border: 1px solid var(--color-border); border-radius: var(--radius-card); padding: var(--space-2) var(--space-1); display: flex; flex-direction: column; align-items: center; position: relative; cursor: pointer; text-align: center; transition: background var(--transition-fast);}
.home-stat-box:active { background: var(--color-bg-subtle); }
.home-stat-number { font-size: var(--text-display-size); font-weight: var(--text-display-weight); color: var(--color-text-primary); line-height: 1;}
.home-stat-label { font-size: var(--text-label-size); font-weight: var(--text-label-weight); color: var(--color-text-muted); margin-top: 4px;}
.home-stat-caret { position: absolute; bottom: 6px; right: 6px; color: var(--color-text-muted);}
.home-logo-area { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-4) var(--space-2);}
.home-logo { font-size: var(--text-display-size); font-weight: var(--text-display-weight); color: var(--color-text-primary); letter-spacing: -0.5px;}
.home-tagline { font-size: var(--text-caption-size); color: var(--color-text-muted); margin-top: var(--space-1); text-align: center;}
.home-cta-area { padding: 0 var(--space-2) max(var(--safe-bottom), var(--space-3)); flex-shrink: 0;}
.home-cta-primary { width: 100%; padding: var(--space-2); font-size: var(--text-subtitle-size); font-weight: 700; color: var(--color-text-primary); /* Deep Walnut */ background: var(--color-accent); border: none; border-radius: var(--radius-card); cursor: pointer;}
/* ============================================================ Shared screen primitives (Sources, Ideas) ============================================================ */
.screen-header { display: flex; justify-content: space-between; align-items: center; padding: max(var(--safe-top), var(--space-2)) var(--space-2) 0; flex-shrink: 0;}
.screen-content { flex: 1; overflow-y: auto; padding: var(--space-2) var(--space-2) max(var(--safe-bottom), var(--space-3));}
.screen-title { font-size: var(--text-display-size); font-weight: var(--text-display-weight); color: var(--color-text-primary); margin: 0 0 2px;}
.screen-subtitle { font-size: var(--text-caption-size); color: var(--color-text-muted); margin: 0 0 var(--space-3);}
/* ============================================================ Sources Screen ============================================================ */
.sources-screen { display: flex; flex-direction: column; height: 100%; background: var(--color-bg-primary);}
.sources-add-form { margin-bottom: var(--space-2); display: flex; flex-direction: column; gap: var(--space-1);}
.sources-add-row { display: flex; gap: var(--space-1);}
.sources-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1);}
.sources-list-item { background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-card); padding: var(--space-card-padding); display: flex; justify-content: space-between; align-items: center; box-shadow: var(--shadow-card);}
.sources-item-title { font-size: var(--text-subtitle-size); font-weight: var(--text-title-weight); color: var(--color-text-primary);}
.sources-item-author { font-size: var(--text-caption-size); color: var(--color-text-secondary); margin-top: 2px;}
.btn-text-destructive { background: none; border: none; padding: 0; font-size: var(--text-caption-size); color: var(--color-destructive); cursor: pointer; text-decoration: underline;}
/* ============================================================ Ideas Screen ============================================================ */
.ideas-screen { display: flex; flex-direction: column; height: 100%; background: var(--color-bg-primary);}
.ideas-screen-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1);}
.ideas-screen-item { background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-card); padding: var(--space-card-padding); display: flex; justify-content: space-between; align-items: center; cursor: pointer; box-shadow: var(--shadow-card); transition: background var(--transition-fast);}
.ideas-screen-item:active { background: var(--color-bg-subtle); }
.ideas-screen-item-left { display: flex; align-items: center; gap: var(--space-1);}
.ideas-screen-item-name { font-size: var(--text-subtitle-size); font-weight: var(--text-title-weight); color: var(--color-text-primary);}
.ideas-screen-badge { border-radius: var(--radius-badge); padding: var(--space-chip-y) var(--space-chip-x); font-size: var(--text-caption-size); font-weight: 600;}
.empty-state-msg { font-size: var(--text-body-size); color: var(--color-text-muted); text-align: center; padding: var(--space-4) 0;}
/* ============================================================ Note Form v2 ============================================================ */
.note-form-header { display: flex; justify-content: space-between; align-items: center; padding: max(var(--safe-top), var(--space-2)) var(--space-2) var(--space-1); border-bottom: 1px solid var(--color-border); flex-shrink: 0;}
.note-form-title { font-size: var(--text-title-size); font-weight: var(--text-title-weight); color: var(--color-text-primary);}
.note-form-cancel { background: none; border: none; padding: 4px; cursor: pointer; color: var(--color-text-secondary); display: flex; align-items: center;}
.form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-2);}
.form-label { font-size: var(--text-label-size); font-weight: var(--text-label-weight); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px;}
.link-btn { background: none; border: none; padding: 0; font-size: var(--text-caption-size); color: var(--color-accent-text); cursor: pointer; text-align: left; text-decoration: underline;}
.btn-text { background: none; border: none; padding: 0; font-size: var(--text-caption-size); color: var(--color-text-secondary); cursor: pointer; text-decoration: underline;}
/* Transcription error banner — warm amber, not red */.transcribe-error-banner { background: var(--color-warning-bg); border: 1px solid var(--color-warning); border-radius: var(--radius-input); padding: var(--space-card-padding); margin-bottom: var(--space-2);}
.transcribe-error-msg { font-size: var(--text-body-size); color: var(--color-warning-text); margin: 0 0 var(--space-1); line-height: var(--text-body-height);}
.transcribe-error-actions { display: flex; gap: var(--space-1); align-items: center;}
/* Save button — Deep Walnut text on amber (overrides existing .btn-gold white text) */.btn-gold { color: var(--color-text-primary);}- Commit
git add src/styles.cssgit commit -m "style: add home, sources, ideas, note-form-v2 styles"Task 12: Update tests — capture.test.jsx
Files:
- Modify:
src/test/capture.test.jsx
The test file has four breaking changes from the redesign:
- App now starts at
captureview — thegoToCaptureViewhelper is no longer needed. ReviewScreenis gone — tests expectinggetByAltText('Captured')orTranscription failedtext need updating.- Error copy changed to “We couldn’t quite read that passage…”.
discoverIdeasnow fires automatically — add new auto-discover tests.
- Replace the file
/** * Tests for the image capture and transcription flow (v2). * * Key changes from v1: * - App launches at 'capture' view by default — no navigation helper needed. * - ReviewScreen removed — transcription state is handled inline on NoteForm. * - discoverIdeas fires automatically after successful transcription. */import { describe, it, expect, beforeEach, vi } from 'vitest'import { render, screen, waitFor, fireEvent } from '@testing-library/react'import App from '../App.jsx'
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock('../db.js', async () => import('./mocks/db.js'))vi.mock('../supabase.js', async () => import('./mocks/supabase.js'))
vi.mock('../api.js', () => ({ callTranscribeImage: vi.fn(), callDiscoverIdeas: vi.fn()}))
vi.mock('../utils.js', () => ({ uid: () => 'test-uid-' + Math.random().toString(36).slice(2, 6), fileToBase64: vi.fn(() => Promise.resolve('data:image/jpeg;base64,rawmock')), compressImage: vi.fn(() => Promise.resolve('data:image/jpeg;base64,compressed')), downloadJSON: vi.fn()}))
import { _state as db, reset as resetDb } from './mocks/db.js'import { _state as supa, reset as resetSupa } from './mocks/supabase.js'import { callTranscribeImage, callDiscoverIdeas } from '../api.js'
const MOCK_SESSION = { user: { id: 'user-1', email: 'test@example.com' } }
// ── Render helper ─────────────────────────────────────────────────────────────
async function renderLoggedIn(apiKey = 'sk-test-key') { supa.session = MOCK_SESSION db.apiKey = apiKey render(<App />) // IdeasSidebar (desktop panel) still renders — its Filter input is a stable // sentinel that the app has finished its loading sequence. await screen.findByPlaceholderText('Filter…')}
// ── Fixtures ──────────────────────────────────────────────────────────────────
function makeMockFile(name = 'photo.jpg') { return new File(['mock-image-data'], name, { type: 'image/jpeg' })}
beforeEach(() => { resetDb() resetSupa() vi.clearAllMocks()})
// ── Inline error on NoteForm (replaces ReviewScreen tests) ────────────────────
describe('transcription failure — inline error on NoteForm', () => { it('shows the warm error banner when callTranscribeImage throws', async () => { callTranscribeImage.mockRejectedValue(new Error('Network error')) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => { expect(screen.getByText(/couldn't quite read that passage/i)).toBeInTheDocument() }) })
it('shows Retake and Type manually actions after failure', async () => { callTranscribeImage.mockRejectedValue(new Error('Rate limited')) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => { expect(screen.getByRole('button', { name: /retake/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /type manually/i })).toBeInTheDocument() }) })
it('does not show the error banner when transcription succeeds', async () => { callTranscribeImage.mockResolvedValue({ case: 1, text: 'The world is the totality of facts.', title: null, author: null, page: null }) callDiscoverIdeas.mockResolvedValue([]) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => { const matches = screen.getAllByDisplayValue('The world is the totality of facts.') expect(matches.length).toBeGreaterThan(0) }) expect(screen.queryByText(/couldn't quite read/i)).not.toBeInTheDocument() })})
// ── Auto-discover chaining ─────────────────────────────────────────────────────
describe('auto-discover chaining', () => { it('calls callDiscoverIdeas automatically after successful transcription', async () => { callTranscribeImage.mockResolvedValue({ case: 1, text: 'The world is the totality of facts.', title: null, author: null, page: null }) callDiscoverIdeas.mockResolvedValue(['Knowledge']) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => { expect(callDiscoverIdeas).toHaveBeenCalledTimes(1) }) })
it('does not call callDiscoverIdeas when transcription fails', async () => { callTranscribeImage.mockRejectedValue(new Error('API error')) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => { expect(screen.getByText(/couldn't quite read/i)).toBeInTheDocument() }) expect(callDiscoverIdeas).not.toHaveBeenCalled() })
it('does not call callDiscoverIdeas a second time if the user edits the text', async () => { callTranscribeImage.mockResolvedValue({ case: 1, text: 'Initial text.', title: null, author: null, page: null }) callDiscoverIdeas.mockResolvedValue(['Knowledge']) await renderLoggedIn()
const [cameraInput] = document.querySelectorAll('input[type="file"]') fireEvent.change(cameraInput, { target: { files: [makeMockFile()] } })
await waitFor(() => expect(callDiscoverIdeas).toHaveBeenCalledTimes(1))
// Simulate user editing the transcribed text const textarea = screen.getByPlaceholderText(/transcribed text appears here/i) fireEvent.change(textarea, { target: { value: 'Initial text. Edited.' } })
// Still only called once — hasAutoDiscovered prevents re-trigger expect(callDiscoverIdeas).toHaveBeenCalledTimes(1) })})
// ── Capture screen controls ───────────────────────────────────────────────────
describe('capture screen — shutter CTA', () => { it('routes the Capture Text button to the camera input ref', async () => { // App starts at capture view by default — no navigation needed. callTranscribeImage.mockResolvedValue({ case: 1, text: '', title: null, author: null, page: null }) await renderLoggedIn()
const cameraInput = document.querySelector('input[capture="environment"]') const captureButton = await screen.findByRole('button', { name: /capture text/i })
const clickSpy = vi.spyOn(cameraInput, 'click').mockImplementation(() => {}) fireEvent.click(captureButton) expect(clickSpy).toHaveBeenCalledTimes(1) clickSpy.mockRestore() })})- Run the updated tests
npm run test:run -- --reporter=verbose src/test/capture.test.jsxExpected: all tests in capture.test.jsx pass.
- Run the full test suite
npm run test:runExpected: all tests pass. If App.behaviour.test.jsx has failures related to the openLibrary helper (which uses .main-tab-row, now removed), update that helper:
Find openLibrary in src/test/App.behaviour.test.jsx and remove or skip any test that relied on the Library tab — the Library tab is removed in this redesign. Mark those tests with it.skip and add a comment: // Library tab removed in v2 — functionality moved to SourcesScreen.
- Commit
git add src/test/capture.test.jsx src/test/App.behaviour.test.jsxgit commit -m "test(capture): update for capture-first launch, inline errors, auto-discover"Task 13: Build verification and ReviewScreen cleanup
Files:
-
Delete:
src/components/ReviewScreen.jsx -
Delete ReviewScreen
git rm src/components/ReviewScreen.jsx- Run the production build
npm run buildExpected: exits with 0 errors. If there are import errors referencing ReviewScreen, they will surface here — fix by removing any remaining imports.
- Run full test suite one final time
npm run test:runExpected: all green.
- Commit
git add -Agit commit -m "chore: remove ReviewScreen — replaced by inline NoteForm transcription state"Task 14: Push and open PR
- Push branch
git push -u origin design/ui-structure-overhaul- Open PR
gh pr create \ --title "feat: Capture-First UI overhaul — Branch 1 (structure)" \ --body "$(cat <<'EOF'## Summary- App now launches on the camera (Capture screen) by default- New screens: Sources, Active Ideas — both navigable from Home stat boxes- Home Screen redesigned: 3 tappable stat boxes, logo placeholder, single CTA, no bottom nav- ReviewScreen removed — transcription errors handled inline on NoteForm- NoteForm overhauled: source dropdown, auto-discover chaining, Phosphor icons, Deep Walnut save button- Global `<header>` removed — each screen owns its header area- 3-button bottom nav (Home / Capture / Index) replaces 4-button nav- FUNCTIONAL.md updated with rules 5–9
## Spec`docs/ui-ux/user-journey-v2.md`
## Test plan- [ ] `npm run test:run` — all tests green- [ ] `npm run build` — zero errors- [ ] Manual: open app, confirm camera view on launch- [ ] Manual: tap stat boxes from Home — confirm correct navigation- [ ] Manual: capture photo, confirm direct transition to Note form (no Review screen)- [ ] Manual: confirm ideas appear without tapping Discover Ideas- [ ] Manual: add source from Sources screen, confirm it appears in Note form dropdown- [ ] Manual: write note manually, confirm Discover Ideas button appears
🤖 Generated with [Claude Code](https://claude.com/claude-code)EOF)"Branch 2: Capture Animations (High-Level)
Plan Branch 2 in detail once Branch 1 is merged. These tasks are pure CSS — no data or navigation changes.
Branch: design/capture-animations (create from merged design/ui-structure-overhaul)
B2-Task 1: Card slide animation
Files: src/styles.css, src/components/CaptureScreen.jsx, src/App.jsx
Add a CSS @keyframes card-slide-down that scales and translates the captured image preview from full-size to a small card at the bottom. Trigger by adding a CSS class when the shutter is tapped. The animation completes (~400ms) before goToNote is called.
Animation spec: transform: scale(1) translateY(0) → transform: scale(0.3) translateY(calc(100vh - 80px)), cubic-bezier(0.4, 0, 0.8, 0.6), 400ms.
B2-Task 2: Ink bleed text animation
Files: src/components/NoteForm.jsx, src/styles.css
When transcribeLoading transitions from true to false with a non-empty noteText, split the text into word groups and render each as an animated <span> instead of plain text in the textarea. Use a read-only display <div> for the animation; swap to a live <textarea> once animation ends (onAnimationEnd).
Keyframe: opacity: 0, filter: blur(2px) → opacity: 1, filter: blur(0), 300ms per group, 50ms animation-delay stagger.
B2-Task 3: Idea chip fade-in
Files: src/styles.css
Add CSS class .idea-tag-entering applied when a chip is first added to pendingTags. Keyframe: opacity: 0, transform: translateY(4px) → opacity: 1, transform: translateY(0), 200ms ease-out.
B2-Task 4: Build + PR
npm run build && npm run test:rungh pr create --title "feat: capture animations — card slide, ink bleed, chip fade" ...