<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cadence — Voice to paragraph &amp; prompt</title>
<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+Tight:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #07080A; --bg-elev: rgba(255,255,255,0.055); --bg-card: rgba(255,255,255,0.075);
    --line: rgba(255,255,255,0.13); --line-soft: rgba(255,255,255,0.07);
    --fg: #F4F1EA; --fg-dim: #9AA0A8; --fg-muted: #6A7079;
    --accent: #E8FF4D; --accent-ink: #0A0B0D;
    --rec: #45D9FF; --green: #4ADE80; --amber: #FFB84D;
    --violet: #9B8CFF;
    --glass-line: rgba(255,255,255,0.14);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { height: 100%; }
  html { background: var(--bg); }
  body {
    color: var(--fg);
    background:
      radial-gradient(62vw 62vw at 10% 4%, color-mix(in oklab, var(--accent) 16%, transparent), transparent 58%),
      radial-gradient(58vw 58vw at 92% 14%, color-mix(in oklab, var(--rec) 20%, transparent), transparent 58%),
      radial-gradient(60vw 60vw at 50% 116%, color-mix(in oklab, var(--violet) 18%, transparent), transparent 60%),
      var(--bg);
    background-attachment: fixed;
    font-family: 'Inter Tight', -apple-system, system-ui, sans-serif;
    font-feature-settings: 'ss01','cv11';
    -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
    line-height: 1.45;
  }
  .glass { backdrop-filter: blur(17px) saturate(1.4); -webkit-backdrop-filter: blur(17px) saturate(1.4); }
  .glass-strong { backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5); }
  html, body { -webkit-tap-highlight-color: transparent; }
  @media (hover: none) and (pointer: coarse) { .kbd-hints { display: none !important; } }
  @media (max-width: 600px) {
    .cad-header { padding: 14px 16px !important; }
    .cad-main { padding: 6px 16px 36px !important; }
  }
  ::selection { background: var(--accent); color: var(--accent-ink); }
  button { font: inherit; }
  textarea::placeholder { color: var(--fg-muted); }
  .mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
  select { font: inherit; }
  ::-webkit-scrollbar { width: 9px; height: 9px; }
  ::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
  ::-webkit-scrollbar-track { background: transparent; }
  @keyframes cad-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
  @keyframes cad-ring { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(1.5); opacity: 0; } }
  @keyframes cad-fadein { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
  @keyframes cad-blink { 50% { opacity: 0; } }
  @keyframes cad-spin { to { transform: rotate(360deg); } }
  html, body { overflow-x: hidden; overflow-y: auto; }
</style>
</head>
<body>
<div id="root"></div>

<!-- libraries -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js" crossorigin></script>

<!-- ============================================================
     CADENCE — offline heuristic transform library (fallback)
     Used when you are not signed in, or if a live call fails.
     ============================================================ -->
<script>
(function () {
  'use strict';
  function detectLang(text) {
    const t = text || '';
    const cjk = (t.match(/[一-鿿]/g) || []).length;
    const latin = (t.match(/[A-Za-z]/g) || []).length;
    if (cjk === 0) return 'en';
    if (latin === 0) return 'zh';
    if (cjk >= latin * 0.6) return 'zh';
    if (cjk >= latin * 0.12) return 'mixed';
    return 'en';
  }
  function langLabel(code) { return { en: 'English', zh: '中文', mixed: 'Mixed 中/EN', '': '—' }[code] || code; }
  const FILLERS = ['um','uh','erm','er','ah','hmm','mhm','like','you know','i mean','sort of','kind of','kinda','sorta','basically','literally','actually','honestly','so yeah','i guess','right','okay so','ok so','and stuff','or whatever','you see'];
  const FILLERS_ZH = ['那个','这个','就是说','然后呢','嗯','呃','啊就是'];
  function stripFillers(text, lang) {
    let s = ' ' + text + ' ';
    FILLERS.forEach(function (f) { s = s.replace(new RegExp('\\s' + f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s', 'gi'), ' '); });
    if (lang === 'zh' || lang === 'mixed') FILLERS_ZH.forEach(function (f) { s = s.split(f).join(''); });
    s = s.replace(/\b(\w+)\s+\1\b/gi, '$1');
    return s.replace(/\s+/g, ' ').trim();
  }
  function countWords(text) {
    const t = (text || '').trim(); if (!t) return 0;
    const cjk = (t.match(/[一-鿿]/g) || []).length;
    const words = (t.replace(/[一-鿿]/g, ' ').match(/[A-Za-z0-9']+/g) || []).length;
    return cjk + words;
  }
  function splitSentences(text) { return text.replace(/([.!?。！？])\s*/g, '$1').split('').map(function (s) { return s.trim(); }).filter(Boolean); }
  function capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
  function toParagraph(raw, opts) {
    opts = opts || {};
    const lang = opts.outLang && opts.outLang !== 'match' ? opts.outLang : detectLang(raw);
    let cleaned = stripFillers(raw, lang);
    if (!cleaned) return '';
    if (lang === 'zh') { let s = cleaned.replace(/\s+/g, ''); if (!/[。！？]$/.test(s)) s += '。'; return s; }
    let sents = splitSentences(cleaned); if (sents.length === 0) sents = [cleaned];
    sents = sents.map(function (s) { s = s.replace(/\s+([,.;:!?])/g, '$1').trim(); if (!/[.!?]$/.test(s)) s += '.'; return capitalize(s); });
    return sents.join(' ');
  }
  function deriveObjective(cleaned) {
    const sents = splitSentences(cleaned); let obj = sents[0] || cleaned;
    obj = obj.replace(/^(i (would like|want|need|wanna|'d like)( to)?|please|can you|could you|i'm trying to|help me|let's)\s+/i, '');
    return capitalize(obj.replace(/[.!?]+$/, '')).trim();
  }
  function bulletsFrom(cleaned, max) {
    const sents = splitSentences(cleaned).slice(1); const items = [];
    sents.forEach(function (s) { s = s.replace(/[.!?]+$/, '').trim(); if (s.length > 3) items.push(capitalize(s)); });
    return items.slice(0, max || 6);
  }
  function toPrompt(raw, opts) {
    opts = opts || {};
    const lang = opts.outLang && opts.outLang !== 'match' ? opts.outLang : detectLang(raw);
    const cleaned = stripFillers(raw, lang); if (!cleaned) return '';
    const zh = lang === 'zh';
    const objective = deriveObjective(cleaned);
    const details = bulletsFrom(cleaned, 8);
    // Mirror the live model's Role / Context / Components / Criteria format so the
    // offline fallback is at least structurally consistent (heuristic, not analyzed).
    const blocks = [];
    blocks.push('## ' + (zh ? '角色' : 'Role') + '\n' + (zh
      ? '你是一名资深专家。目标：' + objective + '。'
      : 'You are an expert specialist. Objective: ' + objective + '.'));
    blocks.push('## ' + (zh ? '背景' : 'Context') + '\n' + (zh
      ? '（基于用户口述整理；请补充相关背景、受众、目标与现状。）'
      : '(Derived from the user’s dictation; add relevant background, audience, goals and current state.)'));
    blocks.push('## ' + (zh ? '组成部分' : 'Components') + '\n'
      + (details.length ? '- ' + details.join('\n- ') : '- ' + objective));
    blocks.push('## ' + (zh ? '验收标准' : 'Criteria') + '\n- ' + (zh
      ? '完整覆盖上述所有要点\n- 输出结构清晰、可直接使用\n- 保持用户原意，不臆造需求'
      : 'Fully covers every point above\n- Output is clear, structured and ready to use\n- Preserve the user’s intent; do not invent requirements'));
    return blocks.join('\n\n');
  }
  function improvementSummary(raw, out, mode, lang) {
    const before = countWords(raw);
    const removed = Math.max(0, before - countWords(stripFillers(raw, lang)));
    const zh = lang === 'zh';
    if (mode === 'paragraph') return zh ? ('清理了 ' + removed + ' 处口语填充词，整理了断句与标点。') : ('Removed ' + removed + ' filler word' + (removed === 1 ? '' : 's') + ', fixed punctuation and casing.');
    return zh ? ('提取核心目标并整理为结构化提示词。') : ('Extracted your core objective into a structured prompt.');
  }
  const SAMPLES = {
    en: "okay so um I want to like build a little tool right, where I can just talk into my mic and you know it takes my voice and after thinking it kind of turns it into either a clean paragraph or basically a prompt for an AI coding assistant, and uh it needs to handle both english and chinese input honestly",
    zh: "嗯就是说我想做一个那个工具，可以用语音输入，然后呢经过思考之后，把我说的话变成一段通顺的文字，或者变成一个给 AI 编程助手用的提示词。"
  };
  window.CadenceLib = {
    detectLang: detectLang, langLabel: langLabel, stripFillers: stripFillers, countWords: countWords,
    toParagraph: toParagraph, toPrompt: toPrompt, improvementSummary: improvementSummary, SAMPLES: SAMPLES
  };
})();
</script>

<!-- ============================================================
     CADENCE — app (voice-first, real Codex sign-in + model picker)
     ============================================================ -->
<script type="text/babel" data-presets="env,react">
const { useState, useEffect, useRef, useCallback } = React;

const CAD_DEFAULTS = { defaultMode: 'paragraph', defaultLang: 'auto' };
const OPTS = { outLang: 'en' };
const MODE_HINT = {
  paragraph: 'Speak in any language → a polished English paragraph.',
  prompt: 'Speak in any language → a structured English prompt.'
};

/* language is always auto. With an OpenAI API key the audio is auto-detected by
   gpt-4o-transcribe (handles mixed EN/中/AR) precisely. The browser recognizer
   can't auto-detect, so it falls back to the browser's own language. */
function localeFor(code) {
  if (code === 'en') return 'en-US';
  if (code === 'zh') return 'zh-CN';
  if (code === 'ar') return 'ar-SA';
  const n = (navigator.language || 'en').toLowerCase();   // 'auto' → browser's language
  if (n.indexOf('zh') === 0) return 'zh-CN';
  if (n.indexOf('ar') === 0) return 'ar-SA';
  return 'en-US';
}
// language hint for OpenAI transcription: '' means auto-detect (best for mixed)
function transcribeHint(code) { return (code === 'en' || code === 'zh' || code === 'ar') ? code : ''; }

/* ---------- backend API ---------- */
const api = {
  status: function () { return fetch('/api/status').then(function (r) { return r.json(); }); },
  generate: function (mode, text, model) {
    return fetch('/api/generate', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ mode: mode, text: text, model: model })
    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); });
  },
  transcribe: function (blob, lang) {
    return fetch('/api/transcribe?lang=' + encodeURIComponent(lang || ''), {
      method: 'POST', headers: { 'Content-Type': blob.type || 'audio/webm' }, body: blob
    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); });
  },
  setKey: function (key) {
    return fetch('/api/set-key', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key })
    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); });
  },
  logout: function () { return fetch('/logout').then(function (r) { return r.json(); }); }
};

/* ---------- speech recognition ---------- */
function getSR() { return window.SpeechRecognition || window.webkitSpeechRecognition || null; }
function useSpeech(onResult, locale) {
  const recRef = useRef(null);
  const wantRef = useRef(false);   // true while the user is still recording
  const supported = !!getSR();
  const start = useCallback(function () {
    const SR = getSR(); if (!SR) return false;
    wantRef.current = true;
    function spawn() {
      const rec = new SR();
      rec.continuous = true; rec.interimResults = true;
      rec.lang = locale || 'en-US';   // set per the user's chosen input language
      rec.onresult = function (e) {
        let finalT = '', interimT = '';
        for (let i = e.resultIndex; i < e.results.length; i++) {
          const r = e.results[i];
          if (r.isFinal) finalT += r[0].transcript; else interimT += r[0].transcript;
        }
        onResult(finalT, interimT);
      };
      rec.onerror = function (ev) {
        // 'no-speech'/'aborted' are transient — onend will relaunch. Only
        // surface fatal mic/permission problems.
        if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { wantRef.current = false; onResult(null, null, ev.error); }
        else if (ev.error !== 'no-speech' && ev.error !== 'aborted') { onResult(null, null, ev.error); }
      };
      // Browsers stop recognition on silence or after ~a minute. Relaunch so
      // long, paused dictation is captured in full until the user taps stop.
      rec.onend = function () {
        if (!wantRef.current) return;
        try { rec.start(); }
        catch (e) { setTimeout(function () { if (wantRef.current) { try { spawn(); } catch (e2) {} } }, 250); }
      };
      recRef.current = rec; rec.start();
    }
    try { spawn(); return true; }
    catch (err) { wantRef.current = false; onResult(null, null, 'start-failed'); return false; }
  }, [onResult, locale]);
  const stop = useCallback(function () { wantRef.current = false; try { if (recRef.current) recRef.current.stop(); } catch (e) {} recRef.current = null; }, []);
  useEffect(function () { return function () { stop(); }; }, [stop]);
  return { supported: supported, start: start, stop: stop };
}

/* ---------- audio meter + recorder ---------- */
function pickMime() {
  if (!window.MediaRecorder) return '';
  const c = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'];
  for (let i = 0; i < c.length; i++) { try { if (MediaRecorder.isTypeSupported(c[i])) return c[i]; } catch (e) {} }
  return '';
}
function useMeter() {
  const [levels, setLevels] = useState(function () { return new Array(44).fill(0.04); });
  const raf = useRef(null), audioCtx = useRef(null), analyser = useRef(null), stream = useRef(null), synthetic = useRef(false), t = useRef(0);
  const recorder = useRef(null), chunks = useRef([]), stopRes = useRef(null);
  const loop = useCallback(function () {
    if (synthetic.current) {
      t.current += 0.18; const arr = [];
      for (let i = 0; i < 44; i++) arr.push(Math.min(1, 0.18 + Math.abs(Math.sin(t.current + i * 0.5)) * 0.55 * (0.5 + Math.random() * 0.5)));
      setLevels(arr);
    } else if (analyser.current) {
      const data = new Uint8Array(analyser.current.frequencyBinCount);
      analyser.current.getByteFrequencyData(data);
      const arr = [], step = Math.floor(data.length / 44) || 1;
      for (let i = 0; i < 44; i++) arr.push(Math.min(1, (data[i * step] / 255) * 1.6 + 0.03));
      setLevels(arr);
    }
    raf.current = requestAnimationFrame(loop);
  }, []);
  const start = useCallback(async function () {
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
      try {
        const s = await navigator.mediaDevices.getUserMedia({ audio: true });
        stream.current = s;
        const Ctx = window.AudioContext || window.webkitAudioContext;
        audioCtx.current = new Ctx();
        const src = audioCtx.current.createMediaStreamSource(s);
        analyser.current = audioCtx.current.createAnalyser();
        analyser.current.fftSize = 256; src.connect(analyser.current);
        // record the raw audio too, for high-accuracy server-side transcription
        try {
          chunks.current = [];
          const mime = pickMime();
          recorder.current = mime ? new MediaRecorder(s, { mimeType: mime }) : new MediaRecorder(s);
          recorder.current.ondataavailable = function (e) { if (e.data && e.data.size) chunks.current.push(e.data); };
          recorder.current.onstop = function () {
            const type = (recorder.current && recorder.current.mimeType) || mime || 'audio/webm';
            const b = chunks.current.length ? new Blob(chunks.current, { type: type }) : null;
            if (stopRes.current) { stopRes.current(b); stopRes.current = null; }
          };
          recorder.current.start();
        } catch (e) { recorder.current = null; }
        synthetic.current = false; raf.current = requestAnimationFrame(loop); return true;
      } catch (e) {}
    }
    synthetic.current = true; raf.current = requestAnimationFrame(loop); return false;
  }, [loop]);
  function cleanup() {
    if (raf.current) cancelAnimationFrame(raf.current); raf.current = null;
    if (stream.current) { stream.current.getTracks().forEach(function (tr) { tr.stop(); }); stream.current = null; }
    if (audioCtx.current) { try { audioCtx.current.close(); } catch (e) {} audioCtx.current = null; }
    analyser.current = null; synthetic.current = false;
    setLevels(new Array(44).fill(0.04));
  }
  // stop recording and resolve with the captured audio Blob (or null)
  const finish = useCallback(function () {
    return new Promise(function (res) {
      const rec = recorder.current;
      if (rec && rec.state !== 'inactive') { stopRes.current = res; try { rec.stop(); } catch (e) { stopRes.current = null; res(null); } }
      else { res(chunks.current.length ? new Blob(chunks.current) : null); }
    }).then(function (blob) { recorder.current = null; cleanup(); return blob; });
  }, []);
  // hard stop (cancel), no blob
  const stop = useCallback(function () {
    const rec = recorder.current;
    if (rec && rec.state !== 'inactive') { try { rec.stop(); } catch (e) {} }
    recorder.current = null; stopRes.current = null; cleanup();
  }, []);
  useEffect(function () { return function () { stop(); }; }, [stop]);
  return { levels: levels, start: start, finish: finish, stop: stop };
}

function Waveform({ levels, active }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, height: 44, width: 260 }}>
      {levels.map(function (l, i) {
        return <div key={i} style={{ width: 3, borderRadius: 2, height: Math.max(3, l * 44) + 'px', background: active ? 'var(--accent)' : 'var(--line)', transition: 'height .07s linear', opacity: active ? (0.45 + l * 0.55) : 1 }} />;
      })}
    </div>
  );
}

/* ============================================================ APP ============================================================ */
function App() {
  const [phase, setPhase] = useState('idle');      // idle | recording | thinking | done | typing
  const [stage, setStage] = useState('');          // '' | 'transcribe' | 'generate'
  const [interim, setInterim] = useState('');
  const [liveText, setLiveText] = useState('');
  const [typed, setTyped] = useState('');
  const [mode, setMode] = useState(function () { try { return localStorage.getItem('cadence_mode') || CAD_DEFAULTS.defaultMode; } catch (e) { return CAD_DEFAULTS.defaultMode; } });
  const inputLang = 'auto';   // always auto-detect — no manual language picker
  const [result, setResult] = useState('');
  const [summary, setSummary] = useState('');
  const [autoCopied, setAutoCopied] = useState(false);
  const [resultLive, setResultLive] = useState(false);   // result came from the model?
  const [backendDown, setBackendDown] = useState(false); // local helper unreachable?
  const [toast, setToast] = useState(false);
  const [note, setNote] = useState('');

  // connection / model state (driven by the backend)
  const [connected, setConnected] = useState(false);
  const [email, setEmail] = useState(null);
  const [acctMode, setAcctMode] = useState(null);   // 'apikey' | 'chatgpt'
  const [transcribeAvail, setTranscribeAvail] = useState(false);
  const [models, setModels] = useState([]);
  const [model, setModel] = useState(function () { try { return localStorage.getItem('cadence_model') || ''; } catch (e) { return ''; } });
  const [connectOpen, setConnectOpen] = useState(false);
  const [apiKeyInput, setApiKeyInput] = useState('');
  const [keyBusy, setKeyBusy] = useState(false);
  const [keyErr, setKeyErr] = useState('');

  const liveRef = useRef(''); const sourceRef = useRef(''); const modeRef = useRef(mode); const phaseRef = useRef(phase);
  const meter = useMeter(); const timer = useRef(null); const toastTimer = useRef(null);
  const connRef = useRef({ connected: false, model: '', transcribe: false });
  connRef.current = { connected: connected, model: model, transcribe: transcribeAvail };
  const inLangRef = useRef(inputLang); inLangRef.current = inputLang;

  useEffect(function () { modeRef.current = mode; }, [mode]);
  useEffect(function () { try { localStorage.setItem('cadence_mode', mode); } catch (e) {} }, [mode]);
  useEffect(function () { phaseRef.current = phase; }, [phase]);
  useEffect(function () { if (model) try { localStorage.setItem('cadence_model', model); } catch (e) {} }, [model]);

  // load connection status from the backend on mount
  const refreshStatus = useCallback(function () {
    return api.status().then(function (s) {
      setBackendDown(false);
      setConnected(!!s.connected); setEmail(s.email || null); setAcctMode(s.mode || null);
      setTranscribeAvail(!!s.transcribe); setModels(s.models || []);
      setModel(function (m) { return (m && (s.models || []).indexOf(m) >= 0) ? m : (s.default_model || (s.models || [])[0] || ''); });
    }).catch(function () { setBackendDown(true); setConnected(false); });
  }, []);
  useEffect(function () {
    refreshStatus();
    if (location.search.indexOf('connected=1') >= 0) {
      flashToast(); try { history.replaceState(null, '', location.pathname); } catch (e) {}
    }
  }, [refreshStatus]);

  const inLocale = localeFor(inputLang);   // speech recognizer locale for the chosen input language

  function flashToast() { setToast(true); if (toastTimer.current) clearTimeout(toastTimer.current); toastTimer.current = setTimeout(function () { setToast(false); }, 1900); }
  function doCopy(text) {
    if (!navigator.clipboard) return Promise.resolve(false);
    return navigator.clipboard.writeText(text).then(function () { flashToast(); return true; }).catch(function () { return false; });
  }

  /* ---- core: organize (live model when connected, else heuristic) + auto-copy ---- */
  const runGenerate = useCallback(function (raw, m) {
    raw = (raw || '').trim();
    if (!raw) { setPhase('idle'); return; }
    sourceRef.current = raw;
    if (timer.current) clearTimeout(timer.current);
    setNote(''); setAutoCopied(false); setPhase('thinking');
    const useMode = m || modeRef.current;
    const conn = connRef.current;

    function finish(out, srcLabel, live) {
      out = (out || '').trim();
      setStage(''); setResult(out); setSummary(srcLabel); setResultLive(!!live); setPhase('done');
      doCopy(out).then(function (ok) { setAutoCopied(ok); });
    }
    function builtin(noteMsg) {
      if (noteMsg) setNote(noteMsg);
      const out = useMode === 'paragraph' ? window.CadenceLib.toParagraph(raw, OPTS) : window.CadenceLib.toPrompt(raw, OPTS);
      finish(out, window.CadenceLib.improvementSummary(raw, out, useMode, window.CadenceLib.detectLang(raw)), false);
    }

    if (conn.connected) {
      setStage('generate');
      fetch('/api/generate', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ mode: useMode, text: raw, model: conn.model })
      }).then(function (resp) {
        if (!resp.ok) {
          return resp.json().then(function (j) {
            const err = (j && j.error) || 'error';
            if (err === 'not_connected') { setConnected(false); builtin('Session expired — used built-in cleanup. Sign in again.'); }
            else { builtin('Live model unavailable (' + err + ') — used built-in cleanup.'); }
          }).catch(function () { builtin('Live model unavailable — used built-in cleanup.'); });
        }
        const usedModel = resp.headers.get('X-Cadence-Model') || conn.model;
        const via = (resp.headers.get('X-Cadence-Mode') === 'chatgpt') ? 'ChatGPT subscription' : 'OpenAI';
        const label = 'Generated with ' + usedModel + ' · ' + via + '.';
        // stream the result in as it is generated (first words appear in <1s)
        if (!resp.body || !resp.body.getReader) {
          return resp.text().then(function (t) { t = (t || '').trim(); if (t) finish(t, label, true); else builtin('Live model returned nothing — used built-in cleanup.'); });
        }
        const reader = resp.body.getReader(); const dec = new TextDecoder(); let acc = ''; let started = false;
        function pump() {
          return reader.read().then(function (res) {
            if (res.done) {
              acc = acc.trim();
              if (acc) finish(acc, label, true); else builtin('Live model returned nothing — used built-in cleanup.');
              return;
            }
            acc += dec.decode(res.value, { stream: true });
            if (!started) { started = true; setStage(''); setResultLive(true); setSummary(label); setPhase('done'); }
            setResult(acc);
            return pump();
          });
        }
        return pump();
      }).catch(function () { builtin('Couldn’t reach the live model — used built-in cleanup.'); });
      return;
    }
    // not connected: brief organizing beat, then heuristic
    timer.current = setTimeout(function () { builtin(''); }, 700);
  }, []);

  const speech = useSpeech(useCallback(function (finalT, interimT, error) {
    if (error) {
      if (error === 'not-allowed' || error === 'service-not-allowed') setNote('Mic blocked — tap “type instead” below.');
      else if (error !== 'no-speech') setNote('Speech unavailable — tap “type instead” below.');
      return;
    }
    if (finalT) { liveRef.current = (liveRef.current ? liveRef.current + ' ' : '') + finalT.trim(); setLiveText(liveRef.current); setInterim(''); }
    if (interimT != null) setInterim(interimT);
  }, []), inLocale);

  const startRec = useCallback(async function () {
    liveRef.current = ''; setLiveText(''); setInterim(''); setResult(''); setSummary(''); setNote(''); setStage(''); setPhase('recording');
    const micOk = await meter.start();
    const ok = speech.start();
    // With audio recording + server transcription we can still work without
    // the browser recognizer (e.g. Firefox/Safari) as long as the mic works.
    if (!ok && !getSR()) {
      if (!(micOk && connRef.current.transcribe)) setNote('No speech recognition here — tap “type instead”.');
    }
  }, [meter, speech]);

  const stopRec = useCallback(async function () {
    speech.stop();
    let raw = liveRef.current;
    if (interim) raw = (raw ? raw + ' ' : '') + interim.trim();
    raw = raw.trim(); setInterim('');
    const conn = connRef.current;
    const blob = await meter.finish();

    // High-accuracy path: when an API key is available, transcribe the recorded
    // audio with OpenAI (far better than the browser, and the only transcript
    // on browsers without speech recognition). Fall back to the live transcript.
    if (conn.transcribe && blob && blob.size > 1400) {
      setPhase('thinking'); setStage('transcribe'); setNote('');
      try {
        const tr = await api.transcribe(blob, transcribeHint(inLangRef.current));
        const txt = tr.ok && tr.body && tr.body.text ? tr.body.text.trim() : '';
        if (txt && (raw.length < 2 || txt.length >= raw.length * 0.5)) raw = txt;
      } catch (e) {}
    }
    setStage('');
    runGenerate(raw, modeRef.current);
  }, [speech, meter, interim, runGenerate]);

  function switchMode(m) { if (m === modeRef.current) return; setMode(m); modeRef.current = m; if (phase === 'done' && sourceRef.current) runGenerate(sourceRef.current, m); }
  function startOver() { if (timer.current) clearTimeout(timer.current); liveRef.current = ''; sourceRef.current = ''; setLiveText(''); setInterim(''); setTyped(''); setResult(''); setSummary(''); setNote(''); setStage(''); setPhase('idle'); }
  function loadExample() { runGenerate(window.CadenceLib.SAMPLES.en, modeRef.current); }
  function submitTyped(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runGenerate(typed, modeRef.current); } }

  function signIn() { window.location.href = '/login'; }
  function disconnect() { api.logout().then(function () { setConnected(false); setEmail(null); setAcctMode(null); refreshStatus(); }); }
  function connectKey() {
    const k = apiKeyInput.trim(); if (!k || keyBusy) return;
    setKeyBusy(true); setKeyErr('');
    api.setKey(k).then(function (res) {
      if (res.ok) { setApiKeyInput(''); setConnectOpen(false); refreshStatus(); }
      else {
        const e = res.body && res.body.error;
        setKeyErr(e === 'invalid_key' ? 'OpenAI rejected that key.' : e === 'invalid_format' ? 'Key should start with “sk-”.' : 'Could not verify the key.');
      }
    }).catch(function () { setKeyErr('Could not reach the local helper.'); }).then(function () { setKeyBusy(false); });
  }

  useEffect(function () { return function () { if (timer.current) clearTimeout(timer.current); if (toastTimer.current) clearTimeout(toastTimer.current); }; }, []);

  /* ---- keyboard: ←/→ choose style · Right Alt push-to-talk ---- */
  const keyRec = useRef({ startedByKey: false, pressStart: 0, toggle: false });
  const actionsRef = useRef({});
  actionsRef.current = { startRec: startRec, stopRec: stopRec, switchMode: switchMode };
  useEffect(function () {
    function resetKey() { keyRec.current = { startedByKey: false, pressStart: 0, toggle: false }; }
    function inField(t) { return t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.tagName === 'SELECT'); }
    function onKeyDown(e) {
      if (inField(e.target)) return;
      const p = phaseRef.current;
      if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (p === 'thinking') return; e.preventDefault(); actionsRef.current.switchMode(e.key === 'ArrowLeft' ? 'paragraph' : 'prompt'); return; }
      if (e.code === 'AltRight') {
        e.preventDefault();
        if (e.repeat || p === 'thinking') return;
        if (p !== 'recording') { keyRec.current = { startedByKey: true, pressStart: performance.now(), toggle: false }; actionsRef.current.startRec(); }
        else { actionsRef.current.stopRec(); resetKey(); }
      }
    }
    function onKeyUp(e) {
      if (e.code !== 'AltRight') return; e.preventDefault();
      const k = keyRec.current;
      if (phaseRef.current === 'recording' && k.startedByKey && !k.toggle) {
        const held = performance.now() - k.pressStart;
        if (held >= 380) { actionsRef.current.stopRec(); resetKey(); } else { k.toggle = true; }
      }
    }
    window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
    return function () { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); };
  }, []);

  const recording = phase === 'recording';
  const busy = recording || phase === 'thinking';
  const showLive = recording && (liveText || interim);

  const segBtn = function (val, label) {
    const on = mode === val;
    return <button key={val} onClick={function () { switchMode(val); }} disabled={busy}
      style={{ flex: 1, fontSize: 14, fontWeight: 600, padding: '10px 0', cursor: busy ? 'default' : 'pointer', fontFamily: 'Inter Tight, sans-serif', background: on ? 'var(--accent)' : 'transparent', color: on ? 'var(--accent-ink)' : 'var(--fg-dim)', border: 'none', borderRadius: 6, transition: 'all .14s', opacity: busy ? 0.55 : 1 }}>{label}</button>;
  };

  const shortModel = model || (connected ? '…' : '');

  return (
    <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
      <header className="cad-header" style={{ flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '18px 24px' }}>
        <div style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>Cadence<span style={{ color: 'var(--accent)' }}>.</span></div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          {connected && models.length > 0 && (
            <select value={model} onChange={function (e) { setModel(e.target.value); }} className="glass"
              title="Model" style={{ background: 'var(--bg-elev)', color: 'var(--fg)', border: '1px solid var(--line)', borderRadius: 999, padding: '7px 11px', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', outline: 'none', maxWidth: 168 }}>
              {models.map(function (m) { return <option key={m} value={m} style={{ background: '#16181c', color: 'var(--fg)' }}>{m}</option>; })}
            </select>
          )}
          <button onClick={function () { setConnectOpen(true); }} className="glass" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 13px', borderRadius: 999, border: '1px solid var(--line)', background: 'var(--bg-elev)', color: 'var(--fg)', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
            <span style={{ width: 7, height: 7, borderRadius: '50%', background: connected ? 'var(--green)' : 'var(--fg-muted)', boxShadow: connected ? '0 0 7px var(--green)' : 'none' }} />
            {connected ? 'ChatGPT' : 'Sign in'}
          </button>
        </div>
      </header>

      {/* offline / not-connected banner — explains why results aren't AI-grade */}
      {!connected && (
        <div onClick={function () { if (!backendDown) setConnectOpen(true); }}
          style={{ flexShrink: 0, margin: '0 16px 4px', padding: '9px 14px', borderRadius: 9, cursor: backendDown ? 'default' : 'pointer',
            border: '1px solid color-mix(in oklab, var(--amber) 40%, var(--line))', background: 'color-mix(in oklab, var(--amber) 10%, transparent)',
            color: 'var(--fg)', fontSize: 12.5, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 9, justifyContent: 'center', textAlign: 'center' }}>
          <span style={{ fontSize: 14 }}>⚠️</span>
          {backendDown
            ? <span>Offline mode — results use built-in cleanup, not AI. Run <span className="mono" style={{ color: 'var(--amber)' }}>python server.py</span> and open <span className="mono" style={{ color: 'var(--amber)' }}>http://localhost:1455</span> for real model output.</span>
            : <span><strong>Sign in with ChatGPT</strong> to get AI-engineered prompts &amp; paragraphs. Right now you’re on offline cleanup (it only reorganizes your words).</span>}
        </div>
      )}

      {/* copied toast */}
      <div style={{ position: 'fixed', top: 22, left: '50%', zIndex: 200, transform: 'translateX(-50%) translateY(' + (toast ? '0' : '-16px') + ')', opacity: toast ? 1 : 0, transition: 'opacity .25s, transform .25s', pointerEvents: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, padding: '9px 15px', background: 'var(--accent)', color: 'var(--accent-ink)', borderRadius: 999, fontSize: 13.5, fontWeight: 700, boxShadow: '0 10px 30px rgba(0,0,0,0.35)' }}>
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M4 12l5 5L20 6" strokeLinecap="round" strokeLinejoin="round" /></svg>
        {connected && location.search.indexOf('connected') >= 0 ? 'Signed in' : 'Copied — paste it anywhere'}
      </div>

      <main className="cad-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '8px 20px 48px' }}>
        <div style={{ width: '100%', maxWidth: 560, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>

          <div className="glass" style={{ width: 300, maxWidth: '100%', marginBottom: 8, display: 'flex', gap: 4, padding: 4, background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 9 }}>
            {segBtn('paragraph', 'Paragraph')}
            {segBtn('prompt', 'Prompt')}
          </div>
          {/* one quiet line: conveys multilingual-in, English-out, auto-detected */}
          <div style={{ fontSize: 13, color: 'var(--fg-muted)', marginBottom: 36, textAlign: 'center', minHeight: 18, display: 'inline-flex', alignItems: 'center', gap: 7 }}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" style={{ opacity: 0.7 }}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" /></svg>
            {MODE_HINT[mode]}
          </div>

          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
            <div style={{ position: 'relative' }}>
              {!busy && phase !== 'done' && <div style={{ position: 'absolute', inset: -22, borderRadius: '50%', background: 'radial-gradient(circle, color-mix(in oklab, var(--accent) 22%, transparent) 0%, transparent 70%)', pointerEvents: 'none' }} />}
              <button onClick={recording ? stopRec : (phase === 'thinking' ? undefined : startRec)} disabled={phase === 'thinking'} className="glass" aria-label={recording ? 'Stop and organize' : 'Start recording'}
                style={{ position: 'relative', width: 120, height: 120, borderRadius: '50%', border: '1px solid ' + (recording ? 'var(--rec)' : 'var(--line)'), background: recording ? 'color-mix(in oklab, var(--rec) 16%, transparent)' : 'var(--bg-elev)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: phase === 'thinking' ? 'default' : 'pointer', transition: 'all .15s' }}
                onMouseEnter={function (e) { if (!busy && phase !== 'thinking') e.currentTarget.style.borderColor = 'var(--accent)'; }}
                onMouseLeave={function (e) { if (!recording) e.currentTarget.style.borderColor = 'var(--line)'; }}>
                {phase === 'thinking'
                  ? <span style={{ width: 32, height: 32, borderRadius: '50%', border: '2px solid var(--line)', borderTopColor: 'var(--accent)', animation: 'cad-spin .8s linear infinite' }} />
                  : recording
                    ? <span style={{ width: 32, height: 32, background: 'var(--rec)', borderRadius: 6 }} />
                    : <svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.6"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3" strokeLinecap="round" /></svg>}
                {recording && <span style={{ position: 'absolute', inset: -7, borderRadius: '50%', border: '1px solid var(--rec)', opacity: 0.4, animation: 'cad-ring 1.6s ease-out infinite' }} />}
              </button>
            </div>

            {recording ? <Waveform levels={meter.levels} active={true} /> : <div style={{ height: 44 }} />}

            <div style={{ fontSize: 14.5, color: recording ? 'var(--rec)' : 'var(--fg-dim)', textAlign: 'center', fontWeight: recording ? 600 : 400 }}>
              {phase === 'thinking' ? (stage === 'transcribe' ? 'Transcribing your audio…' : (connected ? ('Analyzing with ' + shortModel + '…') : 'Organizing your thoughts…'))
                : recording ? 'Listening — tap when you’re done'
                : phase === 'done' ? 'Tap to record something new'
                : (speech.supported || transcribeAvail ? 'Tap and just start talking' : 'Tap mic, or type below')}
            </div>
          </div>

          {showLive && (
            <div className="glass" dir="auto" style={{ width: '100%', marginTop: 24, padding: '14px 16px', background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 8, fontSize: 15.5, lineHeight: 1.6, color: 'var(--fg)' }}>
              {liveText}{interim && <span style={{ color: 'var(--fg-dim)' }}>{liveText ? ' ' : ''}{interim}</span>}
            </div>
          )}

          {phase === 'typing' && (
            <div style={{ width: '100%', marginTop: 24 }}>
              <textarea autoFocus value={typed} onChange={function (e) { setTyped(e.target.value); }} onKeyDown={submitTyped} className="glass" placeholder="Type your messy thoughts… press Enter when done." spellCheck={false} rows={3}
                style={{ width: '100%', resize: 'vertical', minHeight: 92, background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--fg)', fontFamily: 'Inter Tight, sans-serif', fontSize: 16, lineHeight: 1.6, padding: '14px 16px', outline: 'none' }}
                onFocus={function (e) { e.target.style.borderColor = 'var(--fg-dim)'; }} onBlur={function (e) { e.target.style.borderColor = 'var(--line)'; }} />
              <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 6 }}>Enter to clean up · Shift+Enter for a new line</div>
            </div>
          )}

          {note && <div style={{ marginTop: 16, fontSize: 13, color: 'var(--amber)', textAlign: 'center' }}>{note}</div>}

          {phase === 'idle' && (
            <div style={{ display: 'flex', justifyContent: 'center', gap: 18, marginTop: 22 }}>
              <button onClick={function () { setPhase('typing'); }} style={linkBtn}>Type instead</button>
              <span style={{ color: 'var(--line)' }}>·</span>
              <button onClick={loadExample} style={linkBtn}>Try an example</button>
            </div>
          )}

          {phase === 'done' && (
            <div style={{ width: '100%', marginTop: 28, animation: 'cad-fadein .3s ease' }}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13.5, fontWeight: 700, color: autoCopied ? 'var(--green)' : 'var(--fg-dim)' }}>
                  {autoCopied && <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M4 12l5 5L20 6" strokeLinecap="round" strokeLinejoin="round" /></svg>}
                  {autoCopied ? 'Copied to clipboard' : (mode === 'prompt' ? 'Your prompt' : 'Your paragraph')}
                  {!resultLive && <span title="Built-in cleanup, not AI — sign in for analyzed results" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.04em', color: 'var(--amber)', border: '1px solid color-mix(in oklab, var(--amber) 45%, var(--line))', borderRadius: 5, padding: '1px 6px', fontFamily: 'JetBrains Mono, monospace' }}>OFFLINE</span>}
                </span>
                <button onClick={function () { doCopy(result).then(function (ok) { setAutoCopied(ok); }); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, background: 'transparent', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 12px', color: 'var(--fg)', cursor: 'pointer', fontFamily: 'inherit' }}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><rect x="9" y="9" width="11" height="11" rx="1.5" /><path d="M5 15V5a1 1 0 0 1 1-1h10" /></svg>
                  Copy again
                </button>
              </div>
              <textarea value={result} onChange={function (e) { setResult(e.target.value); }} spellCheck={false} className="glass"
                style={{ width: '100%', minHeight: 170, resize: 'vertical', background: 'var(--bg-card)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--fg)', fontFamily: mode === 'prompt' ? 'JetBrains Mono, monospace' : 'Inter Tight, sans-serif', fontSize: mode === 'prompt' ? 13.5 : 16, lineHeight: 1.65, padding: '16px', outline: 'none', whiteSpace: 'pre-wrap' }}
                onFocus={function (e) { e.target.style.borderColor = 'var(--fg-dim)'; }} onBlur={function (e) { e.target.style.borderColor = 'var(--line)'; }} />
              {summary && <div style={{ marginTop: 10, fontSize: 13, color: 'var(--fg-dim)', lineHeight: 1.5 }}>{summary}</div>}
              <div style={{ textAlign: 'center', marginTop: 18 }}><button onClick={startOver} style={linkBtn}>Start over</button></div>
            </div>
          )}
        </div>

        {/* connect-account modal */}
        {connectOpen && (
          <div onClick={function () { setConnectOpen(false); }} style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(3px)', WebkitBackdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
            <div onClick={function (e) { e.stopPropagation(); }} className="glass-strong" style={{ width: 400, maxWidth: '100%', maxHeight: '88vh', overflowY: 'auto', background: 'var(--bg-card)', border: '1px solid var(--glass-line)', borderRadius: 12, padding: 22, boxShadow: '0 30px 70px rgba(0,0,0,0.5)' }}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
                <span style={{ fontSize: 16, fontWeight: 700 }}>{connected ? 'Your ChatGPT connection' : 'Sign in with ChatGPT'}</span>
                <button onClick={function () { setConnectOpen(false); }} aria-label="Close" style={{ background: 'none', border: 'none', color: 'var(--fg-dim)', cursor: 'pointer', fontSize: 20, lineHeight: 1, padding: 0 }}>×</button>
              </div>

              {connected ? (
                <div>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '11px 13px', border: '1px solid color-mix(in oklab, var(--green) 45%, var(--line))', borderRadius: 8, background: 'color-mix(in oklab, var(--green) 9%, transparent)', marginBottom: 14 }}>
                    <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--green)', boxShadow: '0 0 8px var(--green)' }} />
                    <span style={{ fontSize: 13.5, color: 'var(--fg)' }}>Signed in{email ? <span> as <strong style={{ fontWeight: 700 }}>{email}</strong></span> : ''}.</span>
                  </div>
                  <div style={{ fontSize: 12, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-muted)', fontFamily: 'JetBrains Mono, monospace', marginBottom: 8 }}>Model · {models.length} available</div>
                  <select value={model} onChange={function (e) { setModel(e.target.value); }} style={{ width: '100%', background: 'var(--bg-elev)', color: 'var(--fg)', border: '1px solid var(--line)', borderRadius: 8, padding: '11px 12px', fontSize: 14, marginBottom: 12, outline: 'none', cursor: 'pointer' }}>
                    {models.map(function (m) { return <option key={m} value={m} style={{ background: '#16181c' }}>{m}</option>; })}
                  </select>
                  <div style={{ fontSize: 12.5, color: 'var(--fg-dim)', lineHeight: 1.55, marginBottom: 16 }}>
                    {acctMode === 'apikey'
                      ? 'Calls go to api.openai.com using a key minted from your account (billed to your OpenAI API credits). Speech is transcribed by OpenAI for high accuracy, then analyzed into a polished result.'
                      : 'Calls go through your ChatGPT subscription (chatgpt.com Codex backend). Speech uses your browser’s recognizer; the model then cleans up and structures it.'}
                  </div>
                  <button onClick={disconnect} style={{ width: '100%', padding: '12px 0', fontSize: 14, fontWeight: 600, fontFamily: 'inherit', background: 'transparent', color: 'var(--fg)', border: '1px solid var(--line)', borderRadius: 7, cursor: 'pointer' }}>Disconnect</button>
                </div>
              ) : (
                <div>
                  <div style={{ fontSize: 13.5, color: 'var(--fg-dim)', lineHeight: 1.55, marginBottom: 18 }}>
                    Sign in with your ChatGPT / Codex account to clean up speech with a real OpenAI model. Without it, Cadence uses fast built-in cleanup. Your tokens stay on your machine — handled by the local helper, never sent to the browser.
                  </div>
                  <button onClick={signIn} style={{ width: '100%', padding: '13px 0', fontSize: 15, fontWeight: 700, fontFamily: 'inherit', background: 'var(--accent)', color: 'var(--accent-ink)', border: 'none', borderRadius: 7, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 9 }}>
                    <svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 4.5a3 3 0 11-3 3 3 3 0 013-3zM6 17.2a6.8 6.8 0 0112 0 8 8 0 01-12 0z"/></svg>
                    Sign in with ChatGPT
                  </button>
                  <div style={{ fontSize: 11.5, color: 'var(--fg-muted)', textAlign: 'center', marginTop: 10, lineHeight: 1.5 }}>Opens auth.openai.com · the standard Codex sign-in flow.</div>
                </div>
              )}

              {acctMode !== 'apikey' && (
                <div style={{ marginTop: 18, paddingTop: 16, borderTop: '1px solid var(--line)' }}>
                  <div style={{ fontSize: 12, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-muted)', fontFamily: 'JetBrains Mono, monospace', marginBottom: 8 }}>OpenAI API key — auto language detection</div>
                  <div style={{ fontSize: 12.5, color: 'var(--fg-dim)', lineHeight: 1.55, marginBottom: 10 }}>
                    Paste a key to auto-detect the spoken language (incl. mixed 中文 / العربية) with gpt-4o-transcribe — no picker. Stored only on this machine.
                  </div>
                  <div style={{ display: 'flex', gap: 8 }}>
                    <input type="password" value={apiKeyInput} onChange={function (e) { setApiKeyInput(e.target.value); }}
                      onKeyDown={function (e) { if (e.key === 'Enter') connectKey(); }} placeholder="sk-..." spellCheck={false} autoComplete="off"
                      style={{ flex: 1, minWidth: 0, background: 'var(--bg-elev)', color: 'var(--fg)', border: '1px solid var(--line)', borderRadius: 7, padding: '10px 12px', fontSize: 13, fontFamily: 'JetBrains Mono, monospace', outline: 'none' }} />
                    <button onClick={connectKey} disabled={keyBusy || !apiKeyInput.trim()}
                      style={{ padding: '0 16px', fontSize: 13.5, fontWeight: 700, fontFamily: 'inherit', background: 'var(--rec)', color: 'var(--accent-ink)', border: 'none', borderRadius: 7, cursor: (keyBusy || !apiKeyInput.trim()) ? 'default' : 'pointer', opacity: (keyBusy || !apiKeyInput.trim()) ? 0.55 : 1 }}>{keyBusy ? '…' : 'Use'}</button>
                  </div>
                  {keyErr && <div style={{ marginTop: 8, fontSize: 12, color: 'var(--amber)' }}>{keyErr}</div>}
                  <div style={{ marginTop: 8, fontSize: 11, color: 'var(--fg-muted)' }}>Get one at platform.openai.com/api-keys</div>
                </div>
              )}
            </div>
          </div>
        )}

        <div className="kbd-hints" style={{ marginTop: 32, display: 'flex', gap: 18, flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', fontSize: 11.5, color: 'var(--fg-muted)', fontFamily: 'JetBrains Mono, monospace', letterSpacing: '0.02em' }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Kbd>←</Kbd><Kbd>→</Kbd> choose style</span>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Kbd>Right Alt</Kbd> hold or tap to record</span>
        </div>
      </main>
    </div>
  );
}

const linkBtn = { background: 'none', border: 'none', color: 'var(--fg-dim)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: 3, padding: 0, fontFamily: 'inherit' };
function Kbd({ children }) {
  return <span className="glass" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 17, padding: '2px 6px', borderRadius: 4, border: '1px solid var(--line)', borderBottomWidth: 2, background: 'var(--bg-elev)', color: 'var(--fg-dim)', fontSize: 11, fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.3 }}>{children}</span>;
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
