// CIS490 dashboard front-end.
//
// Layers:
//   1. Transport + typed message bus
//   2. Scrollytelling controller (hotkeys, click, prev/next, FAB)
//   3. Public API + demo machinery
//   4. Widgets — one per scene
//
// Events on the bus:
//
//   Real (from server feeders):
//     hello       — {type, clients}                      one-shot on connect
//     snapshot    — {type, total_episodes, total_alerts,
//                    total_bytes, host_counts,
//                    recent_episodes: [...]}             every 30 s + on connect
//     episode     — {type, episode_id, host_id, sha256,
//                    size_bytes, received_at}            one per index.jsonl line
//     alert       — {type, host_id, symptom, detail,
//                    suggested_fix, detected_at}         one per alerts.jsonl line
//
//   Real (from future producers — overwrite synthetic if demo is on):
//     phase           — {type, phase}
//     prediction      — {type, episode_id, window_idx, predicted, actual}
//     model_metric    — {type, model, accuracy}
//     embedding       — {type, x, y, phase}
//     model_perf      — {type, model, latency_us, accuracy}
//     attack_profile  — {type, name, shape, curve: [...]}
//
//   Local (browser-only, never hit the wire):
//     demo_start      — {type}        emitted when demo toggles on
//     demo_stop       — {type}        emitted when demo toggles off
//
// Demo-only widgets render NOTHING by default (they show an "awaiting"
// row). They populate synthetic data on demo_start and clear on
// demo_stop. Real producer events overwrite either way.

(function () {
  'use strict';

  // ────────────────────────────────────────────────────────────────
  // 1. Transport + bus
  // ────────────────────────────────────────────────────────────────
  const statusEl = document.getElementById('status');
  const subscribers = new Map();
  const wildcardSubs = new Set();

  function on(type, fn) {
    if (type === '*') { wildcardSubs.add(fn); return () => wildcardSubs.delete(fn); }
    if (!subscribers.has(type)) subscribers.set(type, new Set());
    subscribers.get(type).add(fn);
    return () => subscribers.get(type).delete(fn);
  }
  function dispatch(msg) {
    const t = (msg && msg.type) || '__no_type__';
    const subs = subscribers.get(t);
    if (subs) subs.forEach(fn => { try { fn(msg); } catch (e) { console.error(e); } });
    wildcardSubs.forEach(fn => { try { fn(msg); } catch (e) { console.error(e); } });
  }

  let ws = null;
  function connect() {
    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
    ws = new WebSocket(`${proto}//${location.host}/ws`);
    ws.onopen   = () => { statusEl.textContent = 'live'; statusEl.className = 'status ok'; };
    ws.onclose  = () => {
      statusEl.textContent = 'reconnecting…'; statusEl.className = 'status bad';
      setTimeout(connect, 1500);
    };
    ws.onerror  = () => {};
    ws.onmessage = ev => {
      let msg; try { msg = JSON.parse(ev.data); } catch { msg = { type: 'raw', raw: ev.data }; }
      dispatch(msg);
    };
  }
  connect();
  function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }

  // ────────────────────────────────────────────────────────────────
  // 2. Scrollytelling controller
  // ────────────────────────────────────────────────────────────────
  const scenes = Array.from(document.querySelectorAll('.scene[data-stage]'));
  const stageViews = new Map();
  document.querySelectorAll('.stage-view[data-view]').forEach(el => {
    stageViews.set(el.dataset.view, el);
  });

  const sceneEnterHandlers = new Map();
  const sceneExitHandlers = new Map();
  function onScene(name, { onEnter, onExit } = {}) {
    if (onEnter) sceneEnterHandlers.set(name, onEnter);
    if (onExit)  sceneExitHandlers.set(name, onExit);
  }

  const sceneIdxEl = document.getElementById('scene-idx');
  const sceneTotalEl = document.getElementById('scene-total');
  const prevBtn = document.getElementById('prev-btn');
  const nextBtn = document.getElementById('next-btn');
  const fab = document.getElementById('next-fab');
  sceneTotalEl.textContent = String(scenes.length);

  // Mount window: the active scene plus its immediate neighbours
  // get [data-mounted]; everything else is skipped from paint via
  // CSS (content-visibility: hidden on .scene, display: none on
  // the matching .stage-view). The window is recomputed every time
  // the active scene changes — and pre-computed before any
  // programmatic scroll so the destination is ready to render
  // before it scrolls into view.
  function updateMountedRange(idx) {
    for (let i = 0; i < scenes.length; i++) {
      const inWindow = Math.abs(i - idx) <= 1;
      const scene = scenes[i];
      const view = stageViews.get(scene.dataset.stage);
      if (inWindow) {
        scene.setAttribute('data-mounted', '');
        if (view) view.setAttribute('data-mounted', '');
      } else {
        scene.removeAttribute('data-mounted');
        if (view) view.removeAttribute('data-mounted');
      }
    }
  }

  let activeIdx = -1;
  function setActiveIdx(idx) {
    if (idx === activeIdx) return;
    if (activeIdx >= 0) {
      const prevName = scenes[activeIdx].dataset.stage;
      const view = stageViews.get(prevName);
      if (view) view.removeAttribute('data-active');
      const fn = sceneExitHandlers.get(prevName);
      if (fn) try { fn(); } catch (e) { console.error(e); }
      scenes[activeIdx].removeAttribute('data-active');
    }
    activeIdx = idx;
    updateMountedRange(idx);
    sceneIdxEl.textContent = String(idx + 1);
    const name = scenes[idx].dataset.stage;
    const view = stageViews.get(name);
    if (view) view.setAttribute('data-active', '');
    scenes[idx].setAttribute('data-active', '');
    const fn = sceneEnterHandlers.get(name);
    if (fn) try { fn(); } catch (e) { console.error(e); }
    prevBtn.disabled = idx === 0;
    nextBtn.disabled = idx === scenes.length - 1;
    if (fab) fab.disabled = idx === scenes.length - 1;
  }

  const sceneRatios = new Map();
  const io = new IntersectionObserver(entries => {
    entries.forEach(e => sceneRatios.set(e.target, e.intersectionRatio));
    let bestIdx = activeIdx >= 0 ? activeIdx : 0;
    let bestRatio = -1;
    scenes.forEach((s, i) => {
      const r = sceneRatios.get(s) || 0;
      if (r > bestRatio) { bestRatio = r; bestIdx = i; }
    });
    setActiveIdx(bestIdx);
  }, {
    threshold: [0, 0.25, 0.5, 0.75, 1],
    rootMargin: '-30% 0px -30% 0px',
  });
  scenes.forEach(s => io.observe(s));
  setActiveIdx(0);

  function scrollToScene(idx) {
    idx = Math.max(0, Math.min(scenes.length - 1, idx));
    if (idx === activeIdx) return;
    // Pre-mount the destination's window before initiating the smooth
    // scroll. Without this, an Home/End jump scrolls past unmounted
    // scenes that are content-visibility: hidden, and the target only
    // mounts once activation flips at the end of the animation —
    // showing a flash of empty space mid-scroll.
    updateMountedRange(idx);
    scenes[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
  }
  function next() { scrollToScene(activeIdx + 1); }
  function prev() { scrollToScene(activeIdx - 1); }

  window.addEventListener('keydown', e => {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    const tag = (e.target && e.target.tagName) || '';
    if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
    switch (e.key) {
      case 'ArrowDown': case 'ArrowRight': case 'PageDown': case 'j': case ' ':
        e.preventDefault(); next(); break;
      case 'ArrowUp': case 'ArrowLeft': case 'PageUp': case 'k':
        e.preventDefault(); prev(); break;
      case 'Home': e.preventDefault(); scrollToScene(0); break;
      case 'End':  e.preventDefault(); scrollToScene(scenes.length - 1); break;
      case 'c': case 'C': e.preventDefault(); setClickNav(!clickNavOn); break;
      case 'd': case 'D': e.preventDefault(); setDemo(!demoActive); break;
    }
  });
  prevBtn.addEventListener('click', prev);
  nextBtn.addEventListener('click', next);
  if (fab) fab.addEventListener('click', next);

  // Click-on-stage to advance — gated by the click-nav toggle so
  // interactive widgets (db table rows, search boxes) don't compete
  // with the next-slide gesture by default. Topbar arrows / FAB /
  // hotkeys always work regardless.
  const clickNavBtn = document.getElementById('click-nav-btn');
  let clickNavOn = false;
  function setClickNav(on) {
    clickNavOn = on;
    clickNavBtn.textContent = `click-nav: ${on ? 'on' : 'off'}`;
    clickNavBtn.classList.toggle('active', on);
  }
  clickNavBtn.addEventListener('click', e => { e.stopPropagation(); setClickNav(!clickNavOn); });
  setClickNav(false);

  const stageCol = document.getElementById('stage-col');
  stageCol.addEventListener('click', e => {
    if (!clickNavOn) return;
    if (e.target.closest('[data-no-advance]')) return;
    if (e.target.closest('button, a, input, select, textarea')) return;
    next();
  });

  // ────────────────────────────────────────────────────────────────
  // 3. Public API + demo machinery
  // ────────────────────────────────────────────────────────────────
  window.dashboard = { on, send, scene: onScene, dispatch, next, prev, scrollToScene };

  const demoBtn = document.getElementById('demo-btn');
  let demoTimer = null;
  let demoActive = false;
  // Demo mode is intentionally narrow. Real data wins everywhere —
  // demo only seeds widgets that haven't received any real producer
  // output yet. demoTick is now a no-op; the per-widget demo_start
  // handlers do the (one-shot) seeding, so we don't drown out real
  // data with periodic synthetic noise.
  function demoTick() {}
  function setDemo(active) {
    if (demoActive === active) return;
    demoActive = active;
    if (active) {
      dispatch({ type: 'demo_start' });
      demoTimer = setInterval(demoTick, 350);
      demoBtn.textContent = 'demo: on'; demoBtn.classList.add('active');
    } else {
      if (demoTimer) clearInterval(demoTimer); demoTimer = null;
      dispatch({ type: 'demo_stop' });
      demoBtn.textContent = 'demo: off'; demoBtn.classList.remove('active');
    }
  }
  demoBtn.addEventListener('click', e => { e.stopPropagation(); setDemo(!demoActive); });

  // Helper for empty-state rows.
  function awaitingNote(text) {
    const d = document.createElement('div');
    d.className = 'awaiting'; d.textContent = text;
    return d;
  }

  // ────────────────────────────────────────────────────────────────
  // 3.5. Theme machinery (OKLCH palette · continuous harmony · 5 BGs)
  // ────────────────────────────────────────────────────────────────
  // Harmony is parameterized continuously by `count` (1..6 colors)
  // and `spread` (0..300° angular range). Together they cover every
  // named scheme: mono = count 1; complementary = count 2 spread 180;
  // analogous = count 3-5 with low spread; triadic = count 3 spread
  // 240; tetradic = count 4 spread 270; etc. Markers fan symmetrically
  // around the primary so a 60° spread with 3 colors is [0, +30, -30].
  // Any marker can be dragged individually to break the symmetry.
  //
  // Animations across the bg-canvas read --anim-speed; the bg blur
  // reads --bg-blur. State persists in localStorage.
  (function () {
    // Per-theme settings spec. Each entry becomes a slider in the
    // panel under the active-theme section. `step` rounds the value
    // and decides how many decimals to show.
    const THEMES = {
      drift: {
        label: 'drift settings',
        params: {
          count:    { default: 6,    min: 3,  max: 12, step: 1,    label: 'blobs',         rebuild: true },
          sizeVw:   { default: 36,   min: 12, max: 60, step: 1,    label: 'size (vw)',     rebuild: true },
          blur:     { default: 70,   min: 20, max: 150, step: 1,   label: 'blur (px)' },
          opacity:  { default: 0.55, min: 0.1, max: 1,  step: 0.05, label: 'opacity' },
        },
      },
      lava: {
        label: 'lava settings',
        params: {
          count:      { default: 8,  min: 4,  max: 16, step: 1,   label: 'bubbles',        rebuild: true },
          sizeMean:   { default: 9,  min: 3,  max: 18, step: 0.5, label: 'mean size (vw)', rebuild: true },
          sizeSpread: { default: 4,  min: 0,  max: 8,  step: 0.5, label: 'size σ (spread)', rebuild: true },
          gooStr:     { default: 26, min: 8,  max: 50, step: 1,   label: 'merge strength' },
          gooBlur:    { default: 22, min: 8,  max: 40, step: 1,   label: 'merge blur (σ)' },
        },
      },
      vaporwave: {
        label: 'vaporwave settings',
        params: {
          gridSize:    { default: 80, min: 30, max: 200, step: 5, label: 'grid cell (px)' },
          perspective: { default: 62, min: 30, max: 80,  step: 1, label: 'horizon angle (°)' },
          sunSize:     { default: 50, min: 20, max: 80,  step: 1, label: 'sun size (vmin)' },
          horizonPct:  { default: 55, min: 35, max: 70,  step: 1, label: 'horizon position (%)' },
          blindWidth:  { default: 11, min: 6,  max: 30,  step: 1, label: 'blind width (px)' },
        },
      },
      laser: {
        label: 'laser settings',
        params: {
          count:     { default: 5,    min: 2, max: 12, step: 1,   label: 'beams',         rebuild: true },
          thickness: { default: 3,    min: 1, max: 12, step: 1,   label: 'thickness (px)' },
          blur:      { default: 2,    min: 0, max: 10, step: 1,   label: 'blur (px)' },
          opacity:   { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' },
        },
      },
    };

    function defaultThemeSettings() {
      const out = {};
      for (const t in THEMES) {
        out[t] = {};
        for (const p in THEMES[t].params) {
          out[t][p] = THEMES[t].params[p].default;
        }
      }
      return out;
    }

    const DEFAULTS = {
      background: 'black',
      L: 70, C: 0.15, H: 250,
      count:  3,
      spread: 60,
      offsets: null,
      lVar:   0,
      cVar:   0,
      animSpeed: 1.0,
      bgBlur: 0,
      tint:   0.10,
      contentBackdrop: 0.30,   // 0 = fully transparent foreground; 1 = fully opaque
      themes: defaultThemeSettings(),
    };
    const state = Object.assign({}, DEFAULTS);

    // Even, symmetric distribution: primary at 0, siblings fan
    // alternately to +/- around it. e.g. n=4 spread=270 → [0, 90,
    // -90, 180] (which is tetradic, just stated symmetrically).
    function evenOffsets(n, spread) {
      const r = [0];
      if (n <= 1) return r;
      const step = spread / (n - 1);
      for (let i = 1; i < n; i++) {
        const sign = i % 2 === 1 ? 1 : -1;
        const k = Math.ceil(i / 2);
        r.push(sign * k * step);
      }
      return r;
    }

    try {
      const stored = JSON.parse(localStorage.getItem('cis490-theme') || '{}');
      delete stored.harmony;     // discrete-harmony field (gone)
      Object.assign(state, stored);
      if (!Array.isArray(state.offsets) || state.offsets.length !== state.count) {
        state.offsets = evenOffsets(state.count, state.spread);
      }
      if (typeof state.spread === 'number' && state.spread <= 5) {
        state.spread = 60;
        state.offsets = evenOffsets(state.count, state.spread);
      }
      // Merge in theme defaults for any missing per-theme params
      // (state shape grows over time as new sliders ship).
      const fresh = defaultThemeSettings();
      state.themes = state.themes || {};
      for (const t in fresh) {
        state.themes[t] = Object.assign({}, fresh[t], state.themes[t] || {});
      }
    } catch {}
    if (!state.offsets) state.offsets = evenOffsets(state.count, state.spread);

    const $ = id => document.getElementById(id);
    const els = {
      bg:       $('theme-bg'),
      L:        $('theme-l'),        Lv:       $('theme-l-val'),
      C:        $('theme-c'),        Cv:       $('theme-c-val'),
      H:        $('theme-h'),        Hv:       $('theme-h-val'),
      count:    $('theme-count'),    countV:   $('theme-count-val'),
      spread:   $('theme-spread'),   spreadV:  $('theme-spread-val'),
      hint:     $('theme-harmony-hint'),
      lvar:     $('theme-lvar'),     lvarV:    $('theme-lvar-val'),
      cvar:     $('theme-cvar'),     cvarV:    $('theme-cvar-val'),
      speed:    $('theme-speed'),    speedV:   $('theme-speed-val'),
      blur:     $('theme-blur'),     blurV:    $('theme-blur-val'),
      tint:     $('theme-tint'),     tintV:    $('theme-tint-val'),
      backdrop: $('theme-backdrop'), backdropV: $('theme-backdrop-val'),
      swatches: $('theme-swatches'), markers:  $('wheel-markers'),
      wheel:    $('theme-wheel'),    meta:     $('theme-meta'),
      panel:    $('theme-panel'),    btn:      $('theme-btn'),
      close:    $('theme-close'),    reset:    $('theme-reset'),
    };

    // Best-effort name for the current (count, spread) combination —
    // labels purely informational, not used for state.
    function harmonyLabel() {
      const n = state.count, s = state.spread;
      if (n === 1) return 'mono';
      if (n === 2) {
        if (s >= 170 && s <= 190) return 'complementary';
        if (s < 60) return 'split-mono';
        return null;
      }
      if (n === 3) {
        if (s <= 60)            return 'analogous';
        if (s >= 230 && s <= 250) return 'triadic';
        if (s >= 170 && s <= 210) return 'split-complementary';
      }
      if (n === 4 && s >= 260 && s <= 280) return 'tetradic';
      return null;
    }

    // Compute one palette color (OKLCH triple) for index i.
    function colorAt(i) {
      const off = state.offsets[i] || 0;
      const h = (state.H + off + 360) % 360;
      const sign = i === 0 ? 0 : (i % 2 === 1 ? 1 : -1);
      const L = Math.max(5, Math.min(98, state.L + sign * state.lVar));
      const C = Math.max(0, state.C + sign * state.cVar);
      return { L, C, H: h };
    }
    function ok(c) { return `oklch(${c.L}% ${c.C.toFixed(3)} ${c.H.toFixed(1)})`; }

    function applyCSSVars() {
      const root = document.documentElement.style;
      for (let i = 0; i < 5; i++) {
        const c = colorAt(i % state.offsets.length);
        root.setProperty(`--c${i + 1}`, ok(c));
      }
      const primary = colorAt(0);
      root.setProperty('--accent',      ok(primary));
      root.setProperty('--accent-soft',
        `oklch(${primary.L}% ${primary.C.toFixed(3)} ${primary.H.toFixed(1)} / 0.15)`);
      root.setProperty('--theme-l', `${state.L}%`);
      // Unitless version for math in calc() (e.g. clamp ramps).
      // var(--theme-l) is "70%" — usable directly inside oklch but
      // not as a unitless input to calc steps; --theme-l-num is the
      // raw number (e.g. 70).
      root.setProperty('--theme-l-num', state.L);
      root.setProperty('--theme-c', state.C);
      root.setProperty('--theme-h', state.H);
      // Unitless H, parallel to --theme-l-num — used by the KNN
      // scatter's cluster-color generator (it needs to do
      // arithmetic on H, can't with a degree-suffixed value).
      root.setProperty('--theme-h-num', state.H);
      root.setProperty('--anim-speed', state.animSpeed);
      // Only apply the filter when there's something to blur — at
      // zero, applying `filter: blur(0px)` still creates a stacking
      // context and forces a compositor layer that can flatten 3D
      // children and produce artifacts on neighboring elements.
      if (state.bgBlur > 0) {
        root.setProperty('--bg-filter', `blur(${state.bgBlur}px)`);
      } else {
        root.removeProperty('--bg-filter');
      }
      root.setProperty('--tint-strength', state.tint);
      root.setProperty('--content-backdrop', state.contentBackdrop);
      document.body.dataset.theme = state.background;
      applyThemeSpecificVars();
    }

    // Per-theme CSS variables derived from state.themes.<name>. Only
    // the variables for the currently-active theme need to be set,
    // but it's cheap to set them all so the dropdown switch is
    // instant.
    function applyThemeSpecificVars() {
      const root = document.documentElement.style;
      const t = state.themes;
      // drift
      root.setProperty('--drift-blur',    `${t.drift.blur}px`);
      root.setProperty('--drift-opacity', t.drift.opacity);
      // vaporwave
      root.setProperty('--vw-grid-size',   `${t.vaporwave.gridSize}px`);
      root.setProperty('--vw-perspective', `${t.vaporwave.perspective}deg`);
      root.setProperty('--vw-sun-size',    `${t.vaporwave.sunSize}vmin`);
      root.setProperty('--vw-horizon',     `${t.vaporwave.horizonPct}%`);
      root.setProperty('--vw-blind',       `${t.vaporwave.blindWidth}px`);
      // laser
      root.setProperty('--laser-thickness', `${t.laser.thickness}px`);
      root.setProperty('--laser-blur',      `${t.laser.blur}px`);
      root.setProperty('--laser-opacity',   t.laser.opacity);
      // lava goo filter — has to be set on the SVG element directly
      const fb = document.querySelector('#goo feGaussianBlur');
      const fc = document.querySelector('#goo feColorMatrix');
      if (fb) fb.setAttribute('stdDeviation', String(t.lava.gooBlur));
      if (fc) {
        const a = t.lava.gooStr;
        const b = Math.round(a / 2 + 1);
        fc.setAttribute('values',
          `1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${a} -${b}`);
      }
    }

    function applyForm() {
      els.bg.value      = state.background;
      els.L.value       = state.L;        els.Lv.textContent      = state.L;
      els.C.value       = state.C;        els.Cv.textContent      = state.C.toFixed(3);
      els.H.value       = state.H;        els.Hv.textContent      = Math.round(state.H);
      els.count.value   = state.count;    els.countV.textContent  = state.count;
      els.spread.value  = state.spread;   els.spreadV.textContent = Math.round(state.spread);
      els.lvar.value    = state.lVar;     els.lvarV.textContent   = state.lVar;
      els.cvar.value    = state.cVar;     els.cvarV.textContent   = state.cVar.toFixed(3);
      els.speed.value    = state.animSpeed;       els.speedV.textContent    = state.animSpeed.toFixed(2);
      els.blur.value     = state.bgBlur;          els.blurV.textContent     = state.bgBlur;
      els.tint.value     = state.tint;            els.tintV.textContent     = state.tint.toFixed(2);
      els.backdrop.value = state.contentBackdrop; els.backdropV.textContent = state.contentBackdrop.toFixed(2);

      const label = harmonyLabel();
      els.hint.textContent = label ? `≈ ${label}` : '· custom';

      const primary = colorAt(0);
      els.meta.textContent = ok(primary);

      els.swatches.innerHTML = '';
      for (let i = 0; i < state.offsets.length; i++) {
        const c = colorAt(i);
        const s = document.createElement('div');
        s.className = 'theme-swatch';
        s.style.background = ok(c);
        s.title = ok(c);
        els.swatches.appendChild(s);
      }
    }

    // Marker rendering: full rebuild (called when offsets array
    // length changes, e.g. on harmony switch) vs. in-place update
    // (called every other time, including during drag — does NOT
    // tear down DOM, so pointer capture stays alive).
    const RADIUS = 84;
    function positionMarker(m, i) {
      const c = colorAt(i);
      const angleRad = (c.H - 90) * Math.PI / 180;
      const x = 100 + RADIUS * Math.cos(angleRad);
      const y = 100 + RADIUS * Math.sin(angleRad);
      m.style.left = x.toFixed(1) + 'px';
      m.style.top  = y.toFixed(1) + 'px';
      m.style.background = ok(c);
    }
    function renderMarkersFull() {
      els.markers.innerHTML = '';
      state.offsets.forEach((_, i) => {
        const m = document.createElement('div');
        m.className = 'wheel-marker' + (i === 0 ? ' primary' : '');
        m.dataset.idx = String(i);
        m.title = i === 0
          ? 'primary · drag to rotate the whole palette'
          : 'drag to move just this color';
        positionMarker(m, i);
        m.addEventListener('pointerdown', startDrag);
        els.markers.appendChild(m);
      });
    }
    function updateMarkers() {
      const ms = els.markers.querySelectorAll('.wheel-marker');
      ms.forEach((m, i) => positionMarker(m, i));
    }

    function apply({ rebuildMarkers = false, rebuildBg = false } = {}) {
      applyCSSVars();
      applyForm();
      if (rebuildMarkers) renderMarkersFull();
      else updateMarkers();
      if (rebuildBg) rebuildBackgroundElements();
      showActiveBgSection();
      try { localStorage.setItem('cis490-theme', JSON.stringify(state)); } catch {}
    }

    // ── Per-theme background element generation ───────────────────
    // drift / lava / laser each have collections of small DOM nodes
    // whose count and individual properties depend on per-theme
    // settings. Rebuild only when count or statistical-spread params
    // change; styling-only updates (blur, opacity, thickness) flow
    // through CSS variables and don't need a rebuild.

    // Box-Muller for size variance.
    function gauss(mean, std) {
      let u = 0, v = 0;
      while (u === 0) u = Math.random();
      while (v === 0) v = Math.random();
      return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
    }

    // Graceful removal helpers. retireOnIteration waits for the
    // element's animation to complete its current cycle, then
    // removes it — the "back to translateY(0)" frame of the rise
    // animations is offscreen, so removal is invisible. fadeOut
    // transitions opacity to 0 over 800ms then removes; used for
    // lasers (no offscreen moment in their continuous rotation).
    function retireOnIteration(el) {
      let removed = false;
      const kill = () => { if (!removed) { removed = true; el.remove(); } };
      el.addEventListener('animationiteration', kill, { once: true });
      // Safety net: enforce removal after a generous max so a
      // setting-spam doesn't leak old nodes forever.
      setTimeout(() => { if (el.isConnected) kill(); }, 60_000);
    }
    function fadeOutAndRemove(el) {
      el.style.transition = 'opacity 800ms ease-out';
      requestAnimationFrame(() => { el.style.opacity = '0'; });
      setTimeout(() => { if (el.isConnected) el.remove(); }, 900);
    }

    function makeDriftBlob(i) {
      const t = state.themes.drift;
      const b = document.createElement('span');
      b.className = 'drift-blob';
      b.style.width  = b.style.height = `${t.sizeVw}vw`;
      b.style.left   = `${(Math.random() * 90).toFixed(1)}%`;
      b.style.bottom = `-${(15 + Math.random() * 25).toFixed(0)}%`;
      b.style.background = `var(--c${(i % 5) + 1})`;
      const dur   = (14 + Math.random() * 16).toFixed(1);
      const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
      b.style.animation =
        `drift-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
      return b;
    }
    function makeLavaBubble(i) {
      const t = state.themes.lava;
      const b = document.createElement('span');
      b.className = 'goo-bubble';
      const sz = Math.max(2, Math.min(28, gauss(t.sizeMean, t.sizeSpread)));
      b.style.width = b.style.height = `${sz.toFixed(1)}vw`;
      b.style.left  = `${(Math.random() * 92).toFixed(1)}%`;
      b.style.bottom = `-${(sz + 4).toFixed(1)}vw`;
      b.style.background = `var(--c${(i % 5) + 1})`;
      const dur   = (14 + Math.random() * 14).toFixed(1);
      const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
      b.style.animation =
        `goo-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
      return b;
    }
    function makeLaserBeam(i) {
      const b = document.createElement('span');
      b.className = 'laser-beam';
      b.style.background =
        `linear-gradient(90deg, transparent, var(--c${(i % 5) + 1}), transparent)`;
      const dur = (8 + Math.random() * 12).toFixed(1);
      const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
      const dir = i % 2 === 0 ? 'normal' : 'reverse';
      b.style.animation =
        `laser-sweep calc(${dur}s / var(--anim-speed, 1)) linear ${delay}s infinite ${dir}`;
      return b;
    }

    function rebuildDrift() {
      const root = document.getElementById('bg-drift');
      if (!root) return;
      // Phase out existing blobs at end of their current cycle —
      // they finish their visible rise/fall before vanishing instead
      // of all popping out of existence at once.
      Array.from(root.children).forEach(retireOnIteration);
      const t = state.themes.drift;
      for (let i = 0; i < t.count; i++) root.appendChild(makeDriftBlob(i));
    }
    function rebuildLava() {
      const root = document.getElementById('bg-lava-bubbles');
      if (!root) return;
      Array.from(root.children).forEach(retireOnIteration);
      const t = state.themes.lava;
      for (let i = 0; i < t.count; i++) root.appendChild(makeLavaBubble(i));
    }
    function rebuildLaser() {
      const root = document.getElementById('bg-laser-beams');
      if (!root) return;
      // Lasers have no offscreen moment in their rotation, so fade.
      Array.from(root.children).forEach(fadeOutAndRemove);
      const t = state.themes.laser;
      for (let i = 0; i < t.count; i++) root.appendChild(makeLaserBeam(i));
    }

    function rebuildBackgroundElements() {
      rebuildDrift();
      rebuildLava();
      rebuildLaser();
    }

    // (Vaporwave floor is now CSS-only again — see the .vw-floor*
    //  rules in dashboard.css. Heavy compositor-layer hints
    //  prevent the scroll-induced re-rasterization.)

    // ── Drag handling ──────────────────────────────────────────────
    // Primary marker (idx 0): drag rotates the entire palette
    // (changes state.H). Other markers: drag moves just that
    // marker (changes state.offsets[i]). pointermove is on the
    // captured marker itself, NOT the document, so we keep capture
    // even if the cursor leaves the wheel area.
    let dragMarker = null;
    let dragIdx = -1;
    let dragIsPrimary = false;
    function startDrag(e) {
      e.preventDefault();
      e.stopPropagation();
      dragMarker = e.currentTarget;
      dragIdx = parseInt(dragMarker.dataset.idx, 10);
      dragIsPrimary = (dragIdx === 0);
      dragMarker.classList.add('dragging');
      try { dragMarker.setPointerCapture(e.pointerId); } catch {}
      dragMarker.addEventListener('pointermove', onDrag);
      dragMarker.addEventListener('pointerup', endDrag, { once: true });
      dragMarker.addEventListener('pointercancel', endDrag, { once: true });
    }
    function angleFromCenter(clientX, clientY) {
      const r = els.wheel.getBoundingClientRect();
      const cx = r.left + r.width / 2;
      const cy = r.top  + r.height / 2;
      let a = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90;
      if (a < 0) a += 360;
      return a;
    }
    function onDrag(e) {
      const angle = angleFromCenter(e.clientX, e.clientY);
      if (dragIsPrimary) {
        state.H = (angle + 360) % 360;
      } else {
        // Marker's absolute hue should equal `angle`; its hue is
        // (H + offset). So offset = angle - H, normalized to -180..180.
        const target = (angle - state.H + 540) % 360 - 180;
        state.offsets[dragIdx] = target;
      }
      apply();
    }
    function endDrag(e) {
      if (!dragMarker) return;
      dragMarker.classList.remove('dragging');
      dragMarker.removeEventListener('pointermove', onDrag);
      try { dragMarker.releasePointerCapture(e.pointerId); } catch {}
      dragMarker = null; dragIdx = -1;
    }

    // ── Panel show/hide + hotkey ───────────────────────────────────
    // Toggle via the `is-open` class (not the hidden attribute) so
    // the slide-in transform transition is preserved in both
    // directions.
    function togglePanel(force) {
      const willShow = typeof force === 'boolean'
        ? force
        : !els.panel.classList.contains('is-open');
      els.panel.classList.toggle('is-open', willShow);
      els.btn.classList.toggle('active', willShow);
    }
    window.addEventListener('keydown', e => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const tag = (e.target && e.target.tagName) || '';
      if (tag === 'INPUT' || tag === 'TEXTAREA' ||
          (e.target && e.target.isContentEditable)) return;
      if (e.key === 't' || e.key === 'T') {
        e.preventDefault(); togglePanel();
      }
    });

    els.btn.addEventListener('click',   e => { e.stopPropagation(); togglePanel(); });
    els.close.addEventListener('click', e => { e.stopPropagation(); togglePanel(false); });
    els.reset.addEventListener('click', e => {
      e.stopPropagation();
      Object.assign(state, DEFAULTS);
      state.offsets = evenOffsets(state.count, state.spread);
      apply({ rebuildMarkers: true });
    });

    els.bg.addEventListener('change', e => { state.background = e.target.value; apply(); });

    // ── Per-theme settings panel ──────────────────────────────────
    // Build one section per theme (each a <details>) inside
    // #theme-bg-settings. CSS hides all but the active one (via the
    // `.is-active` class managed by showActiveBgSection).
    const $bgSettings = document.getElementById('theme-bg-settings');
    for (const themeName in THEMES) {
      const spec = THEMES[themeName];
      const section = document.createElement('details');
      section.className = 'theme-advanced theme-bg-section';
      section.dataset.theme = themeName;
      section.open = true;

      const summary = document.createElement('summary');
      summary.textContent = spec.label;
      section.appendChild(summary);

      const sliders = document.createElement('div');
      sliders.className = 'theme-sliders';
      for (const key in spec.params) {
        const p = spec.params[key];
        const id = `theme-${themeName}-${key}`;
        const v = state.themes[themeName][key];
        const fmt = p.step >= 1 ? Math.round(v) : v.toFixed(p.step >= 0.1 ? 2 : 3);
        const label = document.createElement('label');
        label.innerHTML =
          `${p.label} · <span id="${id}-val">${fmt}</span>` +
          `<input type="range" id="${id}" min="${p.min}" max="${p.max}" ` +
          `value="${v}" step="${p.step}">`;
        sliders.appendChild(label);
      }
      section.appendChild(sliders);
      $bgSettings.appendChild(section);

      // Bind inputs after they're in the DOM.
      for (const key in spec.params) {
        const p = spec.params[key];
        const id = `theme-${themeName}-${key}`;
        const input = document.getElementById(id);
        const valSpan = document.getElementById(`${id}-val`);
        if (!input || !valSpan) continue;
        input.addEventListener('input', e => {
          const v = parseFloat(e.target.value);
          state.themes[themeName][key] = v;
          valSpan.textContent = p.step >= 1 ? Math.round(v) :
            v.toFixed(p.step >= 0.1 ? 2 : 3);
          apply({ rebuildBg: !!p.rebuild });
        });
      }
    }
    function showActiveBgSection() {
      document.querySelectorAll('.theme-bg-section').forEach(s => {
        s.classList.toggle('is-active', s.dataset.theme === state.background);
      });
    }

    // count + spread together regenerate offsets to an evenly-fanned
    // distribution. Any prior per-marker drags are overwritten —
    // the user is asking for a clean palette by touching these.
    function regenAndApply() {
      state.offsets = evenOffsets(state.count, state.spread);
      apply({ rebuildMarkers: true });
    }
    els.count.addEventListener('input', e => {
      state.count = Math.max(1, Math.min(6, parseInt(e.target.value, 10)));
      regenAndApply();
    });
    els.spread.addEventListener('input', e => {
      state.spread = Math.max(0, Math.min(300, parseFloat(e.target.value)));
      regenAndApply();
    });

    const bind = (input, setter) => input.addEventListener('input',
      e => { setter(parseFloat(e.target.value)); apply(); });
    bind(els.L,    v => state.L         = v);
    bind(els.C,    v => state.C         = v);
    bind(els.H,    v => state.H         = v);
    bind(els.lvar, v => state.lVar      = v);
    bind(els.cvar, v => state.cVar      = v);
    bind(els.speed,    v => state.animSpeed       = v);
    bind(els.blur,     v => state.bgBlur          = v);
    bind(els.tint,     v => state.tint            = v);
    bind(els.backdrop, v => state.contentBackdrop = v);

    // Stop panel interactions from bubbling to stage-col click-to-advance.
    els.panel.addEventListener('click',       e => e.stopPropagation());
    els.panel.addEventListener('pointerdown', e => e.stopPropagation());

    apply({ rebuildMarkers: true, rebuildBg: true });
  })();

  // ────────────────────────────────────────────────────────────────
  // 4. Widgets
  // ────────────────────────────────────────────────────────────────

  // ── Stack scene · pyproject.toml + receiver/app.py header ─────
  // Static content. The point is to surface the "stdlib-first,
  // every dep annotated" stance to the audience without making them
  // open a terminal.
  (function () {
    const PYPROJECT = `# Single project, three install profiles. The base "dependencies"
# list is what every host needs (the receiver, the orchestrator,
# the dashboard); training and dev pull in heavier tooling on demand.
[project]
name = "cis490"
version = "0.0.1"
description = "CIS490 behavioral malware detection — dataset, transport, training"
requires-python = ">=3.11"

# Runtime: HTTP receiver + orchestrator + image build.
dependencies = [
    "starlette>=0.36",       # ASGI app for the receiver and dashboard
    "uvicorn[standard]>=0.27",  # production-grade ASGI server
    "msgpack>=1.0",          # MSF RPC wire format (Tier-3 exploit driver)
    "pycdlib>=1.14",          # build NoCloud cidata ISOs in pure Python
]

[dependency-groups]
# Pulled in only when training. Kept off the receiver Pi.
training = [
    "pyarrow>=15", "polars>=1.0",  # columnar dataset I/O
    "numpy>=1.26", "scipy>=1.11",
    "scikit-learn>=1.4",     # KNN, KMeans, PCA, metrics
    "xgboost>=2.0",           # gradient-boosted trees baseline
    "torch>=2.2",             # LSTM / GRU / RNN / CNN / Transformer
    "zstandard>=0.22",        # streams episode tarballs without buffering
]
dev = [
    "pytest>=8", "pytest-asyncio>=0.23",  # async-aware test runner
    "httpx>=0.27", "paramiko>=3",         # in-guest HTTP / SSH for tests
    "matplotlib>=3.8", "tornado>=6",      # plotting (training reports)
]
`;
    const RECEIVER = `# The receiver is the public-facing endpoint that ingests episode
# tarballs from fleet hosts. Starlette ASGI for the HTTP surface;
# everything else is intentionally stdlib.
from __future__ import annotations

import json, logging, secrets, time
from pathlib import Path

from starlette.applications import Starlette
from starlette.responses import JSONResponse, Response
from starlette.routing import Route

# Per-host episodes get streamed onto disk by the EpisodeStore;
# version_gate rejects schemas the analysis pipeline can't read.
from .store import EpisodeStore, is_valid_id
from .version_gate import VersionGate

log = logging.getLogger("cis490.receiver")
SUFFIX = ".tar.zst"   # zstd-compressed tar — what the fleet ships
SCHEMA_VERSION = 1     # bumped if the on-disk format changes

# Authenticate every upload with a shared bearer token. The
# constant-time compare matters: a naive == leaks token length and
# byte-by-byte progress through timing, which a careful attacker
# can use to recover the secret one character at a time.
def _bearer_check(request, expected):
    if expected is None:
        return None  # auth disabled (dev mode)
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        return JSONResponse({"error": "missing bearer token"}, status_code=401)
    presented = auth[len("Bearer "):]
    if not secrets.compare_digest(presented, expected):
        return JSONResponse({"error": "bad bearer token"}, status_code=401)
    return None  # auth ok — caller proceeds
`;

    const PY_KEYWORDS = new Set([
      'from', 'import', 'def', 'class', 'return', 'async', 'await',
      'if', 'else', 'elif', 'for', 'while', 'in', 'as', 'with',
      'lambda', 'None', 'True', 'False', 'raise', 'try', 'except',
      'finally', 'yield', 'global', 'nonlocal', 'pass', 'break',
      'continue', 'not', 'and', 'or', 'is', 'self',
    ]);

    function escapeHtml(s) {
      return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }

    // Find the first `#` that's not inside a string literal.
    function findCommentStart(line) {
      let inString = false;
      for (let i = 0; i < line.length; i++) {
        const ch = line[i];
        if (inString) {
          if (ch === '\\') { i++; continue; }
          if (ch === inString) inString = false;
        } else if (ch === '"' || ch === "'") {
          inString = ch;
        } else if (ch === '#') {
          return i;
        }
      }
      return -1;
    }

    // Char-by-char Python tokenizer. Handles strings, identifiers,
    // numbers — enough for an imports block. Keyword regex would
    // fire inside strings; we tokenize properly to avoid that.
    function tokenizePython(s) {
      let out = '';
      let i = 0;
      while (i < s.length) {
        const ch = s[i];
        if (ch === '"' || ch === "'") {
          const quote = ch;
          let j = i + 1;
          while (j < s.length && s[j] !== quote) {
            if (s[j] === '\\') j++;
            j++;
          }
          out += `<span class="str">${s.slice(i, j + 1)}</span>`;
          i = j + 1;
        } else if (/[A-Za-z_]/.test(ch)) {
          let j = i;
          while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;
          const word = s.slice(i, j);
          if (PY_KEYWORDS.has(word)) out += `<span class="kw">${word}</span>`;
          else out += word;
          i = j;
        } else if (/\d/.test(ch)) {
          let j = i;
          while (j < s.length && /[\d.]/.test(s[j])) j++;
          out += `<span class="num">${s.slice(i, j)}</span>`;
          i = j;
        } else {
          out += ch;
          i++;
        }
      }
      return out;
    }

    function highlightPython(code) {
      return escapeHtml(code).split('\n').map(line => {
        const idx = findCommentStart(line);
        const codePart = idx >= 0 ? line.slice(0, idx) : line;
        const comment  = idx >= 0 ? line.slice(idx) : '';
        return tokenizePython(codePart) +
          (comment ? `<span class="com">${comment}</span>` : '');
      }).join('\n');
    }

    function highlightToml(code) {
      return escapeHtml(code).split('\n').map(line => {
        const idx = findCommentStart(line);
        const codePart = idx >= 0 ? line.slice(0, idx) : line;
        const comment  = idx >= 0 ? line.slice(idx) : '';
        // Order matters: section headers, then key=, then strings,
        // then numbers. Each replace operates on the result of the
        // previous so we don't double-wrap.
        const coded = codePart
          .replace(/(\[[^\]\n]+\])/g, '<span class="ty">$1</span>')
          .replace(/^([A-Za-z_][\w-]*)(\s*=)/, '<span class="fn">$1</span>$2')
          .replace(/("[^"\n]*"|'[^'\n]*')/g, '<span class="str">$1</span>')
          .replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="num">$1</span>');
        return coded +
          (comment ? `<span class="com">${comment}</span>` : '');
      }).join('\n');
    }

    const TRAINER = `"""Long Short-Term Memory over channel × time windows.

Same input/output as GRU, swap the cell. ~30% more parameters than
the GRU at the same hidden size; included so the comparison report
can speak to the cell-choice question."""
from __future__ import annotations
from torch import nn

# The registry lets the trainer pick a model by string name from
# the training manifest. _SeqBase handles the shared bookkeeping
# (feature selection, standardization, checkpoint I/O) so each
# model class only writes its architecture.
from training.models import register
from training.models._torch_seq import _SeqBase


@register("lstm")
class LSTM(_SeqBase):
    # _build_module is called once per training run with shapes
    # derived from the actual dataset, not hardcoded constants —
    # so the same model class works at any window length / channel
    # count. Defaults reflect what produced the leaderboard numbers.
    def _build_module(self, *, n_channels_in, n_timesteps,
                      n_classes, hidden=128, n_layers=2,
                      dropout=0.1, bidirectional=False):
        return _LSTMClassifier(
            n_channels_in=n_channels_in, n_classes=n_classes,
            hidden=hidden, n_layers=n_layers,
            dropout=dropout, bidirectional=bidirectional,
        )


# Plain PyTorch module; the wrapper above is what the rest of the
# pipeline talks to. Splitting them keeps the model architecture
# pure-torch and easy to inspect / swap.
class _LSTMClassifier(nn.Module):
    def __init__(self, *, n_channels_in, n_classes, hidden,
                 n_layers, dropout, bidirectional):
        super().__init__()
        # batch_first=True so the tensor flows as (batch, time,
        # channels), matching the dataloader layout. Stacking layers
        # with dropout-between is only meaningful when n_layers > 1.
        self.lstm = nn.LSTM(
            input_size=n_channels_in, hidden_size=hidden,
            num_layers=n_layers,
            dropout=dropout if n_layers > 1 else 0.0,
            batch_first=True, bidirectional=bidirectional,
        )
        # Bidirectional LSTMs concat forward + backward states, so
        # the head sees 2× hidden when that flag is on.
        d_out = hidden * (2 if bidirectional else 1)
        # Dropout before the linear head is a cheap regularizer
        # without changing the LSTM's own behaviour.
        self.head = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(d_out, n_classes),
        )

    # Dataset gives (batch, channels, time). Transpose to put time
    # in the middle so PyTorch's batch_first LSTM accepts it.
    def forward(self, x):                # (B, C, T) -> (B, T, C)
        x = x.transpose(1, 2)
        out, _ = self.lstm(x)            # out: (B, T, hidden*dir)
        # Use the last timestep's hidden state for classification —
        # by then the LSTM has integrated the whole window.
        return self.head(out[:, -1, :])
`;

    const TRAIN_LOOP = `# One generic loop runs every neural model. The model class only
# defines architecture; this loop owns the optimizer, learning-rate
# schedule, mixed precision, gradient clipping, and the early-stop
# bookkeeping. Same code trains LSTM, GRU, CNN, Transformer.
def train_nn(*, model, X_train, y_train, X_val, y_val,
             n_classes, epochs=60, batch_size=512,
             base_lr=1e-3, weight_decay=1e-4,
             warmup_frac=0.05, grad_clip=1.0,
             patience=8, device="auto") -> TrainResult:
    """Train a model; return TrainResult with the best-on-val
    state_dict already loaded back into model.module."""
    # Auto-pick CUDA when present so the same script runs on the
    # Pi (CPU) and the A100 (GPU + AMP) without code changes.
    if device == "auto":
        device = "cuda" if torch.cuda.is_available() else "cpu"
    use_amp = device == "cuda"
    mod = model.module.to(device)

    # Inverse-frequency class weights (capped). The dataset is
    # ~50% infected_running and only ~5% armed — without weighting,
    # CE happily ignores the rare classes and reports "good"
    # accuracy by predicting the majority class for everything.
    cw = _compute_class_weights(y_train, n_classes)
    loss_fn = nn.CrossEntropyLoss(
        weight=torch.from_numpy(cw).to(device))

    # AdamW = Adam with decoupled weight decay; cleaner regularisation
    # than L2-in-the-loss for transformers and recurrent nets.
    opt = torch.optim.AdamW(mod.parameters(), lr=base_lr,
                             weight_decay=weight_decay)
    # GradScaler enables mixed-precision training on CUDA: most ops
    # run in fp16 (faster, less memory) but the scaler keeps
    # gradients in a safe range so they don't underflow to zero.
    scaler = torch.amp.GradScaler("cuda") if use_amp else None

    best_f1, best_state, no_improve = -1.0, None, 0
    step, total_steps = 0, epochs * len(train_dl)
    warmup = int(total_steps * warmup_frac)  # 5% of total = warmup

    for ep in range(1, epochs + 1):
        mod.train()
        for xb, yb in train_dl:
            xb, yb = xb.to(device), yb.to(device)
            # Cosine schedule with a linear warmup. Warmup avoids
            # the early-training "loss explodes from a fresh AdamW"
            # problem; cosine then anneals smoothly toward zero.
            for g in opt.param_groups:
                g["lr"] = _cosine_lr(step,
                    total_steps=total_steps,
                    warmup_steps=warmup, base_lr=base_lr)
            opt.zero_grad(set_to_none=True)  # cheaper than zero_()
            if use_amp:
                # AMP path: forward in autocast, scaler handles
                # backward + step so fp16 grads don't underflow.
                with torch.amp.autocast("cuda"):
                    loss = loss_fn(mod(xb), yb)
                scaler.scale(loss).backward()
                scaler.unscale_(opt)
                # Grad clip after unscale — recurrent nets can spike
                # gradients early in training; clipping keeps them sane.
                nn.utils.clip_grad_norm_(mod.parameters(), grad_clip)
                scaler.step(opt); scaler.update()
            else:
                # CPU / fp32 path — no scaler bookkeeping needed.
                loss = loss_fn(mod(xb), yb)
                loss.backward()
                nn.utils.clip_grad_norm_(mod.parameters(), grad_clip)
                opt.step()
            step += 1

        # Track the held-out-by-host macro-F1, NOT accuracy. With
        # imbalanced classes a constant predictor can hit 0.5
        # accuracy; macro-F1 averages per-class F1, so the rare
        # phases actually count.
        f1 = _macro_f1(y_val, _predict(mod, val_dl), n_classes)
        if f1 > best_f1 + 1e-4:
            # New best — snapshot the weights. Cheaper than checkpointing
            # to disk every epoch since we only need the final winner.
            best_f1, best_state, no_improve = f1, mod.state_dict(), 0
        else:
            # No improvement; tick the patience counter.
            no_improve += 1
            if no_improve >= patience:
                break    # early stop — saves an A100-hour or two

    # Restore the best-on-val weights. The last epoch's weights are
    # almost always worse than the best — overfit creep on train.
    mod.load_state_dict(best_state)
    return TrainResult(best_f1=best_f1, best_state=best_state, ...)
`;

    document.getElementById('code-pyproject').innerHTML   = highlightToml(PYPROJECT);
    document.getElementById('code-receiver').innerHTML    = highlightPython(RECEIVER);
    document.getElementById('code-train-lstm').innerHTML  = highlightPython(TRAINER);
    document.getElementById('code-train-loop').innerHTML  = highlightPython(TRAIN_LOOP);
  })();

  // ── Ingest counter + 60-second sparkline ──────────────────────
  // Real-data widget: populated by snapshot + episode events. No
  // demo gating — when demo is off, it just shows real activity.
  (function () {
    const totalEl = document.getElementById('ingest-total');
    const rateEl  = document.getElementById('ingest-rate');
    const bytesEl = document.getElementById('ingest-bytes');
    const pathEl  = document.getElementById('ingest-spark-path');
    const fillEl  = document.getElementById('ingest-spark-fill');
    const W = 600, H = 120, BUCKETS = 60;
    const buckets = new Array(BUCKETS).fill(0);
    let total = 0, totalBytes = 0;

    function bucketIndex() { return Math.floor(Date.now() / 1000) % BUCKETS; }
    let lastBucket = bucketIndex();

    function rotateIfNeeded() {
      const cur = bucketIndex();
      while (lastBucket !== cur) {
        lastBucket = (lastBucket + 1) % BUCKETS;
        buckets[lastBucket] = 0;
      }
    }

    function fmtBytes(n) {
      if (!n) return '0 B';
      const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
      while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
      return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i];
    }

    function render() {
      rotateIfNeeded();
      totalEl.textContent = total.toLocaleString();
      const sum = buckets.reduce((a, b) => a + b, 0);
      rateEl.textContent = (sum / BUCKETS).toFixed(1);
      if (bytesEl) bytesEl.textContent = fmtBytes(totalBytes);
      const max = Math.max(1, ...buckets);
      const pts = [];
      for (let i = 0; i < BUCKETS; i++) {
        const idx = (lastBucket + 1 + i) % BUCKETS;
        const x = (i / (BUCKETS - 1)) * W;
        const y = H - (buckets[idx] / max) * (H - 8) - 4;
        pts.push([x, y]);
      }
      const d = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
      pathEl.setAttribute('d', d);
      fillEl.setAttribute('d', `${d} L${W},${H} L0,${H} Z`);
    }

    on('snapshot', m => {
      if (typeof m.total_episodes === 'number') total = m.total_episodes;
      if (typeof m.total_bytes === 'number') totalBytes = m.total_bytes;
      render();
    });
    on('episode', m => {
      rotateIfNeeded();
      buckets[lastBucket] += 1;
      total += 1;
      if (typeof m.size_bytes === 'number') totalBytes += m.size_bytes;
      render();
    });
    setInterval(render, 1000);
    render();
  })();

  // ── Per-host bars ─────────────────────────────────────────────
  // Real-data widget. Snapshot seeds absolute counts; episode events
  // increment.
  (function () {
    const root = document.getElementById('host-bars');
    const counts = new Map();
    const rows = new Map();

    function clearEmpty() {
      const e = root.querySelector('.bars-empty, .awaiting');
      if (e) e.remove();
    }
    function ensureRow(host) {
      if (rows.has(host)) return rows.get(host);
      clearEmpty();
      const row = document.createElement('div'); row.className = 'bar-row';
      const name = document.createElement('div'); name.className = 'bar-host'; name.textContent = host;
      const track = document.createElement('div'); track.className = 'bar-track';
      const fill  = document.createElement('div'); fill.className = 'bar-fill'; fill.style.width = '0%';
      track.appendChild(fill);
      const label = document.createElement('div'); label.className = 'bar-count'; label.textContent = '0';
      row.append(name, track, label);
      root.appendChild(row);
      const entry = { row, fill, label };
      rows.set(host, entry); return entry;
    }
    function render() {
      const max = Math.max(1, ...counts.values());
      Array.from(counts.keys()).forEach(h => {
        const r = ensureRow(h);
        const c = counts.get(h);
        r.fill.style.width = ((c / max) * 100).toFixed(1) + '%';
        r.label.textContent = c.toLocaleString();
      });
      Array.from(counts.keys()).sort((a, b) => counts.get(b) - counts.get(a))
        .forEach(h => root.appendChild(rows.get(h).row));
    }

    on('snapshot', m => {
      if (m.host_counts && typeof m.host_counts === 'object') {
        Object.entries(m.host_counts).forEach(([h, c]) => counts.set(h, c));
        render();
      }
    });
    on('episode', m => {
      if (!m.host_id) return;
      counts.set(m.host_id, (counts.get(m.host_id) || 0) + 1); render();
    });
  })();

  // ── Phase mix (dataset-derived) ───────────────────────────────
  // Real-data widget. Driven by the dashboard's `phase_mix` feeder,
  // which periodically samples random episode tarballs on disk and
  // aggregates labels.jsonl phase durations across them. The feeder
  // tucks the result into `broadcaster.state["phase_mix"]` so it
  // arrives in the snapshot the WS sends on connect, and republishes
  // a `phase_mix` event each time it recomputes.
  (function () {
    const stack = document.getElementById('phase-stack');
    const legend = document.getElementById('phase-legend');
    const eyebrow = document.getElementById('phase-mix-eyebrow');
    const sub = document.getElementById('phase-mix-sub');
    const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
    const segs = new Map();

    PHASES.forEach(p => {
      const seg = document.createElement('div');
      seg.className = `phase-seg ${p}`; stack.appendChild(seg); segs.set(p, seg);
      const li = document.createElement('span');
      const swatchVar = p === 'infected_running' ? 'running' : p;
      li.innerHTML = `<span class="swatch" style="background:var(--phase-${swatchVar})"></span>${p}`;
      legend.appendChild(li);
    });

    function fmtInt(n) { return (typeof n === 'number') ? n.toLocaleString() : '—'; }

    // Tracks whether real phase_mix data has arrived. demo_start uses
    // this to decide whether to inject a synthetic fallback — if real
    // data is already present (from snapshot or the phase_mix feeder),
    // the demo toggle does NOT clobber it.
    let hasRealMix = false;

    function applyMix(mix) {
      if (!mix) return;
      hasRealMix = true;
      const w = mix.weighted_seconds || {};
      const c = mix.counts || {};
      // Prefer time-weighted proportions; fall back to label counts.
      const useWeighted = Object.values(w).some(v => v > 0);
      const src = useWeighted ? w : c;
      // Sum only the canonical phases so non-displayed phases (e.g.
      // `failed` from the orchestrator) don't shrink the visible bars.
      const total = PHASES.reduce((a, p) => a + (src[p] || 0), 0) || 1;
      PHASES.forEach(p => {
        segs.get(p).style.flexGrow = ((src[p] || 0) / total).toFixed(4);
      });
      if (eyebrow) {
        const tag = useWeighted ? 'time-weighted' : 'label-count';
        eyebrow.textContent =
          `phase mix · ${fmtInt(mix.sampled_episodes)} of ${fmtInt(mix.population_episodes)} episodes · ${tag}`;
      }
      if (sub) {
        const hours = useWeighted
          ? Math.round(PHASES.reduce((a, p) => a + (w[p] || 0), 0) / 3600)
          : null;
        sub.innerHTML =
          `Aggregated across <strong>${fmtInt(mix.sampled_episodes)}</strong> ` +
          `randomly-sampled episodes ` +
          `(<strong>${fmtInt(mix.total_labels)}</strong> phase records` +
          (hours != null ? `, ~<strong>${fmtInt(hours)}</strong> hours` : '') +
          `). Refreshes every ~10 min from disk.`;
      }
    }

    on('snapshot', m => { if (m.phase_mix) applyMix(m.phase_mix); });
    on('phase_mix', applyMix);
    on('demo_start', () => {
      // Synthetic fallback: only fires if no real phase_mix has
      // arrived yet (e.g. dashboard offline, or feeder hasn't done its
      // first compute). The numbers below mirror a real production run
      // so the bar reads correctly during a deck-only demo. If real
      // data later arrives via snapshot or phase_mix event, applyMix
      // overwrites this on the spot.
      if (hasRealMix) return;
      applyMix({
        weighted_seconds: {
          clean: 2659.1, armed: 725.7, infecting: 1607.4,
          infected_running: 8308.3, dormant: 3059.3,
        },
        counts: {
          clean: 451, armed: 198, infecting: 379,
          infected_running: 614, dormant: 223,
        },
        sampled_episodes: 500,
        population_episodes: 78705,
        total_labels: 1865,
      });
      // applyMix flipped hasRealMix to true — flip back so a future
      // real arrival is still recognised as the first real one.
      hasRealMix = false;
    });
    on('demo_stop', () => {
      // Demo toggle off doesn't wipe the dataset mix — the dataset is
      // ground truth, the demo only fakes per-event widgets.
    });
  })();

  // ── Database explorer ─────────────────────────────────────────
  // Real-data widget. Initial population from snapshot.recent_episodes
  // (last 200 lines of index.jsonl). New episodes prepend live.
  (function () {
    const tabsEl       = document.getElementById('db-tabs');
    const searchEl     = document.getElementById('db-search');
    const tbodyEl      = document.getElementById('db-tbody');
    const detailEl     = document.getElementById('db-detail');
    const detailMeta   = document.getElementById('db-detail-meta');
    const detailChart  = document.getElementById('db-detail-chart');
    const detailLegend = document.getElementById('db-detail-legend');
    const countEl      = document.getElementById('db-count');

    let records = [];               // newest first
    let activeHost = null;          // null = all
    let query = '';

    function fmtBytes(n) {
      if (!n) return '—';
      const u = ['B', 'KB', 'MB', 'GB']; let i = 0;
      while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
      return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i];
    }
    function fmtTime(iso) {
      if (!iso) return '—';
      try { return new Date(iso).toLocaleTimeString('en-US', { hour12: false }); }
      catch { return iso; }
    }
    function shortId(id) {
      if (!id) return '—';
      return id.length > 24 ? id.slice(0, 16) + '…' + id.slice(-6) : id;
    }

    function rebuildTabs() {
      const hosts = Array.from(new Set(records.map(r => r.host_id).filter(Boolean))).sort();
      tabsEl.innerHTML = '';
      const all = document.createElement('button');
      all.className = 'db-tab' + (activeHost === null ? ' active' : '');
      all.textContent = `all · ${records.length}`;
      all.addEventListener('click', e => { e.stopPropagation(); activeHost = null; rebuildTabs(); rebuildTable(); });
      tabsEl.appendChild(all);
      hosts.forEach(h => {
        const b = document.createElement('button');
        b.className = 'db-tab' + (activeHost === h ? ' active' : '');
        const c = records.filter(r => r.host_id === h).length;
        b.textContent = `${h} · ${c}`;
        b.addEventListener('click', e => { e.stopPropagation(); activeHost = h; rebuildTabs(); rebuildTable(); });
        tabsEl.appendChild(b);
      });
    }

    function matches(rec) {
      if (activeHost && rec.host_id !== activeHost) return false;
      if (!query) return true;
      const q = query.toLowerCase();
      return (rec.host_id || '').toLowerCase().includes(q)
          || (rec.episode_id || '').toLowerCase().includes(q)
          || (rec.sha256 || '').toLowerCase().includes(q);
    }

    function rebuildTable() {
      const filtered = records.filter(matches);
      countEl.textContent = `${filtered.length} of ${records.length}`;
      tbodyEl.innerHTML = '';
      if (!filtered.length) {
        const tr = document.createElement('tr');
        const td = document.createElement('td');
        td.colSpan = 4; td.className = 'awaiting';
        td.textContent = records.length === 0
          ? 'awaiting snapshot…'
          : 'no rows match the current filter';
        tr.appendChild(td); tbodyEl.appendChild(tr);
        return;
      }
      const frag = document.createDocumentFragment();
      filtered.slice(0, 200).forEach(rec => {
        const tr = document.createElement('tr');
        tr.className = 'db-row'; tr.dataset.id = rec.episode_id || '';
        tr.innerHTML = `
          <td><span class="db-host">${rec.host_id || '—'}</span></td>
          <td><span class="db-id">${shortId(rec.episode_id)}</span></td>
          <td>${fmtTime(rec.received_at)}</td>
          <td class="db-size">${fmtBytes(rec.size_bytes)}</td>`;
        tr.addEventListener('click', e => {
          e.stopPropagation();
          detailEl.hidden = false;
          tbodyEl.querySelectorAll('.db-row').forEach(r => r.classList.remove('selected'));
          tr.classList.add('selected');
          showEpisode(rec);
        });
        frag.appendChild(tr);
      });
      tbodyEl.appendChild(frag);
    }

    // Fetch + render the per-episode telemetry chart. Decompresses
    // and parses the .tar.zst on the server (see /api/episode in
    // app.py); here we compute deltas on the cumulative counters
    // and draw lines + phase bands.
    async function showEpisode(rec) {
      detailMeta.innerHTML = `
        <span class="db-host">${rec.host_id || '—'}</span>
        <span class="db-id">${rec.episode_id || '—'}</span>
        <span>${fmtBytes(rec.size_bytes)}</span>
        <span>${rec.received_at || ''}</span>`;
      detailChart.innerHTML =
        '<text x="50%" y="50%" text-anchor="middle" class="placeholder">loading…</text>';
      detailLegend.innerHTML = '';
      try {
        const url = `/api/episode/${encodeURIComponent(rec.host_id)}/${encodeURIComponent(rec.episode_id)}`;
        const resp = await fetch(url);
        if (!resp.ok) {
          if (resp.status === 404) throw new Error('episode tarball not on disk');
          throw new Error(`HTTP ${resp.status}`);
        }
        const data = await resp.json();
        renderEpisodeChart(data);
      } catch (err) {
        detailChart.innerHTML =
          `<text x="50%" y="50%" text-anchor="middle" class="placeholder">${err.message}</text>`;
      }
    }

    function renderEpisodeChart(data) {
      const W = 1000, H = 360;
      const pad = { t: 16, r: 18, b: 32, l: 56 };
      const innerW = W - pad.l - pad.r;
      const innerH = H - pad.t - pad.b;

      const samples = data.samples || [];
      const labels = (data.labels || [])
        .filter(l => typeof l.t_mono_ns === 'number')
        .sort((a, b) => a.t_mono_ns - b.t_mono_ns);

      if (samples.length < 2) {
        detailChart.innerHTML =
          '<text x="50%" y="50%" text-anchor="middle" class="placeholder">no telemetry samples</text>';
        return;
      }

      const tMin = samples[0].t_mono_ns;
      const tMax = samples[samples.length - 1].t_mono_ns;
      const tRange = Math.max(1, tMax - tMin);

      // Per-interval deltas of the cumulative counters. The README
      // envelope uses CPU jiffies (user + sys) and IO bytes (read +
      // write); both are running totals in /proc, so subtracting
      // adjacent samples gives instantaneous-ish rates.
      const cpu = [], io = [];
      for (let i = 1; i < samples.length; i++) {
        const a = samples[i - 1], b = samples[i];
        if (b.t_mono_ns - a.t_mono_ns <= 0) continue;
        const cv = ((b.cpu_user_jiffies || 0) - (a.cpu_user_jiffies || 0))
                 + ((b.cpu_sys_jiffies  || 0) - (a.cpu_sys_jiffies  || 0));
        const iv = ((b.io_read_bytes  || 0) - (a.io_read_bytes  || 0))
                 + ((b.io_write_bytes || 0) - (a.io_write_bytes || 0));
        cpu.push({ t: b.t_mono_ns, v: Math.max(0, cv) });
        io.push({  t: b.t_mono_ns, v: Math.max(0, iv) });
      }
      const cpuMax = Math.max(1, ...cpu.map(p => p.v));
      const ioMax  = Math.max(1, ...io.map(p => p.v));

      const tToX = t => pad.l + ((t - tMin) / tRange) * innerW;
      const cpuToY = v => pad.t + innerH - (v / cpuMax) * innerH;
      const ioToY  = v => pad.t + innerH - (v / ioMax)  * innerH;

      const cpuPath = cpu.map((p, i) =>
        `${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${cpuToY(p.v).toFixed(1)}`).join(' ');
      const ioPath = io.map((p, i) =>
        `${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${ioToY(p.v).toFixed(1)}`).join(' ');

      // Colored band per labeled-phase span.
      const phaseBands = [];
      const phasesUsed = new Set();
      for (let i = 0; i < labels.length; i++) {
        const start = labels[i].t_mono_ns;
        const end = i + 1 < labels.length ? labels[i + 1].t_mono_ns : tMax;
        const phase = labels[i].phase;
        if (!phase) continue;
        phasesUsed.add(phase);
        const x = tToX(Math.max(start, tMin));
        const w = tToX(Math.min(end, tMax)) - x;
        if (w > 0.5) {
          phaseBands.push(
            `<rect class="phase-band ${phase}" x="${x.toFixed(1)}" y="${pad.t}" ` +
            `width="${w.toFixed(1)}" height="${innerH}" />`);
        }
      }

      const axisY = pad.t + innerH;
      const durSec = (tRange / 1e9).toFixed(1);

      detailChart.innerHTML = `
        ${phaseBands.join('')}
        <line class="axis" x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${axisY}" />
        <line class="axis" x1="${pad.l}" y1="${axisY}" x2="${W - pad.r}" y2="${axisY}" />
        <text class="tick" x="${pad.l - 4}" y="${pad.t + 8}" text-anchor="end">peak</text>
        <text class="tick" x="${pad.l - 4}" y="${axisY}" text-anchor="end">0</text>
        <text class="tick" x="${pad.l}" y="${axisY + 14}" text-anchor="start">0 s</text>
        <text class="tick" x="${W - pad.r}" y="${axisY + 14}" text-anchor="end">${durSec} s</text>
        <path class="metric-line" d="${cpuPath}" stroke="var(--c1)" />
        <path class="metric-line" d="${ioPath}"  stroke="var(--c2)" />
      `;

      const phaseList = Array.from(phasesUsed).map(p => {
        const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`;
        return `<span><span class="swatch" style="background:var(--${v})"></span>${p}</span>`;
      }).join('');
      detailLegend.innerHTML = `
        <span><span class="swatch" style="background:var(--c1)"></span>cpu jiffies / interval</span>
        <span><span class="swatch" style="background:var(--c2)"></span>io bytes / interval</span>
        ${phaseList}`;
    }

    on('snapshot', m => {
      if (Array.isArray(m.recent_episodes)) {
        records = m.recent_episodes.slice();
        rebuildTabs(); rebuildTable();
      }
    });
    on('episode', m => {
      // Prepend; cap.
      records.unshift({
        episode_id: m.episode_id, host_id: m.host_id,
        sha256: m.sha256, size_bytes: m.size_bytes, received_at: m.received_at,
      });
      if (records.length > 200) records.length = 200;
      // Cheap update: only rebuild if scene visible.
      rebuildTabs(); rebuildTable();
    });
    on('demo_stop', () => {
      // Drop demo-injected records (their ids start with "demo-").
      const before = records.length;
      records = records.filter(r => !(r.episode_id && r.episode_id.startsWith('demo-')));
      if (records.length !== before) { rebuildTabs(); rebuildTable(); }
    });
    searchEl.addEventListener('input', e => { query = e.target.value; rebuildTable(); });
    searchEl.addEventListener('click', e => e.stopPropagation());
    rebuildTabs(); rebuildTable();
  })();

  // ── Attack envelope thumbnails — DEMO ONLY ───────────────────
  (function () {
    const root = document.getElementById('profile-grid');
    const W = 200, H = 56;
    function emptyState() {
      root.innerHTML = '';
      root.appendChild(awaitingNote('awaiting attack_profile events · turn demo on for examples'));
    }
    function curveToPath(values) {
      const max = Math.max(1, ...values);
      return values.map((v, i) => {
        const x = (i / (values.length - 1)) * W;
        const y = H - (v / max) * (H - 6) - 3;
        return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
      }).join(' ');
    }
    function gen(seed, fn, n = 80) {
      let s = seed;
      const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s & 0xffff) / 0xffff; };
      return Array.from({ length: n }, (_, i) => fn(i / n, rand));
    }
    const cards = new Map();
    function render(profile) {
      let card = cards.get(profile.name);
      if (!card) {
        if (root.querySelector('.awaiting')) root.innerHTML = '';
        card = document.createElement('div'); card.className = 'profile-card';
        card.innerHTML = `
          <div class="profile-name"></div>
          <div class="profile-shape"></div>
          <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><path d=""></path></svg>`;
        root.appendChild(card); cards.set(profile.name, card);
      }
      card.querySelector('.profile-name').textContent = profile.name;
      card.querySelector('.profile-shape').textContent = profile.shape || '';
      card.querySelector('path').setAttribute('d', curveToPath(profile.curve));
    }
    function clearAll() { cards.clear(); emptyState(); }
    function syntheticProfiles() {
      return [
        { name: 'cpu-saturate',   shape: 'sustained 1-vCPU peg (XMRig)',
          curve: gen(1, (t, r) => 0.1 + (t > 0.15 ? 0.85 : 0) + 0.05 * r()) },
        { name: 'scan-and-dial',  shape: 'SYN-style probes + dial-home',
          curve: gen(2, (t, r) => t < 0.2 ? 0.05 : 0.15 + 0.7 * Math.exp(-Math.pow((t-0.5)*4, 2)) + 0.05 * r()) },
        { name: 'io-walk',        shape: 'fs traversal + 4 KiB urandom writes',
          curve: gen(3, (t, r) => 0.2 + 0.3 * Math.sin(t * 14) + 0.4 * (t > 0.3 && t < 0.85 ? 1 : 0) + 0.05 * r()) },
        { name: 'bursty-c2',      shape: 'long idle + 3-packet egress bursts',
          curve: gen(4, (t, r) => 0.05 + (Math.sin(t * 30) > 0.95 ? 0.9 : 0) + 0.02 * r()) },
        { name: 'low-and-slow',   shape: 'minimal CPU + periodic memory churn',
          curve: gen(5, (t, r) => 0.12 + 0.08 * Math.sin(t * 6) + 0.05 * r()) },
        { name: 'shell-resident', shape: 'one long TCP socket + command ticks',
          curve: gen(6, (t, r) => 0.08 + (Math.sin(t * 22) > 0.7 ? 0.5 : 0) + 0.03 * r()) },
      ];
    }
    // Attack envelopes show real `attack_profile` events only — no
    // demo synthesis. The producer side has the canonical envelopes
    // from the orchestrator's profile catalog; demo mode shouldn't
    // overwrite that with hand-tuned curves.
    on('attack_profile', m => {
      if (!m.name || !Array.isArray(m.curve)) return;
      render({ name: m.name, shape: m.shape || '', curve: m.curve });
    });
    emptyState();
  })();

  // ── Chunking timeline — DEMO ONLY ────────────────────────────
  (function () {
    const ruleEl = document.getElementById('chunk-rule');
    const rowEl  = document.getElementById('chunk-row');
    const axisEl = document.getElementById('chunk-axis');
    const N = 6;
    function clearAll() {
      ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = '';
      rowEl.appendChild(awaitingNote('awaiting prediction events · turn demo on for examples'));
    }
    function buildExample() {
      const labels = ['clean', 'clean', 'armed', 'infecting', 'infected_running', 'dormant'];
      ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = '';
      for (let i = 0; i < N; i++) ruleEl.appendChild(Object.assign(document.createElement('div'), { className: 'tick' }));
      for (let i = 0; i < N; i++) {
        const c = document.createElement('div');
        c.className = `chunk-cell ${labels[i]}`;
        c.textContent = labels[i].replace('_', ' ');
        rowEl.appendChild(c);
      }
      for (let i = 0; i < N; i++) {
        const t = document.createElement('span');
        t.textContent = `${i * 10}s`;
        axisEl.appendChild(t);
      }
    }
    on('demo_start', buildExample);
    on('demo_stop',  clearAll);
    on('prediction', m => {
      // Real predictions can update individual cells.
      if (typeof m.window_idx !== 'number') return;
      const cells = rowEl.querySelectorAll('.chunk-cell');
      const cell = cells[m.window_idx];
      if (!cell) return;
      const phase = m.predicted || m.actual;
      if (!phase) return;
      cell.className = `chunk-cell ${phase}`;
      cell.textContent = phase.replace('_', ' ');
    });
    clearAll();
  })();

  // ── Model comparison bars — DEMO ONLY (until model_metric arrives) ─
  (function () {
    const root = document.getElementById('model-bars');
    const rows = new Map();
    function emptyState() {
      root.innerHTML = '';
      root.appendChild(awaitingNote('awaiting model_metric events · turn demo on for examples'));
    }
    // Derive a gradient deterministically from the model name. Any
    // string the producer publishes (mlp / mlp_realistic / cnn_oracle
    // / knn_semi / something we never anticipated) maps to a stable
    // hue, so the bar always paints. Beats hardcoding CSS rules per
    // known name and missing whatever the producer adds tomorrow.
    function modelHue(s) {
      // FNV-1a 32-bit, cheap and deterministic across browsers.
      let h = 2166136261;
      for (let i = 0; i < s.length; i++) {
        h ^= s.charCodeAt(i);
        h = (h * 16777619) >>> 0;
      }
      return h % 360;
    }
    function gradientFor(model) {
      const hue = modelHue(String(model));
      return `linear-gradient(90deg, oklch(72% 0.18 ${hue}), oklch(46% 0.20 ${hue}))`;
    }
    function ensureRow(model) {
      if (rows.has(model)) return rows.get(model);
      if (root.querySelector('.awaiting')) root.innerHTML = '';
      const row = document.createElement('div'); row.className = 'model-row';
      row.innerHTML = `
        <div class="model-name">${model}</div>
        <div class="model-track"><div class="model-fill" style="width:0%"></div></div>
        <div class="model-acc">0.000</div>`;
      root.appendChild(row);
      const fill = row.querySelector('.model-fill');
      // Inline style beats any CSS rule, so the gradient survives no
      // matter what's in dashboard.css.
      fill.style.background = gradientFor(model);
      const entry = { row, fill, acc: row.querySelector('.model-acc') };
      rows.set(model, entry); return entry;
    }
    function render(model, accuracy) {
      const r = ensureRow(model);
      const visible = Math.max(0, Math.min(1, accuracy));
      r.fill.style.width = (visible * 100).toFixed(1) + '%';
      r.acc.textContent = accuracy.toFixed(3);
    }
    // Exclusive: demo on → only synthetic; demo off → only real.
    // cachedReal accumulates real model_metric events even while
    // demo is on, so toggling demo off restores the real picture
    // without waiting for the producer to re-publish.
    let demoActive = false;
    const cachedReal = new Map();
    function repaintFrom(src) {
      rows.clear(); root.innerHTML = '';
      if (src.size === 0) { emptyState(); return; }
      src.forEach((acc, model) => render(model, acc));
    }
    on('demo_start', () => {
      demoActive = true;
      const synth = new Map([
        ['knn', 0.736], ['rnn', 0.872], ['gru', 0.911],
        ['lstm', 0.928], ['bert', 0.954],
      ]);
      repaintFrom(synth);
    });
    on('demo_stop', () => {
      demoActive = false;
      repaintFrom(cachedReal);
    });
    on('model_metric', m => {
      if (!m.model || typeof m.accuracy !== 'number') return;
      cachedReal.set(m.model, m.accuracy);
      if (!demoActive) render(m.model, m.accuracy);
    });
    emptyState();
  })();

  // ── KNN 3-D scatter (canvas) ──────────────────────────────────
  // Drag-to-rotate 3-D scatter on the KNN scene. Listens for
  // `embedding` events with optional z / predicted / cluster fields;
  // mode toggle picks which categorical dimension colors each point
  // (ground-truth phase, KNN-predicted label, cluster id).
  (function () {
    const wrap   = document.querySelector('.scatter3d-wrap');
    const canvas = document.getElementById('knn-scatter-canvas');
    const legend = document.getElementById('knn-legend');
    if (!canvas || !wrap || !legend) return;
    const ctx = canvas.getContext('2d');

    const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
    const phaseCenters3d = {
      clean:             [0.18, 0.75, 0.30],
      armed:             [0.42, 0.58, 0.65],
      infecting:         [0.72, 0.40, 0.55],
      infected_running:  [0.85, 0.18, 0.20],
      dormant:           [0.30, 0.30, 0.85],
    };

    const points = [];
    let mode = 'phase';
    let rotX = 0.45, rotY = 0.55;
    const HOME_X = rotX, HOME_Y = rotY;
    let dragging = false, lastPx = 0, lastPy = 0;
    let didDrag = false;

    // Hidden probe to resolve var(--…) and oklch(…) to RGB for canvas.
    const probe = document.createElement('div');
    probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
    document.body.appendChild(probe);
    const colorCache = new Map();
    function cssColor(varExpr) {
      if (colorCache.has(varExpr)) return colorCache.get(varExpr);
      probe.style.color = varExpr;
      const c = getComputedStyle(probe).color || 'rgb(150,150,150)';
      colorCache.set(varExpr, c);
      return c;
    }
    function refreshColors() { colorCache.clear(); }

    function phaseColor(p) {
      const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`;
      return cssColor(`var(--${v})`);
    }
    function clusterColor(i) {
      const baseH = parseFloat(getComputedStyle(document.documentElement)
        .getPropertyValue('--theme-h-num') || '250');
      const h = (baseH + i * 47) % 360;
      return cssColor(`oklch(72% 0.18 ${h.toFixed(1)})`);
    }

    function rebuildLegend() {
      legend.innerHTML = '';
      if (mode === 'cluster') {
        const ids = Array.from(new Set(points.map(p => p.cluster).filter(c => c != null))).sort((a,b)=>a-b);
        if (ids.length === 0) {
          legend.innerHTML = '<span style="opacity:.6">no cluster ids in current points</span>';
          return;
        }
        ids.forEach(id => {
          const li = document.createElement('span');
          li.innerHTML = `<span class="swatch" style="background:${clusterColor(id)}"></span>cluster ${id}`;
          legend.appendChild(li);
        });
        return;
      }
      PHASES.forEach(p => {
        const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`;
        const li = document.createElement('span');
        li.innerHTML = `<span class="swatch" style="background:var(--${v})"></span>${p}`;
        legend.appendChild(li);
      });
    }

    function pointColor(p) {
      if (mode === 'phase'     && p.phase)            return phaseColor(p.phase);
      if (mode === 'predicted' && p.predicted)        return phaseColor(p.predicted);
      if (mode === 'cluster'   && p.cluster != null)  return clusterColor(p.cluster);
      return cssColor('var(--fg-mute)');
    }

    function resize() {
      const r = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      const w = Math.max(1, Math.floor(r.width  * dpr));
      const h = Math.max(1, Math.floor(r.height * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w; canvas.height = h;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
    }
    if (window.ResizeObserver) new ResizeObserver(resize).observe(canvas);

    // ── Auto-fit: running mean / std per axis ─────────────────────
    // The producer rescales PCA output to [0,1]³ by min-max of its fit
    // subsample, but PCA-3 is Gaussian-ish so the bulk lands in a
    // narrow band near the centroid. We track running mean+std as
    // points arrive and project around mean ± SPREAD_K·σ → [-0.5,0.5]
    // so the data fills the bounding cube regardless of where in
    // [0,1] the producer happens to put it. Outliers clamp to ±0.7
    // so they're visible just outside the cube.
    const SPREAD_K = 2.5;
    const MIN_STD = 0.02;     // floor so degenerate (all-equal) data doesn't blow up
    const stats = {
      n: 0,
      sx: 0, sx2: 0, sy: 0, sy2: 0, sz: 0, sz2: 0,
      mx: 0.5, my: 0.5, mz: 0.5,
      dx: 0.4 / SPREAD_K, dy: 0.4 / SPREAD_K, dz: 0.4 / SPREAD_K,
      dirty: false,
    };
    function resetStats() {
      stats.n = 0;
      stats.sx = stats.sx2 = stats.sy = stats.sy2 = stats.sz = stats.sz2 = 0;
      stats.mx = stats.my = stats.mz = 0.5;
      stats.dx = stats.dy = stats.dz = 0.4 / SPREAD_K;
      stats.dirty = false;
    }
    function addStat(p) {
      const z = (typeof p.z === 'number') ? p.z : 0.5;
      stats.n++;
      stats.sx += p.x; stats.sx2 += p.x * p.x;
      stats.sy += p.y; stats.sy2 += p.y * p.y;
      stats.sz += z;   stats.sz2 += z * z;
      stats.dirty = true;
    }
    function recomputeStats() {
      if (stats.n < 2) { stats.dirty = false; return; }
      const n = stats.n;
      stats.mx = stats.sx / n;
      stats.my = stats.sy / n;
      stats.mz = stats.sz / n;
      stats.dx = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sx2 / n - stats.mx * stats.mx)));
      stats.dy = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sy2 / n - stats.my * stats.my)));
      stats.dz = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sz2 / n - stats.mz * stats.mz)));
      stats.dirty = false;
    }

    function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

    // Project already-normalized (centered, σ-scaled) coords to canvas
    // pixels. nx, ny, nz are in roughly [-0.5, 0.5] for the bulk of
    // the data; outliers go a bit beyond.
    function projectNorm(nx, ny, nz) {
      const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY);
      const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX);
      const x1 =  nx * cy_ + nz * sy_;
      const z1 = -nx * sy_ + nz * cy_;
      const y2 =  ny * cx_ - z1 * sx_;
      const z2 =  ny * sx_ + z1 * cx_;
      const camZ = 2.5;
      const persp = camZ / (camZ - z2);
      const w = canvas.clientWidth, h = canvas.clientHeight;
      const span = Math.min(w, h) * 0.46;
      return {
        sx: w / 2 + x1 * span * persp,
        sy: h / 2 + y2 * span * persp,
        depth: z2,
        scale: persp,
      };
    }

    // Project a raw data point: normalize via running stats, then
    // hand off to projectNorm.
    function project(p) {
      if (stats.dirty) recomputeStats();
      const z = (typeof p.z === 'number') ? p.z : stats.mz;
      const nx = clamp(((p.x - stats.mx) / (SPREAD_K * stats.dx)) * 0.5, -0.7, 0.7);
      const ny = clamp(((p.y - stats.my) / (SPREAD_K * stats.dy)) * 0.5, -0.7, 0.7);
      const nz = clamp(((z    - stats.mz) / (SPREAD_K * stats.dz)) * 0.5, -0.7, 0.7);
      return projectNorm(nx, ny, nz);
    }

    const cubeEdges = [
      [0,1],[1,3],[3,2],[2,0],[4,5],[5,7],[7,6],[6,4],
      [0,4],[1,5],[2,6],[3,7],
    ];
    function drawCube() {
      // The cube outlines mean ± k·σ — i.e. the data spread, not the
      // raw [0,1]³ producer-unit cube. Stays consistent with the
      // auto-fit projection above.
      const corners = [];
      for (let i = 0; i < 8; i++) {
        corners.push(projectNorm(
          (i & 1) ? 0.5 : -0.5,
          (i & 2) ? 0.5 : -0.5,
          (i & 4) ? 0.5 : -0.5,
        ));
      }
      ctx.save();
      ctx.strokeStyle = cssColor('var(--line)');
      ctx.globalAlpha = 0.7;
      ctx.lineWidth = 1;
      for (const [a, b] of cubeEdges) {
        ctx.beginPath();
        ctx.moveTo(corners[a].sx, corners[a].sy);
        ctx.lineTo(corners[b].sx, corners[b].sy);
        ctx.stroke();
      }
      ctx.restore();
    }

    function tick() {
      requestAnimationFrame(tick);
      const sceneActive = document.querySelector('.stage-view[data-view="knn"][data-active]');
      if (!sceneActive) return;
      resize();
      ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
      drawCube();

      const projected = points.map(p => ({ p, ...project(p) }));
      projected.sort((a, b) => a.depth - b.depth);
      for (const item of projected) {
        const r = Math.max(2, 4.5 * item.scale);
        ctx.fillStyle = pointColor(item.p);
        ctx.globalAlpha = 0.55 + 0.4 * Math.min(1, item.scale);
        ctx.beginPath();
        ctx.arc(item.sx, item.sy, r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    }
    requestAnimationFrame(tick);

    wrap.addEventListener('pointerdown', e => {
      dragging = true; didDrag = false;
      lastPx = e.clientX; lastPy = e.clientY;
      try { wrap.setPointerCapture(e.pointerId); } catch {}
      e.stopPropagation();
    });
    wrap.addEventListener('pointermove', e => {
      if (!dragging) return;
      const dx = e.clientX - lastPx, dy = e.clientY - lastPy;
      if (Math.hypot(dx, dy) > 2) didDrag = true;
      rotY += dx * 0.008;
      rotX += dy * 0.008;
      rotX = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, rotX));
      lastPx = e.clientX; lastPy = e.clientY;
    });
    function endDrag(e) {
      dragging = false;
      try { wrap.releasePointerCapture(e.pointerId); } catch {}
    }
    wrap.addEventListener('pointerup', endDrag);
    wrap.addEventListener('pointercancel', endDrag);
    // Eat the click that follows a drag so click-nav doesn't advance.
    wrap.addEventListener('click', e => { if (didDrag) { e.stopPropagation(); didDrag = false; } });

    document.querySelectorAll('.scatter3d-mode').forEach(btn => {
      btn.addEventListener('click', e => {
        e.stopPropagation();
        mode = btn.dataset.mode;
        document.querySelectorAll('.scatter3d-mode').forEach(b => b.classList.toggle('active', b === btn));
        rebuildLegend();
      });
    });
    document.querySelector('.scatter3d-reset')?.addEventListener('click', e => {
      e.stopPropagation();
      rotX = HOME_X; rotY = HOME_Y;
      refreshColors();
    });

    // Synthetic 3-D demo: 5 phase clusters, deterministic LCG. ~7%
    // mislabeled by the demo "predictor" so predicted-mode visibly
    // differs from ground truth.
    function loadSynthetic() {
      points.length = 0;
      resetStats();
      let seed = 7;
      const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; };
      const wrand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return (seed & 0xffff) / 0xffff; };
      PHASES.forEach((p, idx) => {
        const [cx, cy, cz] = phaseCenters3d[p];
        for (let i = 0; i < 18; i++) {
          const wrong = wrand() < 0.07;
          const predicted = wrong
            ? PHASES[(idx + 1 + Math.floor(wrand() * 4)) % PHASES.length]
            : p;
          const pt = {
            x: cx + rand() * 0.18,
            y: cy + rand() * 0.18,
            z: cz + rand() * 0.18,
            phase: p,
            predicted,
            cluster: idx,
          };
          points.push(pt);
          addStat(pt);
        }
      });
      rebuildLegend();
    }

    // KNN scatter is real-data only: it has a working producer and
    // doesn't participate in demo mode at all.
    on('embedding', m => {
      if (typeof m.x !== 'number' || typeof m.y !== 'number') return;
      const pt = {
        x: m.x, y: m.y,
        z: typeof m.z === 'number' ? m.z : 0.5,
        phase: m.phase,
        predicted: m.predicted,
        cluster: typeof m.cluster === 'number' ? m.cluster : undefined,
      };
      points.push(pt);
      addStat(pt);
      rebuildLegend();
    });

    rebuildLegend();
  })();

  // ── A100 live inference (scene: live) ─────────────────────────
  // Per-MODEL swim lanes of recent prediction cells. Each row is a
  // model the A100 is running inference with; each cell is one
  // ten-second-window prediction colored by predicted phase. When
  // the producer also sends `actual`, mismatch cells get a hatched
  // overlay and the running hit-rate updates. The "latest" callout
  // below the lanes leads with the model name + predicted phase.
  (function () {
    const lanesEl = document.getElementById('live-lanes');
    const latestEl = document.getElementById('live-latest');
    const statsModels = document.getElementById('live-stats-hosts'); // id kept for HTML compat
    const statsRate   = document.getElementById('live-stats-rate');
    const statsHost   = document.getElementById('live-stats-model'); // id kept; meaning flipped
    const statsAcc    = document.getElementById('live-stats-acc');
    if (!lanesEl) return;

    const MAX_CELLS = 60;
    const RATE_WINDOW_MS = 5000;

    const lanes = new Map();
    const eventTimes = [];
    let totalCorrect = 0, totalLabeled = 0;
    let lastHost = null;

    function ensureLane(modelName) {
      if (lanes.has(modelName)) return lanes.get(modelName);
      const row = document.createElement('div');
      row.className = 'live-lane';
      row.innerHTML = `
        <div class="live-lane-host" title="${modelName}">${modelName}</div>
        <div class="live-lane-cells"></div>`;
      lanesEl.appendChild(row);
      const lane = { row, cellsEl: row.querySelector('.live-lane-cells'), cells: [] };
      lanes.set(modelName, lane);
      return lane;
    }

    function paintCell(d) {
      const lane = ensureLane(d.model || 'unknown');
      const cell = document.createElement('div');
      cell.className = `live-cell ${d.predicted}`;
      if (d.actual && d.actual !== d.predicted) cell.classList.add('miss');
      cell.title = `${d.model ?? '?'} · ${d.host_id ?? '?'} w${d.window_idx ?? '?'} · ${d.predicted}` +
        (d.actual ? ` (truth: ${d.actual})` : '') +
        (d.confidence != null ? ` · conf ${d.confidence.toFixed(2)}` : '');
      lane.cellsEl.appendChild(cell);
      lane.cells.push(d);
      while (lane.cells.length > MAX_CELLS) {
        lane.cells.shift();
        const first = lane.cellsEl.firstChild;
        if (first) lane.cellsEl.removeChild(first);
      }
    }

    function paintLatest(d) {
      const conf = d.confidence != null
        ? `${(d.confidence * 100).toFixed(0)}%`
        : '—';
      const truthLine = d.actual
        ? (d.actual === d.predicted
            ? `<span style="color: var(--phase-clean)">✓ matches ground truth</span>`
            : `truth: <code>${d.actual}</code> (model disagrees)`)
        : `truth: <span style="color: var(--fg-mute)">awaiting label</span>`;
      const phaseLabel = (d.predicted || '').replace('_', '<br>');
      latestEl.innerHTML = `
        <div class="live-phase-block ${d.predicted}">${phaseLabel}</div>
        <div class="live-meta">
          <div class="live-meta-host">model: ${d.model ?? '—'}${
            d.latency_ms != null ? ` · A100 ${d.latency_ms.toFixed(1)} ms` : ''
          }</div>
          <div class="live-meta-line">window from: <code>${d.host_id ?? '—'}</code>${
            d.profile ? ` · profile <code>${d.profile}</code>` : ''
          }</div>
          <div class="live-meta-line">${truthLine}</div>
        </div>
        <div class="live-conf">
          <span class="live-conf-label">confidence</span>
          ${conf}
        </div>`;
    }

    function updateStats() {
      const now = Date.now();
      while (eventTimes.length && now - eventTimes[0] > RATE_WINDOW_MS) eventTimes.shift();
      const rate = (eventTimes.length / (RATE_WINDOW_MS / 1000)).toFixed(1);
      statsModels.textContent = `${lanes.size} model${lanes.size === 1 ? '' : 's'}`;
      statsRate.textContent   = `${rate} infer / sec`;
      statsHost.textContent   = `last window: ${lastHost ?? '—'}`;
      statsAcc.textContent    = totalLabeled > 0
        ? `hit-rate: ${(100 * totalCorrect / totalLabeled).toFixed(0)}% (${totalCorrect}/${totalLabeled})`
        : `hit-rate: —`;
    }
    setInterval(updateStats, 500);

    // Exclusive: demo on → only synthetic detections; demo off →
    // only real. The caps mean we hold the most recent N real
    // detections so toggling demo off restores them.
    let demoActive = false;
    const cachedReal = [];
    const REAL_CACHE_CAP = 600;
    function clearLanes() {
      lanes.forEach(l => l.row.remove());
      lanes.clear();
      eventTimes.length = 0;
      totalCorrect = 0; totalLabeled = 0;
      lastHost = null;
      latestEl.innerHTML = '<div class="live-latest-empty">awaiting <code>live_detection</code> events from the A100 inference loop</div>';
      updateStats();
    }
    function paintDetection(d) {
      eventTimes.push(Date.now());
      if (d.host_id) lastHost = d.host_id;
      if (d.actual) {
        totalLabeled++;
        if (d.actual === d.predicted) totalCorrect++;
      }
      paintCell(d);
      paintLatest(d);
    }
    function handleDetection(d) {
      if (!d.model || !d.predicted) return;
      paintDetection(d);
    }

    on('live_detection', m => {
      if (!m.model || !m.predicted) return;
      cachedReal.push(m);
      if (cachedReal.length > REAL_CACHE_CAP) cachedReal.shift();
      if (!demoActive) paintDetection(m);
    });

    // Synthetic demo: A100 cycling through 5 trained models, scoring
    // windows from rotating fleet hosts. Each model has its own
    // accuracy + latency profile (KNN faster but less accurate;
    // BERT slower but more accurate) so the lanes look distinct.
    let demoTimer = null;
    function demoStart() {
      demoActive = true;
      clearLanes();
      if (demoTimer) clearInterval(demoTimer);
      const MODELS = [
        { name: 'knn',  acc: 0.78, latMs: 0.4, phaseIdx: 0 },
        { name: 'rnn',  acc: 0.86, latMs: 1.8, phaseIdx: 0 },
        { name: 'gru',  acc: 0.91, latMs: 2.4, phaseIdx: 1 },
        { name: 'lstm', acc: 0.93, latMs: 3.2, phaseIdx: 2 },
        { name: 'bert', acc: 0.95, latMs: 8.6, phaseIdx: 0 },
      ];
      // Only hosts that actually contributed training data — elliott-lab
      // produced none, so it should never be the source of an inference
      // window in the live scene.
      const HOSTS = ['elliott-thinkpad', 'k-gamingcom'];
      const PROFILES = ['cpu-saturate', 'ransomware-lite', 'bursty-c2', 'fork-bomb', 'crypto-miner'];
      const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
      let counter = 0;
      // ~0.4 events/sec. Real ceiling with two contributing hosts
      // (elliott-thinkpad, k-gamingcom) is ~1 event/sec — we stay
      // well under so the lane cells read as deliberate, not as a
      // contrived stream.
      demoTimer = setInterval(() => {
        const m = MODELS[counter % MODELS.length];
        counter++;
        if (Math.random() < 0.18) m.phaseIdx = (m.phaseIdx + 1) % PHASES.length;
        const truth = PHASES[m.phaseIdx];
        const right = Math.random() < m.acc;
        const predicted = right
          ? truth
          : PHASES[(m.phaseIdx + 1 + Math.floor(Math.random() * 4)) % PHASES.length];
        handleDetection({
          host_id: HOSTS[counter % HOSTS.length],
          profile: PROFILES[counter % PROFILES.length],
          predicted, actual: truth,
          confidence: 0.62 + Math.random() * 0.36,
          model: m.name,
          latency_ms: m.latMs + (Math.random() - 0.5) * m.latMs * 0.3,
          episode_id: 'demo',
          window_idx: counter,
          t_wall: Date.now() / 1000,
        });
      }, 2500);
    }
    function demoStop() {
      demoActive = false;
      if (demoTimer) { clearInterval(demoTimer); demoTimer = null; }
      clearLanes();
      // Replay cached real detections so the lanes don't sit empty
      // until the next live_detection event arrives.
      for (const m of cachedReal) paintDetection(m);
    }
    on('demo_start', demoStart);
    on('demo_stop', demoStop);

    updateStats();
  })();

  // ── References viewer (scene: references) ────────────────────
  // Lists PDFs from /api/references; clicking a tab swaps the
  // iframe's src to /refs/<filename>. List is fetched once at
  // init; iframe is lazy — src isn't set until the user enters
  // the references scene OR clicks a tab, so the browser doesn't
  // download a PDF that never gets viewed.
  (function () {
    const tabsEl = document.getElementById('ref-tabs');
    const viewerEl = document.getElementById('ref-viewer');
    const descEl = document.getElementById('ref-description');
    if (!tabsEl || !viewerEl || !descEl) return;

    let refs = [];
    let activeIdx = -1;
    let pendingFirst = true;   // load first PDF only when scene becomes active

    function loadFirstIfReady() {
      if (!pendingFirst) return;
      if (refs.length === 0) return;
      pendingFirst = false;
      selectRef(0);
    }

    function rebuildTabs() {
      tabsEl.innerHTML = '';
      if (refs.length === 0) {
        const empty = document.createElement('div');
        empty.className = 'awaiting';
        empty.textContent = 'no PDFs found in /opt/cis490/references/';
        tabsEl.appendChild(empty);
        renderDescription(null);
        return;
      }
      refs.forEach((r, i) => {
        const btn = document.createElement('button');
        btn.className = 'ref-tab' + (i === activeIdx ? ' active' : '');
        btn.textContent = r.name;
        btn.title = r.name;
        btn.addEventListener('click', e => {
          e.stopPropagation();   // don't bubble to stage-col click-nav
          selectRef(i);
        });
        tabsEl.appendChild(btn);
      });
    }

    function selectRef(i) {
      if (i < 0 || i >= refs.length) return;
      activeIdx = i;
      rebuildTabs();
      // #zoom=page-width forces the browser's PDF viewer to fit the
      // page horizontally to the iframe — without it, an 8.5×11
      // page leaves whitespace on either side when the iframe is
      // wider than the page's natural width.
      viewerEl.src = refs[i].path + '#zoom=page-width';
      renderDescription(refs[i].description);
    }

    // Tiny markdown-ish renderer: enough to display headings,
    // paragraphs, bold/italic, lists, inline code. Keeps this widget
    // dependency-free (no marked.js / showdown.js / etc).
    function renderDescription(md) {
      if (!md) {
        descEl.innerHTML =
          '<p class="awaiting">no description for this reference yet — drop a sidecar &lt;stem&gt;.md next to the PDF in /opt/cis490/references/</p>';
        return;
      }
      // Escape HTML first so user content can't inject markup.
      let s = md.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
      // Inline: bold, italic, code.
      s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
           .replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>')
           .replace(/`([^`\n]+)`/g, '<code>$1</code>');
      // Block-level: split on blank lines, then handle headings + lists.
      const blocks = s.split(/\n{2,}/).map(block => {
        const stripped = block.trim();
        if (!stripped) return '';
        if (stripped.startsWith('# '))   return `<h2>${stripped.slice(2)}</h2>`;
        if (stripped.startsWith('## '))  return `<h2>${stripped.slice(3)}</h2>`;
        if (stripped.startsWith('### ')) return `<h3>${stripped.slice(4)}</h3>`;
        const lines = stripped.split('\n');
        if (lines.every(l => /^[-*]\s/.test(l))) {
          return '<ul>' + lines.map(l => `<li>${l.replace(/^[-*]\s+/, '')}</li>`).join('') + '</ul>';
        }
        if (lines.every(l => /^\d+\.\s/.test(l))) {
          return '<ol>' + lines.map(l => `<li>${l.replace(/^\d+\.\s+/, '')}</li>`).join('') + '</ol>';
        }
        return `<p>${stripped.replace(/\n/g, '<br>')}</p>`;
      });
      descEl.innerHTML = blocks.join('');
    }

    fetch('/api/references')
      .then(r => r.json())
      .then(data => {
        refs = (data && data.references) || [];
        rebuildTabs();
        // If user is already on the references scene at boot, load now.
        if (document.querySelector('.scene[data-stage="references"][data-active]')) {
          loadFirstIfReady();
        }
      })
      .catch(err => {
        tabsEl.innerHTML = '';
        const e = document.createElement('div');
        e.className = 'awaiting';
        e.textContent = `failed to load references list: ${err.message}`;
        tabsEl.appendChild(e);
      });

    // Defer the first iframe load until the references scene actually
    // becomes active (no point fetching a PDF the user may never see).
    window.dashboard.scene('references', { onEnter: loadFirstIfReady });
  })();

  // ── Performance scatter (log-x, anti-overlap labels) ──────────
  // X-axis is log10(latency_us) so the 35× range KNN→BERT spreads
  // evenly. Labels are sorted by x and placed alternately above /
  // below their points so adjacent labels can't horizontally
  // overlap even when points cluster (e.g. RNN/GRU/LSTM near each
  // other in latency).
  (function () {
    const svg = document.getElementById('perf-scatter');
    const ns = 'http://www.w3.org/2000/svg';
    const W = 600, H = 360;
    // Log-scale x bounds: 10 μs (1e1) to 10 ms (1e4).
    const xLogMin = 1, xLogMax = 4;
    const ymin = 0.5, ymax = 1.0;
    const points = new Map();   // model → { g, latency, accuracy }

    function setupAxes() {
      svg.innerHTML = '';
      const ax = document.createElementNS(ns, 'line');
      ax.setAttribute('x1', 50); ax.setAttribute('y1', H - 40);
      ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 40);
      ax.setAttribute('class', 'axis'); svg.appendChild(ax);
      const ay = document.createElementNS(ns, 'line');
      ay.setAttribute('x1', 50); ay.setAttribute('y1', 10);
      ay.setAttribute('x2', 50); ay.setAttribute('y2', H - 40);
      ay.setAttribute('class', 'axis'); svg.appendChild(ay);
      // x-axis log ticks at 10μs, 100μs, 1ms, 10ms.
      [
        { v: 10,    lbl: '10μs' },
        { v: 100,   lbl: '100μs' },
        { v: 1000,  lbl: '1ms' },
        { v: 10000, lbl: '10ms' },
      ].forEach(({ v, lbl }) => {
        const lx = Math.log10(v);
        const x = 50 + ((lx - xLogMin) / (xLogMax - xLogMin)) * (W - 60);
        const tick = document.createElementNS(ns, 'line');
        tick.setAttribute('x1', x.toFixed(1)); tick.setAttribute('x2', x.toFixed(1));
        tick.setAttribute('y1', H - 40); tick.setAttribute('y2', H - 35);
        tick.setAttribute('class', 'axis');
        svg.appendChild(tick);
        const tlbl = document.createElementNS(ns, 'text');
        tlbl.setAttribute('x', x.toFixed(1));
        tlbl.setAttribute('y', H - 22);
        tlbl.setAttribute('class', 'axis-label');
        tlbl.setAttribute('text-anchor', 'middle');
        tlbl.style.fontSize = '11px';
        tlbl.textContent = lbl;
        svg.appendChild(tlbl);
      });
      // y-axis ticks at 0.6, 0.8, 1.0
      [0.6, 0.8, 1.0].forEach(v => {
        const y = (H - 40) - ((v - ymin) / (ymax - ymin)) * (H - 50);
        const tick = document.createElementNS(ns, 'line');
        tick.setAttribute('x1', 45); tick.setAttribute('x2', 50);
        tick.setAttribute('y1', y.toFixed(1)); tick.setAttribute('y2', y.toFixed(1));
        tick.setAttribute('class', 'axis');
        svg.appendChild(tick);
        const tlbl = document.createElementNS(ns, 'text');
        tlbl.setAttribute('x', 42);
        tlbl.setAttribute('y', (y + 4).toFixed(1));
        tlbl.setAttribute('class', 'axis-label');
        tlbl.setAttribute('text-anchor', 'end');
        tlbl.style.fontSize = '11px';
        tlbl.textContent = v.toFixed(1);
        svg.appendChild(tlbl);
      });
      const xl = document.createElementNS(ns, 'text');
      xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 6);
      xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle');
      xl.textContent = 'inference latency (log scale, μs / window) →';
      svg.appendChild(xl);
      const yl = document.createElementNS(ns, 'text');
      yl.setAttribute('transform', `translate(14,${H/2}) rotate(-90)`);
      yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle');
      yl.textContent = '↑ held-out macro-F1'; svg.appendChild(yl);
    }
    function emptyState() {
      points.clear(); svg.innerHTML = '';
      const t = document.createElementNS(ns, 'text');
      t.setAttribute('x', W / 2); t.setAttribute('y', H / 2);
      t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'axis-label');
      t.textContent = 'awaiting model_perf events · turn demo on for examples';
      svg.appendChild(t);
    }
    function project(latency, accuracy) {
      const lat = Math.max(1, latency);
      const lx = Math.log10(lat);
      const x = 50 + Math.min(1, Math.max(0, (lx - xLogMin) / (xLogMax - xLogMin))) * (W - 60);
      const y = (H - 40) - Math.max(0, Math.min(1, (accuracy - ymin) / (ymax - ymin))) * (H - 50);
      return [x, y];
    }
    // Anti-overlap: sort points by x, place labels alternately
    // above (even index) and below (odd index) so adjacent labels
    // never share both x and y bands. Re-runs every render so a
    // late-arriving model_perf event slots into the staircase.
    function repaintLabels() {
      const entries = Array.from(points.entries()).map(([model, rec]) => ({
        model, rec, x: rec.cx, y: rec.cy,
      })).sort((a, b) => a.x - b.x);
      entries.forEach((e, i) => {
        const t = e.rec.g.querySelector('text');
        const above = (i % 2 === 0);
        t.setAttribute('x', e.x.toFixed(1));
        t.setAttribute('y', (above ? e.y - 12 : e.y + 18).toFixed(1));
        t.setAttribute('text-anchor', 'middle');
      });
    }
    function render(model, latency, accuracy) {
      let rec = points.get(model);
      if (!rec) {
        if (!points.size) setupAxes();
        const g = document.createElementNS(ns, 'g');
        const c = document.createElementNS(ns, 'circle');
        c.setAttribute('r', 7); c.setAttribute('class', 'perf-point');
        g.appendChild(c);
        const t = document.createElementNS(ns, 'text');
        t.setAttribute('class', 'perf-label');
        g.appendChild(t);
        svg.appendChild(g);
        rec = { g, cx: 0, cy: 0 };
        points.set(model, rec);
      }
      const [px, py] = project(latency, accuracy);
      rec.cx = px; rec.cy = py;
      rec.g.querySelector('circle').setAttribute('cx', px.toFixed(1));
      rec.g.querySelector('circle').setAttribute('cy', py.toFixed(1));
      rec.g.querySelector('text').textContent = model;
      repaintLabels();
    }
    // Exclusive: demo on → only synthetic; demo off → only real.
    let demoActive = false;
    const cachedReal = new Map();   // model → {latency, accuracy}
    function repaintFrom(src) {
      points.clear(); svg.innerHTML = '';
      if (src.size === 0) { emptyState(); return; }
      src.forEach((p, model) => render(model, p.latency, p.accuracy));
    }
    on('demo_start', () => {
      demoActive = true;
      const synth = new Map([
        ['knn',  { latency:  90, accuracy: 0.84 }],
        ['rnn',  { latency: 380, accuracy: 0.87 }],
        ['gru',  { latency: 520, accuracy: 0.91 }],
        ['lstm', { latency: 700, accuracy: 0.93 }],
        ['bert', { latency: 3200, accuracy: 0.95 }],
      ]);
      repaintFrom(synth);
    });
    on('demo_stop', () => {
      demoActive = false;
      repaintFrom(cachedReal);
    });
    on('model_perf', m => {
      if (!m.model || typeof m.latency_us !== 'number' || typeof m.accuracy !== 'number') return;
      cachedReal.set(m.model, { latency: m.latency_us, accuracy: m.accuracy });
      if (!demoActive) render(m.model, m.latency_us, m.accuracy);
    });
    emptyState();
  })();

})();
