// shared: icons, audio engine, waveform canvas, small components
const IC = {
  inbox: <g><path d="M3 12h4l2 3h6l2-3h4"/><path d="M5 4h14l2 8v7H3v-7z"/></g>,
  wave: <g><path d="M2 12h2M6 8v8M10 4v16M14 7v10M18 9v6M22 12h-2"/></g>,
  chart: <g><path d="M3 20h18M6 16v-5M11 16V7M16 16v-8M21 16V11"/></g>,
  doc: <g><path d="M6 2h9l5 5v15H6z"/><path d="M14 2v6h6"/></g>,
  queue: <g><path d="M4 6h16M4 12h16M4 18h10"/></g>,
  play: <path d="M7 4l13 8-13 8z" fill="currentColor" stroke="none"/>,
  pause: <g><path d="M7 4v16M17 4v16" strokeWidth="3"/></g>,
  lock: <g><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/></g>,
  sun: <g><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></g>,
  moon: <path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/>,
  prev: <g><path d="M19 5v14L8 12z" fill="currentColor" stroke="none"/><path d="M5 5v14" strokeWidth="2.2"/></g>,
  next: <g><path d="M5 5v14l11-7z" fill="currentColor" stroke="none"/><path d="M19 5v14" strokeWidth="2.2"/></g>,
  tail: <g><path d="M3 12h11"/><path d="M10 7l5 5-5 5"/><path d="M19 5v14" strokeWidth="2.2"/></g>,
  // --- product nav ---
  home: <g><path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></g>,
  explore: <g><circle cx="12" cy="12" r="9"/><path d="M15.2 8.8l-1.9 4.5-4.5 1.9 1.9-4.5z"/></g>,
  tts: <g><path d="M4 7h9M4 12h6M4 17h4"/><path d="M16 9v6M19 7v10M22 10v4"/></g>,
  changer: <g><path d="M3 12v0M6.5 8.5v7M10 5v14M13.5 8v8M17 10.5v3M20.5 7.5v9"/></g>,
  fx: <g><path d="M4 9v6h3l4.5 3.5V5.5L7 9z"/><path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.8 7a7 7 0 0 1 0 10"/></g>,
  imgvid: <g><rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 15.5l4.5-4 3.5 3 3-2.5 6 5"/><circle cx="8.5" cy="9.5" r="1.4"/></g>,
  isolator: <g><path d="M12 3v11a3 3 0 1 0 3-3h-3"/><path d="M6 13v1a6 6 0 0 0 12 0v-1"/><path d="M12 20v1"/></g>,
  studio: <g><rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 10h18"/><circle cx="8" cy="14.5" r="1.6"/><path d="M12 14.5h6"/></g>,
  dubbing: <g><circle cx="12" cy="12" r="9"/><path d="M9.5 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"/></g>,
  music: <g><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></g>,
  productions: <g><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M7 6 5.5 3M12 6l-1.5-3M17 6l-1.5-3M3 11h18"/></g>,
  help: <g><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.3 2.4c-.6.2-.9.8-.9 1.4v.4"/><path d="M12 17v.01" strokeWidth="2.2"/></g>,
  settings: <g><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2 2 2 0 1 1-4 0 1.7 1.7 0 0 0-2.9-1.2l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.7 1.7 0 0 0 2.6 15a2 2 0 1 1 0-4 1.7 1.7 0 0 0 1.2-2.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.7 1.7 0 0 0 9.5 4.1a2 2 0 1 1 4 0 1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0 1.2 2.9 2 2 0 1 1 0 4z"/></g>,
  bank: <g><path d="M3 10 12 4l9 6"/><path d="M5 10v9h14v-9"/><path d="M9 19v-5h6v5"/></g>,
  // --- chrome / actions ---
  collapse: <g><path d="M15 5v14"/><path d="M10 8.5 6.5 12 10 15.5"/><path d="M4 12h6"/></g>,
  expand: <g><path d="M9 5v14"/><path d="M14 8.5 17.5 12 14 15.5"/><path d="M20 12h-6"/></g>,
  search: <g><circle cx="11" cy="11" r="7"/><path d="m16.2 16.2 4.3 4.3"/></g>,
  bell: <g><path d="M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6"/><path d="M10.5 20a2 2 0 0 0 3 0"/></g>,
  chat: <g><path d="M21 12a8 8 0 0 1-11.6 7.1L4 21l1.9-5.4A8 8 0 1 1 21 12z"/></g>,
  crown: <g><path d="M3 8l4 3.5L12 5l5 6.5L21 8l-1.6 11H4.6z"/></g>,
  plus: <g><path d="M12 5v14M5 12h14"/></g>,
  mail: <g><path d="M3 6.5h18v11H3z"/><path d="m3.5 7 8.5 6 8.5-6"/></g>,
  people: <g><circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.4 6-5.4s6 2.1 6 5.4"/><path d="M16 5.6a3.2 3.2 0 0 1 0 6.3M17.5 14.9c2.1.6 3.5 2.3 3.5 5.1"/></g>,
  logout: <g><path d="M15 4h3.5A1.5 1.5 0 0 1 20 5.5v13A1.5 1.5 0 0 1 18.5 20H15"/><path d="M11 8 7 12l4 4M7 12h9"/></g>,
  qr: <g><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4z"/><path d="M14 14h2.5v2.5H14zM17.5 17.5H20V20h-2.5zM14 19.5h1.5M19.5 14H20"/></g>,
  shield: <g><path d="M12 3l7 2.5v5.8c0 4.2-2.8 7.6-7 9.2-4.2-1.6-7-5-7-9.2V5.5z"/><path d="m9 12 2.2 2.2L15.5 10"/></g>,
  more: <g><circle cx="12" cy="5" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/></g>,
  pencil: <g><path d="M16.8 3.8a2.1 2.1 0 0 1 3 3L8.5 18.1 4 20l1.9-4.5z"/></g>,
  download: <g><path d="M12 3v12"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M4 20h16"/></g>,
  undo: <g><path d="M4 9h9a5 5 0 0 1 0 10H8"/><path d="M7.5 5.5 4 9l3.5 3.5"/></g>,
  stop: <rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none"/>,
  share: <g><circle cx="18" cy="5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="19" r="2.6"/><path d="m8.4 10.7 7.2-4.2M8.4 13.3l7.2 4.2"/></g>,
  trash: <g><path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7l1 13h10l1-13"/><path d="M10.5 11v5.5M13.5 11v5.5"/></g>,
  mic: <g><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6 12.5v.5a6 6 0 0 0 12 0v-.5"/><path d="M12 19v2"/></g>,
  back: <g><path d="M14.5 6 8.5 12l6 6"/></g>,
  chevR: <g><path d="M9.5 6l6 6-6 6"/></g>,
  chevL: <g><path d="M14.5 6l-6 6 6 6"/></g>,
  chevD: <g><path d="M6 9.5l6 6 6-6"/></g>,
  chevUD: <g><path d="M8 10l4-4 4 4M8 14l4 4 4-4"/></g>,
  swap: <g><path d="M4 8h13l-3-3M20 16H7l3 3"/></g>,
  zoomIn: <g><circle cx="11" cy="11" r="7"/><path d="M11 8.5v5M8.5 11h5M16.2 16.2l4.3 4.3"/></g>,
  zoomOut: <g><circle cx="11" cy="11" r="7"/><path d="M8.5 11h5M16.2 16.2l4.3 4.3"/></g>,
  headphone: <g><path d="M4 14v-2a8 8 0 0 1 16 0v2"/><rect x="2.5" y="13" width="4.5" height="7" rx="2"/><rect x="17" y="13" width="4.5" height="7" rx="2"/></g>,
  mute: <g><path d="M4 9v6h3l4.5 3.5V5.5L7 9z"/><path d="m16 10 5 4M21 10l-5 4"/></g>,
  speaker: <g><path d="M4 9v6h3l4.5 3.5V5.5L7 9z"/><path d="M15.5 10a3 3 0 0 1 0 4"/></g>,
  file: <g><path d="M6 2h9l5 5v15H6z"/><path d="M14 2v6h6"/><path d="M9.5 13.5h5M9.5 17h3"/></g>,
  note: <g><path d="M5 4h14v11l-4 5H5z"/><path d="M9 9h6M9 13h4"/></g>,
  reset: <g><path d="M4 12a8 8 0 1 0 3-6.2"/><path d="M4 4v4h4"/></g>,
  sort: <g><path d="M4 6h13M4 12h9M4 18h5"/><path d="M18 13v6l2.5-2.5"/></g>,
  check: <g><path d="M5 12.5 9.5 17 19 7" strokeWidth="2.2"/></g>,
  eq: <g><path d="M6 4v16M12 4v16M18 4v16"/><circle cx="6" cy="9" r="2" fill="currentColor" stroke="none"/><circle cx="12" cy="14" r="2" fill="currentColor" stroke="none"/><circle cx="18" cy="8" r="2" fill="currentColor" stroke="none"/></g>,
  reviewer: <g><path d="M4 12h2M8 8v8M12 5v14M16 9v6M20 11v2"/></g>,
  bars: <g><path d="M7 15v-4M12 15V9M17 15v-6" strokeWidth="2.2"/></g>,
  voicescan: <g><path d="M7 4H5.5A1.5 1.5 0 0 0 4 5.5V7M17 4h1.5A1.5 1.5 0 0 1 20 5.5V7M7 20H5.5A1.5 1.5 0 0 1 4 18.5V17M17 20h1.5a1.5 1.5 0 0 0 1.5-1.5V17"/><path d="M9 10v4M12 8v8M15 10v4" strokeWidth="1.9"/></g>,
  chatprofile: <g><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"/><circle cx="12" cy="9.5" r="2.2"/><path d="M8.4 15.6a4 4 0 0 1 7.2 0"/></g>
};
function Icon({ n, s = 15, sw = 1.8, flip }) {
  // Prefer the real Figma icon set (console/icons/icon-data.window.js);
  // fall back to the hand-drawn stroke set for anything it doesn't carry.
  const figName = FIGMAP[n];
  const fig = figName && window.FIGICONS && window.FIGICONS[figName];
  if (fig) return <svg width={s} height={s} viewBox={fig.viewBox} fill="none"
    style={flip ? { transform: 'scaleX(-1)' } : undefined}
    dangerouslySetInnerHTML={{ __html: fig.body }}/>;
  return <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={flip ? { transform: 'scaleX(-1)' } : undefined}>{IC[n]}</svg>;
}

// semantic name -> Figma component name (from console/icons/Icon.d.ts)
const FIGMAP = {
  home: 'Home50', explore: 'DiscoverCircle', tts: 'Type', changer: 'SongsWaveScan',
  review: 'SongsWave', fx: 'VolumeHigh', imgvid: 'FolderFileImage', isolator: 'MicrophoneAddPlus',
  studio: 'Layout04', dubbing: 'PlaySquare', music: 'MusicLibrary', productions: 'FlimSlate',
  help: 'QuestionMarkSquare', settings: 'Setting9', bank: 'Home50',
  search: 'Search', bell: 'NewRingtoneMessage', chat: 'Messages4', crown: 'Crown',
  plus: 'Plus4', more: 'InfoMenu3', pencil: 'Edit', download: 'Download1', share: 'Share3',
  trash: 'Delete2', mic: 'MicrophoneAddPlus', back: 'ArrowLeft2',
  chevR: 'ArrowRightSLine', chevL: 'ArrowLeftSLine', chevD: 'ArrowDown2', chevUD: 'UnfoldMore',
  swap: 'EnlargeHorizontal2', zoomIn: 'SearchAdd', zoomOut: 'SearchMinus',
  headphone: 'Headphone2', mute: 'VolumeOff', speaker: 'VolumeHigh', file: 'Mp301',
  video: 'Video', note: 'DocumentBadgePlus', reset: 'Update1', sort: 'SortDown',
  check: 'Checkmark2', play: 'Play', pause: 'Pause', prev: 'Backward', next: 'Forward',
  collapse: 'LeftLine', expand: 'LeftLine', filter: 'Filter', copy: 'Copy1',
  save: 'FloppyDiskSave', close: 'CloseRemove', time: 'TimeCircle', seen: 'SeenCheckmark',
  /* reviewer: SongsWaveScan is a solid-fill glyph that reads as a dark tile at 20px —
     fall through to the local stroke wave-scan icon instead */
  wave: 'SongsWave', chart: 'WhiteboardChart', doc: 'BookOpen01',
  queue: 'PlayList', inbox: 'PhoneWaveMusic', lock: 'Lock', eq: 'Setting7', upload: 'CloudUpload',
  translate: 'Translation', caption: 'ClosedCaptionAlt', book: 'AudioBook02', cpu: 'CpuProcessorCharge',
  mail: 'Email2', logout: 'Logout', bars: 'Analytics01', voicescan: 'SongsWaveScan', chatprofile: 'ChatProfile'
};

// --- audio engine: speech-like murmur synth, simulated playback clock ---
const Player = {
  ctx: null, nodes: null, el: null, raf: 0, t0: 0, from: 0, art: null, cb: null, endCb: null, until: null,
  ensure() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); return this.ctx; },
  start(art, from, seed, cb, endCb, until) {
    this.stop();
    if (art && art.src) return this.startFile(art, from, cb, endCb, until);
    const ctx = this.ensure(); if (ctx.state === 'suspended') ctx.resume();
    this.art = art; this.from = from; this.cb = cb; this.endCb = endCb; this.until = until || art.dur;
    const v = LAB.voices[art.voice] || art;
    const osc = ctx.createOscillator(); osc.type = 'sawtooth';
    const base = v.g === 'F' ? 195 : 112;
    osc.frequency.value = base;
    const vib = ctx.createOscillator(); vib.frequency.value = 4.6;
    const vibG = ctx.createGain(); vibG.gain.value = base * 0.06;
    vib.connect(vibG); vibG.connect(osc.frequency);
    const filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 720; filt.Q.value = 0.8;
    const gain = ctx.createGain(); gain.gain.value = 0;
    osc.connect(filt); filt.connect(gain); gain.connect(ctx.destination);
    const peaks = wavePeaks(seed, 220);
    const now = ctx.currentTime, remain = this.until - from;
    for (let i = 0; i < 220; i++) {
      const tt = (i / 220) * art.dur;
      if (tt < from || tt > this.until) continue;
      gain.gain.setTargetAtTime(peaks[i] * 0.05, now + (tt - from), 0.03);
      osc.frequency.setTargetAtTime(base * (0.9 + peaks[i] * 0.35), now + (tt - from), 0.05);
    }
    gain.gain.setTargetAtTime(0, now + remain, 0.02);
    osc.start(); vib.start();
    this.nodes = { osc, vib, gain };
    this.t0 = performance.now();
    const tick = () => {
      const t = this.from + (performance.now() - this.t0) / 1000;
      if (t >= this.until) { const e = this.endCb; this.stop(); e && e(); return; }
      this.cb && this.cb(t);
      this.raf = requestAnimationFrame(tick);
    };
    this.raf = requestAnimationFrame(tick);
  },
  // real evaluation audio — the console plays the actual rendered WAV
  startFile(art, from, cb, endCb, until) {
    this.art = art; this.from = from; this.cb = cb; this.endCb = endCb; this.until = until || art.dur;
    const el = this.el || (this.el = new Audio());
    if (el.getAttribute('src') !== art.src) { el.setAttribute('src', art.src); el.load(); }
    el.currentTime = from;
    el.play().catch(() => {});
    const tick = () => {
      const t = el.currentTime;
      if (t >= this.until - 0.02 || el.ended) { const e = this.endCb; this.stop(); e && e(); return; }
      this.cb && this.cb(t);
      this.raf = requestAnimationFrame(tick);
    };
    this.raf = requestAnimationFrame(tick);
  },
  stop() {
    cancelAnimationFrame(this.raf);
    if (this.el) { try { this.el.pause(); } catch (e) {} }
    if (this.nodes) { try { this.nodes.gain.gain.cancelScheduledValues(0); this.nodes.gain.gain.value = 0; this.nodes.osc.stop(); this.nodes.vib.stop(); } catch (e) {} this.nodes = null; }
    this.art = null;
  }
};
window.Player = Player;

// --- waveform canvas ---
// Bars are a FIXED size (barW + gap) and the count is derived from the container,
// so a wave looks identical at any width instead of stretching. Peaks are resampled.
function WaveCanvas({ peaks, progress = 0, height = 150, region, markers = [], onSeek, onRegion, accent, dim: dimC, className, barW = 4, gap = 2, minH = 3, caps = true }) {
  const ref = React.useRef(null);
  const drag = React.useRef(null);
  const [tmpRegion, setTmpRegion] = React.useState(null);
  const draw = () => {
    const cv = ref.current; if (!cv) return;
    const dpr = window.devicePixelRatio || 1;
    const w = cv.clientWidth, h = cv.clientHeight;
    if (!w || !h) return;
    if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
    const x = cv.getContext('2d'); x.setTransform(dpr, 0, 0, dpr, 0, 0); x.clearRect(0, 0, w, h);
    const cs = getComputedStyle(document.body);
    const dim = dimC || cs.getPropertyValue('--wave-dim').trim() || '#dddddd';
    const inkC = cs.getPropertyValue('--ink').trim() || '#010101';
    const warnC = cs.getPropertyValue('--warn').trim();
    const accC = accent || cs.getPropertyValue('--wave').trim() || cs.getPropertyValue('--accent').trim();
    const step = barW + gap;
    const n = Math.max(1, Math.floor((w + gap) / step));
    const src = peaks && peaks.length ? peaks : [0];
    const r = Math.min(barW / 2, 2.5);
    const mid = h / 2;
    // Fade toward the SAME colour at zero alpha. Using `transparent` (rgba(0,0,0,0))
    // makes canvas interpolate through black, which is the dark grey fringe.
    const zeroAlpha = col => {
      x.fillStyle = col;
      const c = x.fillStyle;
      if (c[0] === '#') {
        const n = c.length === 4
          ? [c[1] + c[1], c[2] + c[2], c[3] + c[3]]
          : [c.slice(1, 3), c.slice(3, 5), c.slice(5, 7)];
        const [r, g, b] = n.map(h => parseInt(h, 16));
        return `rgba(${r},${g},${b},0)`;
      }
      const p = c.match(/[\d.]+/g) || [0, 0, 0];
      return `rgba(${p[0]},${p[1]},${p[2]},0)`;
    };
    const grad = (col, y0, y1) => {
      const clear = zeroAlpha(col);
      const g = x.createLinearGradient(0, y0, 0, y1);
      g.addColorStop(0, clear); g.addColorStop(.30, col);
      g.addColorStop(.70, col); g.addColorStop(1, clear);
      return g;
    };
    for (let i = 0; i < n; i++) {
      // resample: average the source bucket so density never distorts the shape
      const a = Math.floor((i / n) * src.length), b = Math.max(a + 1, Math.floor(((i + 1) / n) * src.length));
      let sum = 0; for (let k = a; k < b; k++) sum += src[k];
      const p = sum / (b - a);
      const ph = Math.max(minH, p * (h - 4));
      const bx = i * step, by = mid - ph / 2;
      const played = (i + 0.5) / n <= progress;
      const col = played ? accC : dim;
      // both played and unplayed bars fade toward their tips, each to its own
      // colour at zero alpha (never through black)
      x.fillStyle = ph > 8 ? grad(col, by, by + ph) : col;
      x.beginPath();
      if (x.roundRect) x.roundRect(bx, by, barW, ph, r); else x.rect(bx, by, barW, ph);
      x.fill();
    }
    const reg = tmpRegion || region;
    if (reg) {
      const x0 = reg[0] * w, x1 = reg[1] * w;
      // selection sits in our own palette, not an amber highlighter
      x.fillStyle = 'rgba(27,171,68,.07)'; x.fillRect(x0, 0, x1 - x0, h);
      x.strokeStyle = accC; x.lineWidth = 1; x.globalAlpha = .5;
      x.beginPath(); x.moveTo(x0 + 0.5, 0); x.lineTo(x0 + 0.5, h);
      x.moveTo(x1 - 0.5, 0); x.lineTo(x1 - 0.5, h); x.stroke();
      x.globalAlpha = 1;
    }
    markers.forEach(m => { x.fillStyle = accC; x.fillRect(m * w - 1, 0, 2, 6); x.fillRect(m * w - 1, h - 6, 2, 6); });
    // playhead: hairline in the accent with round caps, the way the kit draws it
    if (progress > 0 && progress < 1) {
      const px = Math.round(progress * w) + 0.5;
      x.strokeStyle = accC; x.lineWidth = 1.5; x.lineCap = 'round';
      x.beginPath(); x.moveTo(px, caps ? 5 : 0); x.lineTo(px, h - (caps ? 5 : 0)); x.stroke();
      if (caps) {
        x.fillStyle = accC;
        x.beginPath(); x.arc(px, 5, 4, 0, 7); x.fill();
        x.beginPath(); x.arc(px, h - 5, 4, 0, 7); x.fill();
      }
    }
  };
  React.useEffect(draw);
  React.useEffect(() => { const r = () => draw(); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); });
  const frac = e => { const r = ref.current.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); };
  const down = e => { drag.current = { start: frac(e), moved: false }; };
  const move = e => {
    if (!drag.current || !onRegion) return;
    const f = frac(e);
    if (Math.abs(f - drag.current.start) > 0.01) { drag.current.moved = true; setTmpRegion([Math.min(drag.current.start, f), Math.max(drag.current.start, f)]); }
  };
  const up = e => {
    if (!drag.current) return;
    const f = frac(e);
    if (drag.current.moved && onRegion) onRegion([Math.min(drag.current.start, f), Math.max(drag.current.start, f)]);
    else if (onSeek) onSeek(f);
    drag.current = null; setTmpRegion(null);
  };
  return <canvas ref={ref} className={className} style={{ width: '100%', height }} onMouseDown={down} onMouseMove={move} onMouseUp={up} onMouseLeave={() => { drag.current = null; setTmpRegion(null); }}></canvas>;
}

// static mini waveform for review cards (SVG, cheap) — dense continuous bars
function MiniWave({ seed, played = 0, accentAll = false, height = 28 }) {
  const N = 96, W = 300, pitch = W / N, bw = pitch * 0.55;
  const peaks = React.useMemo(() => wavePeaks(seed, N), [seed]);
  return <svg className="wavemini" width="100%" height={height} viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="none">
    {peaks.map((p, i) => {
      const h = Math.max(2, p * height * 0.92);
      const on = accentAll || (i / N) <= played;
      return <rect key={i} x={i * pitch} y={(height - h) / 2} width={bw} height={h}
        fill={on ? 'var(--accent)' : 'var(--wave-dim, #DDD)'}/>;
    })}
  </svg>;
}

// table-cell waveform thumbnail (the kit's "Voice Duration" cell) — fixed 2px bars
function CellWave({ seed, bars, w = 96, h = 22, barW = 2, gap = 1.5, played = 0 }) {
  const n = bars || Math.floor((w + gap) / (barW + gap));
  const peaks = React.useMemo(() => wavePeaks(seed, n), [seed, n]);
  return <svg className="cellwave" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
    {peaks.map((p, i) => {
      const ph = Math.max(2, p * (h - 2));
      return <rect key={i} x={i * (barW + gap)} width={barW} y={(h - ph) / 2} height={ph} rx={barW / 2}
        fill={(i + 0.5) / n <= played ? 'var(--wave)' : 'var(--wave-dim)'}/>;
    })}
  </svg>;
}

function Chip({ kind, children }) { return <span className={'chip ' + (kind || '')}>{children}</span>; }
function Tag({ kind, children }) { return <span className={'tag ' + (kind || '')}>{children}</span>; }
function VDot({ voice, s = 26 }) { const v = LAB.voices[voice]; return <span className="vdot" style={{ background: v.c, width: s, height: s, fontSize: s * 0.4 }}>{v.name[0]}</span>; }

// the kit's Logo component (36×36, r8, primary-600 with a primary-500 inset
// and the 3-path white glyph transcribed from /Component/components/Logo/Logo.jsx)
function KLogo({ s = 36 }) {
  return <span className="klogo" style={{ width: s, height: s }}>
    <svg width={s * 0.53} height={s * 0.56} viewBox="0 0 19 20" fill="currentColor">
      <path d="M 9.5 11.304 L 14.25 8.696 L 14.25 2.609 L 9.5 0 L 4.75 2.609 L 9.5 5.652 L 9.5 11.304 Z"/>
      <path d="M 9.5 11.304 L 4.75 8.696 L 0 11.304 L 0 17.391 L 4.75 20 L 4.75 14.348 L 9.5 11.304 Z"/>
      <path d="M 9.5 11.304 L 14.25 14.348 L 19 11.304 L 19 17.391 L 14.25 20 L 9.5 17.391 L 9.5 11.304 Z"/>
    </svg>
  </span>;
}

// the kit's Avatar-1 bitmap (copied verbatim out of the .fig)
function KAvatar({ s = 32 }) {
  return <img className="kavatar" src="console/icons/avatar-4e4941d99fe3fc0a.png" alt="" width={s} height={s}/>;
}

// voice identity mark — typographic monogram in the kit's neutral tokens.
// No illustration/bitmap imagery: the .fig's Voice Component art is not ours to ship here.
function VoiceAvatar({ s = 30, id = '', name = '' }) {
  const ch = (name || id || '?').trim().charAt(0).toUpperCase();
  return <span className="vavatar" style={{ width: s, height: s, fontSize: Math.round(s * 0.4) }}>{ch}</span>;
}

// checkbox in the kit's style
function Cbx({ on, onChange }) {
  return <button className={'cbx' + (on ? ' on' : '')} onClick={e => { e.stopPropagation(); onChange && onChange(!on); }} aria-checked={!!on} role="checkbox">
    {on && <Icon n="check" s={11}/>}
  </button>;
}

// pagination footer used by every table screen
function Pager({ page = 1, total = 24, per = 6, onPage }) {
  const nums = [1, 2, 3, 4, 5];
  return <div className="pager">
    <span className="pg-of">Page {page} of {total}</span>
    <div className="pg-nums">
      <button className="pg-arrow" disabled={page <= 1} onClick={() => onPage && onPage(page - 1)}><Icon n="chevL" s={13}/></button>
      {nums.map(n => <button key={n} className={'pg-n' + (n === page ? ' on' : '')} onClick={() => onPage && onPage(n)}>{n}</button>)}
      <span className="pg-gap">…</span>
      <button className="pg-n" onClick={() => onPage && onPage(total)}>{total}</button>
      <button className="pg-arrow" disabled={page >= total} onClick={() => onPage && onPage(page + 1)}><Icon n="chevR" s={13}/></button>
    </div>
    <button className="pg-per">{per} / Page <Icon n="chevD" s={12}/></button>
  </div>;
}

// Slider [1.0] — transcribed from /Component/components/Percentage75:
// 62 tall / gap 6, label Open Sans 500 16/20, track 6px r96 gray-200,
// progress primary-600, dot 16px white ring + inner 6px primary-600.
function VSlider({ label, value, min = 0, max = 1, lo, hi, onChange }) {
  const pct = ((value - min) / (max - min)) * 100;
  const ref = React.useRef(null);
  const set = e => {
    const r = ref.current.getBoundingClientRect();
    const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
    onChange && onChange(min + f * (max - min));
  };
  return <div className="vsl">
    {label && <div className="vsl-h"><span>{label}</span></div>}
    <div className="vsl-track" ref={ref} onMouseDown={e => { set(e); const mv = ev => set(ev); const up = () => { window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', mv); window.addEventListener('mouseup', up); }}>
      <span className="vsl-fill" style={{ width: pct + '%' }}></span>
      <span className="vsl-knob" style={{ left: pct + '%' }}><i></i></span>
    </div>
    <div className="vsl-ends"><span>{lo}</span><span>{hi}</span></div>
  </div>;
}

function Toggle({ on, onChange, label }) {
  return <button className="tgl-row" onClick={() => onChange && onChange(!on)}>
    <span className={'tgl' + (on ? ' on' : '')}></span>
    <span className="tgl-lbl">{label}</span>
  </button>;
}

// --- edge fades on every scroll region ---
// A scroller gets a mask that fades only the edge it can actually scroll toward,
// so a full list fades top+bottom, a list at rest fades only downward, and a
// container with nothing to scroll shows no fade at all.
const FADE_Y = '.nav-scroll,.scroll,.col-cards,.pane,.qlist,.doclist,.docbody,.submenu,.train,.wb-stage,.wb-side,.tl-insp,.board,.rs-main,.rss-list,.dv-main,.dv-side,.ar-page,.pop,.sm-takes';
const FADE_X = '.dubrow,.lanestrip,.tl-lanes,.vb-tags,.rv-rail,.ar-chips,.dv-langs';
const FADE = 28;

function paintFade(el, horiz) {
  const span = horiz ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
  // a nav row must never sit half-masked (it reads as disabled), so the rail fades short
  const size = el.classList.contains('nav-scroll') ? 10 : FADE;
  if (span <= 2) { el.style.setProperty('--f-a', '0px'); el.style.setProperty('--f-b', '0px'); return; }
  const pos = horiz ? el.scrollLeft : el.scrollTop;
  el.style.setProperty('--f-a', Math.round(Math.max(0, Math.min(size, pos))) + 'px');
  el.style.setProperty('--f-b', Math.round(Math.max(0, Math.min(size, span - pos))) + 'px');
}

function scanFades() {
  document.querySelectorAll(FADE_Y).forEach(el => { el.classList.add('fade-y'); paintFade(el, false); });
  document.querySelectorAll(FADE_X).forEach(el => { el.classList.add('fade-x'); paintFade(el, true); });
}

function initScrollFades() {
  document.addEventListener('scroll', e => {
    const el = e.target;
    if (!(el instanceof Element)) return;
    if (el.classList.contains('fade-y')) paintFade(el, false);
    if (el.classList.contains('fade-x')) paintFade(el, true);
  }, true);
  window.addEventListener('resize', scanFades);
  new MutationObserver(scanFades).observe(document.documentElement, { childList: true, subtree: true });
  // content can change size without mutating (fonts, canvases) — cheap settle pass
  setInterval(scanFades, 400);
  scanFades();
}

Object.assign(window, { Icon, WaveCanvas, MiniWave, CellWave, Chip, Tag, VDot, VoiceAvatar, KLogo, KAvatar, Cbx, Pager, VSlider, Toggle, initScrollFades, scanFades, wavePeaks });
