# Project export: AId Hominem

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: Argue better. In real time.
- Devpost: https://devpost.com/software/aid-hominem
- GitHub: https://github.com/uci-tuan4/AI_Hominem
- Video: https://www.youtube.com/embed/p4KI3gGM5nw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — tu-vn-consulting (11 commits), Claude Opus 4.8 (1M context) (4 commits)

## Devpost submission (written by the team)

### What it does

AId Hominem is a live debate coach. It: Listens and transcribes speech in real time via streaming speech-to-text Flags logical fallacies as they're spoken — ad hominem, strawman, false dilemma, slippery slope, circular reasoning, emotional appeals, and more; each with the offending quote, a severity/confidence rating, an explanation, and a suggested follow-up question Fact-checks claims in real time by extracting a checkable claim, searching the web, reading sources, and returning a verdict (supported / contradicted / unclear) with citations

### How we built it

Backend (server.js) — a Node.js HTTP server with a hand-rolled WebSocket proxy. Speech-to-text — Deepgram streaming STT over a WebSocket proxy. Fallacy analysis — Gemini 3.1-Flash-Lite calls with a structured JSON output schema. Fact-checking — Gemini extracts one checkable claim, Browserbase searches and fetches pages, and a second LLM call produces the verdict with sources.

### Challenges we ran into

Real-time without latency death — running STT, fallacy analysis, and web fact-checking simultaneously meant carefully throttling work (analysis every ~15s, fact-checks every ~30s) so the UI stayed responsive. Writing a WebSocket proxy from scratch — to keep the dependency footprint tiny we implemented the WebSocket framing protocol over raw TCP ourselves, which meant debugging frame parsing by hand.

### What we learned

How to architect a single codebase that runs in both browser and Node/Electron contexts. The nuts and bolts of the WebSocket framing protocol. How to coax reliable structured JSON out of LLMs and combine multiple model calls into a search-and-verify pipeline.

### What's next

Speaker diarization — attribute flags to who said what in multi-person debates. Post-debate reports — a scored summary of each participant's argument quality and fallacy count. Rebuttal suggestions — go beyond flagging and propose how to respond. Mobile and meeting integrations — Zoom/Meet/Teams plugins for live calls. Tunable strictness — let users dial sensitivity up for casual chats or down for formal debate. Personal coaching mode — analyze your own arguments over time to help you debate better.

## README (from the GitHub repository)

# AId Hominem

A live debate coach that transcribes speech, flags logical fallacies, and fact-checks claims in real time.

## Setup

Copy `.env.example` to `.env` and fill in your keys:

| Variable | Purpose |
|---|---|
| `DEEPGRAM_API_KEY` | Streaming speech-to-text |
| `OPENROUTER_API_KEY` | Fallacy analysis and fact-checking |
| `OPENROUTER_MODEL` | Model override (default: `google/gemini-3.1-flash-lite`) |
| `BROWSERBASE_API_KEY` | Web search for fact-checking |

## Run

```sh
npm start
```

Open `http://localhost:4173`. Use **Start** for live Deepgram streaming or **Demo** for a canned debate.

## Desktop app (Electron)

```sh
npm install
npm run desktop
```

Use **Mic** for microphone input or **Desktop** for system audio. macOS will prompt for microphone, screen, and system-audio permissions. Closing the window hides the app to the tray; **Quit** from the tray menu exits.


## Detected evidence (automated analysis)

Indexed codebase: 6 recognized source files, 55 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (11 of 11)

```
.env.example
.gitignore
app.js
electron/main.js
electron/preload.cjs
index.html
package.json
README.md
server.js
styles.css
test.mjs
```

### Dependencies

- package.json: electron@^42.4.1

### Recent commits (newest first)

- Merge pull request #4 from uci-tuan4/flag-transcript-connector
- Show all flags and link hovered flag to its transcript line
- Merge pull request #3 from uci-tuan4/codex/ui-notifications
- Rename to AId Hominem, add logo, and revamp the demo
- Add high-severity-only filter for unsupported claims
- Merge pull request #2 from uci-tuan4/codex/ui-notifications
- Add analysis sensitivity control and fix macOS notifications
- Merge pull request #1 from uci-tuan4/codex/ui-notifications
- Update debate coach notifications and layout
- Add live STT analysis and fact checks
- Build MVP debate coach app

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

### package.json

```
{
  "name": "ai-hominem",
  "version": "0.1.0",
  "main": "electron/main.js",
  "type": "module",
  "private": true,
  "scripts": {
    "desktop": "electron .",
    "start": "node --env-file=.env server.js",
    "test": "node test.mjs"
  },
  "devDependencies": {
    "electron": "^42.4.1"
  }
}

```

### server.js

```javascript
import { createReadStream, existsSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";

const root = resolve(fileURLToPath(new URL(".", import.meta.url)));
const maxAudioBytes = 25 * 1024 * 1024;
const types = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png"
};

const port = process.env.PORT || 4173;
const host = "127.0.0.1";
const defaultOpenRouterModel = "google/gemini-3.1-flash-lite";

// Controls how aggressively the analyzer flags claims. Override with
// ANALYSIS_SENSITIVITY=low|medium|high (default: medium). `minConfidence` is a
// hard post-filter; `guidance` steers the model's own threshold.
const sensitivityProfiles = {
  low: {
    minConfidence: 0.8,
    guidance: "Be conservative: flag only egregious, clear-cut fallacies and central claims asserted as fact with no support whatsoever. Ignore hedged, qualified, or offhand statements. When in doubt, do not flag."
  },
  medium: {
    minConfidence: 0.6,
    guidance: "Flag substantive unsupported claims and clear fallacies. Skip trivial, hedged, or well-qualified statements."
  },
  high: {
    minConfidence: 0,
    guidance: "Flag every factual claim stated without evidence and any potential fallacy, even minor ones."
  }
};

export function analysisProfile(name) {
  const key = String(name || process.env.ANALYSIS_SENSITIVITY || "medium").toLowerCase();
  return sensitivityProfiles[key] || sensitivityProfiles.medium;
}

const claimSchema = {
  type: "object",
  properties: {
    claim: { type: "string" }
  },
  required: ["claim"]
};
const analysisSchema = {
  type: "object",
  properties: {
    flags: {
      type: "array",
      items: {
        type: "object",
        properties: {
          type: { type: "string" },
          quote: { type: "string" },
          severity: { type: "string", enum: ["low", "medium", "high"] },
          confidence: { type: "number" },
          explanation: { type: "string" },
          followUp: { type: "string" }
        },
        required: ["type", "quote", "severity", "confidence", "explanation", "followUp"]
      }
    }
  },
  required: ["flags"]
};
const factCheckSchema = {
  type: "object",
  properties: {
    verdict: { type: "string", enum: ["supported", "contradicted", "unclear"] },
    explanation: { type: "string" },
    sources: {
      type: "array",
      items: {
        type: "object",
        properties: {
          title: { type: "string" },
          url: { type: "string" }
        },
        required: ["title", "url"]
      }
    }
  },
  required: ["verdict", "explanation", "sources"]
};
const deepgramListenParams = new URLSearchParams({
  model: "nova-3",
  interim_results: "true",
  smart_format: "true",
  punctuate: "true"
});

function json(res, status, body) {
  res.writeHead(status, { "Content-Type": types[".json"] });
  res.end(JSON.stringify(body));
}

function readBody(req) {
  return new Promise((resolveBody, reject) => {
    const chunks = [];
    let size = 0;

    req.on("data", (chunk) => {
      size += chunk.length;
      if (size > maxAudioBytes) {
        reject(new Error("Audio upload too large"));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () => resolveBody(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

export function extractDeepgramTranscript(result) {
  return result?.results?.channels?.[0]?.alternatives?.[0]?.transcript?.trim() || "";
}

export function openRouterModelId() {
  return process.env.OPENROUTER_MODEL || defaultOpenRouterModel;
}

export function deepgramListenUrl() {
  return `wss://api.deepgram.com/v1/listen?${deepgramListenParams}`;
}

export function browserbaseEnabled() {
  return Boolean(process.env.BROWSERBASE_API_KEY);
}

export function shouldSkipFactCheck(claim) {
  const text = String(claim || "").toLowerCase();
  return /moon .*made of .*cheese|earth .*flat|pigs .*fly|sky .*green|sun .*cold/.test(text);
}

async function wsDataToString(data) {
  if (typeof data === "string") return data;
  if (Buffer.isBuffer(data)) return data.toString();
  if (data instanceof ArrayBuffer) return Buffer.from(data).toString();
  if (data?.text) return data.text();
  return String(data);
}

function sendWsFrame(socket, data, opcode = 1) {
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
  const length = payload.length;
  let header;
  if (length < 126) {
    header = Buffer.from([0x80 | opcode, length]);
  } else if (length < 65536) {
    header = Buffer.from([0x80 | opcode, 126, length >> 8, length & 255]);
  } else {
    header = Buffer.alloc(10);
    header[0] = 0x80 | opcode;
    header[1] = 127;
    header.writeBigUInt64BE(BigInt(length), 2);
  }
  socket.write(Buffer.concat([header, payload]));
}

function readWsFrames(buffer, onFrame) {
  let offset = 0;
  while (buffer.length - offset >= 2) {
    const first = buffer[offset];
    const second = buffer[offset + 1];
    let length = second & 127;
    let headerLength = 2;

    if (length === 126) {
      if (buffer.length - offset < 4) break;
      length = buffer.readUInt16BE(offset + 2);
      headerLength = 4;
    } else if (length === 127) {
      if (buffer.length - offset < 10) break;
      length = Number(buffer.readBigUInt64BE(offset + 2));
      headerLength = 10;
    }

    const masked = Boolean(second & 0x80);
    const maskLength = masked ? 4 : 0;
    const frameLength = headerLength + maskLength + length;
    if (buffer.length - offset < frameLength) break;

    const mask = masked ? buffer.subarray(offset + headerLength, offset + headerLength + 4) : null;
    const payload = Buffer.from(buffer.subarray(offset + headerLength + maskLength, offset + frameLength));
    if (mas
[truncated — 8871 more characters]
```

### electron/main.js

```javascript
import { app, BrowserWindow, Menu, Notification, Tray, desktopCapturer, ipcMain, nativeImage, session } from "electron";
import { createServer } from "node:http";
import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { handleRequest, handleUpgrade } from "../server.js";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const iconPath = join(root, "assets/icon-256.png");
let mainWindow;
let tray;
let server;
let isQuitting = false;

function loadEnv() {
  const file = join(root, ".env");
  if (!existsSync(file)) return;
  for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
    const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
    if (!match || process.env[match[1]]) continue;
    process.env[match[1]] = match[2].replace(/^["']|["']$/g, "");
  }
}

function startServer() {
  return new Promise((resolveServer) => {
    server = createServer(handleRequest);
    server.on("upgrade", handleUpgrade);
    server.listen(0, "127.0.0.1", () => {
      resolveServer(`http://127.0.0.1:${server.address().port}`);
    });
  });
}

function createTray() {
  const icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 });
  tray = new Tray(icon);
  tray.setToolTip("AId Hominem");
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: "Show AId Hominem", click: showMainWindow },
    { label: "Hide", click: () => mainWindow?.hide() },
    { type: "separator" },
    {
      label: "Quit",
      click: () => {
        isQuitting = true;
        app.quit();
      }
    }
  ]));
  tray.on("click", showMainWindow);
}

function showMainWindow() {
  if (!mainWindow) return;
  if (mainWindow.isMinimized()) mainWindow.restore();
  mainWindow.show();
  mainWindow.focus();
}

async function createWindow() {
  session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
    desktopCapturer.getSources({ types: ["screen"] }).then((sources) => {
      if (!sources[0]) callback({});
      else callback({ video: sources[0], audio: "loopback" });
    }).catch(() => callback({}));
  });

  const url = await startServer();
  mainWindow = new BrowserWindow({
    width: 1180,
    height: 780,
    title: "AId Hominem",
    icon: iconPath,
    webPreferences: {
      preload: join(root, "electron/preload.cjs"),
      backgroundThrottling: false,
      contextIsolation: true,
      nodeIntegration: false
    }
  });

  mainWindow.on("close", (event) => {
    if (isQuitting) return;
    event.preventDefault();
    mainWindow.hide();
  });
  await mainWindow.loadURL(url);
}

loadEnv();
app.setName("AId Hominem");
app.dock?.setIcon(iconPath);

app.whenReady().then(async () => {
  createTray();
  await createWindow();
});

app.on("activate", showMainWindow);
app.on("before-quit", () => {
  isQuitting = true;
  server?.close();
});

function notifyWithAppleScript(title, body) {
  const quotedTitle = JSON.stringify(title);
  const quotedBody = JSON.stringify(body);
  execFile("osascript", ["-e", `display notification ${quotedBody} with title ${quotedTitle}`]);
}

ipcMain.handle("notify-flag", (_event, flag) => {
  const title = `AId Hominem: ${String(flag.type || "flag").replaceAll("_", " ")}`;
  const body = flag.followUp || "What is the strongest answer to this?";
  // Native Electron notifications silently no-op in an unpackaged dev app on
  // macOS (no bundle id / notification authorization), so route macOS through
  // AppleScript, which works regardless.
  if (process.platform === "darwin") {
    notifyWithAppleScript(title, body);
    return true;
  }
  if (!Notification.isSupported()) return false;
  const notification = new Notification({
    title,
    body,
    icon: iconPath
  });
  notification.on("click", showMainWindow);
  notification.show();
  return true;
});

```

### app.js

```javascript
const demoLines = [
  { text: "If we let students use AI, nobody will learn anything anymore. Teachers will become useless, schools will collapse, and employers will never trust graduates again." },
  { text: "The other side only supports AI because they do not care whether students can think for themselves." },
  {
    text: "And the evidence is obvious: Finland banned smartphones from every classroom back in 2015 and immediately became the number one country in the world for math.",
    fact: {
      claim: "Finland banned smartphones in all classrooms in 2015 and became number one in global math rankings.",
      verdict: "contradicted",
      explanation: "Finland moved to restrict phones in 2024–25, not 2015, and ranks high but not first in PISA math.",
      sources: [{ title: "OECD — Programme for International Student Assessment (PISA)", url: "https://www.oecd.org/en/about/programmes/pisa.html" }]
    }
  },
  { text: "My cousin used an AI tutor and passed calculus, so clearly every school should replace homework with AI coaching." },
  { text: "Either we ban AI from classrooms completely or we accept that cheating is now normal." },
  { text: "I am not saying there are no benefits, but nobody has shown how schools will preserve original thinking at scale." }
];

const rules = [
  {
    type: "slippery_slope",
    severity: "medium",
    confidence: 0.78,
    test: /nobody will learn|schools will collapse|never trust/i,
    explanation: "Predicts an extreme outcome without evidence.",
    followUp: "What evidence supports that chain of outcomes?"
  },
  {
    type: "unsupported_claim",
    severity: "medium",
    confidence: 0.72,
    test: /clearly|obviously|everyone|nobody|never|always|useless|causes?|proves?|guarantees?|destroys?|fixes?|leads to|makes .* (better|worse|useless|dangerous)/i,
    skip: /stupid|ignorant|idiot|moron/i,
    explanation: "Makes a broad claim without support.",
    followUp: "What data or source supports that claim?"
  },
  {
    type: "false_dilemma",
    severity: "high",
    confidence: 0.82,
    test: /either .* or |ban .* completely|only two/i,
    explanation: "Frames the choice as narrower than it is.",
    followUp: "What middle-ground options are being excluded?"
  },
  {
    type: "ad_hominem",
    severity: "low",
    confidence: 0.68,
    test: /do not care|doesn't care|ignorant|stupid/i,
    explanation: "Attacks motive or character instead of the argument.",
    followUp: "Can you answer the argument without judging the person?"
  },
  {
    type: "anecdotal_evidence",
    severity: "low",
    confidence: 0.7,
    test: /my cousin|my friend|one time|in my experience/i,
    explanation: "Uses one example as if it proves the broader point.",
    followUp: "Is there broader evidence beyond that example?"
  }
];

let segments = [];
let flags = [];
let facts = [];
let recognition;
let recorder;
let mediaStream;
let streamSocket;
let currentMode = "mic";
let micActive = false;
let analyzeTimer;
let factTimer;
let demoTimer;
let demoFactTimer;
let demoActive = false;
let lastAnalyzed = 0;
let lastFactChecked = 0;
let partialText = "";
let deepgramFailed = false;
let analyzing = false;
let factChecking = false;
const minAnalysisWords = 3;

const isBrowser = typeof document !== "undefined";
const $ = (id) => document.getElementById(id);

const statusEl = isBrowser ? $("status") : null;
const transcriptEl = isBrowser ? $("transcript") : null;
const flagsEl = isBrowser ? $("flags") : null;
const factsEl = isBrowser ? $("facts") : null;
const startBtn = isBrowser ? $("startBtn") : null;
const stopBtn = isBrowser ? $("stopBtn") : null;
const demoBtn = isBrowser ? $("demoBtn") : null;
const sensitivityEl = isBrowser ? $("sensitivity") : null;
const highSeverityClaimsEl = isBrowser ? $("highSeverityClaims") : null;
const modeButtons = isBrowser ? Array.from(document.querySelectorAll(".mode")) : [];
const connectorEl = isBrowser ? $("connector") : null;
const connectorPath = isBrowser ? $("connectorPath") : null;
const connectorDotA = isBrowser ? $("connectorDotA") : null;
const connectorDotB = isBrowser ? $("connectorDotB") : null;
let activeLink = null;

function words(text) {
  return text.trim().split(/\s+/).filter(Boolean);
}

function isFlag(flag) {
  return flag && flag.type && flag.quote;
}

function flagKey(flag) {
  return `${flag.type}:${normalizeQuote(flag.quote)}`;
}

function normalizeQuote(quote) {
  return String(quote || "").toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
}

function flagRank(flag) {
  const severity = { high: 3, medium: 2, low: 1 }[flag.severity] || 0;
  return severity + (Number(flag.confidence) || 0);
}

function overlaps(a, b) {
  return a === b || (a.length > 8 && b.includes(a)) || (b.length > 8 && a.includes(b));
}

export function mergeNewFlags(existingFlags, candidateFlags) {
  const merged = existingFlags.filter(isFlag).map((flag) => ({ flag, existing: true }));

  for (const flag of candidateFlags.filter(isFlag)) {
    const quote = normalizeQuote(flag.quote);
    const index = merged.findIndex((item) =>
      item.flag.type === flag.type && overlaps(normalizeQuote(item.flag.quote), quote)
    );
    if (index === -1) {
      merged.push({ flag, existing: false });
    } else if (!merged[index].existing && flagRank(flag) > flagRank(merged[index].flag)) {
      merged[index] = { flag, existing: false };
    }
  }

  return merged.filter((item) => !item.existing).map((item) => item.flag).slice(0, 5);
}

function clip(text, max = 90) {
  const value = String(text || "");
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
}

function flagTitle(flag) {
  return String(flag.type || "flag").replaceAll("_", " ");
}

function counterQuestion(flag) {
  return flag.followUp || "What is the strongest answer to this?";
}

async function notifyFlags(newFlags) {
  for (const flag of newFlags) {
    if (window.aiHominem?.notifyFlag) {
      window.aiHominem.notifyFlag(flag);
    } else i
[truncated — 18531 more characters]
```

### index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>AId Hominem</title>
  <link rel="icon" type="image/svg+xml" href="assets/icon.svg">
  <link rel="icon" type="image/png" sizes="32x32" href="assets/icon-32.png">
  <link rel="apple-touch-icon" href="assets/icon-256.png">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main class="app">
    <header class="topbar">
      <div>
        <img class="logo" src="assets/icon.svg" alt="" width="34" height="34">
        <h1>AId Hominem</h1>
        <p id="status">Ready</p>
      </div>
      <div class="controls" aria-label="Debate controls">
        <div class="mode-control" aria-label="Audio source">
          <button id="micModeBtn" class="mode active" type="button" data-mode="mic">Mic</button>
          <button id="desktopModeBtn" class="mode" type="button" data-mode="desktop">Desktop</button>
        </div>
        <select id="sensitivity" class="sensitivity" aria-label="Flag sensitivity">
          <option value="low">Low sensitivity</option>
          <option value="medium" selected>Medium sensitivity</option>
          <option value="high">High sensitivity</option>
        </select>
        <label class="check-control">
          <input id="highSeverityClaims" type="checkbox">
          High-severity claims only
        </label>
        <button id="startBtn" type="button">Start</button>
        <button id="stopBtn" type="button" disabled>Stop</button>
        <button id="demoBtn" type="button">Try Demo</button>
      </div>
    </header>

    <section class="workspace" aria-label="Live debate coach">
      <section class="panel transcript-panel">
        <div class="panel-title">
          <h2>Live Transcript</h2>
          <span id="wordCount">0 words</span>
        </div>
        <div id="transcript" class="transcript" aria-live="polite">
          <p class="empty">Start the mic or run the demo.</p>
        </div>
      </section>

      <section class="panel flags-panel">
        <div class="panel-title">
          <h2>Live Flags</h2>
          <span id="flagCount">0 flags</span>
        </div>
        <div id="flags" class="flags" aria-live="polite">
          <p class="empty">No issues flagged yet.</p>
        </div>
      </section>

      <section class="panel fact-panel">
        <div class="panel-title">
          <h2>Fact Checks</h2>
          <span id="factCount">0 checked</span>
        </div>
        <div id="facts" class="facts" aria-live="polite">
          <p class="empty">No claims checked yet.</p>
        </div>
      </section>
    </section>

  </main>

  <svg id="connector" class="connector" aria-hidden="true">
    <path id="connectorPath"></path>
    <circle id="connectorDotA" r="4"></circle>
    <circle id="connectorDotB" r="4"></circle>
  </svg>

  <script type="module" src="app.js"></script>
</body>
</html>

```

### styles.css

```css
:root {
  color-scheme: light;

  /* Apple system colors */
  --bg:           #f2f2f7;
  --bg2:          #ffffff;
  --label:        #000000;
  --label2:       #3c3c43cc;   /* 80% */
  --label3:       #3c3c4399;   /* 60% */
  --separator:    rgba(60, 60, 67, 0.12);
  --fill:         rgba(120, 120, 128, 0.12);
  --fill2:        rgba(120, 120, 128, 0.08);

  --blue:         #007aff;
  --blue-light:   #e8f0fe;
  --green:        #34c759;
  --green-light:  #e6f9ec;
  --orange:       #ff9500;
  --orange-light: #fff3e0;
  --red:          #ff3b30;
  --red-light:    #ffebea;
  --gray:         #8e8e93;
  --gray-light:   rgba(142, 142, 147, 0.12);

  --radius-sm:    8px;
  --radius-md:    12px;
  --radius-lg:    16px;
  --radius-xl:    20px;

  --shadow-card:  0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
}

* {
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

html, body {
  height: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--label);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
  font-size: 15px;
  line-height: 1.47059;
  letter-spacing: -0.01em;
}

h1, h2, h3, p {
  margin: 0;
}

h1 {
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.025em;
  line-height: 1.2;
}

h2 {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.005em;
  text-transform: uppercase;
  color: var(--label3);
}

h3 {
  font-size: 13px;
  font-weight: 600;
  text-transform: capitalize;
  letter-spacing: -0.005em;
}

/* ── Layout ───────────────────────────────────── */

.app {
  height: 100vh;
  padding: 20px;
  display: grid;
  gap: 12px;
  grid-template-rows: auto 1fr;
  overflow: hidden;
}

/* ── Topbar ───────────────────────────────────── */

.topbar {
  background: var(--bg2);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-card);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 20px;
}

.topbar > div:first-child {
  display: flex;
  align-items: center;
  gap: 10px;
}

.logo {
  flex-shrink: 0;
  border-radius: 8px;
  display: block;
}

#status {
  font-size: 12px;
  font-weight: 500;
  color: var(--label3);
  background: var(--fill);
  border-radius: 20px;
  padding: 3px 10px;
  white-space: nowrap;
}

#status.listening {
  color: var(--green);
  background: var(--green-light);
}

/* ── Controls ─────────────────────────────────── */

.controls {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

/* Segmented control — iOS style */
.mode-control {
  display: flex;
  padding: 2px;
  background: var(--fill);
  border-radius: var(--radius-sm);
  gap: 2px;
}

.mode {
  min-height: 30px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: var(--label2);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  padding: 0 14px;
  cursor: pointer;
  transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease;
}

.mode.active {
  background: var(--bg2);
  color: var(--label);
  font-weight: 600;
  box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 1px rgba(0,0,0,.06);
}

/* Buttons */
button {
  min-height: 36px;
  border: none;
  border-radius: 20px;
  background: var(--fill);
  color: var(--label);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 0 18px;
  cursor: pointer;
  transition: opacity 120ms ease, filter 120ms ease;
  letter-spacing: -0.01em;
}

button:hover:not(:disabled) {
  filter: brightness(0.94);
}

button:active:not(:disabled) {
  filter: brightness(0.88);
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.3;
}

#startBtn {
  background: var(--blue);
  color: #fff;
}

#demoBtn {
  background: var(--fill);
  color: var(--label2);
}

#stopBtn {
  background: var(--red-light);
  color: var(--red);
}

.sensitivity {
  min-height: 36px;
  border: none;
  border-radius: 20px;
  background: var(--fill);
  color: var(--label);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 0 30px 0 14px;
  cursor: pointer;
  letter-spacing: -0.01em;
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%238e8e93' d='M0 0l5 6 5-6z'/></svg>");
  background-repeat: no-repeat;
  background-position: right 12px center;
  transition: filter 120ms ease;
}

.sensitivity:hover {
  filter: brightness(0.94);
}

.check-control {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  font-weight: 500;
  color: var(--label2);
  white-space: nowrap;
  cursor: pointer;
}

.check-control input {
  accent-color: var(--blue);
  cursor: pointer;
  margin: 0;
}

/* ── Workspace ────────────────────────────────── */

.workspace {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(260px, .75fr) minmax(260px, .75fr);
  gap: 12px;
  min-height: 0;
}

/* ── Panels ───────────────────────────────────── */

.panel {
  background: var(--bg2);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-card);
  min-height: 0;
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr;
  overflow: hidden;
}

.panel-title {
  padding: 14px 18px 12px;
  border-bottom: 1px solid var(--separator);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.panel-title span {
  font-size: 12px;
  font-weight: 500;
  color: var(--label3);
  background: var(--fill);
  border-radius: 20px;
  padding: 2px 9px;
}

.transcript,
.flags,
.facts {
  min-height: 0;
  padding: 14px 18px;
  overflow-y: auto;
  overflow-x: hidden;
}

/* Scrollbar — thin macOS style */
.transcript::-webkit-scrollbar,
.flags::-webkit-scrollbar,
.facts::-webkit-scrollbar {
  width: 4px;
}
.transcript::-webkit-scrollbar-thumb,
.flags::-webkit-scrollbar-thumb,
.facts::-webkit-scrollbar-thumb {
  background: rgba(0,0,0,.15);
  border-radius: 2px;
}

/* ── Empty state ──────────────────────
[truncated — 2917 more characters]
```