# Project export: Signify

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: TreeHacks 2026
- Tagline: 50% of deaf adults have a reading level below a fourth grade level. Enter Signify: an AI powered virtual ASL interpreter that converts English to ASL in real time to help deaf students thrive.
- Devpost: https://devpost.com/software/signify-ca8xp7
- GitHub: https://github.com/suhaankhan2007/Signify
- Demo: https://treehacks2026.onrender.com/
- Video: https://www.youtube.com/embed/M0VEiCnXAVI?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — SYCKGit (59 commits), = (21 commits), Aashima Sisodia (3 commits)

## Devpost submission (written by the team)

### Overview

The Why Behind Signify Over 50% of Deaf students reach age 18 with reading comprehension at or below a fourth-grade level. For many Deaf students, this is not about intelligence. It is about access. Most classrooms are still built around spoken and written English, even though sign language is often a Deaf student’s first language. Live captions do not fully solve this problem. Captions present fast, academic English text and require students to continuously translate a second language while also following slides, visuals, and classroom discussion. They also remove key linguistic information that exists in sign languages, such as spatial structure and non-manual cues (facial expression and emphasis), which are essential for meaning. As a result, captions provide access to words, but not to language in the way Deaf students naturally process information. At the policy level, this gap has also been shaped by legislation. In Board of Education v. Rowley, the case of Amy Rowley, the Supreme Court of the United States ruled that schools are only required to provide “adequate” access, not access that allows Deaf students to fully reach their potential. That standard still influences what support students receive today. After researching existing accessibility and sign language tools, we found that most systems focus on text-to-sign or pre-recorded content, rather than live classroom instruction. We built Signify to address this gap. Signify delivers live classroom speech directly into ASL as the lesson is happening, so Deaf students can follow instruction in real time and in their own language.

### What it does

Signify is an automated AI English to ASL converter that takes English from a Zoom recording (integrated as an app in Zoom) and converts it into ASL. It also has various modes, the most powerful being a podcast mode that converts a PDF, slideshow, or presentation into a mini natural podcast in ASL between two AI agents (developed via Open AI's API) in ASL that can help users not only understand the content better, but also have more fun learning it. We used Zoom to build the app because we designed it so that both the instructor and the student can join the same live session and access Signify simultaneously, which lets our system capture the lecture audio in real time and deliver synchronized ASL output directly inside the classroom workflow without requiring any additional software or setup.

### How we built it

We split development into four parallel tracks: frontend & UI/UX, backend and API orchestration, system integration, and the ML / language pipeline. Our system is implemented as an embedded app inside Zoom, which captures live classroom audio and supports optional lecture file uploads (PDFs and slide exports). Audio streams and uploaded assets are forwarded to our backend services hosted on Render. On the backend, we perform document and content preprocessing using PDFParse and JSZip to extract and normalize structured lecture text. For live lectures, speech is first transcribed and aligned with the slide and document content to preserve instructional structure and turn boundaries. The cleaned and segmented lecture content is then passed through OpenAI APIs (using GPT-4o-mini) with a custom, ASL-optimized prompting pipeline. A major technical challenge was designing a transformation layer that converts standard English instructional material into ASL Gloss, a structured intermediate representation that reflects ASL word order and discourse structure rather than English syntax. This restructuring step is critical for accessibility and required iterative prompt engineering and constraint-based formatting to ensure consistency across dialogue turns. Each finalized ASL-Gloss segment is then dispatched to the Sign.mt generation API, which returns an ASL avatar video for that segment. Finally, the in-browser Zoom app synchronizes the original lecture video and the generated ASL avatar stream. The client supports low-latency segment playback, buffering, rewind, and speed control, allowing Deaf students to view the instructor and the ASL rendering side-by-side while the lecture is still in progress.

### Challenges we ran into

Permission issues!! We needed access to the Zoom RTMS (but the Zoom team came in clutch). After figuring out various security issues, a major hurdle we had to go over was configuring iframes. In the end, we refactored an entire open source Github into a specific API that we needed and plugged into our application. Executing and optimizing the real-time 3D ASL avatar rendering pipeline was a major technical challenge. We addressed this by refactoring the rendering and networking layers to run asynchronously, parallelizing video generation and delivery, and introducing lightweight buffering to reduce latency, allowing the 3D ASL visualizations to stream smoothly during live sessions. Shoutout (to the goats) Thank you to Zoom, Render, and OpenAI for giving us credits. Thank you especially to the Render/Zoom team for helping us debug and giving us feedback.

### What we learned

Facts about inaccessible educational systems that are shocking and not very well known -- especially since we assumed d/Deaf people could just "use live captions to read / understand" Working collaboratively in a highly fast paced environment with people we have never worked with before Juggling many different parts of an application at once (ASL translation server, backend server, frontend zoom app, zoom RTMS, websockets, etc.) Deploying code on cloud platforms like render Juggling many different parts of an application at once (ASL translation server, backend server, frontend zoom app, zoom RTMS, websockets, etc.) Deploying code on cloud platforms like render

### What's next

Getting feedback from deaf students, measuring improvement in learning, improving based on findings Polishing app UI and getting the 3D visualization Deploying it across educational institutions (starting at highschools in Urbana/Austin Area) Integrating it with more conferencing platforms (e.g. Microsoft Teams)

## README (from the GitHub repository)

# Treehacks2026
Our treehacks 2026 project!


## Detected evidence (automated analysis)

Indexed codebase: 9 recognized source files, 3509 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
- OpenAI (technology) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Python (language) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (12 of 12)

```
.gitignore
client/app.js
client/index.html
client/lib/zoom-meeting-embedded-ES5.min.js
client/styles.css
client/test-sdk.html
env.example
package.json
README.md
render.yaml
scripts/copy-sdk.js
server/index.js
```

### Dependencies

- package.json: @zoom/meetingsdk@^5.1.2, cors@^2.8.5, dotenv@^16.6.1, express@^4.21.2, helmet@^8.1.0, jsonwebtoken@^9.0.2, jszip@^3.10.1, multer@^1.4.5-lts.2, openai@^4.76.1, pdf-parse@^1.1.1, ws@^8.19.0

### Recent commits (newest first)

- Merge branch 'main' of https://github.com/suhaankhan2007/Treehacks2026
- Update ngrok url
- IT WORKS
- Update TRANSLATE_API
- Use new blob structure for teaching avatars
- Revert to original proportion for panels
- try fixing asl/caption panel proportions
- Adjust asl/caption panel proportions further
- Relax warning popups further
- Fix asl/caption panel proportions
- Remove signify logo
- Remove redundant asl panel header
- Change app name to signify
- Relax warning popups
- Make caption below video
- Remove textInput value reset
- Get username from zoom
- Hide Input Area
- Remove initial name input
- update ngrok link

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

### package.json

```
{
  "name": "treehacks2026-asl-translator",
  "version": "1.0.0",
  "description": "Real-time Zoom-to-ASL translation pipeline using Zoom Meeting SDK and sign.mt",
  "main": "server/index.js",
  "scripts": {
    "postinstall": "node scripts/copy-sdk.js",
    "start": "node server/index.js",
    "dev": "node server/index.js"
  },
  "dependencies": {
    "@zoom/meetingsdk": "^5.1.2",
    "cors": "^2.8.5",
    "dotenv": "^16.6.1",
    "express": "^4.21.2",
    "helmet": "^8.1.0",
    "jszip": "^3.10.1",
    "jsonwebtoken": "^9.0.2",
    "multer": "^1.4.5-lts.2",
    "openai": "^4.76.1",
    "pdf-parse": "^1.1.1",
    "ws": "^8.19.0"
  },
  "keywords": [
    "asl",
    "zoom",
    "sign-language",
    "accessibility"
  ],
  "license": "MIT",
  "engines": {
    "node": ">=18.0.0"
  }
}

```

### server/index.js

```javascript
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const cors = require('cors');
const path = require('path');
const http = require('http');
const https = require('https');
const helmet = require('helmet');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const JSZip = require('jszip');
const OpenAI = require('openai');
const WebSocket = require('ws');

const app = express();
const PORT = process.env.PORT || 3000;
const TRANSLATE_API = 'https://9b99-68-65-164-132.ngrok-free.app';
const openai = process.env.OPENAI_API_KEY ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) : null;
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const teachingSessions = new Map();

// ===== Translation video cache (in-memory) =====
// Key: "text|spoken|signed"  Value: { buffer, contentType, createdAt }
const translationVideoCache = new Map();

app.use(cors());
app.use(
  helmet({
    frameguard: false,
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'unsafe-inline'", 'https://appssdk.zoom.us'],
        styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
        fontSrc: ["'self'", 'https://fonts.gstatic.com'],
        imgSrc: ["'self'", 'data:', 'blob:'],
        connectSrc: ["'self'", 'https://appssdk.zoom.us', TRANSLATE_API],
        mediaSrc: ["'self'", 'blob:', 'data:', TRANSLATE_API],
        frameAncestors: ["'self'", 'https://appssdk.zoom.us'],
        frameSrc: ["'self'"],
      },
    },
  })
);
app.use(express.json({ limit: '8mb' }));

app.use(express.static(path.join(__dirname, '..', 'client')));
app.use('/lib', express.static(path.join(__dirname, '..', 'node_modules', '@zoom', 'meetingsdk', 'dist', 'lib')));
app.use('/ui', express.static(path.join(__dirname, '..', 'node_modules', '@zoom', 'meetingsdk', 'dist', 'ui')));

// ===== Translation proxy — streams MP4 from translation API with caching =====
// Proxies through the server to bypass ngrok interstitial and cache results.
// The ngrok-skip-browser-warning header is required for ngrok free tier.
app.get('/api/translate', (req, res) => {
  const text = (req.query.text || '').trim();
  if (!text) return res.status(400).json({ error: 'text parameter is required' });
  const spoken = req.query.spoken || 'en';
  const signed = req.query.signed || 'ase';
  const cacheKey = `${text.toLowerCase()}|${spoken}|${signed}`;

  // ---- Serve from cache (instant) ----
  if (translationVideoCache.has(cacheKey)) {
    const cached = translationVideoCache.get(cacheKey);
    res.set('Content-Type', cached.contentType || 'video/mp4');
    res.set('Content-Length', String(cached.buffer.length));
    res.set('Cache-Control', 'public, max-age=3600');
    res.set('X-Cache', 'HIT');
    return res.send(cached.buffer);
  }

  // ---- Proxy + stream + cache ----
  const params = new URLSearchParams({ text, spoken, signed, format: 'video' });
  const apiUrl = new URL(`${TRANSLATE_API}/translate?${params}`);

  const options = {
    hostname: apiUrl.hostname,
    port: apiUrl.port || 443,
    path: apiUrl.pathname + apiUrl.search,
    method: 'GET',
    headers: {
      'ngrok-skip-browser-warning': 'true',   // bypass ngrok interstitial
      'User-Agent': 'Signify-Server/1.0',
    },
  };

  https.get(options, (apiRes) => {
    if (apiRes.statusCode !== 200) {
      return res.status(apiRes.statusCode || 502).json({ error: 'Translation API error' });
    }

    const chunks = [];
    const ct = apiRes.headers['content-type'] || 'video/mp4';
    res.set('Content-Type', ct);
    res.set('Cache-Control', 'public, max-age=3600');
    res.set('X-Cache', 'MISS');
    // Forward content-length if available so browser knows total size
    if (apiRes.headers['content-length']) {
      res.set('Content-Length', apiRes.headers['content-length']);
    }

    apiRes.on('data', (chunk) => {
      chunks.push(chunk);
      res.write(chunk); // stream to browser progressively
    });

    apiRes.on('end', () => {
      res.end();
      const fullBuffer = Buffer.concat(chunks);
      if (fullBuffer.length > 0) {
        translationVideoCache.set(cacheKey, {
          buffer: fullBuffer,
          contentType: ct,
          createdAt: Date.now(),
        });
      }
    });

    apiRes.on('error', () => {
      if (!res.headersSent) res.status(502).json({ error: 'Translation stream error' });
      else res.end();
    });
  }).on('error', (err) => {
    console.error('Translation API request failed:', err.message);
    if (!res.headersSent) res.status(502).json({ error: 'Translation API unavailable' });
  });
});

app.post('/api/zoom/signature', (req, res) => {
  const { meetingNumber, role = 0 } = req.body;
  const clientId = process.env.ZOOM_SDK_KEY;
  const clientSecret = process.env.ZOOM_SDK_SECRET;
  if (!clientId || !clientSecret) {
    return res.status(500).json({ error: 'Zoom SDK credentials not configured. Set ZOOM_SDK_KEY and ZOOM_SDK_SECRET in .env' });
  }
  if (!meetingNumber) {
    return res.status(400).json({ error: 'meetingNumber is required' });
  }
  try {
    const iat = Math.round(Date.now() / 1000) - 30;
    const exp = iat + 60 * 60 * 2;
    const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
    const payload = base64url(JSON.stringify({
      sdkKey: clientId,
      appKey: clientId,
      mn: String(meetingNumber),
      role: parseInt(role, 10),
      iat,
      exp,
      tokenExp: exp,
    }));
    const signature = crypto.createHmac('sha256', clientSecret).update(`${header}.${payload}`).digest('base64url');
    res.json({ signature: `${header}.${payload}.${signature}`, sdkKey: clientId });
  } catch (error) {
    console.error('Error generating signature:', error);
    res.status(500).json({ error: 'Failed to generate signature' });
  }
});

// ===== Teaching mode backend =====
app.post('/api/teaching/upload', upload.array('lectur
[truncated — 13692 more characters]
```

### client/app.js

```javascript
/**
 * Signify client app:
 * - Live translate mode: mic + typed captions -> ASL avatar
 * - Teaching mode: upload lecture files -> generate two-speaker sign dialog
 */

const DEFAULT_SPOKEN_LANGUAGE = 'en';
const DEFAULT_SIGNED_LANGUAGE = 'ase';

// ===== Zoom App SDK init (optional in standalone mode) =====
async function init() {
  try {
    if (typeof zoomSdk === 'undefined') return;
    await zoomSdk.config({
      version: '0.16',
      capabilities: ['shareApp', 'getMeetingUUID', 'getAppContext', 'startRTMS', 'stopRTMS'],
    });
    const ws = new WebSocket(`wss://treehacks2026.onrender.com/ws?meetingUuid=${(await zoomSdk.getMeetingUUID()).meetingUUID}`);

    ws.onopen = () => {
      console.log('WebSocket connected');
    };

    ws.onmessage = (event) => {
      console.log('Received transcript:', event.data);
      const data = JSON.parse(event.data);
      sendTextInput(data.text, data.user);
    };

    ws.onclose = () => {
      console.log('WebSocket disconnected');
    };

    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
  } catch (e) {
    console.log('Zoom App SDK not available:', e.message);
  }
}

// ===== State =====
const state = {
  mode: 'live',
  recognition: null,
  listening: false,
  captionCount: 0,
  lastStableFinal: '',
  lastStableTime: 0,
  liveAvatarResetTimer: null,
  liveVideoPlaying: false,          // true while a video is actively playing
  liveQueue: [],                    // queued {text, urlPromise} waiting to play after current
  playbackRate: 1.0,                // 1.0 – 2.0 speed multiplier
  teachingSessionId: null,
  teachingDialog: [],
  teachingPlayIndex: 0,
  teachingPlaying: false,
  teachingPlayTimer: null,
  teachingAvatarTimers: { A: null, B: null },
  teachingCurrentSpeaker: null,
  prefetchStarted: new Set(),
};

// zoomSdk.startRTMS when document loaded and stopRTMS when document exiting
document.addEventListener('DOMContentLoaded', async () => {
  if (typeof zoomSdk !== 'undefined' && zoomSdk.startRTMS) {
    await init();
    await zoomSdk.startRTMS();
    // window.addEventListener('beforeunload', () => {
    //   if (zoomSdk.stopRTMS) zoomSdk.stopRTMS();
    // });

    if (joinScreen) joinScreen.hidden = true;
    if (sessionScreen) {
      sessionScreen.hidden = false;
      sessionScreen.style.display = 'flex';
    }
    if (sessionLabel) sessionLabel.textContent = 'Live Session';
    if (statusText) {
      statusText.textContent = "";
      statusText.classList.add('connected');
    }
    if (connectionDot) connectionDot.classList.add('active');
    if (transcriptionStatus) transcriptionStatus.textContent = 'Ready for input';
    clearCaptionPlaceholder();
    addCaption('Signify', 'Session started. You can switch between Live Translate and Teaching Mode.', true);
    switchMode('live');
    resetLiveAvatar();
    setTimeout(() => { if (textInput) textInput.focus(); }, 100);
  }
});

// ===== DOM =====
const joinScreen = document.getElementById('join-screen');
const sessionScreen = document.getElementById('session-screen');
const joinBtn = document.getElementById('join-btn');
const joinError = document.getElementById('join-error');
const userNameInput = document.getElementById('user-name');
const sessionLabel = document.getElementById('session-label');
const statusText = document.getElementById('status-text');
const leaveBtn = document.getElementById('leave-btn');
const connectionDot = document.getElementById('connection-dot');
const transcriptionStatus = document.getElementById('transcription-status');

const modeLiveBtn = document.getElementById('mode-live-btn');
const modeTeachingBtn = document.getElementById('mode-teaching-btn');
const liveWorkspace = document.getElementById('live-workspace');
const teachingWorkspace = document.getElementById('teaching-workspace');

const textInput = document.getElementById('text-input');
const sendBtn = document.getElementById('send-btn');
const micBtn = document.getElementById('mic-btn');
const micStatus = document.getElementById('mic-status');
const wordCountEl = document.getElementById('word-count');
const captionList = document.getElementById('caption-list');
const captionCountEl = document.getElementById('caption-count');
const clearCaptionsBtn = document.getElementById('clear-captions-btn');
const fullscreenAslBtn = document.getElementById('fullscreen-asl-btn');
const liveAslVideo = document.getElementById('live-asl-video');
const aslOverlay = document.getElementById('asl-overlay');
const liveAvatarState = document.getElementById('live-avatar-state');
const translationBadge = document.getElementById('translation-badge');
const speechLanguageSelect = document.getElementById('speech-language-select');
const speechAccuracySelect = document.getElementById('speech-accuracy-select');
const aslSpeedRange = document.getElementById('asl-speed-range');
const aslSpeedLabel = document.getElementById('asl-speed-label');

const teachingFilesInput = document.getElementById('teaching-files');
const teachingFilesCount = document.getElementById('teaching-files-count');
const teachingTopicInput = document.getElementById('teaching-topic');
const teachingAudienceInput = document.getElementById('teaching-audience');
const teachingDurationInput = document.getElementById('teaching-duration');
const generateTeachingBtn = document.getElementById('generate-teaching-btn');
const teachingStatusBadge = document.getElementById('teaching-status-badge');
const teachingScriptList = document.getElementById('teaching-script-list');
const teachingPlayBtn = document.getElementById('teaching-play-btn');
const teachingNextBtn = document.getElementById('teaching-next-btn');
const teachingResetBtn = document.getElementById('teaching-reset-btn');
const speakerAState = document.getElementById('speaker-a-state');
const speakerBState = document.getElementById('speaker-b-state');
const teachingVideoA = document.getElementById('teaching-video-a');
const teachingVideoB = document.getElemen
[truncated — 26492 more characters]
```

### render.yaml

```yaml
services:
  - type: web
    name: asl-live-translator
    runtime: node
    buildCommand: npm install
    startCommand: node server/index.js
    envVars:
      - key: ZOOM_SDK_KEY
        sync: false
      - key: ZOOM_SDK_SECRET
        sync: false
      - key: OPENAI_API_KEY
        sync: false
      - key: OPENAI_MODEL
        value: gpt-4o-mini
      - key: NODE_ENV
        value: production


```

### scripts/copy-sdk.js

```javascript
/**
 * Copy the Zoom Meeting SDK files from node_modules into client/lib/
 * so they can be served as static files on any host (including Render).
 *
 * The SDK requires React, ReactDOM, Redux, Redux-Thunk, and Lodash as external
 * dependencies. Fortunately, the SDK ships its own minified vendor copies in
 * dist/lib/vendor/. We copy those too and load them BEFORE the main SDK file.
 */
const fs = require('fs');
const path = require('path');

const sdkDist = path.join(__dirname, '..', 'node_modules', '@zoom', 'meetingsdk', 'dist');
const dest = path.join(__dirname, '..', 'client', 'lib');

// Create destination directories
if (!fs.existsSync(dest)) {
  fs.mkdirSync(dest, { recursive: true });
}

const vendorDest = path.join(dest, 'vendor');
if (!fs.existsSync(vendorDest)) {
  fs.mkdirSync(vendorDest, { recursive: true });
}

// 1. Copy the main embedded SDK file (ES5 build)
const sdkFile = 'zoom-meeting-embedded-ES5.min.js';
const srcFile = path.join(sdkDist, sdkFile);
const destFile = path.join(dest, sdkFile);

if (fs.existsSync(srcFile)) {
  fs.copyFileSync(srcFile, destFile);
  console.log(`Copied ${sdkFile} to client/lib/`);
} else {
  console.error(`ERROR: ${srcFile} not found!`);
  process.exit(1);
}

// 2. Copy vendor dependencies (React, ReactDOM, Redux, etc.)
const vendorSrc = path.join(sdkDist, 'lib', 'vendor');
const vendorFiles = [
  'react.min.js',
  'react-dom.min.js',
  'redux.min.js',
  'redux-thunk.min.js',
  'lodash.min.js'
];

for (const vf of vendorFiles) {
  const src = path.join(vendorSrc, vf);
  const dst = path.join(vendorDest, vf);
  if (fs.existsSync(src)) {
    fs.copyFileSync(src, dst);
    console.log(`Copied vendor/${vf}`);
  } else {
    console.warn(`WARNING: vendor/${vf} not found at ${src}`);
  }
}

console.log('\nZoom Meeting SDK and vendor files copied successfully.');

```

### client/test-sdk.html

```html
<!DOCTYPE html>
<html>
<head><title>SDK Load Test</title></head>
<body>
<h1>Zoom SDK Load Test</h1>
<div id="status" style="font-size:24px;font-weight:bold;">Testing...</div>
<pre id="log" style="background:#f5f5f5;padding:16px;border-radius:8px;"></pre>

<script>
  var log = document.getElementById('log');
  var status = document.getElementById('status');
  
  function addLog(msg) {
    log.textContent += msg + '\n';
    console.log(msg);
  }

  // Step 1: Clear ALL service workers and caches
  async function cleanup() {
    addLog('Step 1: Cleaning up service workers and caches...');
    if ('serviceWorker' in navigator) {
      var regs = await navigator.serviceWorker.getRegistrations();
      addLog('  Found ' + regs.length + ' service worker(s)');
      for (var reg of regs) {
        await reg.unregister();
        addLog('  Unregistered: ' + reg.scope);
      }
    }
    if ('caches' in window) {
      var names = await caches.keys();
      addLog('  Found ' + names.length + ' cache(s)');
      for (var name of names) {
        await caches.delete(name);
        addLog('  Deleted cache: ' + name);
      }
    }
    addLog('Cleanup complete.\n');
  }

  // Step 2: Load vendor dependencies (React, ReactDOM, etc.)
  function loadScript(src) {
    return new Promise(function(resolve, reject) {
      var s = document.createElement('script');
      s.src = src + '?t=' + Date.now();
      s.onload = resolve;
      s.onerror = reject;
      document.body.appendChild(s);
    });
  }

  async function loadVendorDeps() {
    addLog('Step 2: Loading vendor dependencies...');
    var deps = [
      '/lib/vendor/react.min.js',
      '/lib/vendor/react-dom.min.js',
      '/lib/vendor/redux.min.js',
      '/lib/vendor/redux-thunk.min.js',
      '/lib/vendor/lodash.min.js'
    ];
    for (var dep of deps) {
      try {
        await loadScript(dep);
        addLog('  Loaded: ' + dep);
      } catch (e) {
        addLog('  FAILED: ' + dep);
      }
    }
    addLog('  React defined: ' + (typeof React !== 'undefined'));
    addLog('  ReactDOM defined: ' + (typeof ReactDOM !== 'undefined'));
    addLog('  Redux defined: ' + (typeof Redux !== 'undefined'));
    addLog('  _ (lodash) defined: ' + (typeof _ !== 'undefined'));
    addLog('Vendor deps loaded.\n');
  }

  // Step 3: Load SDK
  async function loadSDK() {
    addLog('Step 3: Loading Zoom Meeting SDK...');
    try {
      await loadScript('/lib/zoom-meeting-embedded-ES5.min.js');
      addLog('  Script loaded successfully');
      
      if (typeof ZoomMtgEmbedded !== 'undefined') {
        addLog('  ZoomMtgEmbedded is DEFINED!');
        addLog('  typeof ZoomMtgEmbedded = ' + typeof ZoomMtgEmbedded);
        addLog('  ZoomMtgEmbedded.createClient = ' + typeof ZoomMtgEmbedded.createClient);
        status.textContent = 'SUCCESS - ZoomMtgEmbedded is available!';
        status.style.color = 'green';
      } else {
        addLog('  ZoomMtgEmbedded is UNDEFINED after script load!');
        status.textContent = 'FAIL - ZoomMtgEmbedded not defined';
        status.style.color = 'red';
      }
    } catch (e) {
      addLog('  Script FAILED to load: ' + e);
      status.textContent = 'FAIL - Script failed to load';
      status.style.color = 'red';
    }
  }

  // Run all tests
  (async function() {
    await cleanup();
    await loadVendorDeps();
    await loadSDK();
    addLog('\n=== ALL TESTS COMPLETE ===');
    if (typeof ZoomMtgEmbedded !== 'undefined') {
      addLog('You can now go to the main app and it should work!');
    }
  })();
</script>
</body>
</html>

```

### client/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>Signify — Real-time ASL Translation | TreeHacks 2026</title>
  <link rel="stylesheet" href="styles.css?v=20260215r">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>

  <!-- ===== POPUP ===== -->
  <div id="warning-popup" class="hidden">
    <div class="icon">⚠️</div>
    <div class="content">
      <h4 id="warning-title">Warning</h4>
      <p id="warning-message">This message will be hard for the deaf person to follow.</p>
    </div>
  </div>

  <!-- ===== LANDING / JOIN SCREEN ===== -->
  <!-- <div id="join-screen" class="screen">
    <div class="bg-blob blob-1"></div>
    <div class="bg-blob blob-2"></div>
    <div class="bg-blob blob-3"></div>

    <div class="landing-wrapper">
      <div class="join-card">
        <div class="logo-area">
          <div class="logo-icon">
            <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
              <rect width="40" height="40" rx="12" fill="url(#logoGrad)"/>
              <path d="M12 20C12 16 14 13 17 12C15 15 16 18 20 20C24 22 25 25 23 28C26 27 28 24 28 20C28 15.6 24.4 12 20 12C15.6 12 12 15.6 12 20Z" fill="white" opacity="0.9"/>
              <path d="M16 22C16 19 18 17 20 17C22 17 24 19 24 22C24 25 22 27 20 27C18 27 16 25 16 22Z" fill="white"/>
              <defs><linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#a855f7"/></linearGradient></defs>
            </svg>
          </div>
          <h1>Signify</h1>
          <p class="tagline">Breaking communication barriers with real-time ASL translation</p>
        </div>

        <div class="features-strip">
          <div class="feature-chip">
            <span class="chip-icon">🎙️</span>
            <span>Voice Input</span>
          </div>
          <div class="feature-chip">
            <span class="chip-icon">🤟</span>
            <span>ASL Avatar</span>
          </div>
          <div class="feature-chip">
            <span class="chip-icon">📝</span>
            <span>Live Captions</span>
          </div>
        </div>

        <div class="form-group">
          <label for="user-name">Your Name</label>
          <input type="text" id="user-name" placeholder="Enter your name..." autocomplete="off">
        </div>

        <button id="join-btn" class="btn-primary">
          <span>Start Translating</span>
          <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 10h12m0 0l-4-4m4 4l-4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </button>
        <p id="join-error" class="error-text" hidden></p>

        <p class="built-at">Built at <strong>TreeHacks 2026</strong> · Stanford University</p>
      </div>
    </div>
  </div> -->

  <!-- ===== MAIN SESSION SCREEN ===== -->
  <div id="session-screen" class="screen" hidden>
    <header>
      <div class="header-left">
        <!-- <div class="header-logo">
          <svg width="28" height="28" viewBox="0 0 40 40" fill="none">
            <rect width="40" height="40" rx="12" fill="url(#logoGrad2)"/>
            <path d="M12 20C12 16 14 13 17 12C15 15 16 18 20 20C24 22 25 25 23 28C26 27 28 24 28 20C28 15.6 24.4 12 20 12C15.6 12 12 15.6 12 20Z" fill="white" opacity="0.9"/>
            <path d="M16 22C16 19 18 17 20 17C22 17 24 19 24 22C24 25 22 27 20 27C18 27 16 25 16 22Z" fill="white"/>
            <defs><linearGradient id="logoGrad2" x1="0" y1="0" x2="40" y2="40"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#a855f7"/></linearGradient></defs>
          </svg>
        </div>
        <h2>Signify</h2> -->
        <span id="session-label" class="badge">Live Session</span>
      </div>
      <div class="header-center">
        <div class="mode-switch" role="tablist" aria-label="Mode switch">
          <button id="mode-live-btn" class="mode-btn active" type="button">Live Translate</button>
          <button id="mode-teaching-btn" class="mode-btn" type="button">Teaching Mode</button>
        </div>
        <span id="status-text" class="status-pill">Ready</span>
      </div>
      <div class="header-right">
        <div class="panel-header-controls">
            <div class="speed-control">
              <label for="asl-speed-range">Speed</label>
              <input type="range" id="asl-speed-range" min="1" max="2" step="0.1" value="1">
              <span id="asl-speed-label">1.0x</span>
            </div>
            <span id="translation-badge" class="meta-badge">Waiting for input</span>
          </div>
        <button id="clear-captions-btn" class="btn-icon" title="Clear captions">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </button>
        <button id="fullscreen-asl-btn" class="btn-icon" title="Fullscreen ASL">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </button>
      </div>
    </header>

    <main id="live-workspace">
      <!-- Left panel: Input area -->
      <!-- <section id="input-panel">
        <div class="panel-header">
          <h3>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M1
[truncated — 10062 more characters]
```

### client/styles.css

```css
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

:root {
  /* Core palette — warm dark with indigo/purple accent */
  --bg-primary: #09090b;
  --bg-secondary: #18181b;
  --bg-card: #1c1c22;
  --bg-elevated: #27272a;
  --bg-input: #1a1a1f;

  --accent: #6366f1;
  --accent-light: #818cf8;
  --accent-glow: rgba(99, 102, 241, 0.15);
  --accent-hover: #4f46e5;
  --gradient: linear-gradient(135deg, #6366f1, #a855f7);

  --text-primary: #fafafa;
  --text-secondary: #a1a1aa;
  --text-muted: #71717a;

  --danger: #ef4444;
  --danger-hover: #dc2626;
  --success: #22c55e;
  --success-glow: rgba(34, 197, 94, 0.2);
  --warning: #f59e0b;

  --border: rgba(255, 255, 255, 0.06);
  --border-hover: rgba(255, 255, 255, 0.12);

  --radius: 16px;
  --radius-sm: 10px;
  --radius-xs: 6px;

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.4);
  --shadow-glow: 0 0 30px var(--accent-glow);
}

html,
body {
  height: 100%;
  overflow: hidden;
}

body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: var(--bg-primary);
  color: var(--text-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.screen {
  width: 100vw;
  height: 100dvh;
}

/* ===== Animated Background Blobs ===== */
.bg-blob {
  position: fixed;
  border-radius: 50%;
  filter: blur(100px);
  opacity: 0.4;
  pointer-events: none;
  z-index: 0;
}

.blob-1 {
  width: 500px;
  height: 500px;
  background: radial-gradient(circle, #6366f1 0%, transparent 70%);
  top: -150px;
  left: -100px;
  animation: blobFloat1 20s ease-in-out infinite;
}

.blob-2 {
  width: 400px;
  height: 400px;
  background: radial-gradient(circle, #a855f7 0%, transparent 70%);
  bottom: -100px;
  right: -50px;
  animation: blobFloat2 25s ease-in-out infinite;
}

.blob-3 {
  width: 300px;
  height: 300px;
  background: radial-gradient(circle, #ec4899 0%, transparent 70%);
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  animation: blobFloat3 18s ease-in-out infinite;
}

@keyframes blobFloat1 {

  0%,
  100% {
    transform: translate(0, 0) scale(1);
  }

  33% {
    transform: translate(60px, 40px) scale(1.1);
  }

  66% {
    transform: translate(-30px, 60px) scale(0.95);
  }
}

@keyframes blobFloat2 {

  0%,
  100% {
    transform: translate(0, 0) scale(1);
  }

  33% {
    transform: translate(-50px, -30px) scale(1.05);
  }

  66% {
    transform: translate(30px, -50px) scale(0.9);
  }
}

@keyframes blobFloat3 {

  0%,
  100% {
    transform: translate(-50%, -50%) scale(1);
  }

  50% {
    transform: translate(-50%, -50%) scale(1.3);
  }
}

/* ===== Join Screen ===== */
#join-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

.landing-wrapper {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 480px;
  padding: 24px;
}

.join-card {
  background: rgba(28, 28, 34, 0.8);
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
  border: 1px solid var(--border);
  border-radius: 24px;
  padding: 48px 40px 36px;
  text-align: center;
  box-shadow: var(--shadow-lg), var(--shadow-glow);
}

.logo-area {
  margin-bottom: 32px;
}

.logo-icon {
  display: inline-flex;
  margin-bottom: 16px;
  filter: drop-shadow(0 4px 12px rgba(99, 102, 241, 0.3));
}

.join-card h1 {
  font-size: 36px;
  font-weight: 800;
  letter-spacing: -0.5px;
  background: var(--gradient);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  margin-bottom: 8px;
}

.tagline {
  color: var(--text-secondary);
  font-size: 15px;
  line-height: 1.5;
  max-width: 320px;
  margin: 0 auto;
}

/* Feature chips */
.features-strip {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin: 28px 0;
  flex-wrap: wrap;
}

.feature-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 100px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
  white-space: nowrap;
}

.chip-icon {
  font-size: 14px;
}

/* Form */
.form-group {
  text-align: left;
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
  margin-bottom: 8px;
  text-transform: uppercase;
  letter-spacing: 0.8px;
}

.form-group input {
  width: 100%;
  padding: 14px 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-input);
  color: var(--text-primary);
  font-size: 15px;
  font-family: inherit;
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
}

.form-group input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-glow);
}

.form-group input::placeholder {
  color: var(--text-muted);
}

/* ===== Buttons ===== */
.btn-primary {
  width: 100%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 16px 24px;
  border: none;
  border-radius: var(--radius-sm);
  background: var(--gradient);
  color: white;
  font-size: 16px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: transform 0.15s, box-shadow 0.2s, opacity 0.2s;
  box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
}

.btn-primary:hover {
  transform: translateY(-1px);
  box-shadow: 0 6px 24px rgba(99, 102, 241, 0.4);
}

.btn-primary:active {
  transform: translateY(0) scale(0.98);
}

.btn-primary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  transition: all 0.15s;
}

.btn-
[truncated — 21147 more characters]
```