erin.dev

Back to home

🕰️ Emigre

What would a medieval monk think of TikTok? Ask him yourself.

Source Code

A full-stack AI app that generates historically-grounded, in-character explanations of modern concepts through the eyes of six distinct historical personas — from a Benedictine monk to a 1920s jazz musician.

Users pick a concept, choose a persona, and watch a streaming explanation unfold in real time, with the entire UI transforming to match the selected era: backgrounds, typography, colors, and decorative elements all shift dynamically. A debate mode pits two personas against each other, with the second persona reacting to the first's argument.

Built to demonstrate prompt engineering, streaming architecture, and product design — not just another ChatGPT wrapper.

🕰️ Emigre

🛠️ Tech Stack

React 19TypeScriptFastAPIPythonClaude API (Anthropic SDK)Server-Sent EventsChakra UI v3Framer MotionSQLite (aiosqlite)Viteuv

🚀 Features

  • Real-time streaming explanations — Claude responses stream token-by-token via SSE, rendered with an animated blinking cursor

  • 6 historically-researched personas with distinct system prompts, voices, and era-appropriate vocabulary (Latin phrases, imperial rhetoric, jazz slang)

  • Debate mode — two personas argue about a concept sequentially, with Persona B receiving a summary of A's argument so their response is a genuine rebuttal

  • Dynamic era theming — selecting a persona transforms the entire UI: background gradients, custom Google Fonts, card styles, accent colors, and decorative elements (illuminated drop caps, hieroglyphic borders, art deco frames)

  • Dual-themed debate layout — each debate column independently renders its persona's visual identity side-by-side

  • Browser-native TTS — listen to explanations read aloud via the Web Speech API, with start/stop toggle

  • Persistent history — explanations saved to SQLite with paginated browsing, expandable previews, relative timestamps, and optimistic deletes

  • Accessible custom widgets — persona selection uses role="radiogroup" / role="radio" with aria-checked, keyboard-visible focus rings, and aria-live on streaming text

🧗‍♂️ Challenges

  • SSE streaming with POST requests — the browser's native EventSource API only supports GET, but the explain/debate endpoints require a request body. Used @microsoft/fetch-event-source to wrap fetch() with SSE parsing. Required adding explicit onopen validation — without it, non-200 responses (auth errors, validation failures) are silently retried instead of surfaced to the user.

  • Debate orchestration across two LLM calls — Persona B's prompt must include a summary of Persona A's full response, making the calls inherently sequential. Built a lightweight _summarize() helper (sentence splitting, first 2-3 sentences) rather than adding a third Claude call for summarization — trading summary quality for latency and cost. The SSE generator manages both streams, DB persistence, and cleanup in a single async generator with proper finally blocks.

  • Dynamic theming beyond what the component library supports — Chakra UI v3's theme system is designed for light/dark toggles, not six wildly different visual identities with custom fonts and decorative elements. Solved with CSS custom properties (--era-bg, --era-accent, --era-text) set on a wrapper div, so components reference var(--era-bg) without coupling to the theme layer. For the dual-themed debate layout, each column resolves its theme independently via direct prop application since CSS variables scope downward and can't serve two sibling scopes.

  • Managing SSE generator lifecycles in FastAPI — Depends() cleanup runs when the response handler returns, but SSE generators outlive the handler. Database connections must be opened inside the generator and closed in a finally block — using dependency injection causes premature connection closure mid-stream.

🧠 Learnings

  • State machines over boolean flags for complex UI. The app manages 7 distinct states (idle → selecting → streaming → complete → selectingOpponent → debating → debated). Early prototypes used derived booleans like isComplete = !isStreaming && !!explanationId, which broke as soon as debate flow was added — boolean combinations can't prevent impossible state transitions. A TypeScript union type (AppState) made transitions explicit and self-documenting.

  • CSS custom properties beat framework theme overrides for runtime theming. Rather than fighting Chakra's theme provider, setting CSS variables on a wrapper div decoupled visual identity from the component library entirely. Components just reference var(--era-accent) — the theming layer is invisible to them. This pattern would transfer cleanly to any component library.

  • Slot props keep components decoupled from feature logic. Instead of threading onListen, isSpeaking, and isSupported props through ActionBar (coupling it to TTS), the parent renders a pre-built listenButton ReactNode and passes it as a slot. ActionBar places it in the layout without knowing what it does — making the component reusable without TTS.

  • Accessibility for custom selection widgets requires explicit ARIA roles. Styled <Box as="button"> elements are opaque to screen readers. Adding role="radiogroup" to the persona grid and role="radio" with aria-checked to each card transforms the screen reader experience from "button, Brother Aldric" to "Brother Aldric, radio button, 1 of 6, not checked" — a small code change with outsized accessibility impact.