Skip to content

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
Terminal window
git checkout main && git pull
git checkout -b design/ui-structure-overhaul
  • Confirm tests pass on main before touching anything
Terminal window
npm run test:run

Expected: 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
Terminal window
git add FUNCTIONAL.md
git 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
Terminal window
git add src/hooks/useUI.js
git 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
Terminal window
git add src/hooks/useNoteForm.js
git 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
Terminal window
git add src/components/SourcesScreen.jsx
git 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
Terminal window
git add src/components/IdeasScreen.jsx
git 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
Terminal window
git add src/components/HomeScreen.jsx
git 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
Terminal window
git add src/components/CaptureScreen.jsx
git 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 &amp; 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
Terminal window
git add src/components/NoteForm.jsx
git 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 onOpenSettings to 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 div elements 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
Terminal window
git add src/components/IndexScreen.jsx
git 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:

  1. Add imports for SourcesScreen, IdeasScreen. Remove ReviewScreen, Library (from imports and render).
  2. Update useNoteForm nav arg — pass goToNote instead of goToLibrary/goToReview.
  3. Remove the global <header> block.
  4. Render home, capture, index, sources, active-ideas, related-notes panels.
  5. NoteForm panel: always mounted, CSS-controlled visibility.
  6. Remove review panel entirely.
  7. Bottom nav: 3 buttons, hidden on home view.
  8. 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
Terminal window
git add src/App.jsx
git 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
Terminal window
git add src/styles.css
git 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:

  1. App now starts at capture view — the goToCaptureView helper is no longer needed.
  2. ReviewScreen is gone — tests expecting getByAltText('Captured') or Transcription failed text need updating.
  3. Error copy changed to “We couldn’t quite read that passage…”.
  4. discoverIdeas now 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
Terminal window
npm run test:run -- --reporter=verbose src/test/capture.test.jsx

Expected: all tests in capture.test.jsx pass.

  • Run the full test suite
Terminal window
npm run test:run

Expected: 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
Terminal window
git add src/test/capture.test.jsx src/test/App.behaviour.test.jsx
git 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

Terminal window
git rm src/components/ReviewScreen.jsx
  • Run the production build
Terminal window
npm run build

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

Expected: all green.

  • Commit
Terminal window
git add -A
git commit -m "chore: remove ReviewScreen — replaced by inline NoteForm transcription state"

Task 14: Push and open PR

  • Push branch
Terminal window
git push -u origin design/ui-structure-overhaul
  • Open PR
Terminal window
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

Terminal window
npm run build && npm run test:run
gh pr create --title "feat: capture animations — card slide, ink bleed, chip fade" ...