Back to home
🕰️ Emigre
What would a medieval monk think of TikTok? Ask him yourself.
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.

🛠️ Tech Stack
🚀 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"witharia-checked, keyboard-visible focus rings, andaria-liveon streaming text
🧗♂️ Challenges
SSE streaming with POST requests — the browser's native
EventSourceAPI only supports GET, but the explain/debate endpoints require a request body. Used@microsoft/fetch-event-sourceto wrapfetch()with SSE parsing. Required adding explicitonopenvalidation — 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 properfinallyblocks.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 referencevar(--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 afinallyblock — 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 likeisComplete = !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, andisSupportedprops through ActionBar (coupling it to TTS), the parent renders a pre-builtlistenButtonReactNode 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. Addingrole="radiogroup"to the persona grid androle="radio"witharia-checkedto 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.