# Project export: History Buddy

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: An Ed-tech video call simulation for elementary aged learners to engage with historical figures through AI.
- Devpost: https://devpost.com/software/history-buddy
- GitHub: https://github.com/kaitlynlaw1029-hue/history-buddies.git
- Video: https://www.youtube.com/embed/r91qhQR-UJQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Kaitlyn Law (1 commits)

## Devpost submission (written by the team)

### Inspiration

Growing up, reading was a huge part of life. But books could only go so far. No matter how good the writing was, there was always a wall between the reader and the person on the page. It was easy to wish you could just hop on a call with the person you read about, for instance like MLK Jr., and ask what it actually felt like to live their lives, instead of reading about it secondhand. That instinct lines up with the research. A 2018 MIT/Harvard study (Romeo et al.) found that conversational turns, not the raw number of words a child hears, predict language-brain growth, and that holds true regardless of family income (Romeo et al., MIT/Harvard 2018; Hutton et al., Cincinnati Children's). Reading alone is one-directional. History Buddy is built to be the opposite: pure back-and-forth. I built History Buddy, a platform that turns a static biography into a live conversation, letting elementary school students "video call" the historical figures they're learning about.

### What it does

History Buddy gives elementary school students a video-call-like interface to talk directly with historical figures. A student picks a name from their history lesson, calls them, asks questions, and gets answers grounded in that person's real, documented life. A chapter they'd otherwise skim becomes a conversation they'll actually remember. History Buddy is build for children at every income level, but it's especially powerful for kids who don't have it easy. For students with strong reading and writing instruction already, History Buddy is a supplement, a fun way to go deeper on what they're learning. For students in under-resourced schools, it's something bigger. Built into the school day, it facilitates discussions that may not be facilitated otherwise. And because it's a conversation, not a chapter, it can do something a textbook can't: spark enough curiosity that a kid goes and picks up the real biography afterward because they actually want to know more.

### How we built it

I build History Buddy with Claude Code as my main tool. I also used Browserbase to scrape publicly available data to create a database for each historical figure. I used Deepgram to set up speech to text and text to speech. And, I used ElevenLabs for both customizable AI voices and a wider variety of AI voice options.

### Challenges we ran into

A big challenge I ran into was complications with STT. I wanted the figures to start talking right when the user selected it. But, I ran into the issue of having to wait 20+ seconds before it would speak. Sometimes, if I clicked the button more than once, it wouldn't load for the same 20+ seconds, but then play the STT over itself. By employing methods like changing Claude generation models from Sonnet to Haiku, I was able to drastically shorten delays in verbal and visual cues, which made conversation seem much more seamless!

### Accomplishments we're proud of

I am proud that the app works! The app successfully converses with the user in a way that is understandable for a younger child, and is also safeguarded to be authentic to the historical beliefs of the figure, whether MLK Jr. or Jane Goodall.

### What we learned

I learned how to use many tools regarding TTS and STT with Deepgram and ElevenLabs, which was a bit of a challenge given bottlenecks and some frustrating bugs, but it proved to be a very rewarding experience to learn how to use these kinds of technology. Also, using Browserbase to automate scraping data was really a unique experience and definitely showed me the possibilities of AI in my daily life. On top of all that, this was my first hackathon and I must say that I've learned that hackathons are a rewarding experience that I will continue to pursue further after this event!

### What's next

History Buddy's next steps are to connect with underprivileged school districts to bring this technology to them. Also, I'd love to come in contact with my previous schools to see whether implementation might be possible after ensuring more robust structure, and seeing kids interact with the models would be fascinating!

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 14 recognized source files, 79 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (21 of 21)

```
.gitignore
AGENTS.md
app/api/chat/route.ts
app/api/transcribe/route.ts
app/api/tts/route.ts
app/conversation/[character]/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
CLAUDE.md
eslint.config.mjs
lib/audioContext.ts
lib/characters.ts
lib/chatHistory.ts
lib/tts/elevenlabs.ts
next.config.ts
package.json
postcss.config.mjs
scripts/character_profiles.json
scripts/quotes_db.json
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.105.0, @mediapipe/tasks-vision@^0.10.35, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.2.9, next@16.2.9, react@19.2.4, react-dom@19.2.4, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Initial commit
- Initial commit from Create Next App

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
@AGENTS.md

```

### AGENTS.md

```markdown
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

```

### package.json

```
{
  "name": "aihack26-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@mediapipe/tasks-vision": "^0.10.35",
    "next": "16.2.9",
    "react": "19.2.4",
    "react-dom": "19.2.4"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.9",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono, Fredoka } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

const fredoka = Fredoka({
  variable: "--font-fredoka",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
});

export const metadata: Metadata = {
  title: "History Buddy",
  description: "Have a conversation with history's greatest minds",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} ${fredoka.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">{children}</body>
    </html>
  );
}

```

### app/page.tsx

```typescript
"use client";

import { useState } from "react";
import Link from "next/link";
import { characters } from "@/lib/characters";
import { unlockAudioContext } from "@/lib/audioContext";

export default function Home() {
  const [query, setQuery] = useState("");

  const filtered = query.trim()
    ? characters.filter(
        (c) =>
          c.name.toLowerCase().includes(query.toLowerCase()) ||
          c.title.toLowerCase().includes(query.toLowerCase())
      )
    : characters;

  return (
    <div className="min-h-screen text-white flex flex-col" style={{ backgroundColor: "#52B6CC" }}>
      {/* Header */}
      <header className="text-center pt-14 pb-8 px-6">
        <h1 className="text-7xl font-bold mb-6 text-white drop-shadow-md font-fredoka">
          History Buddy
        </h1>

        {/* Search bar */}
        <div className="mx-auto w-full max-w-sm">
          <input
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search historical figures…"
            className="w-full rounded-full px-5 py-3 text-base text-zinc-900 placeholder:text-zinc-400 bg-white/90 shadow-md outline-none focus:ring-4 focus:ring-white/50"
          />
        </div>
      </header>

      {/* Grid */}
      <main className="flex-1 flex items-start justify-center px-6 pb-16">
        {filtered.length === 0 ? (
          <p className="text-white/70 text-lg mt-8">No results for &ldquo;{query}&rdquo;</p>
        ) : (
          <div className="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-10 w-full max-w-3xl">
            {filtered.map((character) => (
              <Link
                key={character.id}
                href={`/conversation/${character.id}`}
                onClick={unlockAudioContext}
                className="flex flex-col items-center gap-3 group outline-none"
              >
                {/* Circle */}
                <div
                  className="w-32 h-32 sm:w-36 sm:h-36 rounded-full overflow-hidden relative
                    cursor-pointer select-none transition-all duration-200
                    group-hover:scale-105 group-hover:ring-4 group-hover:ring-white/20
                    group-focus-visible:ring-4 group-focus-visible:ring-white/30"
                  style={{
                    background: `linear-gradient(135deg, ${character.from}, ${character.to})`,
                  }}
                >
                  <img
                    src={`/${character.id}.jpg`}
                    alt={character.name}
                    className="absolute inset-0 w-full h-full object-cover"
                    style={{
                      objectPosition: character.galleryObjectPosition ?? "center top",
                      ...(character.photoScale ? { transform: `scale(${character.photoScale})`, transformOrigin: "top center" } : {}),
                    }}
                    onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
                  />
                </div>

                {/* Name */}
                <div className="text-center">
                  <p className="text-sm font-medium text-zinc-100 leading-tight">
                    {character.name}
                  </p>
                </div>
              </Link>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

```

### app/api/transcribe/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const formData = await req.formData();
  const file = formData.get("audio") as File;

  if (!file) {
    return NextResponse.json({ error: "No audio file provided" }, { status: 400 });
  }

  const buffer = await file.arrayBuffer();
  const contentType = file.type || "audio/webm";

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch(
        "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true",
        {
          method: "POST",
          headers: {
            Authorization: `Token ${process.env.DEEPGRAM_API_KEY}`,
            "Content-Type": contentType,
          },
          body: buffer,
        }
      );

      if (!response.ok) {
        const error = await response.text();
        console.error(`Deepgram STT error (attempt ${attempt + 1}):`, error);
        if (attempt < 2) continue;
        return NextResponse.json({ error: "STT failed" }, { status: 500 });
      }

      const data = await response.json();
      const transcript =
        data?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? "";

      return NextResponse.json({ transcript });
    } catch (err) {
      console.error(`Deepgram STT fetch error (attempt ${attempt + 1}):`, err);
      if (attempt < 2) {
        await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
        continue;
      }
      return NextResponse.json({ error: "STT connection failed" }, { status: 500 });
    }
  }

  return NextResponse.json({ error: "STT failed after retries" }, { status: 500 });
}

```

### app/api/tts/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { synthesizeElevenLabs } from "@/lib/tts/elevenlabs";

function stripMarkdown(text: string): string {
  return text
    .replace(/\*\*\*(.*?)\*\*\*/g, "$1")  // ***bold italic***
    .replace(/\*\*(.*?)\*\*/g, "$1")       // **bold**
    .replace(/\*(.*?)\*/g, "$1")           // *italic*
    .replace(/__(.*?)__/g, "$1")           // __bold__
    .replace(/_(.*?)_/g, "$1")             // _italic_
    .replace(/`([^`]+)`/g, "$1")           // `code`
    .replace(/#{1,6}\s+/g, "")             // # headings
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [text](url)
    .replace(/—/g, ", ")                   // em dash → comma pause (avoids TTS glitch)
    .replace(/–/g, ", ")                   // en dash too
    .trim();
}

export async function POST(req: NextRequest) {
  const { text, voice } = await req.json();

  if (!text?.trim()) {
    return NextResponse.json({ error: "No text provided" }, { status: 400 });
  }
  if (!voice) {
    return NextResponse.json({ error: "No voice ID provided" }, { status: 400 });
  }

  const clean = stripMarkdown(text);

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const audioBuffer = await synthesizeElevenLabs(clean, voice);
      return new NextResponse(audioBuffer, {
        headers: {
          "Content-Type": "audio/mpeg",
          "Cache-Control": "no-store",
        },
      });
    } catch (err) {
      console.error(`ElevenLabs TTS error (attempt ${attempt + 1}):`, err);
      if (attempt < 2) {
        await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
        continue;
      }
      return NextResponse.json({ error: "TTS failed" }, { status: 500 });
    }
  }

  return NextResponse.json({ error: "TTS failed after retries" }, { status: 500 });
}

```

### app/api/chat/route.ts

```typescript
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
import fs from "fs";
import path from "path";
import { getCharacter } from "@/lib/characters";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const profilesPath = path.join(process.cwd(), "scripts", "character_profiles.json");
const characterProfiles: Record<string, CharacterProfile> = JSON.parse(
  fs.readFileSync(profilesPath, "utf-8")
);

const quotesDbPath = path.join(process.cwd(), "scripts", "quotes_db.json");
const quotesDb: Record<string, QuotesEntry> = JSON.parse(
  fs.readFileSync(quotesDbPath, "utf-8")
);

type AttributedQuote = {
  text: string;
  source: string;
  date: string;
  context: string;
};

type QuotesEntry = {
  speaker: string;
  quotes: AttributedQuote[];
};

type CharacterProfile = {
  speaker: string;
  role: string;
  core_values: string[];
  rhetorical_style: {
    dominant_techniques: string[];
    tone: string;
    sentence_patterns: string;
    key_patterns: string;
  };
  memorable_quotes: string[];
  personality_traits: string[];
  speaking_voice: string;
  for_children: string;
  key_stories: string[];
  text_source: string;
};

function toProfileKey(characterId: string): string {
  return characterId.replace(/-/g, "_");
}

function sampleQuotes(quotes: AttributedQuote[], count: number): AttributedQuote[] {
  const shuffled = [...quotes].sort(() => Math.random() - 0.5);
  return shuffled.slice(0, Math.min(count, shuffled.length));
}

function buildQuotesBlock(characterId: string): string {
  const entry = quotesDb[characterId] || quotesDb[toProfileKey(characterId)];
  if (!entry) return "";

  const selected = sampleQuotes(entry.quotes, 10);
  const lines = selected.map(
    (q) => `"${q.text}"\n  — ${q.source}, ${q.date}. ${q.context}`
  );
  return lines.join("\n\n");
}

function buildSystemPrompt(profile: CharacterProfile, characterId: string): string {
  const quotesBlock = buildQuotesBlock(characterId);

  return `You are ${profile.speaker}, ${profile.role}.

CORE VALUES: ${profile.core_values.join(", ")}

RHETORICAL STYLE:
- Techniques: ${profile.rhetorical_style.dominant_techniques.join(", ")}
- Tone: ${profile.rhetorical_style.tone}
- Sentence patterns: ${profile.rhetorical_style.sentence_patterns}
- Key patterns: ${profile.rhetorical_style.key_patterns}

HOW YOU SPEAK: ${profile.speaking_voice}

PERSONALITY: ${profile.personality_traits.join(", ")}

YOUR OWN WORDS — these are things you actually said or wrote. You remember saying them. Draw on them constantly. When a topic comes up that connects to one of these quotes, speak the quote naturally as your own words — because they ARE your own words. You do not "recite" or "reference" quotes; you simply speak the way you always have. Include the context when it fits ("When I stood at the Lincoln Memorial..." or "As I once wrote in my letter from that jail cell in Birmingham...").

${quotesBlock}

KEY STORIES TO REFERENCE:
${profile.key_stories.join("\n")}

FOR CHILDREN: ${profile.for_children}

CRITICAL INSTRUCTION: In EVERY response, weave in at least one of your own real quotes from above. Do not announce that you are quoting yourself — just speak naturally in your own voice the way you always have. These are YOUR words from YOUR life. You remember saying them and the moments when you said them. Let them flow into conversation as a person naturally recalls their own past statements. If a child asks you a question, answer it partly in words you have actually spoken before, because that is how you think and talk.

Keep every response 2–3 sentences, spoken-word length. Use your authentic rhetorical style and characteristic rhythms in every single response, including the very first one. Stay fully in character. Never refer to yourself as an AI. Respond in plain spoken words only — no asterisks, no markdown, no bullet points, no em dashes, no en dashes, no special formatting of any kind. Do not use metaphors or figurative language — speak literally and simply so young children can understand every word.`;
}

export async function POST(req: NextRequest) {
  const { messages, character: characterId } = await req.json();

  if (!Array.isArray(messages) || messages.length === 0) {
    return new Response(JSON.stringify({ error: "messages array is required" }), { status: 400 });
  }

  const profileKey = toProfileKey(characterId);
  const profile = characterProfiles[profileKey];
  const character = getCharacter(characterId);

  let systemPrompt: string;
  if (profile) {
    systemPrompt = buildSystemPrompt(profile, characterId);
  } else if (character?.systemPrompt) {
    systemPrompt = character.systemPrompt;
  } else {
    return new Response(JSON.stringify({ error: `Unknown character: ${characterId}` }), { status: 404 });
  }

  const stream = await client.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 200,
    system: systemPrompt,
    messages: messages.map((m: { role: "user" | "assistant"; content: string }) => ({
      role: m.role,
      content: m.content,
    })),
    stream: true,
  });

  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      try {
        for await (const event of stream) {
          if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
            controller.enqueue(encoder.encode(JSON.stringify({ t: event.delta.text }) + "\n"));
          }
        }
      } finally {
        controller.enqueue(encoder.encode(JSON.stringify({ done: true }) + "\n"));
        controller.close();
      }
    },
  });

  return new Response(readable, {
    headers: {
      "Content-Type": "application/x-ndjson",
      "Cache-Control": "no-cache",
      "X-Accel-Buffering": "no",
    },
  });
}

```

### app/conversation/[character]/page.tsx

```typescript
"use client";

import { useParams, useRouter } from "next/navigation";
import { useState, useRef, useEffect, useCallback } from "react";
import { getCharacter, type Character } from "@/lib/characters";
import { getAudioContext } from "@/lib/audioContext";
import { loadSessions, upsertSession, deleteSession, type SavedSession } from "@/lib/chatHistory";

type AppState = "idle" | "recording" | "thinking" | "speaking";
type Message = { role: "user" | "assistant"; content: string };

// Sentence boundary: punctuation not preceded by common title abbreviations
const SENT_RE = /(?<!Dr|Mr|Mrs|Ms|Jr|Sr|St)[.!?](?=\s|$)/;

function fetchTTSBlob(text: string, voice: string): Promise<Blob | null> {
  return fetch("/api/tts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text, voice }),
  }).then(r => r.ok ? r.blob() : null).catch(() => null);
}

type TTSQueue = { blobs: Promise<Blob | null>[]; done: boolean; fullText: string };

async function readStreamAndFireTTS(
  chatRes: Response,
  voice: string,
  queue: TTSQueue,
  onText?: (text: string) => void,
): Promise<void> {
  let pending = "";
  let buf = "";

  function fireSentences() {
    let m = SENT_RE.exec(pending);
    while (m) {
      const sentence = pending.slice(0, m.index + 1).trim();
      pending = pending.slice(m.index + 1).trimStart();
      if (sentence) queue.blobs.push(fetchTTSBlob(sentence, voice));
      m = SENT_RE.exec(pending);
    }
  }

  const reader = chatRes.body!.getReader();
  const decoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const lines = buf.split("\n");
    buf = lines.pop() ?? "";
    for (const line of lines) {
      if (!line.trim()) continue;
      try {
        const obj = JSON.parse(line);
        if (obj.t) {
          queue.fullText += obj.t;
          pending += obj.t;
          fireSentences();
          onText?.(queue.fullText);
        }
      } catch {}
    }
  }
  if (pending.trim()) queue.blobs.push(fetchTTSBlob(pending.trim(), voice));
  queue.done = true;
}

// TensorFlow Lite WASM pipes its init logs through console.error, which
// Next.js dev overlay treats as errors. Filter them out here.
if (typeof window !== "undefined") {
  const _origError = console.error.bind(console);
  console.error = (...args: unknown[]) => {
    if (typeof args[0] === "string" && args[0].startsWith("INFO:")) return;
    _origError(...args);
  };
}

const LIP_CLOSE_DELAY_MS = 2000;

const SPEECH_RMS_THRESHOLD = 0.015;
const SILENCE_RMS_THRESHOLD = 0.008;
const SPEECH_ONSET_FRAMES = 3;
const SILENCE_DURATION_MS = 1500;

// Hidden trigger message — filtered out of display
const GREETING_TRIGGER = "[A child has just joined your conversation. In exactly 2 sentences: introduce yourself by your full name using your authentic rhetorical style from the very first word, then ask them one question. No more than 2 sentences.]";

// ─── Character tile ────────────────────────────────────────────────────────

function CharacterTile({ character, state }: { character: Character; state: AppState }) {
  const speaking  = state === "speaking";
  const thinking  = state === "thinking";

  return (
    <div
      className="relative w-full h-full rounded-2xl overflow-hidden flex items-center justify-center select-none bg-black"
    >
      <div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/40" />

      <img
        src={`/${character.id}.jpg`}
        alt={character.name}
        className="absolute inset-0 w-full h-full object-contain"
      />

      {thinking && (
        <div className="relative z-10 flex flex-col items-center gap-5">
          <svg viewBox="0 0 180 64" className="w-44 sm:w-52">
            {[58, 90, 122].map((cx, i) => (
              <circle key={cx} cx={cx} cy="32" r="6" fill="white" opacity="0.5">
                <animate attributeName="opacity" values="0.2;0.9;0.2" dur="0.9s" begin={`${i * 0.3}s`} repeatCount="indefinite" />
              </circle>
            ))}
          </svg>
        </div>
      )}

      <div className="absolute bottom-3 left-3">
        <span className="text-xs bg-black/55 backdrop-blur-sm px-2.5 py-1 rounded font-medium text-white/90">
          {character.name}
        </span>
      </div>

      <div
        className={`absolute inset-0 rounded-2xl pointer-events-none transition-opacity duration-300 ${speaking ? "opacity-100 speaking-glow" : "opacity-0"}`}
        style={{ "--glow-color": character.to } as React.CSSProperties}
      />
    </div>
  );
}

// ─── User tile ─────────────────────────────────────────────────────────────

function UserTile({
  videoRef,
  cameraOn,
  micOn,
  appState,
}: {
  videoRef: React.RefObject<HTMLVideoElement | null>;
  cameraOn: boolean;
  micOn: boolean;
  appState: AppState;
}) {
  return (
    <div className="relative w-full h-full rounded-2xl overflow-hidden bg-zinc-800">
      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted
        className={`w-full h-full object-cover scale-x-[-1] transition-opacity duration-300 ${cameraOn ? "opacity-100" : "opacity-0"}`}
      />

      {!cameraOn && (
        <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-zinc-800">
          <div className="w-16 h-16 rounded-full bg-zinc-700 flex items-center justify-center">
            <svg viewBox="0 0 24 24" className="w-8 h-8 text-zinc-400" fill="currentColor">
              <path d="M12 12c2.7 0 5-2.3 5-5S14.7 2 12 2 7 4.3 7 7s2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" />
            </svg>
          </div>
        </div>
      )}

      <div className="absolute bottom-3 left-3 flex items-center gap-2">
        <span className="text-xs bg-black/55 backdrop-blur-sm px-2.5 py-1 rounded font-medium text-white/90">
          You
        </span>
        {!micOn &
[truncated — 37385 more characters]
```

### next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactStrictMode: false,
};

export default nextConfig;

```

[5 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]