Web app: user records themselves narrating an idea → audio is transcribed → Claude picks a diagram type and generates Mermaid → diagram is rendered on a shareable page. Tagline: "Pitch your idea, see the picture."
- Single speaker, no diarization
- Post-session processing only (no streaming)
- Web only
- Google OAuth via Supabase
- Recordings capped at 30 min
Out of scope: multi-speaker, real-time, diagram editing, team workspaces, integrations, mobile, PNG export.
- Frontend (
react/): React + TS + Vite, React Router withHashRouter. Plain CSS (no Tailwind — spec listed it but it wasn't installed; styles use CSS vars inreact/src/index.css). Deploys to Netlify. - Backend (
api/): Node + Express + TS. Deploys to Railway or Fly. - Auth + DB + Storage: Supabase (Google OAuth, Postgres, private
audio-recordingsbucket) - STT: Deepgram Nova-3
- LLM: Anthropic
claude-sonnet-4-5 - Diagram render:
mermaidnpm, client-side. Server validates withmermaid.parse+ JSDOM. - Layout: two independent packages (
react/,api/), each with its ownnode_modulesand lockfile. Rootpackage.jsonorchestrates viapnpm -C <dir>and holds deps for thescripts/test utilities.
PitchPicture/
├── react/ # Vite frontend (existing)
├── api/ # Express backend
│ └── src/
│ ├── services/ # claude.ts, deepgram.ts, mermaid.ts, supabase.ts
│ ├── routes/ # sessions.ts, share.ts
│ ├── middleware/ # auth.ts (Supabase JWT)
│ ├── lib/types.ts # API contract — SOURCE OF TRUTH
│ ├── pipeline.ts # transcribe → analyze orchestration
│ ├── app.ts # Express setup + CORS
│ └── index.ts # port listen
├── supabase/migrations/ # schema + RLS
└── scripts/ # dev/test utilities (test-analysis.ts, test-pipeline.ts, transcripts/)
api/src/lib/types.ts is the source of truth. react/src/lib/types.ts is a verbatim copy. When the API contract changes, edit the api version first then copy. Do not create a packages/shared/ workspace — not worth the overhead at this size.
POST /api/sessions → { id, status: 'uploading' }
POST /api/sessions/:id/audio → multipart upload, triggers async pipeline
POST /api/sessions/:id/retry → re-run pipeline against existing audio (status must be 'failed')
POST /api/sessions/:id/refine → multipart upload, refine the diagram with a follow-up recording (status must be 'ready')
GET /api/sessions/:id → full session (frontend polls every 2s)
GET /api/sessions → list current user's sessions
GET /api/share/:share_token → public, no auth
DELETE /api/sessions/:id → delete row + audio file
All routes except /api/share/:token require Authorization: Bearer <supabase_jwt>.
POST /api/sessions/:id/audio returns immediately; processSession(id) runs in the background:
transcribing→ Deepgram (audio buffer → transcript)analyzing→ Claude (transcript →AnalysisJSON, with one Mermaid-validation retry)ready
Any throw inside processSession marks the session failed with the error message. No process crash.
POST /api/sessions/:id/refine lets a user record a short follow-up ("add a step between X and Y") that refines an existing ready diagram. refineSession(id, buffer, mime) runs in the background: transcribe the follow-up → refineWithClaude (existing transcript + current Mermaid + the new instruction → updated Analysis) → save.
Two deliberate differences from processSession:
- Non-destructive on failure. The session already has a valid diagram, so a failed refine restores
status: 'ready'and stashes the reason inerror_message(neverfailed). The old diagram is untouched. The Result page renders a.warnbanner whenever areadysession has anerror_message. - Refine audio is not persisted. The follow-up buffer is passed in-memory to
refineSessionand discarded; only the transcript is appended tosession.transcript(under a--- Refinement ---divider). Consequence: a refine interrupted by a process restart cannot be retried — acceptable v1 fragility on single-instance deploys.
The frontend reuses the Recording page in "refine mode" via the /refine/:id route (presence of the :id param switches it); on stop it calls POST .../refine and navigates to /processing/:id.
flowchart | mindmap | architecture | decision_tree | sequence. Selection heuristics live in the Claude system prompt in api/src/services/claude.ts.
Audio files live in the private audio-recordings bucket at <user_id>/<session_id>.<ext>. The first path segment is the auth user id — storage RLS policies use storage.foldername(name)[1] = auth.uid()::text to scope access per user. Don't change the path shape without updating the policies in supabase/migrations/20260512_init.sql.
Backend uses the Supabase service-role key, which bypasses RLS. The route handlers MUST filter every query by req.user.id themselves — the RLS policies in the migration are belt-and-braces for direct client access, not the backend's primary defense. requireAuth in api/src/middleware/auth.ts verifies the JWT via supabase.auth.getUser(token) and attaches req.user.
- ✅ Validate Claude on hardcoded transcripts.
scripts/test-analysis.ts. Gate: ≥90% first-try Mermaid validity, diagram-type picks feel right. - ✅ Add Deepgram.
scripts/test-pipeline.ts— audio file → transcript → Claude. - ✅ Express API + Supabase. 6 endpoints, multer audio upload, service-role DB + storage client, JWT middleware. Test with curl against an email/password user created via the Supabase dashboard.
- ✅ Frontend, split into three slices:
- ✅ 4a. Auth shell:
AuthProvider+useAuth, Supabase anon client, fetch wrapper that auto-attaches the JWT, Home page with email/password sign-in. - ✅ 4b.
Recordercomponent (MediaRecorder webm/opus @ 64kbps, 25-min warn, 30-min auto-stop, pause/resume, review/discard before upload) +Recordingpage that uploads toPOST /api/sessions/:id/audio. - ✅ 4c.
Processingpage pollsGET /api/sessions/:idevery 2s and auto-navigates to/result/:idonready(retry button onfailed).Resultpage renders Mermaid client-side viaDiagramViewand includes a "Copy share link" button.
- ✅ 4a. Auth shell:
- Share view + history + delete, split into three slices:
- ✅ 5a. Public
Sharepage at/s/:tokencallsGET /api/share/:tokenwithwithAuth: false. ReusesDiagramView. Friendly "Not found" for bad tokens or unready sessions. - ✅ 5b.
Historypage at/historylisting the user's sessions. - ✅ 5c. Delete wired into Result and History (
DELETE /api/sessions/:id).
- ✅ 5a. Public
Build each step end-to-end before the next.
# First-time setup (after a fresh clone):
pnpm install # root devDeps (tsx, mermaid for test scripts, etc.)
pnpm -C react install # frontend deps
pnpm -C api install # backend deps
# Dev (two terminals):
pnpm dev:web # frontend (Vite on 5173)
pnpm dev:api # backend (Express on 3001)
pnpm build # build both
pnpm test:analysis scripts/transcripts/<file>.txt
pnpm test:pipeline <path-to-audio>.{webm,mp3,m4a,wav,ogg,flac}All scripts that touch api/.env pass it via tsx --env-file explicitly — tsx does not auto-load .env. Root: tsx --env-file=api/.env scripts/.... api/ itself: tsx watch --env-file=.env src/index.ts. For production (Railway/Render), env vars come from the platform dashboard, not from a file, so node dist/index.js doesn't need the flag.
Root package.json must have "type": "module" — mermaid.ts uses top-level await import('mermaid') which fails under CJS.
api/.env: ANTHROPIC_API_KEY, DEEPGRAM_API_KEY, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, PORT=3001, CORS_ORIGIN=http://localhost:5173
react/.env: VITE_API_URL, VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY
- CORS: Express must allow
localhost:5173or every dev fetch fails as a "Network Error" that's really a preflight rejection. - Mermaid server-side validation (
mermaid.parseunder JSDOM) is finicky on v11. If it breaks at import, fall back to a lightweight syntactic check and trust the client renderer. - MediaRecorder: use
audio/webm;codecs=opusat 64kbps. Auto-stop at 30 min, warn at 25 min. Pause/resume uses the nativepause()/resume()— while paused the timer interval is cleared (paused time isn't counted) andSpeechRecognitionis stopped (it has no pause; it's torn down and a fresh instance is started on resume, withfinalCaptionpreserved). - Review before upload: stopping a recording enters a
reviewphase insideRecorder— the take is held in component state andonStopfires only when the user clicks "Use this take". "Discard & re-record" drops the take and returns toidle. BecauseonStopis the only thing that callsapi.createSession, a discarded take never creates a session row. The take is previewed via an<audio>element fed byURL.createObjectURL, revoked on discard/unmount. - Background work: don't
await processSession(id)in the route handler — return the response first, let it run, catch internally. react/pnpm-lock.yamlis leftover from before workspaces; delete once Step 4 starts and root install owns the lockfile.- Service-role key bypasses RLS. Never put
SUPABASE_SERVICE_ROLE_KEYinreact/.envor send it to the browser. Frontend uses theanonkey. - Frontend auth model:
AuthProviderin react/src/lib/auth.tsx wrapssupabase.auth.onAuthStateChange. The fetch wrapper in react/src/lib/api.ts pulls the current session'saccess_tokenper request — don't cache it; Supabase auto-refreshes on expiry. - Mermaid client render: react/src/components/DiagramView.tsx initializes mermaid once (
securityLevel: 'strict') and renders into a ref'd<div>viadangerouslySetInnerHTML. Unique render IDs come from a module-level counter so React 19 strict-mode double-mounts don't collide. If render throws, the code is shown as a fallback. - Multer upload field name is
audio.curl -F "audio=@file.m4a;type=audio/mp4". Send the correct MIME type or storage will store the wrong content-type and Deepgram may reject it.
react/src/components/Recorder.tsx shows live captions while recording using the browser's SpeechRecognition API (webkitSpeechRecognition on Safari). This is display-only — the canonical transcript still comes from Deepgram post-upload, so the live captions and the final transcript can differ.
- Runs in parallel with
MediaRecorder; started instart(), stopped inrec.onstopand on unmount. - Auto-restarts on
onendwhilespeechActiveRef.currentis true (browsers cut the stream on silence). - Errors are swallowed — captions are best-effort and must never break the recording.
- Unsupported in Firefox; the caption box is simply not rendered there (no error message in v1).
- Chosen over Deepgram live streaming to avoid paying for transcription twice (streaming + pre-recorded are billed separately).
react/src/lib/theme.tsx provides ThemeProvider + useTheme. Preference ('system' | 'light' | 'dark') is stored in localStorage under pp-theme. The provider listens to matchMedia('(prefers-color-scheme: dark)') so System mode flips live when the OS toggles dark mode.
Theming is driven by data-theme="dark" on <html>, not @media (prefers-color-scheme). The dark CSS variables live under :root[data-theme='dark'] in react/src/index.css.
To prevent a flash of wrong theme on cold load, react/index.html has an inline <script> in <head> that reads localStorage and sets data-theme before React mounts. Don't move this script — it must run synchronously before the stylesheet computes.
Mermaid theme is dynamic: react/src/components/DiagramView.tsx reads useTheme().resolved and calls mermaid.initialize({ theme: 'dark' | 'default' }) inside the render effect, with the resolved theme in the deps array so diagrams re-render when the user toggles. Mermaid's 'default' theme produces dark text on light shapes; 'dark' produces light text on dark shapes. Without this, dark mode shows light-on-light unreadable diagrams.
Installable as a PWA on iOS Safari and Android Chrome. Files:
- react/public/manifest.webmanifest — name, theme color, standalone display, references
favicon.svgfor icons - react/public/sw.js — minimal service worker, no caching; exists only so Chrome's install prompt fires
- react/index.html — manifest link, apple-touch-icon, theme-color,
viewport-fit=coverfor notch safe areas - react/src/main.tsx — registers
sw.jsonly inimport.meta.env.PRODso dev HMR isn't interfered with - react/src/App.css —
min-height: 44pxon buttons, full-width primary action onmax-width: 540px,env(safe-area-inset-*)padding on.page, momentum scroll on.diagram
Limitations to know:
- Install requires HTTPS — only works against the deployed Netlify URL, not
localhost. - Icon is the SVG favicon. For crisp iOS rendering, drop a 180×180 PNG at
react/public/apple-touch-icon.pngand update the<link>href. - iOS still kills MediaRecorder when the app loses focus or the screen locks. No fix in v1; if it bites, the upgrade is a native mobile app.
Sign in with Google → record up to 30 min → see live status → diagram + summary within ~60s of stop → share link works without auth → history + delete works → Mermaid renders ≥95% of the time.