# Project export: CrowdSense

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: Live crowd-risk dashboard for event security. Tracks headcount, density, and motion from camera feeds in real time and speaks alerts via Deepgram
- Devpost: https://devpost.com/software/crowdsense-2btc4g
- GitHub: https://github.com/HebaJeba/securityscan.git
- Video: https://www.youtube.com/embed/Zgj9_mkwFDQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

Crowd crush incidents at concerts, festivals, and public events have spiked post-pandemic. Crowd crush incidents, such as the Astroworld concert incident in 2021, as well as the Itaewon Halloween crowd crush in 2022, both had warning signs visible on security camera footage, but when the emergency was called, many had already been injured or harmed. In these incidents security was present but the crowd density increased rapidly causing the incidents. Every public event with large crowds risks crowd crushes and requires security personnel to be watching the crowd and keeping track of overflow and capacity. As a result, I created a lightweight dashboard tool that watches the security feed as well as live videos of crowds to provide security live updates.

### What it does

CrowdSense is a browser-based crowd risk dashboard that works off a webcam or an uploaded video clip: Tracks four different risk signals which include headcount, density, motion energy, and 10-second crowd surges, and combines these factors into a 0-100 risk score, weighted to give an emphasis on density and motion Detects people in the frame in real time using a tiled scanning of the frame in overlapping zoomed sections to catch people that are further away or covered by other people Has an aerial mode The aerial mode estimates headcount by measuring how much of the frame is "crowd-textured" versus open ground, calibrated against an on-screen reference circle, the same area-based approach used in real safety guidance for very dense crowds. Speaks alerts out loud using Deepgram's Aura voice agent, so any security or user utilizing CrowdSense attention is immediately directed to the urgency of the situation for quick preventative action.

### How we built it

Frontend: HTML/CSS/JS, TensorFlow.js running COCO-SSD client-side for person detection, Chart.js for the live risk timeline, and a custom canvas-based pixel-variance algorithm for the aerial estimator. Backend: a small Node.js/Express server that proxies Deepgram's /v1/speak endpoint for voice alerts, so the API key never has to live in browser JavaScript. No video or images ever leave the browser. Only short alert text is sent to Deepgram for speech synthesis.

### Challenges we ran into

The biggest challenge was that the first version of the dashboard struggled to identify a correct headcount and kept reporting "1 person" on a frame, although there were several hundred, and completely failed on real aerial/drone crowd footage. Debugging that meant pulling the actual frames from our test video and realizing the failure was because the object detector was trained on eye-level photos and wasn't seeing people's faces above. We fixed this by Accomplishments we're proud of Built and validated a custom area crowd-density estimator from scratch, and verified it against real aerial footage before shipping it. Got a full real-time pipeline working client-side entirely with detection, density, motion, and surge, which feeds a live risk score and voice alerts.

### What we learned

General-purpose object detectors carry strong assumptions about camera angle that aren't obvious until you test them outside that assumption, for example, aerial drone shots. Voice alerts change how a monitoring tool actually gets used. A number changing on a screen is easy to miss, while a voice alert helps grab your attention immediately, along with a risk score. Weighting crowd risk by density and other factors is important to efficiently identify the risk of crowd crush while also not underestimating or overestimating the numbers.

### What's next

Integrate an AI agent so security can ask for situational assessments and recommended action on demand, instead of just updates on a dashboard. Swap the area-based aerial estimator for a proper crowd-counting density mode for real per-frame head counts instead of an area estimate. Multiple-angle video support with a single risk dashboard across a whole venue, so an operator can see which entrance or section is vulnerable to the risk of crowd crush. On-device deployment for venues without reliable internet, since the detection pipeline already runs fully client-side.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 5 recognized source files, 62 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (8 of 8)

```
crowd-risk-monitor (6)/crowd-risk-monitor/.env.example
crowd-risk-monitor (6)/crowd-risk-monitor/.gitignore
crowd-risk-monitor (6)/crowd-risk-monitor/package.json
crowd-risk-monitor (6)/crowd-risk-monitor/public/app.js
crowd-risk-monitor (6)/crowd-risk-monitor/public/index.html
crowd-risk-monitor (6)/crowd-risk-monitor/public/style.css
crowd-risk-monitor (6)/crowd-risk-monitor/README.md
crowd-risk-monitor (6)/crowd-risk-monitor/server.js
```

### Dependencies

- crowd-risk-monitor (6)/crowd-risk-monitor/package.json: dotenv@^16.4.5, express@^4.19.2, ws@*

### Recent commits (newest first)

- final files

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

### crowd-risk-monitor (6)/crowd-risk-monitor/package.json

```
{
  "name": "crowd-risk-monitor",
  "version": "1.0.0",
  "description": "Browser-based crowd risk monitoring prototype with Deepgram voice alerts and a Deepgram Voice Agent.",
  "main": "server.js",
  "engines": {
    "node": ">=18.0.0"
  },
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "ws": "*"
  }
}

```

### crowd-risk-monitor (6)/crowd-risk-monitor/server.js

```javascript
// server.js
//
// Express server that:
//  - serves the static frontend (public/)
//  - proxies one-shot text-to-speech alerts to Deepgram's Aura TTS endpoint
//  - relays live mic audio to Deepgram's Voice Agent API for two-way
//    conversation, with the API key attached server-side only
//
// In both cases, the Deepgram API key never reaches the browser.

require('dotenv').config();
const express = require('express');
const path = require('path');
const http = require('http');
const { WebSocketServer, WebSocket } = require('ws');

const app = express();
const PORT = process.env.PORT || 3000;
const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY || '';
const DEEPGRAM_VOICE_MODEL = process.env.DEEPGRAM_VOICE_MODEL || 'aura-asteria-en';
const AGENT_LISTEN_MODEL = process.env.AGENT_LISTEN_MODEL || 'nova-3';
const AGENT_SPEAK_MODEL = process.env.AGENT_SPEAK_MODEL || 'aura-2-thalia-en';
const AGENT_THINK_PROVIDER = process.env.AGENT_THINK_PROVIDER || 'open_ai';
const AGENT_THINK_MODEL = process.env.AGENT_THINK_MODEL || 'gpt-4o-mini';
const AGENT_THINK_API_KEY = process.env.AGENT_THINK_API_KEY || ''; // e.g. your OpenAI key, if the think provider needs one

app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public')));

// In-memory "latest telemetry" snapshot, pushed by the browser on every
// detection tick. The Voice Agent's server-side function reads from this so
// it can answer "how many people are there right now" without the browser
// having to round-trip metrics through the live conversation itself.
let latestTelemetry = null;

app.get('/api/health', (req, res) => {
  res.json({
    ok: true,
    deepgramConfigured: Boolean(DEEPGRAM_API_KEY),
  });
});

// The browser posts its computed metrics here on every detection tick.
app.post('/api/telemetry', (req, res) => {
  const metrics = req.body && req.body.metrics;
  if (!metrics) return res.status(400).json({ error: 'Missing "metrics".' });
  latestTelemetry = metrics;
  res.json({ ok: true });
});

// Text -> speech, proxied to Deepgram's Aura TTS endpoint, used for one-shot
// risk alerts (separate from the live conversational Voice Agent below).
app.post('/api/speak', async (req, res) => {
  const text = (req.body && req.body.text || '').toString().trim();

  if (!text) {
    return res.status(400).json({ error: 'Missing "text" in request body.' });
  }
  if (!DEEPGRAM_API_KEY) {
    return res.status(500).json({
      error: 'DEEPGRAM_API_KEY is not set. Add it to your .env file and restart the server.',
    });
  }

  try {
    const dgResponse = await fetch(
      `https://api.deepgram.com/v1/speak?model=${encodeURIComponent(DEEPGRAM_VOICE_MODEL)}`,
      {
        method: 'POST',
        headers: {
          Authorization: `Token ${DEEPGRAM_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ text }),
      }
    );

    if (!dgResponse.ok) {
      const errText = await dgResponse.text().catch(() => '');
      console.error('Deepgram TTS error:', dgResponse.status, errText);
      return res.status(502).json({
        error: `Deepgram TTS request failed (${dgResponse.status}). Check your API key and plan/credits.`,
      });
    }

    res.setHeader('Content-Type', dgResponse.headers.get('content-type') || 'audio/mpeg');
    const buffer = Buffer.from(await dgResponse.arrayBuffer());
    res.send(buffer);
  } catch (err) {
    console.error('Unexpected error calling Deepgram:', err);
    res.status(500).json({ error: 'Unexpected server error reaching Deepgram.' });
  }
});

const httpServer = http.createServer(app);

// ---------------------------------------------------------------------
// Deepgram Voice Agent — live, conversational, two-way voice. The browser
// streams microphone audio to us over a WebSocket, we relay it to
// Deepgram's Agent API (with our API key attached server-side, never
// exposed to the browser), and relay Deepgram's spoken responses back.
//
// The agent can answer questions about the live crowd metrics ("how many
// people are there", "what's the density right now") via a server-side
// function call that reads `latestTelemetry` above — no audio or video
// ever has to leave the browser for that to work.
// ---------------------------------------------------------------------
const wss = new WebSocketServer({ server: httpServer, path: '/agent-relay' });

const AGENT_SETTINGS = {
  type: 'Settings',
  audio: {
    input: { encoding: 'linear16', sample_rate: 16000 },
    output: { encoding: 'linear16', sample_rate: 24000, container: 'none' },
  },
  agent: {
    language: 'en',
    listen: {
      provider: { type: 'deepgram', model: AGENT_LISTEN_MODEL },
    },
    think: {
      provider: {
        type: AGENT_THINK_PROVIDER,
        model: AGENT_THINK_MODEL,
        ...(AGENT_THINK_API_KEY ? { api_key: AGENT_THINK_API_KEY } : {}),
      },
      prompt: `You are a calm, helpful voice assistant for event security staff using a
crowd-risk monitoring dashboard. You can be asked things like "how many people are there",
"what's the density", "what's the risk level", or "what's the motion score". Call the
get_crowd_metrics function to get current numbers before answering any question about the
crowd. Keep spoken answers short — one or two sentences. If asked something unrelated to the
dashboard, answer briefly and steer back to what you can help with.`,
      functions: [
        {
          name: 'get_crowd_metrics',
          description: 'Returns the current live crowd metrics from the dashboard: headcount, density, motion, surge, and risk score.',
          parameters: { type: 'object', properties: {}, required: [] },
        },
      ],
    },
    speak: {
      provider: { type: 'deepgram', model: AGENT_SPEAK_MODEL },
    },
  },
};

wss.on('connection', (clientWs) => {
  if (!DEEPGRAM_API_KEY) {
    clientWs.send(JSON.stringify({ type: 'Error', description: 'DEEPGRAM_API_KEY is not set on the server.' }));
    cl
[truncated — 2660 more characters]
```

### crowd-risk-monitor (6)/crowd-risk-monitor/public/app.js

```javascript
// app.js — runs entirely in the browser except for the short text
// snippets sent to our own /api/speak endpoint for Deepgram voice playback.

(() => {
  'use strict';

  // ---------- DOM ----------
  const video = document.getElementById('video');
  const overlay = document.getElementById('overlay');
  const overlayCtx = overlay.getContext('2d');
  const stagePlaceholder = document.getElementById('stagePlaceholder');

  const btnWebcam = document.getElementById('btnWebcam');
  const fileInput = document.getElementById('fileInput');
  const btnPlayPause = document.getElementById('btnPlayPause');
  const btnMute = document.getElementById('btnMute');
  const denseModeToggle = document.getElementById('denseModeToggle');
  const venueCapacityInput = document.getElementById('venueCapacity');
  const aerialModeToggle = document.getElementById('aerialModeToggle');
  const headSizePercent = document.getElementById('headSizePercent');
  const headSizePercentValue = document.getElementById('headSizePercentValue');
  const calibrationCircle = document.getElementById('calibrationCircle');
  const aerialHint = document.getElementById('aerialHint');

  const btnAgentToggle = document.getElementById('btnAgentToggle');
  const agentTranscript = document.getElementById('agentTranscript');

  const serverStatus = document.getElementById('serverStatus');
  const voiceStatus = document.getElementById('voiceStatus');
  const modelStatus = document.getElementById('modelStatus');

  const metricCount = document.getElementById('metricCount');
  const metricDensity = document.getElementById('metricDensity');
  const metricMotion = document.getElementById('metricMotion');
  const metricSurge = document.getElementById('metricSurge');

  const riskScoreEl = document.getElementById('riskScore');
  const riskLabelEl = document.getElementById('riskLabel');
  const dialFill = document.getElementById('dialFill');
  const dialNeedle = document.getElementById('dialNeedle');
  const alertLog = document.getElementById('alertLog');

  // ---------- state ----------
  let model = null;
  let analyzing = false;
  let analysisTimer = null;
  let muted = false;
  let deepgramAvailable = false;
  let voiceAgentActive = false;
  let agentSocket = null;
  let agentAudioCtx = null;
  let agentMicStream = null;
  let agentNextPlayTime = 0;
  let lastTelemetry = null;
  let currentAlertAudio = null; // shared across speak() calls so alerts never overlap

  // Downscaled canvas used by the aerial/top-down area-based head estimator.
  // 320x180 is detailed enough to tell "busy/textured" crowd blocks apart
  // from smooth ground/sky, without being too slow to scan every tick.
  const aerialCanvas = document.createElement('canvas');
  aerialCanvas.width = 320;
  aerialCanvas.height = 180;
  const aerialCtx = aerialCanvas.getContext('2d', { willReadFrequently: true });
  const AERIAL_BLOCK_PX = 10;       // block size on the 320x180 canvas
  const AERIAL_TEXTURE_THRESHOLD = 16; // stddev threshold to call a block "crowd-textured"
  const PACKING_FACTOR = 1.15;      // accounts for gaps between people even when dense

  let prevGray = null;             // Uint8ClampedArray, downsampled grayscale
  const motionCanvas = document.createElement('canvas');
  motionCanvas.width = 64; motionCanvas.height = 36;
  const motionCtx = motionCanvas.getContext('2d', { willReadFrequently: true });

  const countHistory = [];         // [{t, count}] over last 10s, for surge
  const smoothingWindow = [];      // last few raw counts, for jitter smoothing
  const scoreHistory = [];         // for the chart
  let lastAlertLevel = 'low';
  let lastAlertAt = 0;

  // Reusable offscreen canvas for tiled detection (zooming into each tile
  // gives the detector more pixels per person than scanning the whole
  // frame at once, which is why far/overlapping people get missed otherwise).
  const tileCanvas = document.createElement('canvas');
  tileCanvas.width = 480;
  tileCanvas.height = 480;
  const tileCtx = tileCanvas.getContext('2d', { willReadFrequently: true });
  const TILE_COLS = 3;
  const TILE_ROWS = 2;
  const TILE_OVERLAP = 0.18; // fraction of tile size, helps catch people split across tile edges
  const SCORE_THRESHOLD = 0.3;

  const DIAL_TOTAL_LEN = approxArcLength(); // for stroke-dasharray

  // ---------- chart ----------
  const chartCtx = document.getElementById('riskChart').getContext('2d');
  const riskChart = new Chart(chartCtx, {
    type: 'line',
    data: {
      labels: [],
      datasets: [{
        data: [],
        borderColor: '#f2a33c',
        backgroundColor: 'rgba(242,163,60,0.12)',
        fill: true,
        tension: 0.3,
        pointRadius: 0,
        borderWidth: 2,
      }],
    },
    options: {
      animation: false,
      responsive: true,
      scales: {
        x: { display: false },
        y: { min: 0, max: 100, ticks: { color: '#7c8894', stepSize: 25 }, grid: { color: '#1f262d' } },
      },
      plugins: { legend: { display: false } },
    },
  });

  // ---------- boot ----------
  init();

  async function init() {
    agentTranscript.dataset.empty = '1';
    checkHealth();
    setInterval(checkHealth, 15000);

    try {
      model = await cocoSsd.load({ base: 'lite_mobilenet_v2' });
      modelStatus.textContent = 'detector ready';
    } catch (err) {
      console.error(err);
      modelStatus.textContent = 'detector failed to load — check internet connection';
    }

    btnWebcam.addEventListener('click', useWebcam);
    fileInput.addEventListener('change', useFile);
    btnPlayPause.addEventListener('click', toggleAnalysis);
    btnMute.addEventListener('click', toggleMute);
    denseModeToggle.addEventListener('change', () => {
      if (analyzing) {
        stopAnalysis();
        analyzing = true;
        analysisTimer = setInterval(runDetectionTick, denseModeToggle.checked ? 1300 : 700);
      }
    });

    aerialModeToggle.addEventListener('change', () => {
      aerialHint.classList.toggle('hid
[truncated — 25148 more characters]
```

### crowd-risk-monitor (6)/crowd-risk-monitor/public/index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crowd Risk Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css" />
</head>
<body>

<header class="topbar">
  <div class="brand">
    <span class="brand-mark" aria-hidden="true"></span>
    <div>
      <h1>Crowd Risk Monitor</h1>
      <p class="brand-sub">Live crowd-density &amp; anomaly heuristics for event security</p>
    </div>
  </div>
  <div class="status-cluster">
    <div id="serverStatus" class="status-pill status-pill--unknown">checking server…</div>
    <div id="voiceStatus" class="status-pill status-pill--unknown">voice: checking…</div>
  </div>
</header>

<main class="layout">

  <!-- LEFT: video + controls -->
  <section class="panel panel--video">
    <div class="panel-head">
      <h2>Feed</h2>
      <div class="source-controls">
        <button id="btnWebcam" class="btn btn--ghost">Use webcam</button>
        <label class="btn btn--ghost file-btn">
          Upload video
          <input type="file" id="fileInput" accept="video/*" hidden />
        </label>
        <button id="btnPlayPause" class="btn btn--primary" disabled>Start analysis</button>
      </div>
    </div>

    <div class="video-stage">
      <video id="video" playsinline muted></video>
      <canvas id="overlay"></canvas>
      <div id="calibrationCircle" class="calibration-circle hidden"></div>
      <div id="stagePlaceholder" class="stage-placeholder">
        <p>Load a webcam feed or upload sample footage to begin.</p>
        <p class="muted">Works fully in-browser. Nothing is uploaded anywhere except short alert text and live mic audio sent to your own server for voice playback and the voice agent.</p>
      </div>
    </div>

    <div class="metrics-row">
      <div class="metric">
        <span class="metric-label">People detected</span>
        <span id="metricCount" class="metric-value">–</span>
      </div>
      <div class="metric">
        <span class="metric-label">Density</span>
        <span id="metricDensity" class="metric-value">–</span>
      </div>
      <div class="metric">
        <span class="metric-label">Motion energy</span>
        <span id="metricMotion" class="metric-value">–</span>
      </div>
      <div class="metric">
        <span class="metric-label">Surge (10s Δ)</span>
        <span id="metricSurge" class="metric-value">–</span>
      </div>
    </div>

    <div class="tuning-row">
      <label class="checkbox-field">
        <input type="checkbox" id="denseModeToggle" checked />
        Dense crowd mode (scans the frame in tiles — slower, finds far more people)
      </label>
      <label class="number-field">
        Venue capacity in view
        <input type="number" id="venueCapacity" min="5" max="2000" step="5" value="80" />
      </label>
    </div>

    <div class="tuning-row tuning-row--aerial">
      <label class="checkbox-field">
        <input type="checkbox" id="aerialModeToggle" />
        Aerial / top-down mode (drone or overhead camera — estimates headcount by area, not by boxing each person)
      </label>
      <label class="number-field number-field--wide">
        Avg. person width
        <input type="range" id="headSizePercent" min="1" max="15" step="0.5" value="4" />
        <span id="headSizePercentValue">4%</span> of frame width
      </label>
    </div>
    <p id="aerialHint" class="muted small hidden">
      Calibration circle is shown on the video — drag the slider until it roughly matches the width of one person's head/shoulders in your footage, then start analysis.
    </p>
  </section>

  <!-- RIGHT: risk dial + log -->
  <section class="panel panel--risk">
    <div class="panel-head">
      <h2>Risk assessment</h2>
      <span id="modelStatus" class="muted small">loading detector…</span>
    </div>

    <div class="dial-wrap">
      <svg id="dial" viewBox="0 0 240 150" class="dial">
        <path d="M 20 130 A 100 100 0 0 1 220 130" class="dial-track" />
        <path id="dialFill" d="M 20 130 A 100 100 0 0 1 220 130" class="dial-fill" />
        <line id="dialNeedle" x1="120" y1="130" x2="120" y2="40" class="dial-needle" />
        <circle cx="120" cy="130" r="6" class="dial-hub" />
      </svg>
      <div class="dial-readout">
        <span id="riskScore" class="risk-score">0</span>
        <span id="riskLabel" class="risk-label">LOW</span>
      </div>
    </div>

    <canvas id="riskChart" class="risk-chart" height="120"></canvas>

    <div class="log-head">
      <h3>Alert log</h3>
      <button id="btnMute" class="btn btn--ghost btn--small">Mute voice alerts</button>
    </div>
    <ul id="alertLog" class="alert-log">
      <li class="alert-row alert-row--muted">No alerts yet.</li>
    </ul>

    <div class="log-head agent-head">
      <h3>Talk to the agent <span class="muted small">(Deepgram Voice Agent)</span></h3>
      <button id="btnAgentToggle" class="btn btn--ghost btn--small">Start voice agent</button>
    </div>
    <p class="muted small">
      Ask it things like "how many people are there", "what's the density", or "what's the
      risk level" — it reads the live dashboard numbers and answers out loud.
    </p>
    <div id="agentTranscript" class="agent-output">
      Click "Start voice agent" and allow microphone access to talk to it.
    </div>
  </section>

</main>

<footer class="disclaimer">
  Prototype heuristic tool, not a certified safety system. Density, motion and surge scores are
  approximations from a general-purpose object detector and frame-difference motion analysis — they
  can miss real incidents and can false-alarm. Use as one input among trained personnel and venue
  procedures, never as the sole basis for safety decisions.
</footer>

<script src="http
[truncated — 320 more characters]
```

### crowd-risk-monitor (6)/crowd-risk-monitor/public/style.css

```css
:root {
  --bg: #0a0d10;
  --panel: #11151a;
  --panel-2: #161b21;
  --border: #232b33;
  --text: #dde4ea;
  --muted: #7c8894;
  --accent-amber: #f2a33c;
  --accent-green: #3fcf8e;
  --accent-red: #e5484d;
  --accent-blue: #57b6ff;
  --font-display: 'IBM Plex Mono', monospace;
  --font-body: 'Inter', system-ui, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  background-image:
    linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px);
  background-size: 100% 28px;
}

.muted { color: var(--muted); }
.small { font-size: 12px; }

/* Topbar */
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 28px;
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
  gap: 12px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--accent-green);
  box-shadow: 0 0 0 4px rgba(63,207,142,0.15);
  flex-shrink: 0;
}
.brand h1 {
  font-family: var(--font-display);
  font-size: 18px;
  letter-spacing: 0.5px;
  margin: 0;
  text-transform: uppercase;
}
.brand-sub { margin: 2px 0 0; font-size: 13px; color: var(--muted); }

.status-cluster { display: flex; gap: 10px; }
.status-pill {
  font-family: var(--font-display);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.4px;
  padding: 6px 12px;
  border-radius: 20px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--muted);
}
.status-pill--ok { color: var(--accent-green); border-color: rgba(63,207,142,0.4); }
.status-pill--warn { color: var(--accent-amber); border-color: rgba(242,163,60,0.4); }
.status-pill--off { color: var(--accent-red); border-color: rgba(229,72,77,0.4); }

/* Layout */
.layout {
  display: grid;
  grid-template-columns: 1.4fr 1fr;
  gap: 18px;
  padding: 22px 28px;
}
@media (max-width: 980px) {
  .layout { grid-template-columns: 1fr; }
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 18px;
  display: flex;
  flex-direction: column;
}
.panel-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 14px;
  flex-wrap: wrap;
  gap: 8px;
}
.panel-head h2 {
  font-family: var(--font-display);
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 1px;
  margin: 0;
  color: var(--muted);
}

/* Video panel */
.source-controls { display: flex; gap: 8px; flex-wrap: wrap; }
.video-stage {
  position: relative;
  background: #000;
  border: 1px solid var(--border);
  border-radius: 8px;
  aspect-ratio: 16/9;
  overflow: hidden;
}
#video, #overlay {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: contain;
}
.stage-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 24px;
  gap: 6px;
  font-size: 13px;
}
.stage-placeholder.hidden { display: none; }

.metrics-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
  margin-top: 14px;
}
.metric {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.metric-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; }
.metric-value { font-family: var(--font-display); font-size: 18px; }

.tuning-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-top: 12px;
  flex-wrap: wrap;
  font-size: 13px;
  color: var(--muted);
}
.checkbox-field { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-field input { accent-color: var(--accent-amber); width: 15px; height: 15px; }
.number-field { display: flex; align-items: center; gap: 8px; }
.number-field--wide { flex: 1; min-width: 240px; }
.number-field--wide input[type="range"] { width: 100%; accent-color: var(--accent-amber); }
.tuning-row--aerial { border-top: 1px dashed var(--border); padding-top: 12px; margin-top: 4px; }
.hidden { display: none !important; }

.calibration-circle {
  position: absolute;
  top: 50%; left: 50%;
  border: 2px dashed var(--accent-amber);
  border-radius: 50%;
  background: rgba(242,163,60,0.12);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.agent-head { margin-top: 16px; }
.agent-output {
  font-size: 13px;
  line-height: 1.5;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px;
  min-height: 48px;
  color: var(--text);
}
.agent-output.muted-text { color: var(--muted); }

/* Buttons */
.btn {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 13px;
  padding: 9px 14px;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text);
  cursor: pointer;
  transition: filter 0.15s ease;
}
.btn:hover { filter: brightness(1.15); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn--primary { background: var(--accent-amber); color: #1a1206; border-color: var(--accent-amber); }
.btn--ghost { background: transparent; }
.btn--small { padding: 6px 10px; font-size: 12px; }
.file-btn { display: inline-flex; align-items: center; }

/* Risk dial */
.dial-wrap { position: relative; display: flex; justify-content: center; margin-bottom: 6px; }
.dial { width: 100%; max-width: 280px; }
.dial-track { fill: none; stroke: var(--panel-2); stroke-width: 14; stroke-linecap: round; }
.dial-fill { fill: none; stroke: var(--accent-green); stroke-width: 14; stroke-linecap: round; transition: stroke 0.3s ease; }
.dial-needle { stroke: var(--text); stroke-width: 3; transform-origin: 120px 130px; transition: transform 0.4s cubic-bezier(.4,1.4,.4,1); }
.dial-hub { fill: var(--text); }
.dial-readout {
  position: absolute;
  bottom: 6px;
  le
[truncated — 1437 more characters]
```