/* ───────────────────────────────────────────────────────────────────────────
   Wivexx config editors — in-page slide-over panels (commands + goals).
   Same look as wivexx.com: ink/volt/bone, Space Grotesk / Inter / Space Mono,
   pill buttons, hairline graphite cards. Talks to Python via WivexxUI.py(...).
   ─────────────────────────────────────────────────────────────────────────── */
(function () {
  "use strict";

  const UI = () => window.WivexxUI || {};
  const body = () => document.getElementById("panel-body");
  const titleEl = () => document.getElementById("panel-title");

  function call(method) {
    const args = Array.prototype.slice.call(arguments, 1);
    const ui = UI();
    const r = ui.py ? ui.py.apply(null, [method].concat(args)) : undefined;
    return (r && r.then) ? r : Promise.resolve(r);
  }
  function openPanel() { UI().openPanel && UI().openPanel(); }
  function toast(t) { UI().toast && UI().toast(t); }
  function esc(s) {
    return (s == null ? "" : String(s)).replace(/[&<>"]/g,
      (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
  }
  const q = (sel) => body().querySelector(sel);
  const qa = (sel) => Array.prototype.slice.call(body().querySelectorAll(sel));

  // ═══════════════════════════════════════════════════════════════════════════
  const Panels = {
    open(name) {
      this._stopActivity();           // any panel switch stops the activity poller
      this._stopMacroPoll();          // …and the recorder status poller
      this._stopCapSpin();            // …and the captions loading ticker
      this._tlStop && this._tlStop(); // …and the reel play-preview loop
      this._msStop && this._msStop(); // …and the mascot-studio preview loop
      this._removeHubBar();           // hubs re-add their own tab bar below
      const _pnl = document.getElementById("panel");
      if (_pnl && name !== "captions" && name !== "captionseditor") _pnl.classList.remove("cap-wide", "cap-vert");
      if (_pnl) _pnl.classList.remove("studio");        // studio re-adds it via _openStudio
      if (name === "commands") return this.openCommands();
      if (name === "goals") return this.openGoals();
      if (name === "shortcuts") return this.openShortcuts();
      if (name === "autoinput") return this.openAutoInput();
      if (name === "memory") return this.openMemory();
      if (name === "capabilities") return this.openCapabilities();
      if (name === "captions") return this.openCaptions();
      if (name === "captionseditor") return this.openCaptionsEditor(true);
      if (name === "timeline") return this.openTimeline();
      if (name === "deliver") return this.openDeliver();
      if (name === "social") return this.openSocialHub();
      if (name === "mascot") return this.openMascotStudio();
      if (name === "actions") return this.openActionsHub();
      if (name === "socialstats") return this.openSocial();
      if (name === "recorder") return this.openRecorder();
      if (name === "activity") return this.openActivity();
      if (name === "notifyprefs") return this.openNotifyPrefs();
      if (name === "diskresults") return this.openDiskResults();
      if (name === "settings") return this.openSettings();
      if (name === "report") return this.openReport();
      if (name === "updates") return this.openUpdates();
      return false;
    },

    // ── HUB TAB BAR (a persistent row above the panel body that groups several
    //    sub-panels into one button: social = captions/deliver/tracking,
    //    actions = autoclicker/recorder). Lives OUTSIDE #panel-body so the
    //    sub-panels can replace the body without wiping the tabs. ──────────────
    _openHub(tabs, activeKey) {
      this._ensureHubBar(tabs);
      const t = tabs.find((x) => x.key === activeKey) || tabs[0];
      this[t.fn]();
      this._setHubActive(t.key);
      return true;
    },
    _ensureHubBar(tabs) {
      const panel = document.getElementById("panel");
      const pbody = document.getElementById("panel-body");
      let bar = document.getElementById("hub-tabs");
      if (!bar) {
        bar = document.createElement("div");
        bar.id = "hub-tabs"; bar.className = "hub-tabs";
        panel.insertBefore(bar, pbody);
      }
      bar.innerHTML = tabs.map((t) =>
        '<button type="button" class="hub-tab" data-hub="' + esc(t.key) + '">' + esc(t.label) + '</button>').join("");
      Array.prototype.slice.call(bar.querySelectorAll(".hub-tab")).forEach((b) =>
        b.addEventListener("click", () => {
          const k = b.getAttribute("data-hub");
          const t = tabs.find((x) => x.key === k);
          if (!t) return;
          this._stopActivity(); this._stopMacroPoll();
          this[t.fn]();
          this._setHubActive(k);
        }));
    },
    _setHubActive(key) {
      const bar = document.getElementById("hub-tabs");
      if (!bar) return;
      Array.prototype.slice.call(bar.querySelectorAll(".hub-tab")).forEach((b) =>
        b.classList.toggle("active", b.getAttribute("data-hub") === key));
    },
    _removeHubBar() {
      const bar = document.getElementById("hub-tabs");
      if (bar) bar.remove();
    },

    openSocialHub() {
      const tabs = [
        { key: "reel",     label: "reel maker", fn: "openTimeline" },
        { key: "captions", label: "captions", fn: "openCaptions" },
        { key: "deliver",  label: "deliver",  fn: "openDeliver" },
        { key: "social",   label: "tracking", fn: "openSocial" },
      ];
      // Mascot Studio: shown by default (it's the owner's build); a public release hides it
      // by setting localStorage.wvx_mascot="0".
      try { if (localStorage.getItem("wvx_mascot") !== "0") tabs.push({ key: "mascot", label: "🎭 mascot", fn: "openMascotStudio" }); } catch (e) { tabs.push({ key: "mascot", label: "🎭 mascot", fn: "openMascotStudio" }); }
      return this._openHub(tabs, "reel");
    },
    openActionsHub() {
      return this._openHub([
        { key: "autoclicker", label: "autoclicker", fn: "openAutoInput" },
        { key: "recorder",    label: "recorder",    fn: "openRecorder" },
        { key: "report",      label: "📣 report",   fn: "openReport" },
        // 'updates' moved into Settings (v1.34) — it opens there, first tab.
      ], "autoclicker");
    },
    // ── AUTO-UPDATE (checks wivexx.com, one-click update + relaunch) ───────────
    openUpdates() {
      titleEl().textContent = "updates";
      body().innerHTML =
        '<div class="rp-wrap" style="max-width:560px">' +
          '<p class="muted" style="margin:0 0 16px">Keep Wivexx current — updates install themselves and relaunch. ' +
          'Your settings, memory and projects are never touched.</p>' +
          '<div id="up-box" style="background:var(--graphite-2,#262624);border:1px solid rgba(255,255,255,.12);' +
            'border-radius:12px;padding:20px;text-align:center">' +
            '<div id="up-state" class="muted">Checking…</div>' +
            '<div id="up-actions" style="margin-top:14px"></div>' +
          "</div>" +
        "</div>";
      openPanel();
      const setState = (h) => { const e = q("#up-state"); if (e) e.innerHTML = h; };
      const setActions = (h) => { const e = q("#up-actions"); if (e) e.innerHTML = h; };
      call("check_update").then((r) => {
        if (!r || (!r.ok && !r.update)) {
          setState(r && r.error ? ("Couldn't check for updates.<br><span style='font-size:12px'>" + esc(r.error) + "</span>")
                                 : "Couldn't check for updates right now.");
          setActions('<button class="pbtn sm" id="up-retry">Try again</button>');
          const b = q("#up-retry"); if (b) b.addEventListener("click", () => this.openUpdates());
          return;
        }
        if (r.update) {
          setState("<b style='color:var(--volt,#c1f73a);font-size:18px'>Update available</b><br>" +
            "<span class='muted'>v" + esc(r.current) + " → v" + esc(r.latest) + "</span>" +
            (r.notes ? "<div style='margin-top:10px;text-align:left;font-size:13px;white-space:pre-wrap'>" + esc(r.notes) + "</div>" : ""));
          setActions('<button class="pbtn" id="up-go" style="background:var(--volt,#c1f73a);color:#0d0d0d;font-weight:600">Update &amp; relaunch</button>');
          const b = q("#up-go"); if (b) b.addEventListener("click", () => {
            setState("Downloading update…"); setActions("");
            call("apply_update", { url: r.url }).then((res) => {
              if (res && res.ok) setState("Update downloaded — Wivexx is relaunching…");
              else { setState("Update failed.<br><span style='font-size:12px'>" + esc((res && res.error) || "") + "</span>");
                     setActions('<button class="pbtn sm" id="up-retry2">Try again</button>');
                     const rb = q("#up-retry2"); if (rb) rb.addEventListener("click", () => this.openUpdates()); }
            });
          });
        } else {
          setState("✅ You're on the latest version<br><span class='muted'>v" + esc(r.current) + "</span>");
          setActions("");
        }
      }).catch(() => setState("Couldn't check for updates right now."));
      return true;
    },

    // ── MASCOT STUDIO (owner-only): a preview-first stage to compose + export the dot ──
    _MS_EMOS: [["happy", "😊"], ["angry", "😠"], ["sad", "😢"], ["excited", "🤩"], ["love", "😍"],
      ["surprised", "😮"], ["suspicious", "🤨"], ["sleepy", "😴"], ["alert", "👀"], ["focused", "🧐"],
      ["squint", "😑"], ["neutral", "😐"]],
    _MS_MOTIONS: [["none", "still"], ["shake", "↔ shake"], ["nod", "↕ nod"], ["bounce", "⤴ bounce"],
      ["wiggle", "〰 wiggle"], ["lean", "↩ sway"]],
    openMascotStudio() {
      titleEl().textContent = "mascot studio";
      const pnl = document.getElementById("panel"); if (pnl) pnl.classList.add("cap-wide");
      if (!this._msState) this._msState = { emo: "happy", motion: "none", talk: true, dur: 4, size: 540,
                                            backdrop: "checker", prores: true, png: false, beats: [], sfx: "none",
                                            color: null,     // null = brand volt; else a #hex orb colour
                                            prop: null, propUrl: null };  // held image: {name,x,y,scale,rot} + its dataURL
      if (this._msState.propUrl && !this._msPropImg) {       // re-decode a held image on panel re-open
        const im = new Image(); im.onload = () => { this._msPropImg = im; this._msPreviewRefresh(); }; im.src = this._msState.propUrl;
      }
      const s = this._msState;
      const chip = (cls, v, label, on) => '<button class="ms-chip ' + cls + (on ? " on" : "") + '" data-v="' + v + '">' + label + "</button>";
      body().innerHTML =
        '<div class="ms-studio">' +
          '<div class="ms-stage">' +
            '<canvas id="ms-preview" width="380" height="380"></canvas>' +
            '<div class="ms-stage-empty" id="ms-stage-empty">loading preview…</div>' +
            '<div class="ms-bd">' + [["checker", "▦"], ["dark", "▪"], ["light", "▫"]].map((b) =>
              '<button class="ms-bd-btn' + (s.backdrop === b[0] ? " on" : "") + '" data-bd="' + b[0] + '" title="' + b[0] + ' backdrop">' + b[1] + "</button>").join("") + "</div>" +
          "</div>" +
          '<div class="ms-ctrl">' +
            '<div class="ms-sec">emotion</div><div class="ms-chips" id="ms-emos">' +
              this._MS_EMOS.map((e) => chip("ms-emo", e[0], e[1] + " " + e[0], s.emo === e[0])).join("") + "</div>" +
            '<div class="ms-sec">motion</div><div class="ms-chips" id="ms-motions">' +
              this._MS_MOTIONS.map((e) => chip("ms-motion", e[0], e[1], s.motion === e[0])).join("") + "</div>" +
            '<div class="ms-sec">colour</div><div class="ms-chips" id="ms-colors">' +
              (() => {
                const sw = (c) => '<span style="display:inline-block;width:11px;height:11px;border-radius:50%;background:' + c +
                  ';margin-right:5px;vertical-align:middle;border:1px solid rgba(255,255,255,.35)"></span>';
                const presets = [["", "#c1f73a", "volt"], ["#39d7f7", "#39d7f7", "cyan"], ["#ff4f9e", "#ff4f9e", "pink"],
                  ["#a35cff", "#a35cff", "violet"], ["#ff8c1a", "#ff8c1a", "amber"], ["#f2f0ea", "#f2f0ea", "white"]];
                const cur = s.color || "#c1f73a";
                return presets.map((c) =>
                  '<button class="ms-chip ms-color' + (((c[0] || null) === s.color) ? " on" : "") + '" data-c="' + c[0] + '">' +
                  sw(c[1]) + c[2] + "</button>").join("") +
                  '<label class="ms-chip ms-color-wrap" title="custom colour">' + sw(cur) + "custom" +
                  '<input type="color" id="ms-color-custom" value="' + cur +
                  '" style="width:0;height:0;opacity:0;position:absolute;pointer-events:none"></label>';
              })() + "</div>" +
            '<div class="ms-row">' +
              '<label class="cap-check sm"><input type="checkbox" id="ms-talk"' + (s.talk ? " checked" : "") + "> talking</label>" +
              '<label class="ms-dur">length <input type="number" id="ms-dur" min="0.5" max="20" step="0.5" value="' + s.dur + '"> s</label>' +
            "</div>" +
            '<div class="ms-sec">🎬 timeline <span class="muted" style="text-transform:none;letter-spacing:0">(chain emotions into a story)</span></div>' +
            '<div class="ms-seq" id="ms-seq">' + this._msSeqHTML() + "</div>" +
            '<div class="ms-row"><button class="pbtn sm" id="ms-addbeat">+ add current as beat</button>' +
              '<button class="pbtn sm" id="ms-clearbeats">clear timeline</button></div>' +
            '<div class="ms-sec">sound</div><div class="ms-chips" id="ms-sfx">' +
              [["none", "🔇 none"], ["pop", "pop"], ["boing", "boing"], ["chime", "chime"], ["sparkle", "sparkle"],
               ["whoosh", "whoosh"], ["buzz", "buzz"]].map((e) => chip("ms-sfxc", e[0], e[1], s.sfx === e[0])).join("") + "</div>" +
            '<div class="ms-sec">🤲 in hand <span class="muted" style="text-transform:none;letter-spacing:0">(hold any image — it bobs with Wivexx)</span></div>' +
            '<div class="ms-row">' +
              '<label class="pbtn sm" style="position:relative;overflow:hidden;cursor:pointer">📎 choose image' +
                '<input type="file" accept="image/*" id="ms-prop-file" style="position:absolute;inset:0;opacity:0;cursor:pointer"></label>' +
              '<button class="pbtn sm" id="ms-prop-clear">clear</button>' +
              '<span id="ms-prop-name" class="muted" style="font-size:11px;align-self:center">' + (s.prop ? esc(s.prop.name) : "none") + "</span>" +
            "</div>" +
            '<div id="ms-prop-ctrls"' + (s.prop ? "" : ' style="display:none"') + ">" +
              (() => {
                const p = s.prop || { x: 0.5, y: 0.72, scale: 0.46, rot: 0 };
                const sl = (id, lab, min, max, val, unit) =>
                  '<label style="display:flex;align-items:center;gap:8px;font-size:11px;margin:4px 0">' +
                  '<span style="width:46px">' + lab + '</span>' +
                  '<input type="range" id="' + id + '" min="' + min + '" max="' + max + '" value="' + val + '" style="flex:1">' +
                  '<span id="' + id + '-v" style="width:42px;text-align:right">' + val + (unit || "") + "</span></label>";
                return sl("ms-prop-scale", "size", 10, 100, Math.round(p.scale * 100), "%") +
                       sl("ms-prop-x", "x", 0, 100, Math.round(p.x * 100), "%") +
                       sl("ms-prop-y", "y", 0, 100, Math.round(p.y * 100), "%") +
                       sl("ms-prop-rot", "rotate", -180, 180, Math.round(p.rot || 0), "°");
              })() + "</div>" +
            '<div class="ms-row ms-fmt">' +
              '<label class="cap-check sm"><input type="checkbox" id="ms-prores"' + (s.prores ? " checked" : "") + "> ProRes 4444 (.mov · alpha)</label>" +
              '<label class="cap-check sm"><input type="checkbox" id="ms-png"' + (s.png ? " checked" : "") + "> PNG seq (.zip)</label>" +
            "</div>" +
            '<button class="pbtn ms-export" id="ms-export">✨ export transparent clip</button>' +
            '<div id="ms-status" class="muted ms-status"></div>' +
          "</div>" +
        "</div>";
      openPanel();
      this._msWire();
      this._msPreviewRefresh();
      return true;
    },
    _msWire() {
      const self = this, s = this._msState, on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      qa("#ms-emos .ms-chip").forEach((c) => c.addEventListener("click", () => {
        s.emo = c.dataset.v; qa("#ms-emos .ms-chip").forEach((x) => x.classList.toggle("on", x === c)); self._msPreviewRefresh();
      }));
      qa("#ms-motions .ms-chip").forEach((c) => c.addEventListener("click", () => {
        s.motion = c.dataset.v; qa("#ms-motions .ms-chip").forEach((x) => x.classList.toggle("on", x === c)); self._msPreviewRefresh();
      }));
      qa(".ms-bd-btn").forEach((b) => b.addEventListener("click", () => {
        s.backdrop = b.dataset.bd; qa(".ms-bd-btn").forEach((x) => x.classList.toggle("on", x === b));  // redraws next frame
      }));
      on("#ms-talk", "change", (e) => { s.talk = e.target.checked; self._msPreviewRefresh(); });
      on("#ms-dur", "change", (e) => { s.dur = Math.max(0.5, Math.min(20, parseFloat(e.target.value) || 4)); });
      on("#ms-prores", "change", (e) => { s.prores = e.target.checked; });
      on("#ms-png", "change", (e) => { s.png = e.target.checked; });
      on("#ms-export", "click", () => self._mascotExport());
      on("#ms-addbeat", "click", () => {
        s.beats.push({ emo: s.emo, motion: s.motion, talk: s.talk, dur: s.dur });
        self._msRenderSeq(); self._msPreviewRefresh();
      });
      on("#ms-clearbeats", "click", () => { s.beats = []; self._msRenderSeq(); self._msPreviewRefresh(); });
      qa("#ms-sfx .ms-chip").forEach((c) => c.addEventListener("click", () => {
        s.sfx = c.dataset.v; qa("#ms-sfx .ms-chip").forEach((x) => x.classList.toggle("on", x === c));
      }));
      // colour: preset swatches + a native custom picker. null = brand volt.
      const csw = () => q("#ms-colors .ms-color-wrap span");
      qa("#ms-colors .ms-color").forEach((c) => c.addEventListener("click", () => {
        s.color = c.dataset.c || null;
        qa("#ms-colors .ms-color").forEach((x) => x.classList.toggle("on", (x.dataset.c || null) === s.color));
        const sw = csw(); if (sw) sw.style.background = s.color || "#c1f73a";
        self._msPreviewRefresh();
      }));
      on(".ms-color-wrap", "click", (e) => {
        e.preventDefault(); const ci = q("#ms-color-custom"); if (!ci) return;
        if (ci.showPicker) { try { ci.showPicker(); } catch (_) { ci.click(); } } else ci.click();
      });
      on("#ms-color-custom", "input", (e) => { const sw = csw(); if (sw) sw.style.background = e.target.value; });
      on("#ms-color-custom", "change", (e) => {
        s.color = e.target.value;
        qa("#ms-colors .ms-color").forEach((x) => x.classList.remove("on"));
        self._msPreviewRefresh();
      });
      // prop "in hand": load any image → composite it into the clip + size/x/y/rotate sliders
      on("#ms-prop-file", "change", (e) => {
        const f = e.target.files && e.target.files[0]; if (!f) return;
        const rd = new FileReader();
        rd.onload = () => {
          const im = new Image();
          im.onload = () => {
            self._msPropImg = im; s.propUrl = rd.result;
            s.prop = { name: f.name, x: 0.5, y: 0.72, scale: 0.46, rot: 0 };
            const nm = q("#ms-prop-name"); if (nm) nm.textContent = f.name;
            const cc = q("#ms-prop-ctrls"); if (cc) cc.style.display = "";
            self._msSyncPropSliders(); self._msPreviewRefresh();
          };
          im.src = rd.result;
        };
        rd.readAsDataURL(f);
      });
      on("#ms-prop-clear", "click", () => {
        s.prop = null; s.propUrl = null; self._msPropImg = null;
        const nm = q("#ms-prop-name"); if (nm) nm.textContent = "none";
        const cc = q("#ms-prop-ctrls"); if (cc) cc.style.display = "none";
        const fi = q("#ms-prop-file"); if (fi) fi.value = "";
        self._msPreviewRefresh();
      });
      const propSlider = (id, key, div, unit) => {
        const el = q(id), vv = q(id + "-v"); if (!el) return;
        el.addEventListener("input", () => { if (vv) vv.textContent = el.value + (unit || ""); });
        el.addEventListener("change", () => { if (s.prop) { s.prop[key] = parseFloat(el.value) / div; self._msPreviewRefresh(); } });
      };
      propSlider("#ms-prop-scale", "scale", 100, "%");
      propSlider("#ms-prop-x", "x", 100, "%");
      propSlider("#ms-prop-y", "y", 100, "%");
      propSlider("#ms-prop-rot", "rot", 1, "°");
      this._msRenderSeq();
    },
    _msSyncPropSliders() {
      const p = this._msState && this._msState.prop; if (!p) return;
      const set = (id, val, unit) => { const el = q(id), vv = q(id + "-v"); if (el) el.value = val; if (vv) vv.textContent = val + (unit || ""); };
      set("#ms-prop-scale", Math.round(p.scale * 100), "%");
      set("#ms-prop-x", Math.round(p.x * 100), "%");
      set("#ms-prop-y", Math.round(p.y * 100), "%");
      set("#ms-prop-rot", Math.round(p.rot || 0), "°");
    },
    _msProp() {
      if (!this._msPropImg || !this._msState.prop) return null;
      const p = this._msState.prop;
      return { img: this._msPropImg, x: p.x, y: p.y, scale: p.scale, rot: p.rot, bob: true };
    },
    _msSeqHTML() {
      const s = this._msState, emap = {};
      this._MS_EMOS.forEach((e) => { emap[e[0]] = e[1].split(" ")[0]; });
      if (!s.beats.length) return '<span class="muted" style="font-size:11px">single clip — tap “+ add” to chain beats into a story (e.g. 😐 → 😠 shake → 😮)</span>';
      return s.beats.map((b, i) =>
        '<button class="ms-beat" data-i="' + i + '" title="remove beat">' + (emap[b.emo] || b.emo) +
        (b.motion !== "none" ? " " + b.motion : "") + " · " + b.dur + "s ✕</button>" +
        (i < s.beats.length - 1 ? '<span class="ms-arrow">→</span>' : "")).join("");
    },
    _msRenderSeq() {
      const el = q("#ms-seq"); if (el) el.innerHTML = this._msSeqHTML();
      qa("#ms-seq .ms-beat").forEach((b) => b.addEventListener("click", () => {
        this._msState.beats.splice(+b.dataset.i, 1); this._msRenderSeq(); this._msPreviewRefresh();
      }));
    },
    _msDrawBackdrop(cx, w, h) {
      const bd = (this._msState && this._msState.backdrop) || "checker";
      if (bd === "dark") { cx.fillStyle = "#0f0f12"; cx.fillRect(0, 0, w, h); return; }
      if (bd === "light") { cx.fillStyle = "#ededf0"; cx.fillRect(0, 0, w, h); return; }
      const sq = 18;                                   // transparency checkerboard
      for (let y = 0; y < h; y += sq) for (let x = 0; x < w; x += sq) {
        cx.fillStyle = ((x / sq + y / sq) & 1) ? "#26262b" : "#34343b"; cx.fillRect(x, y, sq, sq);
      }
    },
    _msStop() { if (this._msRaf) { cancelAnimationFrame(this._msRaf); this._msRaf = 0; } },
    _msPlay() {
      this._msStop();
      const cv = q("#ms-preview"); if (!cv || !this._msFrames || !this._msFrames.length) return;
      const cx = cv.getContext("2d"), frames = this._msFrames, fps = this._msFps || 24;
      let last = performance.now(), idx = 0;
      const loop = (now) => {
        if (now - last >= 1000 / fps) { last = now; idx = (idx + 1) % frames.length; }
        this._msDrawBackdrop(cx, cv.width, cv.height);
        const im = frames[idx]; if (im) cx.drawImage(im, 0, 0, cv.width, cv.height);
        this._msRaf = requestAnimationFrame(loop);
      };
      this._msRaf = requestAnimationFrame(loop);
    },
    _hex2rgb(hex) {                                  // "#rrggbb" → [r,g,b] 0..1, else null
      if (!hex || typeof hex !== "string") return null;
      const m = hex.replace("#", "");
      if (m.length !== 6) return null;
      return [parseInt(m.slice(0, 2), 16) / 255, parseInt(m.slice(2, 4), 16) / 255, parseInt(m.slice(4, 6), 16) / 255];
    },
    // capture a short loop with the current settings → decode → live-loop it in the stage
    _msPreviewRefresh() {
      const D = window.WivexxDot, empty = q("#ms-stage-empty"), s = this._msState;
      const note = (m) => { if (empty) { empty.style.display = ""; empty.textContent = m; } };
      if (!D || typeof D.captureClip !== "function") { note("preview unavailable here"); return; }
      this._msStop(); note("rendering preview…");
      setTimeout(() => {
        let clip;
        const popt = s.beats.length ? { beats: s.beats, fps: 24, size: 300 }
          : { state: s.talk ? "speaking" : "idle", expr: s.emo, talk: s.talk, motion: s.motion,
              fps: 24, duration: Math.min(3, Math.max(1.5, s.dur)), size: 300 };
        popt.color = this._hex2rgb(s.color); popt.prop = this._msProp();
        try { clip = D.captureClip(popt); }
        catch (e) { note("preview failed"); return; }
        if (!clip || !clip.frames || !clip.frames.length) { note("preview failed (no WebGL?)"); return; }
        this._msFps = clip.fps;
        Promise.all(clip.frames.map((f) => fetch(f).then((r) => r.blob()).then((b) => createImageBitmap(b))))
          .then((bmps) => { this._msFrames = bmps; if (empty) empty.style.display = "none"; this._msPlay(); })
          .catch(() => note("preview decode failed"));
      }, 30);
    },
    _mascotExport() {
      const D = window.WivexxDot, st = q("#ms-status"), s = this._msState;
      const set = (m) => { if (st) st.innerHTML = m; };
      if (!D || typeof D.captureClip !== "function") { set("the mascot isn't available here."); return; }
      const formats = [];
      if (s.prores) formats.push("prores");
      if (s.png) formats.push("pngseq");
      if (!formats.length) formats.push("prores");
      const go = q("#ms-export"); if (go) go.disabled = true;
      const total = s.beats.length ? s.beats.reduce((a, b) => a + (b.dur || 1), 0) : s.dur;
      set("recording a " + total + "s clip…");
      setTimeout(() => {
        let clip;
        const eopt = s.beats.length ? { beats: s.beats, fps: 30, size: s.size }
          : { state: s.talk ? "speaking" : "idle", expr: s.emo, talk: s.talk, motion: s.motion,
              fps: 30, duration: s.dur, size: s.size };
        eopt.color = this._hex2rgb(s.color); eopt.prop = this._msProp();
        try { clip = D.captureClip(eopt); }
        catch (e) { set("capture failed: " + e); if (go) go.disabled = false; return; }
        if (!clip || !clip.frames || !clip.frames.length) { set("capture failed (no WebGL?)."); if (go) go.disabled = false; return; }
        set("rendering " + clip.count + " frames → transparent clip…");
        const nm = s.beats.length ? ("wivexx_story_" + s.beats.length + "beats")
          : ("wivexx_" + s.emo + (s.motion !== "none" ? "_" + s.motion : "") + (s.talk ? "_talk" : ""));
        call("mascot_export", { frames: clip.frames, fps: clip.fps, formats: formats, name: nm, sfx: s.sfx }).then((r) => {
          if (go) go.disabled = false;
          if (r && r.ok) set("✅ exported (" + Object.keys(r.outputs || {}).join(", ") + ") → <b>mascot_clips/</b>");
          else set("export failed: " + ((r && r.error) || "unknown"));
        }).catch((e) => { if (go) go.disabled = false; set("export error: " + e); });
      }, 60);
    },

    // ── REPORT / FEEDBACK (sent to the wivexx.com admin panel) ────────────────
    openReport() {
      titleEl().textContent = "send a report";
      if (!this._reportState) this._reportState = { cat: "feedback", photos: [] };
      const s = this._reportState;
      const cats = [["bug", "🐞 bug"], ["idea", "💡 idea"], ["feedback", "💬 feedback"], ["other", "✷ other"]];
      body().innerHTML =
        '<div class="rp-wrap" style="max-width:640px">' +
          '<p class="muted" style="margin:0 0 14px">Found a bug, have an idea, or just want to say something? ' +
          'It goes straight to the Wivexx team. Attach screenshots if it helps.</p>' +
          '<div class="ms-sec">type</div><div class="ms-chips" id="rp-cats">' +
            cats.map((c) => '<button class="ms-chip' + (s.cat === c[0] ? " on" : "") + '" data-v="' + c[0] + '">' + c[1] + "</button>").join("") + "</div>" +
          '<div class="ms-sec">your message</div>' +
          '<textarea id="rp-msg" rows="6" placeholder="What happened, or what would you love to see?" ' +
            'style="width:100%;box-sizing:border-box;background:var(--graphite-2,#262624);color:var(--bone,#f2f0ea);' +
            'border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:12px;font:inherit;resize:vertical"></textarea>' +
          '<div class="ms-sec">contact <span class="muted" style="text-transform:none;letter-spacing:0">(optional — if you want a reply)</span></div>' +
          '<input id="rp-contact" autocomplete="off" placeholder="email or @handle" ' +
            'style="width:100%;box-sizing:border-box;background:var(--graphite-2,#262624);color:var(--bone,#f2f0ea);' +
            'border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:10px 12px;font:inherit">' +
          '<div class="ms-sec">📎 screenshots <span class="muted" style="text-transform:none;letter-spacing:0">(up to 3)</span></div>' +
          '<div class="ms-row">' +
            '<label class="pbtn sm" style="position:relative;overflow:hidden;cursor:pointer">add image' +
              '<input type="file" accept="image/*" multiple id="rp-file" style="position:absolute;inset:0;opacity:0;cursor:pointer"></label>' +
            '<span id="rp-photo-note" class="muted" style="font-size:11px;align-self:center">none attached</span>' +
          "</div>" +
          '<div id="rp-thumbs" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"></div>' +
          '<div style="margin-top:18px;display:flex;gap:12px;align-items:center">' +
            '<button class="pbtn" id="rp-send" style="background:var(--volt,#c1f73a);color:#0d0d0d;font-weight:600">Send report</button>' +
            '<span id="rp-status" class="muted"></span>' +
          "</div>" +
        "</div>";
      openPanel();
      this._rpWire();
      return true;
    },
    _rpWire() {
      const self = this, s = this._reportState;
      qa("#rp-cats .ms-chip").forEach((c) => c.addEventListener("click", () => {
        s.cat = c.dataset.v; qa("#rp-cats .ms-chip").forEach((x) => x.classList.toggle("on", x === c));
      }));
      const note = q("#rp-photo-note"), thumbs = q("#rp-thumbs");
      const renderThumbs = () => {
        if (note) note.textContent = s.photos.length ? (s.photos.length + " attached") : "none attached";
        if (!thumbs) return;
        thumbs.innerHTML = s.photos.map((p, i) =>
          '<span style="position:relative;display:inline-block">' +
          '<img src="' + p + '" style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid rgba(255,255,255,.18)">' +
          '<button data-i="' + i + '" class="rp-rm" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:#e23b3b;color:#fff;cursor:pointer;font-size:11px;line-height:1">✕</button></span>').join("");
        qa("#rp-thumbs .rp-rm").forEach((b) => b.addEventListener("click", () => { s.photos.splice(+b.dataset.i, 1); renderThumbs(); }));
      };
      const resize = (file) => new Promise((res) => {
        const rd = new FileReader();
        rd.onload = () => {
          const im = new Image();
          im.onload = () => {
            const max = 1280, sc = Math.min(1, max / Math.max(im.width, im.height));
            const cv = document.createElement("canvas");
            cv.width = Math.round(im.width * sc); cv.height = Math.round(im.height * sc);
            cv.getContext("2d").drawImage(im, 0, 0, cv.width, cv.height);
            res(cv.toDataURL("image/jpeg", 0.72));
          };
          im.onerror = () => res(null); im.src = rd.result;
        };
        rd.onerror = () => res(null); rd.readAsDataURL(file);
      });
      const on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      on("#rp-file", "change", async (e) => {
        const files = [...(e.target.files || [])];
        for (const f of files) {
          if (s.photos.length >= 3) break;
          const url = await resize(f);
          if (url && url.length <= 360000) s.photos.push(url);
          else if (url) { const st = q("#rp-status"); if (st) st.textContent = "one image was too large after resizing — skipped"; }
        }
        e.target.value = ""; renderThumbs();
      });
      on("#rp-send", "click", () => {
        const st = q("#rp-status"), btn = q("#rp-send");
        const msg = (q("#rp-msg") || {}).value || "", contact = (q("#rp-contact") || {}).value || "";
        if (!msg.trim() && !s.photos.length) { if (st) st.textContent = "write a message or attach a photo first."; return; }
        if (btn) btn.disabled = true; if (st) st.textContent = "sending…";
        call("send_report", { category: s.cat, message: msg, contact: contact, photos: s.photos }).then((r) => {
          if (btn) btn.disabled = false;
          if (r && r.ok) {
            self._reportState = { cat: "feedback", photos: [] };
            body().innerHTML = '<div class="rp-wrap" style="max-width:640px;text-align:center;padding:40px 0">' +
              '<div style="font-size:40px">✅</div><h3 style="margin:12px 0 6px">Report sent</h3>' +
              '<p class="muted">Thank you — it\'s in front of the team now.</p></div>';
          } else if (st) st.textContent = (r && r.error) || "send failed — try again.";
        }).catch((e) => { if (btn) btn.disabled = false; if (st) st.textContent = "send error: " + e; });
      });
      renderThumbs();
    },

    // ── CAPABILITIES (everything Wivexx can do) ────────────────────────────────
    openCapabilities() {
      titleEl().textContent = "what I can do";
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      call("cfg_capabilities_get").then((d) => this._renderCapabilities(d || { categories: [] }));
      return true;
    },

    _renderCapabilities(d) {
      const cats = d.categories || [];
      if (!cats.length) { body().innerHTML = '<p class="muted">Capabilities catalog unavailable.</p>'; return; }
      const html = cats.map((c) => {
        const items = c.items.map((it) => {
          const says = (it.say || []).map((s) =>
            '<span class="say-chip" data-say="' + esc(s) + '">' + esc(s) + "</span>").join("");
          return '<div class="cap-item"><div class="cap-name">' + esc(it.name) + '</div>' +
            '<div class="cap-desc">' + esc(it.desc) + '</div>' +
            (says ? '<div class="cap-says">' + says + "</div>" : "") + "</div>";
        }).join("");
        return '<div class="cap-group"><div class="cap-head">' +
          (c.icon ? '<span class="cap-icon">' + esc(c.icon) + "</span>" : "") +
          esc(c.category) + "</div>" + items + "</div>";
      }).join("");
      body().innerHTML =
        '<p class="muted" style="margin-bottom:14px">Everything Wivexx can do right now (v' + esc(d.version || "") +
        '). Tap an example to run it — or just say it.</p>' + html;
      qa(".say-chip").forEach((s) => s.addEventListener("click", () => {
        const t = s.getAttribute("data-say");
        if (t && t.indexOf("<") === -1 && UI().sendText) { UI().sendText(t); UI().hidePanel && UI().hidePanel(); }
      }));
    },

    // ── COMMANDS ────────────────────────────────────────────────────────────
    _cmd: { section: "apps", data: null, sel: null, devices: null },

    openCommands() {
      titleEl().textContent = "commands";
      this._cmd = { section: "apps", data: null, sel: null, devices: null };
      body().innerHTML =
        '<div class="seg" id="cmd-seg">' +
        '<button data-sec="apps" class="on">open / close</button>' +
        '<button data-sec="environments">environments</button>' +
        '<button data-sec="audio">audio outputs</button>' +
        '<button data-sec="storage">storage</button>' +
        '</div><div id="cmd-sec"><p class="muted">loading…</p></div>';
      qa("#cmd-seg button").forEach((b) =>
        b.addEventListener("click", () => this._cmdSection(b.getAttribute("data-sec"))));
      openPanel();
      call("cfg_apps_get").then((d) => { this._cmd.data = d || { apps: [], environments: [], audio: [] }; this._renderSection(); });
      return true;
    },

    _cmdSection(sec) {
      this._cmd.section = sec; this._cmd.sel = null;
      qa("#cmd-seg button").forEach((b) => b.classList.toggle("on", b.getAttribute("data-sec") === sec));
      this._renderSection();
    },

    _reloadCmd(then) {
      call("cfg_apps_get").then((d) => { this._cmd.data = d || this._cmd.data; (then || (() => this._renderSection()))(); });
    },

    _renderSection() {
      const sec = this._cmd.section;
      if (sec === "apps") return this._renderApps();
      if (sec === "environments") return this._renderEnvs();
      if (sec === "audio") return this._renderAudio();
      if (sec === "storage") return this._renderStorage();
    },

    _renderApps() {
      const apps = (this._cmd.data && this._cmd.data.apps) || [];
      const rows = apps.map((a) =>
        '<div class="item" data-name="' + esc(a.name) + '">' +
          '<div class="it-main"><div class="it-name">' + esc(a.name) + '</div>' +
          '<div class="it-sub">' + esc(a.path) + '</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-del="' + esc(a.name) + '" title="Delete">&#128465;</button></div>' +
        '</div>').join("") || '<p class="muted">No custom commands yet. Add one below — then say “open &lt;name&gt;”.</p>';
      q("#cmd-sec").innerHTML =
        '<div class="sectlabel">your open / close commands</div>' +
        '<div class="itemlist" id="app-list">' + rows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>spoken name &nbsp;·&nbsp; say “open &lt;this&gt;”</label><input id="a-name" autocomplete="off" placeholder="spotify"></div>' +
          '<div class="field"><label>app path &nbsp;·&nbsp; .exe, file, folder or https:// URL</label>' +
            '<div class="with-btn"><input id="a-path" autocomplete="off" placeholder="C:\\…\\app.exe"><button class="pbtn" id="a-browse">browse…</button></div></div>' +
          '<div class="field"><label>extra spoken aliases (comma separated, optional)</label><input id="a-alias" autocomplete="off" placeholder="music, tunes"></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="a-save">save</button><button class="pbtn" id="a-new">clear</button></div>' +
          '<div class="savednote" id="a-note"></div>' +
        '</div>';
      qa("#app-list .item").forEach((it) => it.addEventListener("click", (e) => {
        if (e.target.closest("[data-del]")) return;
        const a = apps.find((x) => x.name === it.getAttribute("data-name")); if (!a) return;
        q("#a-name").value = a.name; q("#a-path").value = a.path; q("#a-alias").value = a.aliases;
        qa("#app-list .item").forEach((n) => n.classList.remove("sel")); it.classList.add("sel");
      }));
      qa("#app-list [data-del]").forEach((b) => b.addEventListener("click", (ev) => {
        ev.stopPropagation();
        this._armDel(b, () => call("cfg_app_delete", b.getAttribute("data-del")).then(() => this._reloadCmd()));
      }));
      q("#a-browse").addEventListener("click", () =>
        call("pick_path", false).then((p) => { if (p) q("#a-path").value = p; }));
      q("#a-new").addEventListener("click", () => { q("#a-name").value = ""; q("#a-path").value = ""; q("#a-alias").value = ""; this._note("a-note", ""); });
      q("#a-save").addEventListener("click", () => {
        call("cfg_app_save", q("#a-name").value, q("#a-path").value, q("#a-alias").value).then((r) => {
          if (r && r.ok) { this._note("a-note", "saved ✓  “open " + q("#a-name").value.trim().toLowerCase() + "” is ready"); this._reloadCmd(() => { this._renderApps(); this._note("a-note", "saved ✓"); }); }
          else this._note("a-note", (r && r.error) || "couldn't save", true);
        });
      });
    },

    _renderEnvs() {
      const envs = (this._cmd.data && this._cmd.data.environments) || [];
      const rows = envs.map((e) =>
        '<div class="item" data-name="' + esc(e.name) + '">' +
          '<div class="it-main"><div class="it-name">' + esc(e.name) + '</div>' +
          '<div class="it-sub">' + e.open_n + ' to open</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-del="' + esc(e.name) + '" title="Delete">&#128465;</button></div>' +
        '</div>').join("") || '<p class="muted">No environments yet. Build one below, then say “activate &lt;name&gt; environment”.</p>';
      q("#cmd-sec").innerHTML =
        '<div class="sectlabel">your environments</div>' +
        '<div class="itemlist" id="env-list">' + rows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>environment name &nbsp;·&nbsp; say “activate &lt;this&gt; environment”</label><input id="e-name" autocomplete="off" placeholder="work"></div>' +
          '<div class="field"><label>extra spoken aliases (comma separated, optional)</label><input id="e-alias" autocomplete="off"></div>' +
          '<div class="field"><label>items to OPEN — one per line (app, path, folder or URL)</label><textarea id="e-open" spellcheck="false"></textarea></div>' +
          '<div class="field"><label>apps to CLOSE — one per line (empty = close the same)</label><textarea id="e-close" spellcheck="false"></textarea></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="e-save">save</button><button class="pbtn" id="e-new">clear</button></div>' +
          '<div class="savednote" id="e-note"></div>' +
        '</div>';
      qa("#env-list .item").forEach((it) => it.addEventListener("click", (ev) => {
        if (ev.target.closest("[data-del]")) return;
        const e = envs.find((x) => x.name === it.getAttribute("data-name")); if (!e) return;
        q("#e-name").value = e.name; q("#e-alias").value = e.aliases; q("#e-open").value = e.open; q("#e-close").value = e.close;
        qa("#env-list .item").forEach((n) => n.classList.remove("sel")); it.classList.add("sel");
      }));
      qa("#env-list [data-del]").forEach((b) => b.addEventListener("click", (ev) => {
        ev.stopPropagation();
        this._armDel(b, () => call("cfg_env_delete", b.getAttribute("data-del")).then(() => this._reloadCmd()));
      }));
      q("#e-new").addEventListener("click", () => { ["#e-name", "#e-alias", "#e-open", "#e-close"].forEach((s) => q(s).value = ""); this._note("e-note", ""); });
      q("#e-save").addEventListener("click", () => {
        call("cfg_env_save", q("#e-name").value, q("#e-alias").value, q("#e-open").value, q("#e-close").value).then((r) => {
          if (r && r.ok) this._reloadCmd(() => { this._renderEnvs(); this._note("e-note", "saved ✓"); });
          else this._note("e-note", (r && r.error) || "couldn't save", true);
        });
      });
    },

    _renderAudio() {
      const audio = (this._cmd.data && this._cmd.data.audio) || [];
      const rows = audio.map((a) =>
        '<div class="item" data-phrase="' + esc(a.phrase) + '">' +
          '<div class="it-main"><div class="it-name">' + esc(a.phrase) + '</div>' +
          '<div class="it-sub">→ ' + esc(a.device_name) + '</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-del="' + esc(a.phrase) + '" title="Delete">&#128465;</button></div>' +
        '</div>').join("") || '<p class="muted">No output commands yet.</p>';
      q("#cmd-sec").innerHTML =
        '<div class="sectlabel">spoken phrase → output device</div>' +
        '<div class="itemlist" id="aud-list">' + rows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>spoken command &nbsp;·&nbsp; e.g. “change to output 1”</label><input id="au-phrase" autocomplete="off"></div>' +
          '<div class="field"><label>output device this switches to</label>' +
            '<div class="with-btn"><select id="au-dev"><option value="">loading devices…</option></select><button class="pbtn" id="au-refresh">↻</button></div></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="au-save">save</button><button class="pbtn" id="au-new">clear</button></div>' +
          '<div class="savednote" id="au-note"></div>' +
          '<p class="muted" style="margin-top:12px">built-in: you can always say “switch output to &lt;device name&gt;” without setting anything up.</p>' +
        '</div>';
      const loadDevices = (preselect) => call("cfg_audio_devices").then((devs) => {
        this._cmd.devices = devs || [];
        const sel = q("#au-dev"); if (!sel) return;
        sel.innerHTML = '<option value="">— pick a device —</option>' +
          this._cmd.devices.map((d) => '<option value="' + esc(d.name) + '"' + (d.name === preselect ? " selected" : "") + ">" + esc(d.name) + "</option>").join("");
      });
      loadDevices();
      qa("#aud-list .item").forEach((it) => it.addEventListener("click", (ev) => {
        if (ev.target.closest("[data-del]")) return;
        const a = audio.find((x) => x.phrase === it.getAttribute("data-phrase")); if (!a) return;
        q("#au-phrase").value = a.phrase; loadDevices(a.device_name);
        qa("#aud-list .item").forEach((n) => n.classList.remove("sel")); it.classList.add("sel");
      }));
      qa("#aud-list [data-del]").forEach((b) => b.addEventListener("click", (ev) => {
        ev.stopPropagation();
        this._armDel(b, () => call("cfg_audio_delete", b.getAttribute("data-del")).then(() => this._reloadCmd()));
      }));
      q("#au-refresh").addEventListener("click", () => loadDevices(q("#au-dev").value));
      q("#au-new").addEventListener("click", () => { q("#au-phrase").value = ""; q("#au-dev").value = ""; this._note("au-note", ""); });
      q("#au-save").addEventListener("click", () => {
        call("cfg_audio_save", q("#au-phrase").value, q("#au-dev").value).then((r) => {
          if (r && r.ok) this._reloadCmd(() => { this._renderAudio(); this._note("au-note", "saved ✓"); });
          else this._note("au-note", (r && r.error) || "couldn't save", true);
        });
      });
    },

    _renderStorage() {
      q("#cmd-sec").innerHTML =
        '<div class="sectlabel">external storage auto-import</div>' +
        '<p class="muted">When an allowed SD card / USB drive is connected, Wivexx copies its photos &amp; videos into your dated import folder. Give each drive a nickname so it&rsquo;s easy to recognise during import.</p>' +
        '<div id="dev-wrap"><p class="muted" style="margin-top:12px">loading devices&hellip;</p></div>';
      call("cfg_devices_get").then((r) => this._renderStorageList((r && r.devices) || [], r && r.dest));
    },

    _renderStorageList(devices, dest) {
      this._cmd.devices2 = devices;
      const badge = (p) => p === "allow"
        ? '<span class="imp" style="color:var(--volt);border-color:rgba(193,247,58,.4)">auto-import</span>'
        : p === "deny" ? '<span class="imp">ignored</span>' : "";
      const rows = devices.map((d) =>
        '<div class="item" data-serial="' + esc(d.serial) + '">' +
          '<div class="it-main"><div class="it-name">' + esc(d.name || d.serial) + " " + badge(d.policy) + '</div>' +
          '<div class="it-sub">' + esc(d.label || "no volume label") +
            (d.tag ? ' &middot; /' + esc(d.tag) : "") +
            (d.last_import ? ' &middot; last ' + esc(d.last_import) : "") + '</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-forget="' + esc(d.serial) + '" title="Forget">&#128465;</button></div>' +
        '</div>').join("") ||
        '<p class="muted">No drives known yet. Plug in an SD card or USB drive and Wivexx will ask whether to import &mdash; it then appears here.</p>';
      q("#dev-wrap").innerHTML =
        '<div class="itemlist" id="dev-list">' + rows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>nickname &nbsp;·&nbsp; shown in the import HUD</label><input id="d-nick" autocomplete="off" placeholder="e.g. Osmo Pocket, camera card"></div>' +
          '<div class="field"><label>when this drive is connected</label>' +
            '<select id="d-policy"><option value="allow">allow — auto-import its photos &amp; videos</option>' +
            '<option value="deny">deny — ignore this drive</option></select></div>' +
          '<div class="field"><label>tag (optional) &nbsp;·&nbsp; sorts into photos/&lt;tag&gt; &amp; video/&lt;tag&gt;</label><input id="d-tag" autocomplete="off" placeholder="osmo"></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="d-save">save</button><button class="pbtn" id="d-forget">forget</button></div>' +
          '<div class="savednote" id="d-note"></div>' +
          (dest ? '<p class="muted" style="margin-top:12px">imports land in <b>' + esc(dest) + '</b>\\&lt;date&gt;\\</p>' : "") +
        '</div>';
      const selSerial = { v: null };
      qa("#dev-list .item").forEach((it) => it.addEventListener("click", (ev) => {
        if (ev.target.closest("[data-forget]")) return;
        const d = devices.find((x) => x.serial === it.getAttribute("data-serial")); if (!d) return;
        selSerial.v = d.serial;
        q("#d-nick").value = d.nickname || ""; q("#d-tag").value = d.tag || "";
        q("#d-policy").value = d.policy === "deny" ? "deny" : "allow";
        qa("#dev-list .item").forEach((n) => n.classList.remove("sel")); it.classList.add("sel");
        this._note("d-note", "");
      }));
      qa("#dev-list [data-forget]").forEach((b) => b.addEventListener("click", (ev) => {
        ev.stopPropagation();
        this._armDel(b, () => call("cfg_device_forget", b.getAttribute("data-forget")).then(() => this._renderStorage()));
      }));
      q("#d-save").addEventListener("click", () => {
        if (!selSerial.v) { this._note("d-note", "pick a drive from the list first", true); return; }
        call("cfg_device_save", selSerial.v, q("#d-policy").value, q("#d-tag").value, q("#d-nick").value).then((r) => {
          if (r && r.ok) this._renderStorageReselect(selSerial.v);
          else this._note("d-note", (r && r.error) || "couldn't save", true);
        });
      });
      q("#d-forget").addEventListener("click", (ev) => {
        if (!selSerial.v) { this._note("d-note", "pick a drive from the list first", true); return; }
        this._armDel(ev.target, () => call("cfg_device_forget", selSerial.v).then(() => this._renderStorage()));
      });
    },

    _renderStorageReselect(serial) {
      call("cfg_devices_get").then((r) => {
        this._renderStorageList((r && r.devices) || [], r && r.dest);
        const it = q('#dev-list .item[data-serial="' + (window.CSS && CSS.escape ? CSS.escape(serial) : serial) + '"]');
        if (it) it.click();
        this._note("d-note", "saved ✓");
      });
    },

    // ── GOALS ─────────────────────────────────────────────────────────────────
    _goals: { data: null, showDone: false, editId: null },

    openGoals() {
      titleEl().textContent = "goals";
      this._goals = { data: null, showDone: false, editId: null };
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._reloadGoals();
      return true;
    },

    _reloadGoals() {
      call("cfg_goals_get").then((d) => { this._goals.data = d || { goals: [], stats: {} }; this._renderGoals(); });
    },

    _renderGoals() {
      const d = this._goals.data || { goals: [], stats: {}, priorities: ["high", "normal", "low"], reminders: ["none", "hourly", "daily", "weekly"] };
      const st = d.stats || {};
      const open = d.goals.filter((g) => !g.done);
      const done = d.goals.filter((g) => g.done);
      const prios = d.priorities || ["high", "normal", "low"];
      const today = new Date().toISOString().slice(0, 10);

      const goalRow = (g) => {
        let meta = [];
        if (g.priority === "high") meta.push('<span class="hi">high</span>');
        if (g.due) {
          const cls = g.due < today ? "over" : (g.due === today ? "today" : "");
          const lbl = g.due < today ? "overdue" : (g.due === today ? "due today" : "due " + g.due);
          meta.push('<span class="' + cls + '">' + lbl + "</span>");
        }
        if (g.reminder && g.reminder !== "none") meta.push(esc(g.reminder));
        return '<div class="goal ' + (g.done ? "done" : "") + '" data-id="' + g.id + '">' +
          '<div class="check" data-check="' + g.id + '"></div>' +
          '<div class="g-main"><div class="g-title">' + esc(g.title) + '</div>' +
          (meta.length ? '<div class="g-meta">' + meta.join(" · ") + "</div>" : "") + "</div>" +
          '<div class="g-actions">' +
            (g.done ? "" : '<button class="iconbtn-sm" data-edit="' + g.id + '" title="Edit">&#9998;</button>') +
            '<button class="iconbtn-sm danger" data-gdel="' + g.id + '" title="Delete">&#128465;</button>' +
          "</div></div>";
      };

      body().innerHTML =
        '<div class="statsbar">' +
          '<div class="stat"><div class="n volt">' + (st.open != null ? st.open : open.length) + '</div><div class="l">open</div></div>' +
          '<div class="stat"><div class="n">' + (st.done_week || 0) + '</div><div class="l">done this week</div></div>' +
          '<div class="stat"><div class="n">' + (st.streak || 0) + '</div><div class="l">day streak</div></div>' +
        '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label id="g-formlabel">new goal</label><input id="g-title" autocomplete="off" placeholder="what needs doing?"></div>' +
          '<div class="fieldrow">' +
            '<div class="field"><label>priority</label><select id="g-prio">' + prios.map((p) => '<option value="' + p + '"' + (p === "normal" ? " selected" : "") + ">" + p + "</option>").join("") + '</select></div>' +
            '<div class="field"><label>due (optional)</label><input id="g-due" type="date"></div>' +
          '</div>' +
          '<div class="fieldrow">' +
            '<div class="field"><label>reminder</label><select id="g-rem">' + (d.reminders || ["none", "hourly", "daily", "weekly"]).map((r) => '<option value="' + r + '"' + (r === "none" ? " selected" : "") + ">" + r + "</option>").join("") + '</select></div>' +
            '<div class="field"><label>remind at</label><input id="g-remtime" type="time" value="09:00"></div>' +
          '</div>' +
          '<div class="btnrow"><button class="pbtn primary" id="g-add">add goal</button><button class="pbtn" id="g-cancel" style="display:none">cancel</button></div>' +
          '<div class="savednote" id="g-note"></div>' +
        '</div>' +
        '<div class="itemlist" style="margin-top:16px">' + (open.map(goalRow).join("") ||
          '<p class="muted">No goals yet — type one above, or just tell Wivexx: “add a goal to finish the report”.</p>') + '</div>' +
        (done.length ? '<div class="toggle-done" id="g-toggledone">' + (this._goals.showDone ? "▾ hide" : "▸ show") + " " + done.length + ' completed</div>' : "") +
        (this._goals.showDone ? '<div class="itemlist">' + done.map(goalRow).join("") + "</div>" : "");

      // edit mode pre-fill
      if (this._goals.editId != null) {
        const g = d.goals.find((x) => x.id === this._goals.editId);
        if (g) {
          q("#g-title").value = g.title; q("#g-prio").value = g.priority || "normal"; if (g.due) q("#g-due").value = g.due;
          q("#g-rem").value = g.reminder || "none"; q("#g-remtime").value = g.remind_time || "09:00";
          q("#g-formlabel").textContent = "edit goal";
          q("#g-add").textContent = "save"; q("#g-cancel").style.display = "";
        }
      }

      qa("[data-check]").forEach((c) => c.addEventListener("click", () => {
        const g = d.goals.find((x) => x.id === parseInt(c.getAttribute("data-check"), 10));
        call("cfg_goal_complete", c.getAttribute("data-check"), !(g && g.done)).then(() => this._reloadGoals());
      }));
      qa("[data-gdel]").forEach((b) => b.addEventListener("click", (ev) => {
        ev.stopPropagation();
        this._armDel(b, () => call("cfg_goal_delete", b.getAttribute("data-gdel")).then(() => this._reloadGoals()));
      }));
      qa("[data-edit]").forEach((b) => b.addEventListener("click", () => { this._goals.editId = parseInt(b.getAttribute("data-edit"), 10); this._renderGoals(); }));
      const tdone = q("#g-toggledone"); if (tdone) tdone.addEventListener("click", () => { this._goals.showDone = !this._goals.showDone; this._renderGoals(); });
      q("#g-cancel").addEventListener("click", () => { this._goals.editId = null; this._renderGoals(); });
      q("#g-add").addEventListener("click", () => {
        const title = q("#g-title").value.trim();
        if (!title) { this._note("g-note", "type a goal first", true); return; }
        const prio = q("#g-prio").value, due = q("#g-due").value;
        const rem = q("#g-rem").value, remt = q("#g-remtime").value;
        if (this._goals.editId != null) {
          call("cfg_goal_update", this._goals.editId, { title: title, priority: prio, due: due || null, reminder: rem, remind_time: remt }).then(() => { this._goals.editId = null; this._reloadGoals(); });
        } else {
          call("cfg_goal_add", title, prio, due, rem, remt).then((r) => {
            if (r && r.ok === false) this._note("g-note", r.error || "couldn't add", true);
            else this._reloadGoals();
          });
        }
      });
      q("#g-title").addEventListener("keydown", (e) => { if (e.key === "Enter") q("#g-add").click(); });
    },

    // ── SHORTCUTS ─────────────────────────────────────────────────────────────
    _hk: { captured: "" },

    openShortcuts() {
      titleEl().textContent = "shortcuts & wake words";
      this._hk = { captured: "", wake: null };
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._reloadHotkeys();
      return true;
    },

    _reloadHotkeys() {
      call("cfg_hotkeys_get").then((d) => { this._hk.data = d || { available: false }; this._renderHotkeys(); });
    },

    _renderHotkeys() {
      const d = this._hk.data || {};
      if (!d.available) {
        body().innerHTML = '<p class="muted">Global shortcuts aren’t available on this system yet. Restart Wivexx and try again.</p>';
        return;
      }
      const binds = d.bindings || [], customs = d.customs || [];
      const bindRows = binds.map((b) =>
        '<div class="item" style="cursor:default">' +
          '<div class="it-main"><div class="it-name">' + esc(b.label) + '</div>' +
          '<div class="it-sub" style="color:var(--volt)">' + esc(b.keys) + '</div></div>' +
          '<div class="it-actions"><button class="pbtn" data-rebind="' + esc(b.action) + '">rebind</button></div>' +
        '</div>').join("");
      const custRows = customs.map((c, i) =>
        '<div class="item" style="cursor:default">' +
          '<div class="it-main"><div class="it-name">' + esc(c.command) + '</div>' +
          '<div class="it-sub" style="color:var(--volt)">' + esc(c.keys) + '</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-cdel="' + i + '" title="Delete">&#128465;</button></div>' +
        '</div>').join("") || '<p class="muted">No custom shortcuts yet.</p>';
      body().innerHTML =
        '<div id="wake-wrap"></div>' +
        '<div class="sectlabel">keyboard · built-in actions</div>' +
        '<p class="muted" style="margin:-4px 0 10px">Click rebind, then press the new combo — keyboard keys and mouse side/middle buttons both work.</p>' +
        '<div class="itemlist">' + bindRows + '</div>' +
        '<div class="btnrow" style="margin-bottom:18px"><button class="pbtn" id="hk-reset">reset to defaults</button></div>' +
        '<div class="sectlabel">custom shortcuts — run any voice command from a combo</div>' +
        '<div class="itemlist">' + custRows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>command to run</label><input id="hk-cmd" autocomplete="off" placeholder="open spotify"></div>' +
          '<div class="field"><label>key combo</label><div class="with-btn"><input id="hk-keys" readonly placeholder="(none captured)" value="' + esc(this._hk.captured) + '"><button class="pbtn" id="hk-capture">capture keys</button></div></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="hk-add">add shortcut</button></div>' +
          '<div class="savednote" id="hk-note"></div>' +
        '</div>';
      if (this._hk.wake) this._renderWake(); else this._loadWake();
      qa("[data-rebind]").forEach((btn) => btn.addEventListener("click", () => {
        const prev = btn.textContent; btn.textContent = "press keys…"; btn.disabled = true;
        call("cfg_hotkey_capture").then((combo) => {
          if (combo) call("cfg_hotkey_set", btn.getAttribute("data-rebind"), combo).then(() => this._reloadHotkeys());
          else { btn.textContent = prev; btn.disabled = false; }
        });
      }));
      qa("[data-cdel]").forEach((b) => b.addEventListener("click", () =>
        this._armDel(b, () => call("cfg_custom_delete", parseInt(b.getAttribute("data-cdel"), 10)).then(() => this._reloadHotkeys()))));
      q("#hk-reset").addEventListener("click", (e) =>
        this._armDel(e.target, () => call("cfg_hotkey_reset").then(() => { this._hk.captured = ""; this._reloadHotkeys(); })));
      q("#hk-capture").addEventListener("click", () => {
        const btn = q("#hk-capture"); btn.textContent = "press keys…"; btn.disabled = true;
        call("cfg_hotkey_capture").then((combo) => {
          this._hk.captured = combo || ""; q("#hk-keys").value = combo || "(none captured)";
          btn.textContent = "capture keys"; btn.disabled = false;
        });
      });
      q("#hk-add").addEventListener("click", () => {
        call("cfg_custom_add", q("#hk-cmd").value, this._hk.captured).then((r) => {
          if (r && r.ok) { this._hk.captured = ""; this._reloadHotkeys(); }
          else this._note("hk-note", (r && r.error) || "couldn't add", true);
        });
      });
    },

    // ── WAKE WORDS (voice triggers, inside the shortcuts panel) ────────────────
    _loadWake(note, isErr) {
      call("cfg_wake_get").then((d) => {
        this._hk.wake = d || { custom: [], defaults: [], display: "hey wivexx" };
        this._renderWake(note, isErr);
      });
    },

    _renderWake(note, isErr) {
      const wrap = q("#wake-wrap"); if (!wrap) return;
      const w = this._hk.wake || { custom: [], defaults: [] };
      const custom = w.custom || [], defs = w.defaults || [];
      const chips = custom.map((c) =>
        '<span class="wake-chip"><span class="wc-t"></span>' +
          '<button type="button" class="wc-x" data-wdel="' + esc(c) + '" aria-label="Remove">&#10005;</button></span>').join("")
        || '<span class="muted">no custom words yet — Wivexx still wakes to the built-ins below</span>';
      const defChips = defs.map((d) => '<span class="wake-chip ro"></span>').join("");
      wrap.innerHTML =
        '<div class="sectlabel">voice · wake words</div>' +
        '<p class="muted" style="margin:-4px 0 10px">Say any of these to wake Wivexx (currently &ldquo;' + esc(w.display || "hey wivexx") +
          '&rdquo;). Add your own pronunciations — handy when &ldquo;' + esc(w.wake_word || "wivexx") + '&rdquo; comes out as &ldquo;wybexx&rdquo; or &ldquo;wibex&rdquo;.</p>' +
        '<div class="formcard">' +
          '<div class="wake-list" id="wake-custom">' + chips + '</div>' +
          '<div class="field" style="margin-top:12px"><label>add a wake word or phrase</label>' +
            '<div class="with-btn"><input id="wake-in" autocomplete="off" placeholder="e.g. wybexx · hey vex · computer"><button class="pbtn primary" id="wake-add">add</button></div></div>' +
          '<div class="savednote" id="wake-note"></div>' +
          '<details class="wake-defs"><summary>built-in variations Wivexx already knows (' + defs.length + ')</summary>' +
            '<div class="wake-list ro" id="wake-defs"></div></details>' +
        '</div>';
      // fill chip text via textContent (never innerHTML — user/voice strings)
      const cc = qa("#wake-custom .wc-t");
      custom.forEach((c, i) => { if (cc[i]) cc[i].textContent = c; });
      const dd = qa("#wake-defs .wake-chip");
      defs.forEach((d, i) => { if (dd[i]) dd[i].textContent = d; });
      qa("[data-wdel]").forEach((b) => b.addEventListener("click", () =>
        call("cfg_wake_delete", b.getAttribute("data-wdel")).then(() => this._loadWake("removed ✓"))));
      const add = () => {
        const v = (q("#wake-in").value || "").trim();
        if (!v) return;
        call("cfg_wake_add", v).then((r) => {
          if (r && r.ok) this._loadWake(r.note ? "already recognised ✓" : "added ✓");
          else this._note("wake-note", (r && r.error) || "couldn't add", true);
        });
      };
      q("#wake-add").addEventListener("click", add);
      q("#wake-in").addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });
      if (note) this._note("wake-note", note, isErr);
    },

    // ── AUTOCLICKER & KEY INPUTS ───────────────────────────────────────────────
    _ai: { newHotkey: "" },

    openAutoInput() {
      titleEl().textContent = "autoclicker & inputs";
      this._ai = { newHotkey: "", newKey: "" };
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._reloadAutoInput();
      return true;
    },

    _reloadAutoInput() {
      call("cfg_autoclicker_get").then((d) => {
        this._ai.data = d || { available: false };
        this._renderAutoInput();
      });
    },

    // capture a key/mouse combo into a "set shortcut" button, then run cb(combo)
    _aiCapture(btn, cb) {
      const prev = btn.textContent;
      btn.textContent = "press keys…"; btn.disabled = true;
      call("cfg_hotkey_capture").then((combo) => {
        btn.textContent = prev; btn.disabled = false;
        if (combo) cb(combo);
      });
    },

    _renderAutoInput() {
      const d = this._ai.data || {};
      if (!d.available) {
        body().innerHTML = '<p class="muted">The autoclicker isn’t available on this system yet. Restart Wivexx and try again.</p>';
        return;
      }
      const ac = d.autoclicker || {};
      const inputs = d.inputs || [];
      const keys = d.key_choices || [], btns = d.button_choices || ["left", "right", "middle"];
      const modes = d.mode_choices || ["toggle", "hold"], fires = d.fire_choices || ["repeat", "single"];
      const sel = (cls, list, cur, attr) =>
        '<select class="' + cls + '"' + (attr || "") + '>' +
        list.map((o) => '<option value="' + esc(o) + '"' + (o === cur ? " selected" : "") + ">" + esc(o) + "</option>").join("") +
        '</select>';
      const hk = (val) => '<span class="ai-hk">' + esc(val || "(no shortcut)") + '</span>';
      const kd = (val) => '<span class="ai-hk">' + esc(val || "(none — capture)") + '</span>';

      // ── auto-clicker card ──
      const acCard =
        '<div class="sectlabel">auto-clicker</div>' +
        '<p class="muted" style="margin:-4px 0 10px">Spam a mouse button at a set speed. Press the shortcut to start/stop (toggle), or hold it (hold mode).</p>' +
        '<div class="formcard">' +
          '<label class="chk" style="margin-bottom:10px"><input type="checkbox" id="ac-en"' + (ac.enabled ? " checked" : "") + '> enabled</label>' +
          '<div class="ai-grid">' +
            '<div class="field"><label>mouse button</label>' + sel("ai-in", btns, ac.button, ' id="ac-btn"') + '</div>' +
            '<div class="field"><label>clicks / sec</label><input id="ac-cps" type="number" min="' + (d.cps_min || 1) + '" max="' + (d.cps_max || 200) + '" value="' + (ac.cps || 10) + '"></div>' +
            '<div class="field"><label>mode</label>' + sel("ai-in", modes, ac.mode, ' id="ac-mode"') + '</div>' +
          '</div>' +
          '<div class="field"><label>shortcut</label><div class="with-btn">' + hk(ac.hotkey) +
            '<button class="pbtn" id="ac-cap">set shortcut</button>' +
            (ac.hotkey ? '<button class="pbtn" id="ac-clear">clear</button>' : '') +
          '</div></div>' +
        '</div>';

      // ── key inputs list ──
      const inputCard = (it) =>
        '<div class="formcard ai-input" data-id="' + esc(it.id) + '">' +
          '<div class="ai-row1">' +
            '<input class="ai-name" placeholder="label (optional)" value="' + esc(it.name || "") + '">' +
            '<label class="chk"><input type="checkbox" class="ai-en"' + (it.enabled ? " checked" : "") + '> on</label>' +
            '<button class="iconbtn-sm danger ai-del" title="Delete">&#128465;</button>' +
          '</div>' +
          '<div class="field"><label>key(s) to press</label><div class="with-btn">' + kd(it.key) +
            '<button class="pbtn ai-keycap">change</button></div></div>' +
          '<div class="ai-grid">' +
            '<div class="field"><label>presses / sec</label><input class="ai-cps" type="number" min="' + (d.cps_min || 1) + '" max="' + (d.cps_max || 200) + '" value="' + (it.cps || 10) + '"></div>' +
            '<div class="field"><label>mode</label>' + sel("ai-mode", modes, it.mode) + '</div>' +
            '<div class="field"><label>fire</label>' + sel("ai-fire", fires, it.fire) + '</div>' +
          '</div>' +
          '<div class="field"><label>shortcut</label><div class="with-btn">' + hk(it.hotkey) +
            '<button class="pbtn ai-cap">set shortcut</button></div></div>' +
        '</div>';
      const inputRows = inputs.map(inputCard).join("") ||
        '<p class="muted">No key inputs yet. Add one below to rapid-fire (or single-press) a key from its own shortcut.</p>';

      // ── add-input form ──
      const addCard =
        '<div class="formcard">' +
          '<div class="field"><label>name (optional)</label><input id="ai-new-name" autocomplete="off" placeholder="e.g. jump spam"></div>' +
          '<div class="field"><label>key(s) to press &nbsp;·&nbsp; press any key or a combo</label><div class="with-btn">' + kd(this._ai.newKey) +
            '<button class="pbtn" id="ai-new-keycap">capture key(s)</button></div></div>' +
          '<div class="ai-grid">' +
            '<div class="field"><label>presses / sec</label><input id="ai-new-cps" type="number" min="' + (d.cps_min || 1) + '" max="' + (d.cps_max || 200) + '" value="10"></div>' +
            '<div class="field"><label>mode</label>' + sel("", modes, "toggle", ' id="ai-new-mode"') + '</div>' +
            '<div class="field"><label>fire</label>' + sel("", fires, "repeat", ' id="ai-new-fire"') + '</div>' +
          '</div>' +
          '<div class="field"><label>shortcut to trigger it</label><div class="with-btn">' + hk(this._ai.newHotkey) +
            '<button class="pbtn" id="ai-new-cap">set shortcut</button></div></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="ai-add">add key input</button></div>' +
          '<div class="savednote" id="ai-note"></div>' +
        '</div>';

      body().innerHTML =
        acCard +
        '<div class="sectlabel" style="margin-top:18px">key inputs — autoclick any key or combo</div>' +
        '<p class="muted" style="margin:-4px 0 10px">Each input rapid-fires the key(s) you capture (a single key like <b>f</b>, or a combo like <b>ctrl+shift+e</b>) from its own shortcut. ' +
          '<b>repeat</b> = spam at the set speed · <b>single</b> = one press per trigger.</p>' +
        inputRows +
        '<div class="sectlabel" style="margin-top:14px">add a key input</div>' +
        addCard;

      this._wireAutoInput();
    },

    _wireAutoInput() {
      // ── auto-clicker controls ──
      const acSet = (fields) => call("cfg_autoclicker_set", fields.cps, fields.button,
        fields.hotkey, fields.mode, fields.enabled).then(() => this._reloadAutoInput());
      const elEn = q("#ac-en"), elBtn = q("#ac-btn"), elCps = q("#ac-cps"), elMode = q("#ac-mode");
      if (elEn) elEn.addEventListener("change", () => acSet({ enabled: elEn.checked }));
      if (elBtn) elBtn.addEventListener("change", () => acSet({ button: elBtn.value }));
      if (elCps) elCps.addEventListener("change", () => acSet({ cps: parseInt(elCps.value, 10) || 10 }));
      if (elMode) elMode.addEventListener("change", () => acSet({ mode: elMode.value }));
      const elCap = q("#ac-cap");
      if (elCap) elCap.addEventListener("click", () =>
        this._aiCapture(elCap, (combo) => acSet({ hotkey: combo })));
      const elClr = q("#ac-clear");
      if (elClr) elClr.addEventListener("click", () => acSet({ hotkey: "" }));

      // ── existing key inputs (inline edit) ──
      qa(".ai-input").forEach((card) => {
        const id = card.getAttribute("data-id");
        const upd = (fields) => call("cfg_input_update", id, fields).then(() => this._reloadAutoInput());
        const sub = (s) => card.querySelector(s);
        sub(".ai-name").addEventListener("change", (e) => upd({ name: e.target.value }));
        sub(".ai-en").addEventListener("change", (e) => upd({ enabled: e.target.checked }));
        sub(".ai-cps").addEventListener("change", (e) => upd({ cps: parseInt(e.target.value, 10) || 10 }));
        sub(".ai-mode").addEventListener("change", (e) => upd({ mode: e.target.value }));
        sub(".ai-fire").addEventListener("change", (e) => upd({ fire: e.target.value }));
        const kcap = sub(".ai-keycap");
        if (kcap) kcap.addEventListener("click", () => this._aiCapture(kcap, (combo) => upd({ key: combo })));
        const cap = sub(".ai-cap");
        cap.addEventListener("click", () => this._aiCapture(cap, (combo) => upd({ hotkey: combo })));
        sub(".ai-del").addEventListener("click", (e) =>
          this._armDel(e.currentTarget, () => call("cfg_input_delete", id).then(() => this._reloadAutoInput())));
      });

      // ── add-input form ──
      const kcap = q("#ai-new-keycap");
      if (kcap) kcap.addEventListener("click", () => this._aiCapture(kcap, (combo) => {
        this._ai.newKey = combo;
        kcap.previousElementSibling.textContent = combo;   // update the key display
      }));
      const cap = q("#ai-new-cap");
      if (cap) cap.addEventListener("click", () => this._aiCapture(cap, (combo) => {
        this._ai.newHotkey = combo;
        cap.previousElementSibling.textContent = combo;   // update the .ai-hk display
      }));
      const addBtn = q("#ai-add");
      if (addBtn) addBtn.addEventListener("click", () => {
        call("cfg_input_add", q("#ai-new-name").value, this._ai.newKey,
          parseInt(q("#ai-new-cps").value, 10) || 10, this._ai.newHotkey,
          q("#ai-new-mode").value, q("#ai-new-fire").value).then((r) => {
            if (r && r.ok) { this._ai.newHotkey = ""; this._ai.newKey = ""; this._reloadAutoInput(); }
            else this._note("ai-note", (r && r.error) || "couldn't add", true);
          });
      });
    },

    // ── SOCIAL TRACKING (per-account cards + click-to-graph) ───────────────────
    _soc: { view: "cards" },

    openSocial() {
      titleEl().textContent = "social tracking";
      this._soc = { view: "cards" };
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._loadSocial();
      return true;
    },

    _loadSocial() {
      call("cfg_socials_get").then((d) => {
        if (titleEl().textContent !== "social tracking") return;
        this._soc.data = d || { accounts: [] };
        this._renderSocialCards();
      });
    },

    _fmtN(n) {
      n = Number(n) || 0;
      if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
      if (Math.abs(n) >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "k";
      return n.toLocaleString();
    },

    _renderSocialCards() {
      const d = this._soc.data || {};
      const accts = d.accounts || [];
      const cards = accts.map((a) => {
        if (a.error) {
          return '<div class="soc-card" data-handle="' + esc(a.handle) + '" data-plat="' + esc(a.platform) + '">' +
            '<div class="soc-top"><div class="soc-handle">@' + esc(a.handle) +
              '<span class="soc-plat">' + esc(a.platform) + '</span></div>' +
              '<div class="soc-acts"><button class="iconbtn-sm soc-rm" title="Stop tracking">&#10005;</button></div></div>' +
            '<div class="muted" style="margin-top:8px">' + esc(a.error) + '</div></div>';
        }
        const chip = (metric, label, val, clickable) =>
          '<button class="soc-chip' + (clickable ? "" : " ro") + '"' +
            (clickable ? ' data-metric="' + metric + '"' : "") + '>' + esc(label) +
            ' <b>' + esc(val) + '</b></button>';
        const chips = [
          chip("following", "following", this._fmtN(a.following), true),
          chip("posts", "posts", this._fmtN(a.posts), true),
        ];
        if (a.follower_delta != null)
          chips.push('<span class="soc-chip ro' + (a.follower_delta >= 0 ? " up" : " dn") + '">' +
            (a.follower_delta >= 0 ? "+" : "") + this._fmtN(a.follower_delta) +
            ' <small>/ ' + (a.baseline_days || 1) + 'd</small></span>');
        if (a.engagement_rate != null)
          chips.push('<span class="soc-chip ro">eng <b>' + a.engagement_rate + '%</b></span>');
        if (a.new_comments != null && a.new_comments > 0)
          chips.push('<span class="soc-chip ro">' + a.new_comments + ' new &#128172;</span>');
        return '<div class="soc-card" data-handle="' + esc(a.handle) + '" data-plat="' + esc(a.platform) + '">' +
          '<div class="soc-top"><div class="soc-handle">@' + esc(a.handle) +
            '<span class="soc-plat">' + esc(a.platform) + '</span></div>' +
            '<div class="soc-acts">' +
              '<button class="iconbtn-sm soc-refresh" title="Refresh now">&#8635;</button>' +
              '<button class="iconbtn-sm soc-rm" title="Stop tracking">&#10005;</button></div></div>' +
          '<button class="soc-big" data-metric="followers">' +
            '<span class="soc-num">' + this._fmtN(a.followers) + '</span>' +
            '<span class="soc-lbl">followers &rsaquo;</span></button>' +
          '<div class="soc-chips">' + chips.join("") + '</div>' +
          (a.checked ? '<div class="soc-checked">updated ' + esc(a.checked) + '</div>' : "") +
          '</div>';
      }).join("") || '<p class="muted">Not tracking any accounts yet. Add an Instagram handle below — Wivexx checks it and graphs your growth over time.</p>';

      body().innerHTML =
        '<p class="muted" style="margin:0 0 12px">Tap a number to see its last-30-days graph.</p>' +
        cards +
        '<div class="formcard" style="margin-top:14px">' +
          '<div class="field"><label>track an Instagram account</label>' +
            '<div class="with-btn"><input id="soc-add" autocomplete="off" placeholder="@handle"><button class="pbtn primary" id="soc-addbtn">track</button></div></div>' +
          '<div class="savednote" id="soc-note"></div>' +
        '</div>';

      qa(".soc-card").forEach((card) => {
        const handle = card.getAttribute("data-handle"), plat = card.getAttribute("data-plat");
        qa(".soc-big, .soc-chip[data-metric]").filter((b) => card.contains(b)).forEach((b) =>
          b.addEventListener("click", () => this._socOpenGraph(plat, handle, b.getAttribute("data-metric"))));
        const rf = card.querySelector(".soc-refresh");
        if (rf) rf.addEventListener("click", () => {
          rf.disabled = true; rf.textContent = "…";
          call("cfg_socials_refresh", plat, handle).then(() => this._loadSocial());
        });
        const rm = card.querySelector(".soc-rm");
        if (rm) rm.addEventListener("click", () =>
          this._armDel(rm, () => call("cfg_socials_remove", handle, plat).then(() => this._loadSocial())));
      });
      const add = () => {
        const v = (q("#soc-add").value || "").trim();
        if (!v) return;
        call("cfg_socials_add", v).then((r) => {
          if (r && r.ok) { q("#soc-add").value = ""; this._loadSocial(); }
          else this._note("soc-note", (r && r.error) || "couldn't add", true);
        });
      };
      q("#soc-addbtn").addEventListener("click", add);
      q("#soc-add").addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });
    },

    _socOpenGraph(platform, handle, metric) {
      this._soc.view = "graph";
      body().innerHTML = '<p class="muted">loading…</p>';
      call("cfg_socials_history", platform, handle, 31).then((d) => {
        if (titleEl().textContent !== "social tracking") return;
        this._renderSocialGraph(handle, metric, (d && d.points) || []);
      });
    },

    _lineChart(vals) {
      const W = 320, H = 120, P = 8;
      const min = Math.min.apply(null, vals), max = Math.max.apply(null, vals);
      const span = (max - min) || 1, n = vals.length;
      const x = (i) => P + (W - 2 * P) * (n === 1 ? 0.5 : i / (n - 1));
      const y = (v) => H - P - (H - 2 * P) * (v - min) / span;
      const pts = vals.map((v, i) => x(i).toFixed(1) + "," + y(v).toFixed(1));
      const line = pts.join(" ");
      const area = "M" + x(0).toFixed(1) + "," + (H - P) + " L" + line.replace(/ /g, " L") +
        " L" + x(n - 1).toFixed(1) + "," + (H - P) + " Z";
      const lastX = x(n - 1).toFixed(1), lastY = y(vals[n - 1]).toFixed(1);
      return '<svg class="soc-chart" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">' +
        '<path d="' + area + '" fill="var(--volt)" opacity="0.08"/>' +
        '<polyline points="' + line + '" fill="none" stroke="var(--volt)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>' +
        '<circle cx="' + lastX + '" cy="' + lastY + '" r="3.2" fill="var(--volt)"/></svg>';
    },

    _renderSocialGraph(handle, metric, points) {
      const back = '<button class="pbtn soc-back" id="soc-back">&lsaquo; back</button>';
      const label = { followers: "followers", following: "following", posts: "posts" }[metric] || metric;
      if (points.length < 2) {
        body().innerHTML = back +
          '<div class="sectlabel" style="margin-top:12px">@' + esc(handle) + ' &middot; ' + esc(label) + '</div>' +
          '<p class="muted">Not enough history yet — graphs build up after a few days of tracking. ' +
          '(' + points.length + ' data point' + (points.length === 1 ? '' : 's') + ' so far.)</p>';
        q("#soc-back").addEventListener("click", () => this.openSocial());
        return;
      }
      const vals = points.map((p) => Number(p[metric]) || 0);
      const cur = vals[vals.length - 1], first = vals[0];
      const min = Math.min.apply(null, vals), max = Math.max.apply(null, vals);
      const delta = cur - first;
      const d0 = (points[0].date || "").split(" ")[0], d1 = (points[points.length - 1].date || "").split(" ")[0];
      body().innerHTML = back +
        '<div class="sectlabel" style="margin-top:12px">@' + esc(handle) + ' &middot; ' + esc(label) + ' &middot; last 30 days</div>' +
        '<div class="soc-graphwrap">' + this._lineChart(vals) +
          '<div class="soc-xaxis"><span>' + esc(d0) + '</span><span>' + esc(d1) + '</span></div>' +
        '</div>' +
        '<div class="soc-gstats">' +
          '<div><span>now</span><b>' + this._fmtN(cur) + '</b></div>' +
          '<div><span>change</span><b class="' + (delta >= 0 ? "up" : "dn") + '">' + (delta >= 0 ? "+" : "") + this._fmtN(delta) + '</b></div>' +
          '<div><span>min</span><b>' + this._fmtN(min) + '</b></div>' +
          '<div><span>max</span><b>' + this._fmtN(max) + '</b></div>' +
          '<div><span>points</span><b>' + points.length + '</b></div>' +
        '</div>';
      q("#soc-back").addEventListener("click", () => this.openSocial());
    },

    // ── ACTION RECORDER (record keyboard+mouse, replay on a loop) ──────────────
    _rec: { lastState: "idle" },

    openRecorder() {
      titleEl().textContent = "action recorder";
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._loadRecorder();
      return true;
    },

    _loadRecorder() {
      call("macro_list").then((d) => {
        if (titleEl().textContent !== "action recorder") return;
        this._rec.data = d || { available: false, recordings: [] };
        this._rec.lastState = (d && d.status && d.status.state) || "idle";
        this._renderRecorder();
        this._startMacroPoll();
      });
    },

    _startMacroPoll() {
      this._stopMacroPoll();
      this._macroPoll = setInterval(() => {
        const panelOn = document.getElementById("panel").classList.contains("on");
        if (!panelOn || titleEl().textContent !== "action recorder") { this._stopMacroPoll(); return; }
        call("macro_status").then((s) => {
          if (!s) return;
          if (s.state !== this._rec.lastState) { this._rec.lastState = s.state; this._loadRecorder(); }
          else this._updateRecorderLive(s);
        });
      }, 700);
    },
    _stopMacroPoll() { if (this._macroPoll) { clearInterval(this._macroPoll); this._macroPoll = null; } },

    _updateRecorderLive(s) {
      const el = q("#rec-elapsed"); if (el) el.textContent = (s.elapsed || 0) + "s";
      const ev = q("#rec-events"); if (ev) ev.textContent = (s.events || 0);
      const lp = q("#rec-loop");
      if (lp) lp.textContent = (s.loops_done || 0) + (s.total_loops ? " / " + s.total_loops : " · ∞");
    },

    _renderRecorder() {
      const d = this._rec.data || {};
      if (!d.available) {
        body().innerHTML = '<p class="muted">The action recorder isn’t available on this system (needs pynput). Restart Wivexx and try again.</p>';
        return;
      }
      const st = d.status || { state: "idle" };
      const recs = d.recordings || [];
      const panic = st.panic_key || "esc";
      const shortcut = d.shortcut || "";
      const playSc = d.play_shortcut || "";
      const activeName = st.active_name || "";
      const sens = (st.game_sensitivity != null ? st.game_sensitivity : 1.0);
      const mm = st.mouse_mode || "desktop";
      const scLine = shortcut ? ' Shortcut <b>' + esc(shortcut) + '</b> starts/stops it (works inside fullscreen games).' : '';

      let control;
      if (st.state === "recording") {
        control =
          '<div class="formcard rec-live recording">' +
            '<div class="rec-status"><span class="rec-dot rec"></span>recording <span class="rec-modetag">' + esc(st.mode || mm) + '</span> — <span id="rec-elapsed">' + (st.elapsed || 0) + 's</span> &middot; <span id="rec-events">' + (st.events || 0) + '</span> actions</div>' +
            '<div class="field" style="margin-top:10px"><label>save as</label><input id="rec-name" autocomplete="off" placeholder="(auto-named if blank)" value="' + esc(st.pending_name || "") + '"></div>' +
            '<div class="btnrow"><button class="pbtn primary" id="rec-save">&#9632; stop &amp; save</button>' +
              '<button class="pbtn danger" id="rec-cancel">discard</button></div>' +
            (shortcut ? '<div class="muted" style="margin-top:8px">…or press <b>' + esc(shortcut) + '</b> to stop &amp; save from in-game.</div>' : '') +
          '</div>';
      } else if (st.state === "playing") {
        control =
          '<div class="formcard rec-live playing">' +
            '<div class="rec-status"><span class="rec-dot play"></span>playing &ldquo;' + esc(st.play_name || "") + '&rdquo; — loop <span id="rec-loop">' + (st.loops_done || 0) + (st.total_loops ? " / " + st.total_loops : " &middot; &#8734;") + '</span></div>' +
            '<div class="muted" style="margin:6px 0 10px">Press <b>' + esc(panic) + '</b> to abort instantly.</div>' +
            '<div class="btnrow"><button class="pbtn danger" id="rec-stop">&#9632; stop playback</button></div>' +
          '</div>';
      } else {
        control =
          '<div class="formcard">' +
            '<div class="field" style="margin-bottom:10px"><label>name this recording</label>' +
              '<input id="rec-name-pre" autocomplete="off" placeholder="(auto-named if blank)" value="' + esc(st.pending_name || "") + '"></div>' +
            '<div class="field" style="margin-bottom:10px"><label>mouse capture</label>' +
              '<select id="rec-mode">' +
                '<option value="auto"' + (mm === "auto" ? " selected" : "") + '>automatic — switches in/out of games on its own (recommended)</option>' +
                '<option value="desktop"' + (mm === "desktop" ? " selected" : "") + '>desktop only — clicks land at exact positions</option>' +
                '<option value="game"' + (mm === "game" ? " selected" : "") + '>game only — raw mouse-look (Minecraft / fullscreen)</option>' +
              '</select></div>' +
            '<div class="btnrow"><button class="pbtn primary big" id="rec-start">&#9679; record</button></div>' +
            '<div class="muted" style="margin-top:8px">Captures every key, click, mouse move &amp; scroll.' + scLine + '</div>' +
            '<div class="muted" style="margin-top:6px;font-size:11px"><b>Automatic</b> records desktop movement as exact cursor positions AND in-game mouse-look as raw motion — so one take can go into a game, move around, then tab back out. Name it first to save a shortcut-started take under that name.</div>' +
          '</div>';
      }

      const rows = recs.map((r) =>
        '<div class="rec-item" data-name="' + esc(r.name) + '">' +
          '<div class="rec-main"><div class="rec-rname">' + esc(r.name) + '</div>' +
            '<div class="rec-meta">' + r.duration + 's &middot; ' + r.events + ' actions' + (r.created ? ' &middot; ' + esc(r.created) : '') + '</div></div>' +
          '<div class="rec-ctrls">' +
            '<label class="rec-forever" title="repeat until you hit the panic key or stop">' +
              '<input type="checkbox" class="rec-inf"> &#8734;</label>' +
            '<input class="rec-loops" type="number" min="1" value="1" title="number of times to play">' +
            '<button class="pbtn rec-play">&#9658; play</button>' +
            '<button class="iconbtn-sm danger rec-del" title="Delete">&#128465;</button>' +
          '</div>' +
        '</div>').join("") || '<p class="muted">No recordings yet. Hit record, do your thing, then stop &amp; save.</p>';

      body().innerHTML =
        control +
        '<div class="sectlabel" style="margin-top:16px">saved recordings</div>' +
        '<div class="rec-list">' + rows + '</div>' +
        '<div class="formcard" style="margin-top:14px">' +
          '<div class="field"><label>start / stop recording shortcut</label>' +
            '<div class="with-btn"><span class="ai-hk" id="rec-sc">' + esc(shortcut || "(none — set one)") + '</span>' +
              '<button class="pbtn" id="rec-sc-set">change</button>' +
              (shortcut ? '<button class="pbtn" id="rec-sc-clr">clear</button>' : '') + '</div></div>' +
          '<div class="field"><label>active recording — fire it from a shortcut, even in-game</label>' +
            '<select id="rec-active"><option value="">(none)</option>' +
              recs.map((r) => '<option value="' + esc(r.name) + '"' + (r.name === activeName ? " selected" : "") + ">" + esc(r.name) + "</option>").join("") +
            '</select></div>' +
          '<div class="field"><label>quick-play shortcut — plays the active recording (press again to stop)</label>' +
            '<div class="with-btn"><span class="ai-hk" id="rec-psc">' + esc(playSc || "(none — set one)") + '</span>' +
              '<button class="pbtn" id="rec-psc-set">change</button>' +
              (playSc ? '<button class="pbtn" id="rec-psc-clr">clear</button>' : '') + '</div></div>' +
          '<div class="field"><label>game mouse-look sensitivity — raise it if a recorded spin replays too small</label>' +
            '<div class="with-btn"><input id="rec-sens" type="number" min="0.1" max="20" step="0.1" value="' + sens + '" style="max-width:90px">' +
              '<span class="muted" style="font-size:12px">×&nbsp; (1.0 = as recorded)</span></div></div>' +
          '<div class="field" style="margin-bottom:0"><label>panic key — aborts a running loop (press any key or combo)</label>' +
            '<div class="with-btn"><span class="ai-hk" id="rec-pk">' + esc(panic || "esc") + '</span>' +
              '<button class="pbtn" id="rec-pk-set">change</button></div></div>' +
        '</div>';

      const npre = q("#rec-name-pre");
      // push the name to the backend as it's typed, so a SHORTCUT-started take
      // (you can't type a name from in-game) still saves under the chosen name
      if (npre) npre.addEventListener("input", () => call("macro_set_name", npre.value || ""));
      const sb = q("#rec-start");
      if (sb) sb.addEventListener("click", () => {
        sb.disabled = true;
        const mode = (q("#rec-mode") && q("#rec-mode").value) || "auto";
        const nm = (q("#rec-name-pre") || {}).value || "";
        call("macro_record_start", mode, nm).then(() => this._loadRecorder());
      });
      const sv = q("#rec-save");
      if (sv) sv.addEventListener("click", () => { sv.disabled = true; call("macro_record_stop", (q("#rec-name") || {}).value || "").then(() => this._loadRecorder()); });
      const cn = q("#rec-cancel");
      if (cn) cn.addEventListener("click", () => call("macro_record_cancel").then(() => this._loadRecorder()));
      const stp = q("#rec-stop");
      if (stp) stp.addEventListener("click", () => { stp.disabled = true; call("macro_stop").then(() => this._loadRecorder()); });
      qa(".rec-item").forEach((it) => {
        const name = it.getAttribute("data-name");
        const inf = it.querySelector(".rec-inf");
        const ln = it.querySelector(".rec-loops");
        if (inf && ln) inf.addEventListener("change", () => { ln.disabled = inf.checked; });
        it.querySelector(".rec-play").addEventListener("click", () => {
          const loops = (inf && inf.checked) ? 0 : (parseInt(ln.value, 10) || 1);
          call("macro_play", name, loops, 1.0).then(() => this._loadRecorder());
        });
        it.querySelector(".rec-del").addEventListener("click", (e) =>
          this._armDel(e.currentTarget, () => call("macro_delete", name).then(() => this._loadRecorder())));
      });
      const pkb = q("#rec-pk-set");
      if (pkb) pkb.addEventListener("click", () => this._aiCapture(pkb, (combo) =>
        call("macro_set_panic", combo).then(() => this._loadRecorder())));
      const scb = q("#rec-sc-set");
      if (scb) scb.addEventListener("click", () => this._aiCapture(scb, (combo) =>
        call("cfg_hotkey_set", "toggle_recording", combo).then(() => this._loadRecorder())));
      const scc = q("#rec-sc-clr");
      if (scc) scc.addEventListener("click", () =>
        call("cfg_hotkey_set", "toggle_recording", " ").then(() => this._loadRecorder()));
      // active recording + quick-play shortcut + game sensitivity
      const av = q("#rec-active");
      if (av) av.addEventListener("change", () => call("macro_set_active", av.value || ""));
      const pscb = q("#rec-psc-set");
      if (pscb) pscb.addEventListener("click", () => this._aiCapture(pscb, (combo) =>
        call("cfg_hotkey_set", "play_active", combo).then(() => this._loadRecorder())));
      const pscc = q("#rec-psc-clr");
      if (pscc) pscc.addEventListener("click", () =>
        call("cfg_hotkey_set", "play_active", " ").then(() => this._loadRecorder()));
      const sensEl = q("#rec-sens");
      if (sensEl) sensEl.addEventListener("change", () => {
        let v = parseFloat(sensEl.value); if (!(v > 0)) v = 1.0;
        v = Math.max(0.1, Math.min(20, v)); sensEl.value = v;
        call("macro_set_sensitivity", v);
      });
    },

    // ── REEL TIMELINE (Phase 3) — import clips → trim/reorder → stitch → caption ──
    _defTl() {
      return { clips: [], overlays: [], media: [], mediaFolders: [], audioTracks: [], timelineDur: 0, laneH: {}, magnet: true, tool: "select", autoEdit: { gap: 0.35, padding: 0.12, minSeg: 0.4, punch: 8, dynamic: true }, inspTab: "selected", preset: "vertical", fps: 30, transition: "none",
               transitionDur: 0.5, music: null, musicName: "", musicVol: 0.5, duck: true,
               voiceover: null, voiceoverName: "", voiceoverVol: 1.0,
               background: "black", fit: "fit", resolution: "1080", quality: "balanced",
               format: "h264", dest: "captions",
               id: null, name: "", playhead: 0, zoom: 1, building: false, studio: false };
    },
    // fold legacy single music/voiceover fields into the audioTracks list (old projects)
    _tlSyncAudio() {
      const t = this._tl;
      if (!t.audioTracks) t.audioTracks = [];
      if (t.music && !t.audioTracks.some((a) => a.path === t.music)) {
        t.audioTracks.push({ path: t.music, name: t.musicName || "music", vol: (t.musicVol != null ? t.musicVol : 0.5), duck: t.duck !== false, loop: true });
      }
      if (t.voiceover && !t.audioTracks.some((a) => a.path === t.voiceover)) {
        t.audioTracks.push({ path: t.voiceover, name: t.voiceoverName || "voiceover", vol: (t.voiceoverVol != null ? t.voiceoverVol : 1.0), duck: false, loop: false });
      }
      t.music = null; t.voiceover = null;   // migrated → the list is the source of truth
    },
    // ── ENTRY: the reel-maker LAUNCHER (a projects gallery). Creating / opening a
    //    project hands off to the full-screen STUDIO. ──────────────────────────
    openTimeline() {
      titleEl().textContent = "reel maker";
      const pnl = document.getElementById("panel");
      pnl.classList.remove("cap-wide", "cap-vert", "studio");
      this._tl = (this._tl && this._tl.clips) ? this._tl : this._defTl();
      this._tl.studio = false;
      openPanel();
      this._renderLauncher();
      call("project_list").then((r) => { this._tlProjects = (r && r.projects) || []; if (titleEl().textContent === "reel maker") this._renderLauncher(); });
      return true;
    },
    _renderLauncher() {
      const projs = this._tlProjects || [], t = this._tl, self = this;
      const ago = (ts) => self._fmtAgo((ts && ts > 1e12) ? ts / 1000 : ts);
      const draft = (t && t.clips && t.clips.length && !t.id)
        ? '<div class="rl-card rl-draft" id="rl-continue"><div class="rl-card-main">' +
            '<div class="rl-card-t">● Unsaved draft</div>' +
            '<div class="rl-card-s muted">' + t.clips.length + ' clip' + (t.clips.length > 1 ? 's' : '') + ' · pick up where you left off</div></div>' +
            '<span class="rl-open">continue →</span></div>'
        : '';
      const sortKey = this._tlSort || "recent";
      const sorted = projs.slice().sort((a, b) => {
        if (sortKey === "name") return (a.name || "").localeCompare(b.name || "");
        if (sortKey === "oldest") return (a.ts || 0) - (b.ts || 0);
        if (sortKey === "clips") return (b.clips || 0) - (a.clips || 0);
        return (b.ts || 0) - (a.ts || 0);                 // recent
      });
      const cards = sorted.map((p) =>
        '<div class="rl-card" data-pid="' + esc(p.id) + '"><div class="rl-card-main">' +
          '<div class="rl-card-t">' + esc(p.name || "Untitled reel") + '</div>' +
          '<div class="rl-card-s muted">' + p.clips + ' clip' + (p.clips === 1 ? '' : 's') +
            (p.overlays ? ' · ' + p.overlays + ' overlay' + (p.overlays === 1 ? '' : 's') : '') +
            ' · ' + ago(p.ts) + '</div></div>' +
          '<button class="rl-del" data-pid="' + esc(p.id) + '" title="delete project">🗑</button>' +
          '<span class="rl-open">open →</span></div>').join("");
      const sortCtl = (projs.length > 1)
        ? '<select id="rl-sort" class="rl-sort">' +
            [["recent", "recent"], ["oldest", "oldest"], ["name", "name A–Z"], ["clips", "most clips"]].map((o) =>
              '<option value="' + o[0] + '"' + (o[0] === sortKey ? " selected" : "") + ">" + o[1] + "</option>").join("") + "</select>"
        : "";
      body().innerHTML =
        '<div class="rl-wrap">' +
        '<p class="muted" style="margin:0 0 12px;font-size:12px">Build social reels from your clips & photos — trim, add transitions, overlays, music and captions, then export. Open a project to launch the full editor.</p>' +
        '<button class="rl-new" id="rl-new"><span class="rl-new-plus">＋</span>' +
          '<span class="rl-new-txt"><b>New reel project</b><small>start a fresh edit in the studio</small></span></button>' +
        '<div class="rl-sechead"><span class="rl-sec">your projects</span>' + sortCtl + "</div>" + draft +
        (cards || '<p class="muted" style="font-size:12px;padding:6px 0">no saved projects yet — create one to get started.</p>') +
        '</div>';
      const nw = q("#rl-new"); if (nw) nw.addEventListener("click", () => { this._tl = this._defTl(); this._openStudio(); });
      const cont = q("#rl-continue"); if (cont) cont.addEventListener("click", () => this._openStudio());
      const so = q("#rl-sort"); if (so) so.addEventListener("change", (e) => { this._tlSort = e.target.value; this._renderLauncher(); });
      qa(".rl-card[data-pid]").forEach((c) => c.addEventListener("click", (e) => {
        if (e.target.closest(".rl-del")) return;
        const id = c.getAttribute("data-pid");
        const meta = (this._tlProjects || []).find((p) => p.id === id);
        call("project_load", id).then((r) => { if (r && r.ok && r.data) { this._tlLoad(r.data, id, meta && meta.name, true); this._openStudio(); } });
      }));
      qa(".rl-del[data-pid]").forEach((b) => b.addEventListener("click", (e) => {
        e.stopPropagation();
        this._armDel(b, () => {                            // double-check before deleting
          const id = b.getAttribute("data-pid");
          call("project_delete", id).then(() => call("project_list").then((lr) => { this._tlProjects = (lr && lr.projects) || []; this._renderLauncher(); }));
        });
      }));
    },
    // ── the full-screen STUDIO (a real NLE: monitor · inspector · timeline) ────
    _openStudio() {
      titleEl().textContent = "reel studio";
      const pnl = document.getElementById("panel");
      pnl.classList.remove("cap-wide", "cap-vert");
      pnl.classList.add("studio");
      this._removeHubBar();
      this._tl.studio = true;
      this._tlSyncMedia();               // make sure the bin reflects the project's clips
      this._tlSyncAudio();               // migrate legacy music/voiceover → audioTracks
      this._tlSyncPositions();           // ensure every clip has a timeline position
      if (!this._tlResizeBound) {        // re-fit the monitor + playhead on window resize
        this._tlResizeBound = true;
        window.addEventListener("resize", () => {
          if (this._tl && this._tl.studio && q(".st-stage")) { this._tlSizeMonitor(); this._tlPositionPlayhead(); }
        });
      }
      openPanel();
      this._renderTimeline();
      if (!(this._tlProjects && this._tlProjects.length)) {
        call("project_list").then((r) => { this._tlProjects = (r && r.projects) || []; });
      }
    },
    _closeStudio() {
      this._tlStop();
      document.getElementById("panel").classList.remove("studio");
      this.openTimeline();
    },
    // a serialisable snapshot of the project (drops transient fields)
    _tlData() {
      const t = this._tl;
      return { clips: t.clips, overlays: t.overlays, media: t.media, mediaFolders: t.mediaFolders,
               audioTracks: t.audioTracks, timelineDur: t.timelineDur, laneH: t.laneH, magnet: t.magnet, autoEdit: t.autoEdit, preset: t.preset, fps: t.fps,
               transition: t.transition, transitionDur: t.transitionDur,
               background: t.background, fit: t.fit, resolution: t.resolution,
               quality: t.quality, format: t.format, dest: t.dest };
    },
    _tlNew() { const s = this._tl && this._tl.studio; this._tl = this._defTl(); this._tl.studio = s; this._renderTimeline(); },
    _tlLoad(data, id, name, quiet) {
      const wasStudio = this._tl && this._tl.studio;
      this._tl = Object.assign(this._defTl(), data || {}, { id: id || null, name: name || (data && data.name) || "", building: false, studio: wasStudio });
      this._tlSyncMedia();      // derive the media library for projects saved before bins existed
      this._tlSyncAudio();      // migrate legacy music/voiceover → audioTracks
      this._tlSyncPositions();  // ensure every clip has a timeline position
      if (!quiet) { this._renderTimeline(); this._note("tl-note", "project loaded ✓"); }
    },
    // ── media library (the bin) ────────────────────────────────────────────────
    // ensure every main-clip source is represented once in the media library (the bin
    // is the main-clip library; overlays/audio have their own sections)
    _tlSyncMedia() {
      const t = this._tl;
      if (!t.media) t.media = [];
      const seen = new Set(t.media.map((m) => m.path));
      (t.clips || []).forEach((c) => {
        if (c && c.path && !seen.has(c.path)) {
          seen.add(c.path);
          t.media.push({ path: c.path, name: c.name, kind: c.kind, dur: c.dur || 0 });
        }
      });
    },
    // add picked / dropped media → the library AND the sequence
    _tlAddMedia(items, toSequence) {
      const t = this._tl, folder = t._binFolder || "";   // import into the active folder
      (items || []).forEach((it) => {
        if (!t.media.some((m) => m.path === it.path)) t.media.push({ path: it.path, name: it.name, kind: it.kind, dur: it.dur, folder: folder, log: !!it.log, logAuto: !!it.logAuto });
        if (toSequence !== false) t.clips.push({ path: it.path, name: it.name, kind: it.kind, dur: it.dur, start: 0, end: it.dur || null, log: !!it.log, logAuto: !!it.logAuto });
      });
      this._renderTimeline();
    },
    // append ONE library source to the sequence (clicking a bin card)
    _tlAddFromBin(path) {
      const m = (this._tl.media || []).find((x) => x.path === path);
      if (!m) return;
      this._tl.clips.push({ path: m.path, name: m.name, kind: m.kind, dur: m.dur, start: 0, end: m.dur || null, log: !!m.log });
      this._renderTimeline();
      this._note("tl-note", "added “" + (m.name || "clip") + "” to the timeline");
    },
    _onTimelineDrop(items) {
      if (!this._tl || !this._tl.studio) return;
      const media = (items || []).filter((it) => it && it.path);
      if (!media.length) return;
      this._tlAddMedia(media, false);        // → the media bin (a library); not auto-placed on the timeline
      toast(media.length + (media.length === 1 ? " clip" : " clips") + " added to your media — drag onto the timeline to use");
    },
    _tlBinHTML() {
      const t = this._tl, media = t.media || [], folders = t.mediaFolders || [];
      const active = t._binFolder || "";                       // "" = All
      const shown = active ? media.filter((m) => (m.folder || "") === active) : media;
      const sel = t._binSel || (t._binSel = []);
      const cards = shown.map((m) => {
        const thumb = (t._binThumbs && t._binThumbs[m.path]);
        const glyph = m.kind === "image" ? "🖼" : "🎬";
        const dur = m.dur ? this._capFmt(m.dur) : (m.kind === "image" ? "img" : "");
        const used = (t.clips || []).filter((c) => c.path === m.path).length;
        const si = sel.indexOf(m.path);
        return '<div class="bin-card' + (si >= 0 ? " selected" : "") + '" data-mpath="' + esc(m.path) + '" draggable="true" title="' + esc(m.name) + ' — click or drag onto the timeline">' +
          '<button class="bin-sel' + (si >= 0 ? " on" : "") + '" data-mpath="' + esc(m.path) + '" title="select for auto-edit (order matters)">' + (si >= 0 ? (si + 1) : "") + "</button>" +
          '<div class="bin-thumb"' + (thumb ? ' style="background-image:url(' + thumb + ')"' : '') + '>' +
            (thumb ? '' : '<span class="bin-glyph">' + glyph + '</span>') + "</div>" +
          (dur ? '<span class="bin-dur">' + dur + "</span>" : "") +
          (used ? '<span class="bin-used">' + used + "×</span>" : "") +
          '<span class="bin-name">' + esc((m.name || "clip").replace(/\.[^.]+$/, "")) + "</span>" +
          '<button class="bin-del" data-mpath="' + esc(m.path) + '" title="remove from project">&#10005;</button></div>';
      }).join("");
      const ae = t.autoEdit || {};
      const aeOpen = !!t._aeOpen;
      const aeBar =
        '<div class="bin-ae">' +
          '<button class="pbtn sm bin-ae-go" id="bin-ae-go"' + (sel.length ? "" : " disabled") + '>✨ AI auto-edit' + (sel.length ? " (" + sel.length + ")" : "") + "</button>" +
          '<button class="bin-ae-cog' + (aeOpen ? " on" : "") + '" id="bin-ae-cog" title="auto-edit settings">⚙</button>' +
          (sel.length ? '<button class="bin-ae-clear" id="bin-ae-clear" title="clear selection">✕</button>' : "") +
        "</div>" +
        (aeOpen ?
          '<div class="bin-ae-set"><div class="bin-ae-row">' +
            '<label>pause gap (s)<input type="number" id="ae-gap" min="0.1" max="2" step="0.05" value="' + (ae.gap != null ? ae.gap : 0.35) + '"></label>' +
            '<label>padding (s)<input type="number" id="ae-pad" min="0" max="1" step="0.02" value="' + (ae.padding != null ? ae.padding : 0.12) + '"></label>' +
            '<label>min clip (s)<input type="number" id="ae-min" min="0.1" max="3" step="0.1" value="' + (ae.minSeg != null ? ae.minSeg : 0.4) + '"></label>' +
            '<label>punch-in %<input type="number" id="ae-punch" min="0" max="25" step="1" value="' + (ae.punch != null ? ae.punch : 8) + '"></label>' +
          "</div>" +
          '<label class="cap-check sm"><input type="checkbox" id="ae-dyn"' + (ae.dynamic !== false ? " checked" : "") + "> dynamic punch-ins (alternating zoom)</label>" +
          '<p class="muted" style="font-size:10px;margin:4px 0 0">Select clips above in the order you want them, then auto-edit cuts the silences and assembles a tight cut on V1.</p></div>' : "");
      const fchip = (key, label) => '<button class="bin-fchip' + (active === key ? " on" : "") + '" data-folder="' + esc(key) + '">' + esc(label) + "</button>";
      const folderBar = '<div class="bin-folders">' + fchip("", "all") +
        folders.map((f) => fchip(f, f)).join("") +
        '<button class="bin-fchip" id="bin-newfolder" title="new folder">＋</button></div>';
      return '<div class="st-bin" id="st-bin">' +
        '<div class="st-bin-head">media <span class="muted">(' + media.length + ')</span></div>' +
        '<button class="pbtn sm bin-import" id="bin-import">＋ import</button>' +
        aeBar +
        folderBar +
        '<div class="bin-list" id="bin-list">' +
          (cards || '<div class="bin-empty muted">' + (active ? "this folder is empty — drag tiles onto it." : "drop clips &amp; photos here, or click import") + "</div>") + "</div>" +
        '<div class="bin-hint muted">drag files onto Wivexx to add them · click or drag a tile onto the timeline · drag a tile onto a folder to file it</div></div>';
    },
    // kick off the AI auto-edit on the selected bin clips (in selection order)
    _tlAutoEdit() {
      const t = this._tl, sel = t._binSel || [];
      if (!sel.length) { this._note("tl-note", "select clips in the bin first (tap the circle on each)", true); return; }
      const ae = t.autoEdit || {};
      const settings = { gap: ae.gap, padding: ae.padding, min_seg: ae.minSeg,
                         punch: (ae.punch || 0) / 100, dynamic: ae.dynamic !== false };
      this._tlAEBusy = true;
      const btn = q("#bin-ae-go"); if (btn) { btn.disabled = true; btn.textContent = "✨ auto-editing…"; }
      this._note("tl-note", "AI is transcribing + cutting silences on " + sel.length + " clip" + (sel.length > 1 ? "s" : "") + "… (runs in the background)", false);
      call("timeline_auto_edit", { paths: sel.slice(), settings: settings });
    },
    _onAutoEditProgress(fr) {
      const btn = q("#bin-ae-go");
      if (btn && this._tlAEBusy) btn.textContent = "✨ auto-editing… " + Math.round((+fr || 0) * 100) + "%";
    },
    _onAutoEditDone(r) {
      this._tlAEBusy = false;
      const t = this._tl;
      if (!r || !r.ok) { this._note("tl-note", "auto-edit failed: " + ((r && r.error) || "unknown"), true); this._renderTimeline(); return; }
      (r.clips || []).forEach((c) => { c.tlStart = null; t.clips.push(c); });   // append; sync lays them end-to-end
      t.timelineDur = 0; t._binSel = [];
      this._tlSyncPositions();
      this._renderTimeline();
      this._note("tl-note", "✨ auto-edit done — " + (r.count || 0) + " cuts placed, " + (r.removed_s || 0) + "s of silence removed", false);
    },
    _wireTlBin() {
      const t = this, on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      on("#bin-import", "click", () => {
        call("timeline_pick_clips").then((r) => { if (r && r.ok && r.items) t._tlAddMedia(r.items, false); });   // → bin only
      });
      qa(".bin-card").forEach((card) => {
        card.addEventListener("click", (e) => { if (e.target.closest(".bin-del")) return; t._tlAddFromBin(card.dataset.mpath); });
        card.addEventListener("dragstart", (e) => { t._dragMediaPath = card.dataset.mpath; try { e.dataTransfer.setData("text/wvx-media", card.dataset.mpath); e.dataTransfer.effectAllowed = "copy"; } catch (_) {} });
        card.addEventListener("dragend", () => { t._dragMediaPath = null; });
      });
      qa(".bin-del").forEach((b) => b.addEventListener("click", (e) => {
        e.stopPropagation();
        const p = b.dataset.mpath;
        t._tl.media = (t._tl.media || []).filter((m) => m.path !== p);
        t._tl.clips = (t._tl.clips || []).filter((c) => c.path !== p);
        t._tl._binSel = (t._tl._binSel || []).filter((x) => x !== p);
        t._renderTimeline();
      }));
      // ── AI auto-edit: select clips (ordered) → cut silences → tight dynamic cut ──
      qa(".bin-sel").forEach((b) => b.addEventListener("click", (e) => {
        e.stopPropagation();
        const tl = t._tl, p = b.dataset.mpath; tl._binSel = tl._binSel || [];
        const i = tl._binSel.indexOf(p);
        if (i >= 0) tl._binSel.splice(i, 1); else tl._binSel.push(p);
        t._renderTimeline();
      }));
      on("#bin-ae-cog", "click", () => { t._tl._aeOpen = !t._tl._aeOpen; t._renderTimeline(); });
      on("#bin-ae-clear", "click", () => { t._tl._binSel = []; t._renderTimeline(); });
      on("#ae-gap", "change", (e) => { t._tl.autoEdit.gap = Math.max(0.1, parseFloat(e.target.value) || 0.35); });
      on("#ae-pad", "change", (e) => { t._tl.autoEdit.padding = Math.max(0, parseFloat(e.target.value) || 0); });
      on("#ae-min", "change", (e) => { t._tl.autoEdit.minSeg = Math.max(0.1, parseFloat(e.target.value) || 0.4); });
      on("#ae-punch", "change", (e) => { t._tl.autoEdit.punch = Math.max(0, Math.min(25, parseInt(e.target.value, 10) || 0)); });
      on("#ae-dyn", "change", (e) => { t._tl.autoEdit.dynamic = e.target.checked; });
      on("#bin-ae-go", "click", () => t._tlAutoEdit());
      // ── folders: filter · new folder · drag a tile onto a chip to file it ──
      qa(".bin-fchip[data-folder]").forEach((ch) => {
        ch.addEventListener("click", () => { t._tl._binFolder = ch.dataset.folder; t._renderTimeline(); });
        ch.addEventListener("dragover", (e) => { if (t._dragMediaPath) { e.preventDefault(); ch.classList.add("bin-fdrop"); } });
        ch.addEventListener("dragleave", () => ch.classList.remove("bin-fdrop"));
        ch.addEventListener("drop", (e) => {
          ch.classList.remove("bin-fdrop");
          const p = (e.dataTransfer && e.dataTransfer.getData("text/wvx-media")) || t._dragMediaPath;
          const m = (t._tl.media || []).find((x) => x.path === p);
          if (m) { m.folder = ch.dataset.folder; t._dragMediaPath = null; t._renderTimeline(); }
        });
      });
      on("#bin-newfolder", "click", () => {
        t._tl.mediaFolders = t._tl.mediaFolders || [];
        let n = t._tl.mediaFolders.length + 1, name;
        do { name = "Folder " + n; n++; } while (t._tl.mediaFolders.indexOf(name) >= 0);
        t._tl.mediaFolders.push(name); t._tl._binFolder = name; t._renderTimeline();
      });
      // a media drop zone right on the bin (HTML files also flow through the global handler)
      const bin = q("#st-bin");
      if (bin) {
        bin.addEventListener("dragover", (e) => { e.preventDefault(); bin.classList.add("bin-dropping"); });
        bin.addEventListener("dragleave", () => bin.classList.remove("bin-dropping"));
        bin.addEventListener("drop", () => bin.classList.remove("bin-dropping"));
      }
      this._tlFetchBinThumbs();
    },
    // lazily fetch one preview frame per media source for the bin cards (uses the proxy
    // when available, so it's fast after _tlPrep); cached by path.
    _tlFetchBinThumbs() {
      const t = this._tl, self = this;
      if (!t._binThumbs) t._binThumbs = {};
      const pending = (t.media || []).filter((m) => m.kind !== "image" && !t._binThumbs[m.path]);
      if (!pending.length) return;
      clearTimeout(self._binThumbT);
      let i = 0;
      const next = () => {
        if (i >= pending.length || titleEl().textContent !== "reel studio") return;
        if (self._tlFrameInFlight) { self._binThumbT = setTimeout(next, 280); return; }  // yield to the monitor frame
        const m = pending[i++];
        call("timeline_frame", { clips: [{ path: m.path, name: m.name, kind: m.kind, dur: m.dur, start: 0, end: m.dur || null }],
          t: (m.dur || 2) * 0.3, preset: t.preset, background: "black", fit: "fill" }).then((r) => {
          if (r && r.ok && r.thumb) {
            t._binThumbs[m.path] = r.thumb;
            const card = q('.bin-card[data-mpath="' + (window.CSS && CSS.escape ? CSS.escape(m.path) : m.path) + '"] .bin-thumb');
            if (card) { card.style.backgroundImage = "url(" + r.thumb + ")"; const g = card.querySelector(".bin-glyph"); if (g) g.style.display = "none"; }
          }
          self._binThumbT = setTimeout(next, 60);
        });
      };
      self._binThumbT = setTimeout(next, 700);   // let the first render + monitor frame settle first
    },
    // ── contextual inspector: controls for ONLY the selected clip / overlay ──────
    _tlInspSelected() {
      const t = this._tl, fmt = (s) => this._capFmt(s), i = t.selIdx;
      if (t.selKind === "clip" && t.clips[i]) {
        const c = t.clips[i];
        const motion = (c.kind === "image")
          ? '<label>motion<select class="tl-motion" data-i="' + i + '">' +
            [["none", "none"], ["zoomin", "zoom in"], ["zoomout", "zoom out"]].map((p) =>
              '<option value="' + p[0] + '"' + ((c.motion || "none") === p[0] ? " selected" : "") + ">" + p[1] + "</option>").join("") + "</select></label>" : "";
        return '<div class="insp-card">' +
          '<div class="insp-title"><span class="tl-glyph">' + (c.kind === "image" ? "🖼" : "🎬") + '</span>' +
            '<span class="insp-name">' + esc((c.name || "clip").replace(/\.[^.]+$/, "")) + '</span>' +
            '<span class="muted mono" style="margin-left:auto;font-size:10px">clip ' + (i + 1) + "</span></div>" +
          '<div class="cap-grid">' +
            '<label>in (s)<input type="number" class="tl-in" data-i="' + i + '" min="0" step="0.1" value="' + (+c.start || 0) + '"></label>' +
            '<label>out (s)<input type="number" class="tl-out" data-i="' + i + '" min="0" step="0.1" value="' + (c.end != null ? c.end : (c.dur || 0)) + '"></label>' +
            '<label>speed ×<input type="number" class="tl-spd" data-i="' + i + '" min="0.25" max="4" step="0.25" value="' + (+c.speed || 1) + '"></label>' +
            (c.kind !== "image" ? '<label>volume %<input type="number" class="tl-vol" data-i="' + i + '" min="0" max="200" value="' + Math.round((c.volume != null ? c.volume : 1) * 100) + '"></label>' : "") +
            '<label>rotate<select class="tl-rot" data-i="' + i + '">' +
              [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]].map((p) =>
                '<option value="' + p[0] + '"' + ((+c.rotate || 0) === p[0] ? " selected" : "") + ">" + p[1] + "</option>").join("") + "</select></label>" +
            motion +
          "</div>" +
          (c.kind !== "image" ? '<label class="cap-check sm tl-logrow"><input type="checkbox" class="tl-log" data-i="' + i + '"' + (c.log ? " checked" : "") + "> log → Rec.709 (S-Log colour fix)" + (c.logAuto ? ' <span class="muted">· auto-detected</span>' : "") + "</label>" : "") +
          '<div class="insp-actions">' +
            '<button class="pbtn sm tl-mute' + (c.mute ? " on" : "") + '" data-i="' + i + '">' + (c.mute ? "🔇 muted" : "🔊 sound") + "</button>" +
            (c.kind !== "image" ? '<button class="pbtn sm tl-split" data-i="' + i + '">✂ split</button>' : "") +
            (c.kind !== "image" ? '<button class="pbtn sm tl-detach" data-i="' + i + '" title="move this clip\'s audio onto its own editable track">🎙 detach audio</button>' : "") +
            '<button class="pbtn sm danger tl-del" data-i="' + i + '">✕ remove</button></div>' +
          '<p class="muted" style="font-size:10px;margin:8px 0 0">' + fmt(this._tlClipDur(c)) + " of " + fmt(c.dur || 0) + " used</p></div>";
      }
      if (t.selKind === "ov" && t.overlays[i]) {
        const o = t.overlays[i];
        return '<div class="insp-card">' +
          '<div class="insp-title"><span class="tl-glyph">' + (o.kind === "video" ? "🎞" : "🖼") + '</span>' +
            '<span class="insp-name">' + esc((o.name || "overlay").replace(/\.[^.]+$/, "")) + '</span>' +
            '<span class="muted" style="margin-left:auto;font-size:10px">overlay</span></div>' +
          '<div class="cap-grid">' +
            '<label>at (s)<input type="number" class="tl-ov-st" data-i="' + i + '" min="0" step="0.1" value="' + (+o.start || 0) + '"></label>' +
            '<label>for (s)<input type="number" class="tl-ov-du" data-i="' + i + '" min="0.2" step="0.1" value="' + (+o.dur || 3) + '"></label>' +
            (o.kind === "video" ? '<label>in (s)<input type="number" class="tl-ov-in" data-i="' + i + '" min="0" step="0.1" value="' + (+o.in || 0) + '"></label>' : "") +
            '<label>x %<input type="number" class="tl-ov-x" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.x != null ? o.x : 0.5) * 100) + '"></label>' +
            '<label>y %<input type="number" class="tl-ov-y" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.y != null ? o.y : 0.25) * 100) + '"></label>' +
            '<label>size %<input type="number" class="tl-ov-sc" data-i="' + i + '" min="5" max="100" value="' + Math.round((o.scale != null ? o.scale : 0.4) * 100) + '"></label>' +
            '<label>opacity %<input type="number" class="tl-ov-op" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.opacity != null ? o.opacity : 1) * 100) + '"></label>' +
            '<label>fade (s)<input type="number" class="tl-ov-fd" data-i="' + i + '" min="0" max="3" step="0.1" value="' + (+o.fade || 0) + '"></label>' +
            '<label>video track<input type="number" class="tl-ov-tk" data-i="' + i + '" min="1" max="6" value="' + (+o.track || 1) + '" title="V2 = 1, V3 = 2 … (higher = on top)"></label>' +
          "</div>" +
          '<div class="insp-actions"><button class="pbtn sm danger tl-del tl-ovdel" data-i="' + i + '">✕ remove overlay</button></div></div>';
      }
      if (t.selKind === "aud" && t.audioTracks[i]) {
        const a = t.audioTracks[i];
        return '<div class="insp-card">' +
          '<div class="insp-title"><span class="tl-glyph">' + (a.loop ? "🎵" : "🎙") + '</span>' +
            '<span class="insp-name">' + esc((a.name || "audio").replace(/\.[^.]+$/, "")) + '</span>' +
            '<span class="muted" style="margin-left:auto;font-size:10px">music</span></div>' +
          '<div class="cap-grid">' +
            '<label>volume %<input type="number" class="tl-isvol" data-i="' + i + '" min="0" max="200" value="' + Math.round((a.vol != null ? a.vol : 0.5) * 100) + '"></label>' +
            (a.loop ? "" : '<label>start at (s)<input type="number" class="tl-istl" data-i="' + i + '" min="0" step="0.1" value="' + (+a.tlStart || 0) + '"></label>') +
            (a.loop ? "" : '<label>in (s)<input type="number" class="tl-isin" data-i="' + i + '" min="0" step="0.1" value="' + (+a.start || 0) + '"></label>') +
            (a.loop ? "" : '<label>out (s)<input type="number" class="tl-isout" data-i="' + i + '" min="0" step="0.1" value="' + (a.end != null ? a.end : (a.dur || 0)) + '"></label>') +
            '<label>fade in (s)<input type="number" class="tl-isfi" data-i="' + i + '" min="0" max="10" step="0.1" value="' + (+a.fadein || 0) + '"></label>' +
            '<label>fade out (s)<input type="number" class="tl-isfo" data-i="' + i + '" min="0" max="10" step="0.1" value="' + (+a.fadeout || 0) + '"></label>' +
          "</div>" +
          '<div class="insp-actions">' +
            '<label class="cap-check sm"><input type="checkbox" class="tl-isloop" data-i="' + i + '"' + (a.loop ? " checked" : "") + "> loop to fill</label>" +
            '<label class="cap-check sm"><input type="checkbox" class="tl-isduck" data-i="' + i + '"' + (a.duck ? " checked" : "") + "> duck under speech</label>" +
            '<button class="pbtn sm danger tl-isdel" data-i="' + i + '">✕ remove</button></div></div>';
      }
      return '<div class="insp-empty"><span class="insp-empty-ic">🎯</span>' +
        '<p>Click a clip, overlay or music track on the timeline to edit just that item here.</p>' +
        '<p class="muted" style="font-size:10.5px">The <b>Output</b> tab has transitions, format and export.</p></div>';
    },
    _tlDispDur(c) { return Math.max(0.1, ((c.end != null ? c.end : (c.dur || 0)) - (+c.start || 0)) / (+c.speed || 1)); },
    _tlClipEnd(c) { return (+c.tlStart || 0) + this._tlDispDur(c); },
    // an audio track's on-screen duration (trim window, or full source); looped beds fill all
    _tlAudDur(a) {
      const src = (a.end != null ? +a.end : (+a.dur || 0));
      const d = src - (+a.start || 0);
      return d > 0.05 ? d : (+a.dur || 0);
    },
    // ── MAGNET / snapping: the edges everything can snap to (other blocks + 0 + playhead) ──
    _tlSnapPoints(exKind, exIdx) {
      const t = this._tl, pts = [0];
      const ce = this._tlContentEnd(); if (ce > 0) pts.push(ce);
      if (t.playhead) pts.push(+t.playhead);
      (t.clips || []).forEach((c, i) => { if (!(exKind === "clip" && i === exIdx)) { const s = +c.tlStart || 0; pts.push(s, s + this._tlDispDur(c)); } });
      (t.overlays || []).forEach((o, i) => { if (!(exKind === "ov" && i === exIdx)) { const s = +o.start || 0; pts.push(s, s + (+o.dur || 0)); } });
      (t.audioTracks || []).forEach((a, i) => { if (!(exKind === "aud" && i === exIdx) && !a.loop) { const s = +a.tlStart || 0; pts.push(s, s + this._tlAudDur(a)); } });
      return pts;
    },
    // snap a proposed start (with the block's duration) to the nearest edge within `thresh` (s)
    _tlSnap(start, blockDur, thresh, exKind, exIdx) {
      if (!this._tl.magnet) return Math.max(0, Math.round(start * 100) / 100);
      let best = start, bestD = thresh;
      for (const p of this._tlSnapPoints(exKind, exIdx)) {
        if (Math.abs(start - p) < bestD) { bestD = Math.abs(start - p); best = p; }
        if (Math.abs((start + blockDur) - p) < bestD) { bestD = Math.abs((start + blockDur) - p); best = p - blockDur; }
      }
      return Math.max(0, Math.round(best * 100) / 100);
    },
    _tlClickTime(clientX, rect) { return Math.max(0, Math.round(((clientX - rect.left) / rect.width) * this._tlTotalDur() * 100) / 100); },
    // ── CUT TOOL: split a clip / overlay / audio at an arbitrary timeline time `tt` ──
    _tlCutClipAt(i, tt) {
      const a = this._tl.clips, c = a[i], tl0 = +c.tlStart || 0;
      if (tt <= tl0 + 0.1 || tt >= this._tlClipEnd(c) - 0.1) return;
      const speed = +c.speed || 1, srcStart = +c.start || 0, srcCut = Math.round((srcStart + (tt - tl0) * speed) * 100) / 100;
      a.splice(i, 1, Object.assign({}, c, { end: srcCut, tlStart: tl0 }),
        Object.assign({}, c, { start: srcCut, tlStart: Math.round(tt * 10) / 10 }));
      this._tl.selKind = null; this._tl.selIdx = null; this._renderTimeline();
    },
    _tlCutOverlayAt(i, tt) {
      const a = this._tl.overlays, o = a[i], s = +o.start || 0, d = +o.dur || 0;
      if (tt <= s + 0.1 || tt >= s + d - 0.1) return;
      const off = tt - s, isVid = o.kind === "video";
      a.splice(i, 1, Object.assign({}, o, { start: s, dur: Math.round(off * 100) / 100 }),
        Object.assign({}, o, { start: Math.round(tt * 100) / 100, dur: Math.round((d - off) * 100) / 100, in: isVid ? Math.round(((+o.in || 0) + off) * 100) / 100 : (o.in || 0) }));
      this._tl.selKind = null; this._tl.selIdx = null; this._renderTimeline();
    },
    _tlCutAudioAt(i, tt) {
      const a = this._tl.audioTracks, m = a[i];
      if (m.loop) { this._note("tl-note", "looping audio fills the timeline — turn off loop to cut it", true); return; }
      const tl0 = +m.tlStart || 0, end = this._tlClipEnd ? (tl0 + this._tlAudDur(m)) : 0;
      if (tt <= tl0 + 0.1 || tt >= end - 0.1) return;
      const srcStart = +m.start || 0, srcCut = Math.round((srcStart + (tt - tl0)) * 100) / 100;
      a.splice(i, 1, Object.assign({}, m, { end: srcCut, tlStart: tl0 }),
        Object.assign({}, m, { start: srcCut, tlStart: Math.round(tt * 100) / 100 }));
      this._tl.selKind = null; this._tl.selIdx = null; this._renderTimeline();
    },
    // give every clip a timeline position (end-to-end by default); preserve existing ones
    _tlSyncPositions() {
      const t = this._tl; let acc = 0;
      (t.clips || []).forEach((c) => {
        if (c.tlStart == null) c.tlStart = acc;
        acc = Math.max(acc, (+c.tlStart || 0) + this._tlDispDur(c));
      });
    },
    _tlContentEnd() {
      const t = this._tl;
      const ce = (t.clips || []).reduce((m, c) => Math.max(m, this._tlClipEnd(c)), 0);
      const oe = (t.overlays || []).reduce((m, o) => Math.max(m, (+o.start || 0) + (+o.dur || 0)), 0);
      const ae = (t.audioTracks || []).reduce((m, a) => a.loop ? m : Math.max(m, (+a.tlStart || 0) + this._tlAudDur(a)), 0);
      return Math.max(ce, oe, ae);
    },
    _tlTotalDur() { return Math.max(this._tl.timelineDur || 0, this._tlContentEnd(), 1); },
    // ── the visual multi-track canvas (V / overlay / music lanes + a time ruler) ──
    _renderTlCanvas(opts) {
      opts = opts || {};
      const t = this._tl, tlDur = this._tlTotalDur();
      const clipBlocks = t.clips.map((c, i) => {
        const w = this._tlDispDur(c), left = (+c.tlStart || 0) / tlDur * 100, width = w / tlDur * 100;
        const cwImg = t._audWaves && t._audWaves[c.path];      // clip shows its own audio (Premiere-style)
        const showWave = c.kind !== "image" && !c.mute;
        return '<div class="tl-blk tl-blk-clip' + (showWave ? " has-wave" : "") + (t.selKind === "clip" && t.selIdx === i ? " sel" : "") +
          '" data-ci="' + i + '" style="left:' + left.toFixed(2) + '%;width:' + width.toFixed(2) + '%" title="' + esc(c.name || "") + '">' +
          (showWave ? '<span class="tl-blk-wave" data-cw="' + i + '" style="' + (cwImg ? "background-image:url(" + cwImg + ")" : "") + '"></span>' : "") +
          (c.kind !== "image" ? '<span class="tl-resize tl-resize-l" title="drag to trim the start"></span>' : "") +
          '<span class="tl-blk-lbl">' + esc((c.name || "clip").replace(/\.[^.]+$/, "")) + (c.speed && c.speed != 1 ? " " + c.speed + "×" : "") + (c.rotate ? " ⟳" + c.rotate + "°" : "") + "</span>" +
          '<span class="tl-resize" title="drag to trim the end"></span></div>';
      }).join("");
      // overlays live on VIDEO tracks (V2, V3 …); a block renders in its track's lane.
      const ovBlockFor = (trackNum) => (t.overlays || []).map((o, i) => {
        if ((+o.track || 1) !== trackNum) return "";
        const left = (+o.start || 0) / tlDur * 100, width = Math.max(2, (+o.dur || 1) / tlDur * 100);
        return '<div class="tl-blk tl-blk-ov' + (t.selKind === "ov" && t.selIdx === i ? " sel" : "") +
          '" data-oi="' + i + '" style="left:' + left.toFixed(2) + '%;width:' + width.toFixed(2) + '%" title="' + esc(o.name || "") + '">' +
          '<span class="tl-blk-lbl">' + esc((o.name || "overlay").replace(/\.[^.]+$/, "")) + "</span>" +
          '<span class="tl-resize" title="drag to change duration"></span></div>';
      }).join("");
      const maxVTrack = Math.max(1, ...((t.overlays || []).map((o) => +o.track || 1)));
      const waveBg = (img) => img ? ";background-image:url(" + img + ");background-size:100% 100%;background-repeat:no-repeat" : "";
      const auds = t.audioTracks || [];
      const step = tlDur <= 8 ? 1 : (tlDur <= 20 ? 2 : 5);
      let ticks = "";
      for (let s = 0; s <= tlDur + 0.01; s += step) ticks += '<span class="tl-tick" style="left:' + (s / tlDur * 100).toFixed(2) + '%">' + this._capFmt(s) + "</span>";
      const ar = t.preset === "square" ? "1 / 1" : (t.preset === "landscape" ? "16 / 9" : "9 / 16");
      const zoom = t.zoom || 1;
      const lane = (lbl, dataLane, blocks, empty) => {
        const lh = (t.laneH && t.laneH[dataLane]) ? ' style="height:' + t.laneH[dataLane] + 'px"' : "";
        return '<div class="tl-lane"><span class="tl-lanelbl">' + lbl + '</span><div class="tl-track" data-lane="' + dataLane + '"' + lh + '>' +
          (blocks || '<span class="tl-empty">' + empty + "</span>") +
          '<span class="tl-lane-resize" data-lane="' + dataLane + '" title="drag to resize this track"></span></div></div>';
      };
      const cutOn = t.tool === "cut";
      const zoomHead =
        '<div class="tl-tlhead"><span class="tl-tlhead-lbl">timeline</span>' +
          '<span class="tl-tlhead-hint muted">' + (cutOn ? "✂ cut mode — click a clip, overlay or music to cut it there" : "drag to move · drag an edge to trim · click to edit") + "</span>" +
          '<span class="tl-monspring"></span>' +
          '<button class="tl-zoombtn tl-magnet' + (t.magnet ? " on" : "") + '" id="tl-magnet" title="magnet: snap clips edge-to-edge">🧲</button>' +
          '<button class="tl-zoombtn tl-cut' + (cutOn ? " on" : "") + '" id="tl-cut" title="cut tool — click the timeline to cut">✂</button>' +
          '<button class="tl-zoombtn tl-extend" id="tl-extend" title="add 15s of timeline">+15s</button>' +
          '<div class="tl-zoomctl"><button class="tl-zoombtn" id="tl-zoomout" title="zoom out">−</button>' +
          '<span class="tl-zoomlbl mono">' + zoom.toFixed(1) + "×</span>" +
          '<button class="tl-zoombtn" id="tl-zoomin" title="zoom in">+</button></div></div>';
      return '<div class="tl-canvas' + (cutOn ? " cutmode" : "") + '" id="tl-canvas">' +
        (opts.noMonitor ? "" : this._tlMonitorHTML(ar)) +
        zoomHead +
        '<div class="tl-scroll" id="tl-scroll"><div class="tl-zoomwrap" id="tl-zoomwrap" style="width:' + Math.round(zoom * 100) + '%">' +
          '<div class="tl-ruler" id="tl-ruler" title="click or drag to scrub">' + ticks + "</div>" +
          lane("V1", "clip", clipBlocks, "drag clips here from the media bin →") +
          Array.from({ length: maxVTrack }, (_, k) =>
            lane("V" + (k + 2), "ov" + (k + 1), ovBlockFor(k + 1), "video track " + (k + 2) + " (overlays)")).join("") +
          (auds.length
            ? auds.map((a, i) => {
                const left = a.loop ? 0 : (+a.tlStart || 0) / tlDur * 100;
                const width = a.loop ? 100 : Math.max(2, this._tlAudDur(a) / tlDur * 100);
                const selc = (t.selKind === "aud" && t.selIdx === i) ? " sel" : "";
                const blk = '<div class="tl-blk tl-blk-aud' + selc + '" data-ai="' + i + '" style="left:' + left.toFixed(2) + '%;width:' + width.toFixed(2) + '%' +
                  waveBg(t._audWaves && t._audWaves[a.path]) + '" title="' + esc(a.name || "") + '"><span class="tl-blk-lbl">' +
                  (a.loop ? "🎵 " : "🎙 ") + esc((a.name || "audio").replace(/\.[^.]+$/, "")) + "</span>" +
                  (a.loop ? "" : '<span class="tl-resize" title="drag to trim the end"></span>') + "</div>";
                return lane(a.loop ? "♪" : "🎙", "aud" + i, blk, "");
              }).join("")
            : lane("♪", "aud", "", "add audio in the Audio tab")) +
          '<div class="tl-playhead" id="tl-playhead" style="display:none"></div></div></div></div>';
    },
    // the preview MONITOR (used inline in compact mode + in the studio stage). Has
    // a play/pause that animates the playhead across the timeline, a time readout,
    // and zoom controls.
    _tlMonitorHTML(ar) {
      const t = this._tl;
      const playing = !!this._tlPlaying;
      return '<div class="tl-montop"><div class="tl-monitor" style="aspect-ratio:' + ar + '">' +
        '<img id="tl-monimg" class="tl-monimg" alt="" style="visibility:hidden">' +
        '<span class="tl-monempty muted">press play ▶ or scrub the timeline</span></div>' +
        '<div class="tl-moninfo">' +
          '<button class="tl-playbtn" id="tl-play" title="play / pause preview">' + (playing ? "❚❚" : "▶") + '</button>' +
          '<span class="tl-montime mono">' + this._capFmt(t.playhead || 0) + " / " + this._capFmt(this._tlTotalDur()) + "</span>" +
        "</div></div>";
    },
    // Size the studio monitor to fill the stage at the target aspect ratio. Done in JS
    // because an empty <img> gives the box no intrinsic size, so a pure-CSS aspect box
    // collapses to ~0 until a frame loads (that was the "big black square" bug).
    _tlSizeMonitor() {
      const stage = q(".st-stage"); if (!stage) return;          // studio only
      const mon = stage.querySelector(".tl-monitor"); if (!mon) return;
      const info = stage.querySelector(".tl-moninfo");
      const cs = getComputedStyle(stage);
      const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
      const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
      const availW = stage.clientWidth - padX;
      const availH = stage.clientHeight - padY - (info ? info.offsetHeight : 34) - 10;
      if (availW < 24 || availH < 24) return;
      const t = this._tl;
      const arWH = t.preset === "square" ? 1 : (t.preset === "landscape" ? 16 / 9 : 9 / 16);
      let w = availW, h = w / arWH;
      if (h > availH) { h = availH; w = h * arWH; }
      mon.style.width = Math.floor(w) + "px";
      mon.style.height = Math.floor(h) + "px";
    },
    // smooth zoom — adjusts the zoom-wrap width in place (no full re-render, so scroll
    // position + the monitor frame are preserved).
    _tlApplyZoom(delta) {
      const t = this._tl;
      t.zoom = Math.round(Math.max(1, Math.min(6, (t.zoom || 1) + delta)) * 10) / 10;
      const wrap = q("#tl-zoomwrap"); if (wrap) wrap.style.width = Math.round(t.zoom * 100) + "%";
      const lbl = q(".tl-zoomlbl"); if (lbl) lbl.textContent = t.zoom.toFixed(1) + "×";
      this._tlPositionPlayhead();
    },
    // animate the playhead across the timeline (a lightweight "play" preview — the
    // monitor frame updates at the scrub cadence; not frame-accurate video).
    _tlPlay() {
      const t = this._tl, self = this;
      if (this._tlPlaying) return this._tlStop();
      const total = this._tlTotalDur();
      if ((t.playhead || 0) >= total - 0.05) t.playhead = 0;
      this._tlPlaying = true;
      const btn = q("#tl-play"); if (btn) btn.textContent = "❚❚";
      let last = performance.now();
      this._tlPlayFetchT = 0;
      const tick = (now) => {
        if (!self._tlPlaying) return;
        t.playhead = Math.min(total, (t.playhead || 0) + (now - last) / 1000); last = now;
        self._tlPositionPlayhead();
        const lab = q(".tl-montime"); if (lab) lab.textContent = self._capFmt(t.playhead) + " / " + self._capFmt(total);
        // throttle the frame pull to ~5/s (the old debounced fetch never fired while
        // rAF kept resetting its timer — that was the "frozen until you pause" bug);
        // the in-flight guard means we never queue ffmpeg calls on top of each other.
        if (now - self._tlPlayFetchT > 200) { self._tlPlayFetchT = now; self._tlDoFetchFrame(); }
        if (t.playhead >= total - 0.001) { self._tlStop(); return; }
        self._tlRaf = requestAnimationFrame(tick);
      };
      this._tlRaf = requestAnimationFrame(tick);
    },
    _tlStop() {
      this._tlPlaying = false;
      if (this._tlRaf) { cancelAnimationFrame(this._tlRaf); this._tlRaf = 0; }
      const btn = q("#tl-play"); if (btn) btn.textContent = "▶";
    },
    _tlSelect(kind, idx) {
      this._tl.selKind = kind; this._tl.selIdx = idx;
      if (this._tl.studio) this._tl.inspTab = "selected";   // show that item in the inspector
      this._renderTimeline();
    },
    // position the vertical playhead line over the lanes (pixel-measured so the
    // 24px lane-label offset never throws it off) at the current playhead time
    _tlPositionPlayhead() {
      const wrap = q("#tl-zoomwrap"), ph = q("#tl-playhead"); if (!wrap || !ph) return;
      const track = wrap.querySelector(".tl-track"), ruler = q("#tl-ruler");
      const lanes = wrap.querySelectorAll(".tl-lane");
      if (!track || !ruler || !lanes.length) { ph.style.display = "none"; return; }
      const wr = wrap.getBoundingClientRect(), tr = track.getBoundingClientRect();
      const top = ruler.getBoundingClientRect().top - wr.top;
      const bottom = lanes[lanes.length - 1].getBoundingClientRect().bottom - wr.top;
      const pct = Math.max(0, Math.min(1, (this._tl.playhead || 0) / this._tlTotalDur()));
      ph.style.display = "";
      ph.style.left = (tr.left - wr.left + pct * tr.width) + "px";
      ph.style.top = top + "px";
      ph.style.height = (bottom - top) + "px";
    },
    _tlFetchWaves() {
      const t = this._tl, self = this;
      if (!t._audWaves) t._audWaves = {};
      // every audio track + every non-muted video clip wants a waveform (fetched once per path)
      const paths = [];
      (t.audioTracks || []).forEach((a) => { if (a.path) paths.push(a.path); });
      (t.clips || []).forEach((c) => { if (c.path && c.kind !== "image" && !c.mute) paths.push(c.path); });
      const needDur = (t.audioTracks || []).some((a) => !a.loop && !(+a.dur));
      Array.from(new Set(paths)).forEach((path) => {
        if (t._audWaves[path] && !needDur) { self._tlApplyWave(path); return; }
        call("timeline_waveform", { path: path, width: 600, height: 36 }).then((r) => {
          if (!r || !r.ok) return;
          if (r.img) t._audWaves[path] = r.img;
          if (r.dur) {                              // backfill the source duration for positioning
            let changed = false;
            (t.audioTracks || []).forEach((a) => { if (a.path === path && !(+a.dur)) { a.dur = r.dur; changed = true; } });
            if (changed) self._renderTimeline();
          }
          self._tlApplyWave(path);
        });
      });
    },
    _tlApplyWave(path) {
      const t = this._tl, img = t._audWaves && t._audWaves[path];
      if (!img) return;
      qa('#tl-canvas .tl-blk-aud').forEach((blk, i) => {
        if ((t.audioTracks[i] || {}).path === path) {
          blk.style.backgroundImage = "url(" + img + ")"; blk.style.backgroundSize = "100% 100%"; blk.style.backgroundRepeat = "no-repeat";
        }
      });
      qa('#tl-canvas .tl-blk-wave').forEach((sp) => {
        const c = t.clips[+sp.dataset.cw]; if (c && c.path === path) sp.style.backgroundImage = "url(" + img + ")";
      });
    },
    // signature of everything that changes how a frame looks (drops the cache on change)
    _tlSig() {
      const t = this._tl;
      return t.preset + "|" + t.background + "|" + t.fit + "|" + (t.timelineDur || 0) + "|" +
        t.clips.map((c) => [c.path, c.start, c.end, c.speed, c.mute, c.motion, c.tlStart, c.rotate, c.zoom, c.log].join(",")).join(";");
    },
    _showMonFrame(thumb) {
      const img = q("#tl-monimg"); if (!img) return;
      img.classList.remove("loading"); img.src = thumb; img.style.visibility = "visible";
      const em = q(".tl-monempty"); if (em) em.style.display = "none";
    },
    _tlFetchFrame() {                       // debounced (scrub / render)
      clearTimeout(this._tlFrameT);
      this._tlFrameT = setTimeout(() => this._tlDoFetchFrame(), 140);
    },
    _tlDoFetchFrame() {                     // immediate, cache-aware, never stacks ffmpeg calls
      const t = this._tl;
      if (!t.clips.length) return;
      const sig = this._tlSig();
      if (this._tlCacheSig !== sig) { this._tlCache = {}; this._tlCacheSig = sig; }  // inputs changed → drop cache
      const key = Math.round((t.playhead || 0) * 3) / 3;   // ~0.33 s buckets
      if (this._tlCache && this._tlCache[key]) { this._showMonFrame(this._tlCache[key]); return; }  // instant on revisit
      if (this._tlFrameInFlight) return;    // one ffmpeg frame at a time (kills the scrub/play lag pile-up)
      this._tlFrameInFlight = true;
      const im = q("#tl-monimg"); if (im) im.classList.add("loading");
      const stop = () => { this._tlFrameInFlight = false; clearTimeout(this._tlFrameWatchdog); };
      const fail = () => { stop(); const im2 = q("#tl-monimg"); if (im2) im2.classList.remove("loading"); };
      // watchdog: a rejected OR hung bridge call must NEVER freeze the preview forever
      this._tlFrameWatchdog = setTimeout(fail, 12000);
      call("timeline_frame", { clips: t.clips, t: t.playhead, preset: t.preset, background: t.background, fit: t.fit }).then((r) => {
        stop();
        if (r && r.ok && r.thumb) {
          if (!this._tlCache || Object.keys(this._tlCache).length > 60) this._tlCache = {};
          this._tlCache[key] = r.thumb;
          this._showMonFrame(r.thumb);
        } else { const im2 = q("#tl-monimg"); if (im2) im2.classList.remove("loading"); }
      }).catch(fail);
    },
    // background-build the small proxies so scrubbing is fast (only when the source SET changes)
    _tlPrep() {
      const t = this._tl;
      const sig = t.clips.map((c) => c.path).join("|");
      if (this._tlPrepSig === sig || !t.clips.length) return;
      this._tlPrepSig = sig;
      call("timeline_prep", { clips: t.clips });
    },
    _onTimelineProxyReady() {
      this._tlCache = {}; this._tlCacheSig = null;          // re-pull from the (now fast) proxies
      if (titleEl().textContent === "reel builder" && this._tl && this._tl.clips && this._tl.clips.length) this._tlFetchFrame();
    },
    _wireTlCanvas() {
      const t = this._tl, self = this;
      // MOVE a clip along the timeline (change its tlStart) — gaps are allowed
      qa("#tl-canvas .tl-blk-clip").forEach((blk) => blk.addEventListener("pointerdown", (e) => {
        e.preventDefault();
        const i = +blk.dataset.ci, c = t.clips[i], track = blk.closest(".tl-track"), rect = track.getBoundingClientRect();
        if (t.tool === "cut") { self._tlCutClipAt(i, self._tlClickTime(e.clientX, rect)); return; }
        const x0 = e.clientX, start0 = +c.tlStart || 0, bd = self._tlDispDur(c); let moved = false;
        const move = (ev) => {
          if (Math.abs(ev.clientX - x0) > 3) { moved = true; blk.classList.add("dragging"); }
          const dur = self._tlTotalDur(), dx = (ev.clientX - x0) / rect.width * dur;
          c.tlStart = self._tlSnap(Math.max(0, start0 + dx), bd, 9 / rect.width * dur, "clip", i);
          blk.style.left = ((+c.tlStart) / dur * 100).toFixed(2) + "%";
        };
        const up = () => {
          document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up);
          if (moved) self._renderTimeline(); else self._tlSelect("clip", i);
        };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      qa("#tl-canvas .tl-blk-ov").forEach((blk) => blk.addEventListener("pointerdown", (e) => {
        e.preventDefault();
        const i = +blk.dataset.oi, ov = t.overlays[i], track = blk.closest(".tl-track"), rect = track.getBoundingClientRect();
        if (t.tool === "cut") { self._tlCutOverlayAt(i, self._tlClickTime(e.clientX, rect)); return; }
        const x0 = e.clientX, start0 = +ov.start || 0, bd = +ov.dur || 0; let moved = false;
        const move = (ev) => {
          if (Math.abs(ev.clientX - x0) > 3) moved = true;
          const dur = self._tlTotalDur(), dx = (ev.clientX - x0) / rect.width * dur;
          ov.start = self._tlSnap(Math.max(0, start0 + dx), bd, 9 / rect.width * dur, "ov", i);
          blk.style.left = ((+ov.start) / dur * 100).toFixed(2) + "%";
        };
        const up = () => {
          document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up);
          if (moved) self._renderTimeline(); else self._tlSelect("ov", i);
        };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      // ── MUSIC: move a non-looped audio clip along the timeline (or cut / select it) ──
      qa("#tl-canvas .tl-blk-aud").forEach((blk) => blk.addEventListener("pointerdown", (e) => {
        e.preventDefault();
        const i = +blk.dataset.ai, m = t.audioTracks[i], track = blk.closest(".tl-track"), rect = track.getBoundingClientRect();
        if (t.tool === "cut") { self._tlCutAudioAt(i, self._tlClickTime(e.clientX, rect)); return; }
        const movable = !m.loop, x0 = e.clientX, start0 = +m.tlStart || 0, bd = self._tlAudDur(m); let moved = false;
        const move = (ev) => {
          if (!movable) return;
          if (Math.abs(ev.clientX - x0) > 3) { moved = true; blk.classList.add("dragging"); }
          const dur = self._tlTotalDur(), dx = (ev.clientX - x0) / rect.width * dur;
          m.tlStart = self._tlSnap(Math.max(0, start0 + dx), bd, 9 / rect.width * dur, "aud", i);
          blk.style.left = ((+m.tlStart) / dur * 100).toFixed(2) + "%";
        };
        const up = () => {
          document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up);
          if (moved) self._renderTimeline(); else self._tlSelect("aud", i);
        };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      // ── right-edge resize handles: trim a clip's end / change an overlay's duration ──
      const edgeDrag = (sel, apply) => qa(sel).forEach((h) => h.addEventListener("pointerdown", (e) => {
        e.preventDefault(); e.stopPropagation();           // don't start the block's move/select drag
        const blk = h.closest(".tl-blk"), dur = self._tlTotalDur(), leftPx = blk.getBoundingClientRect().left;
        const track = blk.closest(".tl-track").getBoundingClientRect();
        const move = (ev) => {
          const dd = Math.max(0.2, Math.round((Math.max(6, ev.clientX - leftPx) / track.width * dur) * 10) / 10);
          apply(blk, dd);
          blk.style.width = Math.max(2, dd / dur * 100).toFixed(2) + "%";
        };
        const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); self._renderTimeline(); };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      edgeDrag("#tl-canvas .tl-blk-clip .tl-resize:not(.tl-resize-l)", (blk, dd) => {
        const c = t.clips[+blk.dataset.ci];
        const end = (+c.start || 0) + dd * (+c.speed || 1);
        c.end = Math.round(Math.min(end, c.kind === "image" ? 60 : (c.dur || end)) * 10) / 10;
      });
      edgeDrag("#tl-canvas .tl-blk-ov .tl-resize", (blk, dd) => { t.overlays[+blk.dataset.oi].dur = dd; });
      edgeDrag("#tl-canvas .tl-blk-aud .tl-resize", (blk, dd) => {
        const m = t.audioTracks[+blk.dataset.ai], cap = (+m.dur || 0);
        let end = (+m.start || 0) + dd;
        if (cap > 0) end = Math.min(end, cap);            // can't trim past the source length
        m.end = Math.round(end * 100) / 100;
      });
      // LEFT-edge of a clip = trim the IN point (start); the block shrinks from the left
      // and the sequence re-flows on release.
      qa("#tl-canvas .tl-blk-clip .tl-resize-l").forEach((h) => h.addEventListener("pointerdown", (e) => {
        e.preventDefault(); e.stopPropagation();
        const blk = h.closest(".tl-blk"), c = t.clips[+blk.dataset.ci];
        const track = blk.closest(".tl-track").getBoundingClientRect(), dur = self._tlTotalDur(), x0 = e.clientX;
        const origLeftDisp = (parseFloat(blk.style.left) || 0) / 100 * dur;
        const origDisp = (parseFloat(blk.style.width) || 0) / 100 * dur;
        const start0 = +c.start || 0, speed = +c.speed || 1, end = (c.end != null ? c.end : (c.dur || 0));
        const move = (ev) => {
          const dDisp = (ev.clientX - x0) / track.width * dur;
          c.start = Math.round(Math.max(0, Math.min(end - 0.2 * speed, start0 + dDisp * speed)) * 10) / 10;
          const newDisp = Math.max(0.1, (end - c.start) / speed);
          blk.style.left = ((origLeftDisp + (origDisp - newDisp)) / dur * 100).toFixed(2) + "%";
          blk.style.width = (newDisp / dur * 100).toFixed(2) + "%";
        };
        const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); self._renderTimeline(); };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      // ── scrub: click/drag the ruler → move the playhead + preview a frame ──
      const ruler = q("#tl-ruler");
      if (ruler) {
        const scrub = (clientX) => {
          const trk = q("#tl-canvas .tl-track"); if (!trk) return;
          const tr = trk.getBoundingClientRect();
          t.playhead = Math.round(Math.max(0, Math.min(1, (clientX - tr.left) / tr.width)) * self._tlTotalDur() * 100) / 100;
          self._tlPositionPlayhead();
          const lab = q(".tl-montime"); if (lab) lab.textContent = self._capFmt(t.playhead);
          self._tlFetchFrame();
        };
        ruler.addEventListener("pointerdown", (e) => {
          e.preventDefault(); scrub(e.clientX);
          const mv = (ev) => scrub(ev.clientX);
          const up = () => { document.removeEventListener("pointermove", mv); document.removeEventListener("pointerup", up); };
          document.addEventListener("pointermove", mv); document.addEventListener("pointerup", up);
        });
      }
      // ── zoom + horizontal scroll ──
      const zin = q("#tl-zoomin"); if (zin) zin.addEventListener("click", () => self._tlApplyZoom(0.5));
      const zout = q("#tl-zoomout"); if (zout) zout.addEventListener("click", () => self._tlApplyZoom(-0.5));
      const cutBtn = q("#tl-cut"); if (cutBtn) cutBtn.addEventListener("click", () => { t.tool = t.tool === "cut" ? "select" : "cut"; self._renderTimeline(); });
      const mag = q("#tl-magnet"); if (mag) mag.addEventListener("click", () => { t.magnet = !t.magnet; self._renderTimeline(); });
      const ext = q("#tl-extend"); if (ext) ext.addEventListener("click", () => self._tlExtend(15));
      const play = q("#tl-play"); if (play) play.addEventListener("click", () => self._tlPlay());
      // drag a track's bottom edge to resize just that track (height persisted per lane)
      qa("#tl-canvas .tl-lane-resize").forEach((h) => h.addEventListener("pointerdown", (e) => {
        e.preventDefault(); e.stopPropagation();
        const lane = h.dataset.lane, track = h.closest(".tl-track");
        if (!self._tl.laneH) self._tl.laneH = {};
        const h0 = track.getBoundingClientRect().height, y0 = e.clientY;
        const move = (ev) => {
          const nh = Math.max(26, Math.min(240, h0 + (ev.clientY - y0)));
          track.style.height = nh + "px"; self._tl.laneH[lane] = Math.round(nh);
        };
        const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); };
        document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
      }));
      // ── drag a media-bin tile onto a lane → V1 (insert clip) · V2/V3 (overlay) · ♪ (audio) ──
      const hasMedia = (e) => self._dragMediaPath ||
        (e.dataTransfer && [].indexOf.call(e.dataTransfer.types || [], "text/wvx-media") >= 0);
      const mediaFromDrop = (e) => {
        const path = (e.dataTransfer && e.dataTransfer.getData("text/wvx-media")) || self._dragMediaPath;
        return (t.media || []).find((x) => x.path === path) || null;
      };
      const wireLaneDrop = (laneEl, onDrop) => {
        if (!laneEl) return;
        laneEl.addEventListener("dragover", (e) => { if (hasMedia(e)) { e.preventDefault(); laneEl.classList.add("tl-track-drop"); } });
        laneEl.addEventListener("dragleave", () => laneEl.classList.remove("tl-track-drop"));
        laneEl.addEventListener("drop", (e) => {
          laneEl.classList.remove("tl-track-drop");
          const m = mediaFromDrop(e); if (!m) return;
          e.preventDefault();
          const rect = laneEl.getBoundingClientRect(), dur = self._tlTotalDur();
          const at = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * dur;
          onDrop(m, at);
          self._dragMediaPath = null;
          self._renderTimeline();
        });
      };
      // V1 (main sequence): drop the clip AT the drop time (position-based)
      wireLaneDrop(q('#tl-canvas .tl-track[data-lane="clip"]'), (m, at) => {
        t.clips.push({ path: m.path, name: m.name, kind: m.kind, dur: m.dur, start: 0, end: m.dur || null,
          tlStart: Math.round(at * 10) / 10, log: !!m.log });
      });
      // V2/V3… (overlay video tracks): add an overlay on that track at the drop time
      qa('#tl-canvas .tl-track[data-lane^="ov"]').forEach((ovLane) => {
        const trackNum = parseInt(ovLane.dataset.lane.slice(2), 10) || 1;
        wireLaneDrop(ovLane, (m, at) => {
          t.overlays.push({ path: m.path, name: m.name, kind: m.kind, dur: m.kind === "video" ? Math.min(m.dur || 3, 5) : 3,
            start: Math.round(at * 10) / 10, x: 0.5, y: 0.22, scale: 0.4, opacity: 1, fade: 0.3, track: trackNum });
        });
      });
      // ♪ audio lanes: add the media's audio as a new audio track
      qa('#tl-canvas .tl-track[data-lane^="aud"]').forEach((aLane) => {
        wireLaneDrop(aLane, (m) => {
          t.audioTracks = t.audioTracks || [];
          t.audioTracks.push({ path: m.path, name: m.name, vol: 0.85, duck: false, loop: false });
        });
      });
      this._tlSizeMonitor();
      requestAnimationFrame(() => self._tlSizeMonitor());     // after layout settles
      const scroll = q("#tl-scroll"); if (scroll) scroll.addEventListener("scroll", () => self._tlPositionPlayhead());
      this._tlPositionPlayhead();
      if (t.clips.length) { this._tlFetchFrame(); this._tlPrep(); }
      this._tlFetchWaves();
    },
    _tlClipDur(c) { return Math.max(0, (c.end != null ? c.end : (c.dur || 0)) - (+c.start || 0)); },
    _tlReorder(from, to) {
      const a = this._tl.clips;
      to = Math.max(0, Math.min(a.length - 1, to));
      if (from === to || from < 0 || from >= a.length) return;
      const it = a.splice(from, 1)[0]; a.splice(to, 0, it);
    },
    _tlSplit(i) {
      const a = this._tl.clips, c = a[i];
      const s = +c.start || 0, e = (c.end != null ? c.end : c.dur) || 0;
      if (e - s < 0.4) { this._note("tl-note", "clip is too short to split", true); return; }
      const mid = +((s + e) / 2).toFixed(2), tl0 = +c.tlStart || 0, speed = +c.speed || 1;
      a.splice(i, 1, Object.assign({}, c, { start: s, end: mid, tlStart: tl0 }),
        Object.assign({}, c, { start: mid, end: e, tlStart: Math.round((tl0 + (mid - s) / speed) * 100) / 100 }));
      this._tl.selKind = null; this._tl.selIdx = null;   // indices shifted — drop selection
      this._renderTimeline();
    },
    // razor: cut the clip under the playhead into two at the playhead time
    _tlRazor() {
      const t = this._tl, ph = +t.playhead || 0;
      let i = -1;
      for (let k = 0; k < t.clips.length; k++) {
        const c = t.clips[k];
        if (ph > (+c.tlStart || 0) + 0.05 && ph < this._tlClipEnd(c) - 0.05) { i = k; break; }
      }
      if (i < 0) { this._note("tl-note", "move the playhead over a clip, then cut", true); return; }
      const c = t.clips[i], tl0 = +c.tlStart || 0, speed = +c.speed || 1, srcStart = +c.start || 0;
      const srcCut = Math.round((srcStart + (ph - tl0) * speed) * 100) / 100;
      t.clips.splice(i, 1, Object.assign({}, c, { end: srcCut, tlStart: tl0 }),
        Object.assign({}, c, { start: srcCut, tlStart: Math.round(ph * 10) / 10 }));
      t.selKind = null; t.selIdx = null;
      this._renderTimeline();
    },
    // stretch the working timeline (so you can place clips later than the content)
    _tlExtend(secs) {
      const t = this._tl;
      t.timelineDur = Math.round((this._tlTotalDur() + secs) * 10) / 10;
      this._renderTimeline();
    },
    // DETACH audio (Premiere-style): move the clip's audio onto its own editable track,
    // synced to the clip's trim + position, and mute the clip's embedded audio.
    _tlDetachAudio(i) {
      const t = this._tl, c = t.clips[i];
      if (!c || c.kind === "image") return;
      if (c.mute) { this._note("tl-note", "this clip's audio is already detached / muted", true); return; }
      if (!t.audioTracks) t.audioTracks = [];
      t.audioTracks.push({
        path: c.path,
        name: (c.name || "clip").replace(/\.[^.]+$/, "") + " (audio)",
        vol: (c.volume != null ? c.volume : 1),
        duck: false, loop: false,
        start: +c.start || 0,
        end: (c.end != null ? c.end : (c.dur || null)),
        tlStart: +c.tlStart || 0,
        detached: true
      });
      c.mute = true;                    // video keeps playing; its own audio goes silent
      this._renderTimeline();
      this._note("tl-note", "audio detached to its own track — edit it in the Audio tab", false);
    },
    _renderTimeline() {
      const t = this._tl, fmt = (s) => this._capFmt(s);
      this._tlSyncPositions();                 // any clip without a timeline position → end-to-end
      const sel = (id, opts, val) => '<select id="' + id + '">' + opts.map((p) =>
        '<option value="' + p[0] + '"' + (p[0] === val ? " selected" : "") + ">" + p[1] + "</option>").join("") + "</select>";
      const rows = t.clips.map((c, i) =>
        '<div class="tl-clip' + (t.selKind === "clip" && t.selIdx === i ? " sel" : "") + '" data-i="' + i + '">' +
          '<span class="tl-drag" title="drag to reorder">&#9776;</span>' +
          '<span class="tl-idx mono">' + (i + 1) + '</span>' +
          '<span class="tl-glyph">' + (c.kind === "image" ? "🖼" : "🎬") + "</span>" +
          '<div class="tl-meta"><div class="tl-name">' + esc(c.name) + '</div>' +
            '<div class="tl-trim"><label>in<input type="number" class="tl-in" data-i="' + i + '" min="0" step="0.1" value="' + (+c.start || 0) + '"></label>' +
            '<label>out<input type="number" class="tl-out" data-i="' + i + '" min="0" step="0.1" value="' + (c.end != null ? c.end : (c.dur || 0)) + '"></label>' +
            '<label>×<input type="number" class="tl-spd" data-i="' + i + '" min="0.25" max="4" step="0.25" value="' + (+c.speed || 1) + '"></label>' +
            (c.kind !== "image" ? '<label>vol%<input type="number" class="tl-vol" data-i="' + i + '" min="0" max="200" value="' + Math.round((c.volume != null ? c.volume : 1) * 100) + '"></label>' : "") +
            '<button class="tl-mute' + (c.mute ? " on" : "") + '" data-i="' + i + '" title="mute this clip">' + (c.mute ? "🔇" : "🔊") + "</button>" +
            (c.kind === "image" ? '<select class="tl-motion" data-i="' + i + '">' +
              [["none", "no motion"], ["zoomin", "zoom in"], ["zoomout", "zoom out"]].map((p) =>
                '<option value="' + p[0] + '"' + ((c.motion || "none") === p[0] ? " selected" : "") + ">" + p[1] + "</option>").join("") + "</select>" : "") +
            '<span class="tl-dur muted">' + fmt(this._tlClipDur(c)) + " of " + fmt(c.dur || 0) + "</span></div></div>" +
          '<div class="tl-ctl">' +
            '<button class="tl-mv" data-mv="up" data-i="' + i + '" title="move up"' + (i === 0 ? " disabled" : "") + '>&#9650;</button>' +
            '<button class="tl-mv" data-mv="down" data-i="' + i + '" title="move down"' + (i === t.clips.length - 1 ? " disabled" : "") + '>&#9660;</button>' +
            '<button class="tl-split" data-i="' + i + '" title="split in half">&#9986;</button>' +
            '<button class="tl-del" data-i="' + i + '" title="remove">&#10005;</button></div></div>').join("");
      const ovRows = t.overlays.map((o, i) =>
        '<div class="tl-ov' + (t.selKind === "ov" && t.selIdx === i ? " sel" : "") + '" data-i="' + i + '"><span class="tl-glyph">' + (o.kind === "video" ? "🎞" : "🖼") + "</span>" +
          '<div class="tl-meta"><div class="tl-name">' + esc(o.name) + '</div>' +
          '<div class="tl-ovgrid"><label>at<input type="number" class="tl-ov-st" data-i="' + i + '" min="0" step="0.1" value="' + (+o.start || 0) + '"></label>' +
          '<label>for<input type="number" class="tl-ov-du" data-i="' + i + '" min="0.2" step="0.1" value="' + (+o.dur || 3) + '"></label>' +
          (o.kind === "video" ? '<label>in<input type="number" class="tl-ov-in" data-i="' + i + '" min="0" step="0.1" value="' + (+o.in || 0) + '"></label>' : "") +
          '<label>x%<input type="number" class="tl-ov-x" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.x != null ? o.x : 0.5) * 100) + '"></label>' +
          '<label>y%<input type="number" class="tl-ov-y" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.y != null ? o.y : 0.25) * 100) + '"></label>' +
          '<label>size%<input type="number" class="tl-ov-sc" data-i="' + i + '" min="5" max="100" value="' + Math.round((o.scale != null ? o.scale : 0.4) * 100) + '"></label>' +
          '<label>opacity%<input type="number" class="tl-ov-op" data-i="' + i + '" min="0" max="100" value="' + Math.round((o.opacity != null ? o.opacity : 1) * 100) + '"></label>' +
          '<label>fade s<input type="number" class="tl-ov-fd" data-i="' + i + '" min="0" max="3" step="0.1" value="' + (+o.fade || 0) + '"></label>' +
          "</div></div><button class=\"tl-del tl-ovdel\" data-i=\"" + i + "\" title=\"remove\">&#10005;</button></div>").join("");
      const total = t.clips.reduce((a, c) => a + this._tlClipDur(c), 0);
      const transOpts = [["none", "none (hard cut)"], ["fade", "crossfade"], ["dissolve", "dissolve"],
        ["wipeleft", "wipe"], ["slideleft", "slide"], ["circleopen", "circle"], ["smoothleft", "smooth"]];
      const projOpts = (this._tlProjects || []).map((p) =>
        '<option value="' + p.id + '"' + (p.id === t.id ? " selected" : "") + ">" + esc(p.name) + " · " + p.clips + " clips</option>").join("");
      const projBar =
        '<div class="tl-projbar">' +
          '<select id="tl-projsel"><option value="">— open project —</option>' + projOpts + "</select>" +
          '<input id="tl-projname" placeholder="project name" value="' + esc(t.name || "") + '">' +
          '<button class="pbtn sm" id="tl-projsave">💾 save</button>' +
          '<button class="pbtn sm" id="tl-projnew">+ new</button>' +
          (t.id ? '<button class="pbtn sm tl-projdel" id="tl-projdel" title="delete project">🗑</button>' : "") +
        "</div>";
      const ar = t.preset === "square" ? "1 / 1" : (t.preset === "landscape" ? "16 / 9" : "9 / 16");
      // ── section fragments (shared by the compact + studio layouts) ──
      const clipsSec =
        '<div class="sectlabel">clips <span class="muted" style="font-size:10px">(' + t.clips.length + (t.clips.length ? " · " + fmt(total) + " total" : "") + ")</span></div>" +
        '<button class="pbtn" id="tl-add">📎 add clips / photos <span class="muted">(multi-select ok)</span></button>' +
        '<div class="tl-list">' + (rows || '<p class="muted" style="font-size:11px">no clips yet — add a few to build a reel.</p>') + "</div>";
      const transSec =
        '<div class="sectlabel" style="margin-top:14px">transitions</div>' +
        '<div class="cap-grid"><label>between clips' + sel("tl-trans", transOpts, t.transition) + "</label>" +
          (t.transition !== "none" ? '<label>length<input type="number" id="tl-tdur" min="0.2" max="2" step="0.1" value="' + (+t.transitionDur || 0.5) + '"></label>' : "") + "</div>";
      const ovSec =
        '<div class="sectlabel" style="margin-top:14px">overlays <span class="muted" style="font-size:10px">(logos · stickers · B-roll)</span></div>' +
        '<button class="pbtn sm" id="tl-addov">+ add overlay image / clip</button>' +
        '<div class="tl-ovlist">' + ovRows + "</div>";
      const auds = t.audioTracks || [];
      const audRows = auds.map((a, i) =>
        '<div class="tl-aud" data-i="' + i + '"><div class="tl-aud-top">' +
          '<span class="tl-name">' + (a.loop ? "🎵 " : "🎙 ") + esc((a.name || "audio").replace(/\.[^.]+$/, "")) + "</span>" +
          '<button class="tl-del tl-auddel" data-i="' + i + '" title="remove">&#10005;</button></div>' +
          '<div class="tl-musicctl">' +
            '<label>volume<input type="range" class="tl-audvol" data-i="' + i + '" min="0" max="200" value="' + Math.round((a.vol != null ? a.vol : 0.5) * 100) + '"></label>' +
            '<label class="cap-check sm"><input type="checkbox" class="tl-audduck" data-i="' + i + '"' + (a.duck ? " checked" : "") + "> duck under speech</label>" +
            '<label class="cap-check sm"><input type="checkbox" class="tl-audloop" data-i="' + i + '"' + (a.loop ? " checked" : "") + "> loop to fill</label>" +
          "</div></div>").join("");
      const audioSec =
        '<div class="sectlabel" style="margin-top:14px">audio <span class="muted" style="font-size:10px">(' + auds.length + " track" + (auds.length === 1 ? "" : "s") + " · music beds + voiceover)</span></div>" +
        '<div class="tl-audlist">' + audRows + "</div>" +
        '<button class="pbtn sm" id="tl-addaud">🎵 add audio track</button>';
      const formatSec =
        '<div class="sectlabel" style="margin-top:14px">format</div>' +
        '<div class="cap-grid">' +
          "<label>aspect" + sel("tl-preset", [["vertical", "vertical 9:16"], ["square", "square 1:1"], ["landscape", "landscape 16:9"]], t.preset) + "</label>" +
          "<label>fps" + '<select id="tl-fps">' + [30, 24, 60].map((f) =>
            '<option' + (f === t.fps ? " selected" : "") + ">" + f + "</option>").join("") + "</select></label>" +
          "<label>background" + sel("tl-bg", [["black", "black bars"], ["blur", "blurred fill"]], t.background) + "</label>" +
          "<label>framing" + sel("tl-fit", [["fit", "fit (no crop)"], ["fill", "fill (crop)"]], t.fit) + "</label>" +
        "</div>";
      // ── 6 · export (platform presets + destination / resolution / quality / format) ──
      const pchip = (label, preset, res) =>
        '<button class="tl-pchip' + (t.preset === preset && t.resolution === res ? " on" : "") +
          '" data-preset="' + preset + '" data-res="' + res + '">' + label + "</button>";
      const exportSec =
        '<div class="sectlabel" style="margin-top:14px">export</div>' +
        '<div class="tl-platforms">' +
          pchip("📱 Reels / TikTok", "vertical", "1080") +
          pchip("⬛ Square", "square", "1080") +
          pchip("🖥 YouTube", "landscape", "1080") +
          pchip("✦ 4K", t.preset, "2160") + "</div>" +
        '<div class="cap-grid">' +
          "<label>output" + sel("tl-dest", [["captions", "build + add captions"], ["file", "export to a file"]], t.dest) + "</label>" +
          "<label>resolution" + sel("tl-res", [["720", "720p"], ["1080", "1080p · HD"], ["2160", "4K · UHD"]], t.resolution) + "</label>" +
          "<label>quality" + sel("tl-quality", [["high", "high"], ["balanced", "balanced"], ["small", "small file"]], t.quality) + "</label>" +
          (t.dest === "file"
            ? "<label>format" + sel("tl-format", [["h264", "MP4 · H.264"], ["h265", "MP4 · H.265"], ["webm", "WebM · VP9"], ["gif", "Animated GIF"]], t.format) + "</label>"
            : "") +
        "</div>" +
        (t.dest === "file"
          ? '<p class="muted" style="font-size:10.5px;margin:6px 0 0">Exports straight to a file — you can keep working while it renders in the background.</p>'
          : '<p class="muted" style="font-size:10.5px;margin:6px 0 0">Builds the reel then opens the captions editor.</p>');
      const buildLabel = t.dest === "file" ? "&#11015; export reel" : "&#127902; build reel + captions";
      const progressNote =
        '<div class="tl-progress" id="tl-progress" style="display:none"><div class="tl-bar" id="tl-bar"></div></div>' +
        '<div class="savednote" id="tl-note"></div>';

      if (t.studio) {
        // ── full-screen STUDIO: a monitor (top-left) · inspector (right) · timeline (bottom) ──
        const stBar =
          '<div class="st-bar">' +
            '<button class="st-back" id="st-back" title="back to projects">&larr; projects</button>' +
            '<input id="tl-projname" class="st-name" placeholder="untitled reel" value="' + esc(t.name || "") + '">' +
            '<button class="pbtn sm" id="tl-projsave">💾 save</button>' +
            '<button class="pbtn sm" id="tl-projnew">+ new</button>' +
            (t.id ? '<button class="pbtn sm tl-projdel" id="tl-projdel" title="delete project">🗑</button>' : "") +
            '<span class="st-bar-sp"></span>' +
            '<span class="st-total mono muted">' + fmt(total) + " · " + t.clips.length + " clip" + (t.clips.length === 1 ? "" : "s") + "</span>" +
            '<button class="pbtn primary" id="tl-build"' + (t.clips.length ? "" : " disabled") + ">" + buildLabel + "</button>" +
          "</div>";
        // a quiet Add → Edit → Export spine for orientation
        const hasClips = t.clips.length > 0;
        const onOutput = (t.inspTab || "selected") === "output";
        const steps =
          '<div class="st-steps">' +
            '<button class="st-step' + (hasClips ? " done" : " active") + '" data-step="add"><span class="st-stepn">1</span>add media</button>' +
            '<span class="st-stepsep">›</span>' +
            '<button class="st-step' + (hasClips && !onOutput ? " active" : "") + '" data-step="edit"><span class="st-stepn">2</span>edit</button>' +
            '<span class="st-stepsep">›</span>' +
            '<button class="st-step' + (hasClips && onOutput ? " active" : "") + '" data-step="export"><span class="st-stepn">3</span>export</button>' +
          "</div>";
        // contextual, TABBED inspector — only the active tab is on screen
        const inspTab = t.inspTab || "output";
        const itab = (k, lbl) => '<button class="insp-tab' + (k === inspTab ? " on" : "") + '" data-itab="' + k + '">' + lbl + "</button>";
        const inspContent =
          inspTab === "selected" ? this._tlInspSelected() :
          inspTab === "overlays" ? ovSec :
          inspTab === "audio" ? audioSec :
          (transSec + formatSec + exportSec);
        const inspector =
          '<div class="insp-tabs">' + itab("selected", "Selected") + itab("overlays", "Overlays") +
            itab("audio", "Audio") + itab("output", "Output") + "</div>" +
          '<div class="insp-body">' + inspContent + "</div>";
        const ls = (k, d) => { try { return parseInt(localStorage.getItem(k), 10) || d; } catch (_) { return d; } };
        if (this._tlInspW == null) this._tlInspW = ls("wvx_inspw", 320);
        if (this._tlBinW == null) this._tlBinW = ls("wvx_binw", 208);
        if (this._tlTlH == null) this._tlTlH = ls("wvx_tlh", 0);   // 0 → CSS default 30vh
        const inspW = Math.max(260, Math.min(560, this._tlInspW || 320));
        const binW = Math.max(150, Math.min(360, this._tlBinW || 208));
        const tlH = this._tlTlH ? Math.max(120, this._tlTlH) + "px" : "30vh";
        body().innerHTML =
          stBar + steps +
          '<div class="st-grid" style="--st-inspw:' + inspW + 'px;--st-binw:' + binW + 'px;--st-tlh:' + tlH + '">' +
            this._tlBinHTML() +
            '<div class="st-stage">' + this._tlMonitorHTML(ar) + "</div>" +
            '<div class="st-side">' + inspector + "</div>" +
            '<div class="st-split st-split-bin" id="st-split-bin" title="drag to resize the media panel"></div>' +
            '<div class="st-split st-split-insp" id="st-split-insp" title="drag to resize the inspector"></div>' +
            '<div class="st-split st-split-tl" id="st-split-tl" title="drag to resize the timeline"></div>' +
            '<div class="st-tl">' + this._renderTlCanvas({ noMonitor: true }) + "</div>" +
          "</div>" + progressNote;
      } else {
        body().innerHTML =
          projBar +
          '<p class="muted" style="margin:-2px 0 12px;font-size:11.5px">Import clips & photos, trim / split / reorder, add transitions, overlays and music — then build one video and jump to captions.</p>' +
          this._renderTlCanvas() +
          clipsSec + transSec + ovSec + audioSec + formatSec + exportSec +
          '<button class="pbtn primary big" id="tl-build" style="margin-top:14px"' + (t.clips.length ? "" : " disabled") + ">" + buildLabel + "</button>" +
          progressNote;
      }
      this._wireTimeline();
    },
    _wireTimeline() {
      const t = this._tl, on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      on("#tl-add", "click", () => {
        call("timeline_pick_clips").then((r) => { if (r && r.ok && r.items) this._tlAddMedia(r.items); });
      });
      qa(".tl-mv").forEach((el) => el.addEventListener("click", (e) => {
        const i = +e.currentTarget.dataset.i, dir = e.currentTarget.dataset.mv;
        this._tlReorder(i, dir === "up" ? i - 1 : i + 1); this._renderTimeline();
      }));
      qa(".tl-split").forEach((el) => el.addEventListener("click", (e) => this._tlSplit(+e.currentTarget.dataset.i)));
      qa(".tl-del:not(.tl-ovdel)").forEach((el) => el.addEventListener("click", (e) => {
        const i = +e.currentTarget.dataset.i; t.clips.splice(i, 1);
        if (t.selKind === "clip" && t.selIdx === i) { t.selKind = null; t.selIdx = null; t.inspTab = "output"; }
        this._renderTimeline();
      }));
      qa(".tl-in").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].start = Math.max(0, parseFloat(e.target.value) || 0); this._renderTimeline(); }));
      qa(".tl-out").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].end = Math.max(0, parseFloat(e.target.value) || 0); this._renderTimeline(); }));
      qa(".tl-spd").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].speed = Math.max(0.25, Math.min(4, parseFloat(e.target.value) || 1)); this._renderTimeline(); }));
      qa(".tl-vol").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].volume = Math.max(0, Math.min(200, parseInt(e.target.value, 10) || 0)) / 100; }));
      qa(".tl-mute").forEach((el) => el.addEventListener("click", (e) => { const c = t.clips[+e.currentTarget.dataset.i]; c.mute = !c.mute; this._renderTimeline(); }));
      qa(".tl-motion").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].motion = e.target.value; }));
      qa(".tl-rot").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].rotate = parseInt(e.target.value, 10) || 0; this._renderTimeline(); }));
      qa(".tl-detach").forEach((el) => el.addEventListener("click", (e) => this._tlDetachAudio(+e.currentTarget.dataset.i)));
      qa(".tl-log").forEach((el) => el.addEventListener("change", (e) => { t.clips[+e.target.dataset.i].log = e.target.checked; this._renderTimeline(); }));
      // music-track inspector
      qa(".tl-isvol").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].vol = Math.max(0, Math.min(200, parseInt(e.target.value, 10) || 0)) / 100; }));
      qa(".tl-istl").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].tlStart = Math.max(0, parseFloat(e.target.value) || 0); this._renderTimeline(); }));
      qa(".tl-isin").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].start = Math.max(0, parseFloat(e.target.value) || 0); this._renderTimeline(); }));
      qa(".tl-isout").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].end = Math.max(0.1, parseFloat(e.target.value) || 0); this._renderTimeline(); }));
      qa(".tl-isfi").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].fadein = Math.max(0, parseFloat(e.target.value) || 0); }));
      qa(".tl-isfo").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].fadeout = Math.max(0, parseFloat(e.target.value) || 0); }));
      qa(".tl-isloop").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].loop = e.target.checked; this._renderTimeline(); }));
      qa(".tl-isduck").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].duck = e.target.checked; }));
      qa(".tl-isdel").forEach((el) => el.addEventListener("click", (e) => { const i = +e.currentTarget.dataset.i; t.audioTracks.splice(i, 1); if (t.selKind === "aud" && t.selIdx === i) { t.selKind = null; t.selIdx = null; } this._renderTimeline(); }));
      this._tlWireDrag();
      // transitions
      on("#tl-trans", "change", (e) => { t.transition = e.target.value; this._renderTimeline(); });
      on("#tl-tdur", "change", (e) => { t.transitionDur = Math.max(0.2, Math.min(2, parseFloat(e.target.value) || 0.5)); });
      // overlays
      on("#tl-addov", "click", () => {
        call("timeline_pick_overlay").then((r) => {
          if (r && r.ok && r.items) {
            r.items.forEach((it) => t.overlays.push({ path: it.path, name: it.name, kind: it.kind, dur: it.kind === "video" ? Math.min(it.dur || 3, 5) : 3, start: 0, x: 0.5, y: 0.22, scale: 0.4, opacity: 1, fade: 0.3, track: 1 }));
            this._renderTimeline();
          }
        });
      });
      qa(".tl-ovdel").forEach((el) => el.addEventListener("click", (e) => {
        const i = +e.currentTarget.dataset.i; t.overlays.splice(i, 1);
        if (t.selKind === "ov" && t.selIdx === i) { t.selKind = null; t.selIdx = null; t.inspTab = "output"; }
        this._renderTimeline();
      }));
      const ovEdit = (cls, key, fn) => qa(cls).forEach((el) => el.addEventListener("change", (e) => { t.overlays[+e.target.dataset.i][key] = fn(e.target.value); }));
      ovEdit(".tl-ov-st", "start", (v) => Math.max(0, parseFloat(v) || 0));
      ovEdit(".tl-ov-du", "dur", (v) => Math.max(0.2, parseFloat(v) || 3));
      ovEdit(".tl-ov-x", "x", (v) => Math.max(0, Math.min(100, parseInt(v, 10) || 0)) / 100);
      ovEdit(".tl-ov-y", "y", (v) => Math.max(0, Math.min(100, parseInt(v, 10) || 0)) / 100);
      ovEdit(".tl-ov-sc", "scale", (v) => Math.max(5, Math.min(100, parseInt(v, 10) || 40)) / 100);
      ovEdit(".tl-ov-op", "opacity", (v) => Math.max(0, Math.min(100, parseInt(v, 10) || 100)) / 100);
      ovEdit(".tl-ov-fd", "fade", (v) => Math.max(0, parseFloat(v) || 0));
      ovEdit(".tl-ov-in", "in", (v) => Math.max(0, parseFloat(v) || 0));
      qa(".tl-ov-tk").forEach((el) => el.addEventListener("change", (e) => {   // move to a video track → re-render lanes
        t.overlays[+e.target.dataset.i].track = Math.max(1, Math.min(6, parseInt(e.target.value, 10) || 1));
        this._renderTimeline();
      }));
      // audio: N tracks (music beds + voiceover). First track defaults to a ducked,
      // looping music bed; extra tracks default to full-level (voiceover-style).
      on("#tl-addaud", "click", () => {
        call("timeline_pick_music").then((r) => {
          if (r && r.ok) {
            t.audioTracks = t.audioTracks || [];
            const first = t.audioTracks.length === 0;
            t.audioTracks.push({ path: r.path, name: r.name, vol: first ? 0.45 : 0.85, duck: first, loop: first,
              dur: r.dur || 0, tlStart: 0 });
            this._renderTimeline();
          }
        });
      });
      qa(".tl-auddel").forEach((el) => el.addEventListener("click", (e) => { t.audioTracks.splice(+e.currentTarget.dataset.i, 1); this._renderTimeline(); }));
      qa(".tl-audvol").forEach((el) => el.addEventListener("input", (e) => { t.audioTracks[+e.target.dataset.i].vol = Math.max(0, Math.min(200, parseInt(e.target.value, 10) || 0)) / 100; }));
      qa(".tl-audduck").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].duck = e.target.checked; }));
      qa(".tl-audloop").forEach((el) => el.addEventListener("change", (e) => { t.audioTracks[+e.target.dataset.i].loop = e.target.checked; this._renderTimeline(); }));
      // format + build
      on("#tl-preset", "change", (e) => { t.preset = e.target.value; this._renderTimeline(); });
      on("#tl-fps", "change", (e) => { t.fps = parseInt(e.target.value, 10) || 30; });
      on("#tl-bg", "change", (e) => { t.background = e.target.value; });
      on("#tl-fit", "change", (e) => { t.fit = e.target.value; });
      // export options (destination / resolution / quality / format + platform presets)
      on("#tl-dest", "change", (e) => { t.dest = e.target.value; this._renderTimeline(); });
      on("#tl-res", "change", (e) => { t.resolution = e.target.value; this._renderTimeline(); });
      on("#tl-quality", "change", (e) => { t.quality = e.target.value; });
      on("#tl-format", "change", (e) => { t.format = e.target.value; });
      qa(".tl-pchip").forEach((b) => b.addEventListener("click", () => {
        t.preset = b.dataset.preset; t.resolution = b.dataset.res; this._renderTimeline();
      }));
      on("#st-back", "click", () => this._closeStudio());
      // resizable panels — drag a splitter to build your own workspace (persisted)
      const dragSplit = (id, fn, store, prop, fallback) => {
        const el = q(id); if (!el) return;
        el.addEventListener("pointerdown", (e) => {
          e.preventDefault();
          const grid = q(".st-grid"); if (!grid) return;
          const gr = grid.getBoundingClientRect();
          const move = (ev) => {
            const v = fn(gr, ev);
            grid.style.setProperty(prop, v + "px");
            this._tlSizeMonitor(); this._tlPositionPlayhead();
          };
          const up = () => {
            document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up);
            try { localStorage.setItem(store, String(this[fallback] || 0)); } catch (_) {}
          };
          document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
        });
      };
      dragSplit("#st-split-insp", (gr, ev) => { const w = Math.max(260, Math.min(560, Math.round(gr.right - ev.clientX))); this._tlInspW = w; return w; }, "wvx_inspw", "--st-inspw", "_tlInspW");
      dragSplit("#st-split-bin", (gr, ev) => { const w = Math.max(150, Math.min(360, Math.round(ev.clientX - gr.left))); this._tlBinW = w; return w; }, "wvx_binw", "--st-binw", "_tlBinW");
      dragSplit("#st-split-tl", (gr, ev) => { const h = Math.max(120, Math.min(Math.round(gr.height * 0.7), Math.round(gr.bottom - ev.clientY))); this._tlTlH = h; return h; }, "wvx_tlh", "--st-tlh", "_tlTlH");
      // contextual inspector tabs + the Add → Edit → Export spine
      qa(".insp-tab").forEach((b) => b.addEventListener("click", () => { t.inspTab = b.dataset.itab; this._renderTimeline(); }));
      qa(".st-step").forEach((b) => b.addEventListener("click", () => {
        const s = b.dataset.step;
        if (s === "export") { t.inspTab = "output"; this._renderTimeline(); }
        else if (s === "add") { const imp = q("#bin-import"); if (imp) imp.click(); }
        else { t.inspTab = (t.selKind ? "selected" : "output"); this._renderTimeline(); }
      }));
      on("#tl-build", "click", () => this._tlBuild());
      // ── projects (save / load / new / delete) ──
      on("#tl-projsel", "change", (e) => {
        const id = e.target.value; if (!id) return;
        const meta = (this._tlProjects || []).find((p) => p.id === id);
        call("project_load", id).then((r) => { if (r && r.ok && r.data) this._tlLoad(r.data, id, meta && meta.name); });
      });
      on("#tl-projsave", "click", () => {
        const name = (q("#tl-projname").value || "").trim() || "Untitled reel";
        call("project_save", { id: t.id || null, name: name, data: this._tlData() }).then((r) => {
          if (r && r.ok) {
            t.id = r.id; t.name = r.name;
            call("project_list").then((lr) => { this._tlProjects = (lr && lr.projects) || []; this._renderTimeline(); this._note("tl-note", "project saved ✓"); });
          } else this._note("tl-note", (r && r.error) || "couldn't save", true);
        });
      });
      on("#tl-projnew", "click", () => this._tlNew());
      on("#tl-projdel", "click", (e) => {
        if (!t.id) return;
        this._armDel(e.currentTarget, () => {
          call("project_delete", t.id).then(() => {
            this._tl = this._defTl();                                   // fresh, studio:false
            document.getElementById("panel").classList.remove("studio");
            this.openTimeline();                                        // back to the (refreshed) launcher
          });
        });
      });
      if (q("#st-bin")) this._wireTlBin();
      this._wireTlCanvas();
    },
    _tlWireDrag() {
      const self = this;
      const targetIdx = (clientY) => {
        const rows = qa(".tl-clip");
        for (let i = 0; i < rows.length; i++) {
          const r = rows[i].getBoundingClientRect();
          if (clientY < r.top + r.height / 2) return i;
        }
        return rows.length - 1;
      };
      qa(".tl-drag").forEach((h) => h.addEventListener("pointerdown", (e) => {
        e.preventDefault();
        const row = e.target.closest(".tl-clip"); if (!row) return;
        const from = +row.dataset.i; row.classList.add("tl-dragging");
        const move = (ev) => { const to = targetIdx(ev.clientY); qa(".tl-clip").forEach((r, i) => r.classList.toggle("tl-drop", i === to && i !== from)); };
        const up = (ev) => {
          document.removeEventListener("pointermove", move);
          document.removeEventListener("pointerup", up);
          self._tlReorder(from, targetIdx(ev.clientY)); self._renderTimeline();
        };
        document.addEventListener("pointermove", move);
        document.addEventListener("pointerup", up);
      }));
    },
    _onTimelineProgress(frac) {
      const bar = q("#tl-bar"); if (bar) bar.style.width = Math.round((frac || 0) * 100) + "%";
    },
    // map aspect preset + resolution tier → concrete (even) WxH
    _tlResolveSize() {
      const t = this._tl, tier = parseInt(t.resolution, 10) || 1080;
      const even = (n) => { n = Math.round(n); return n % 2 ? n + 1 : n; };
      if (t.preset === "square") return { w: even(tier), h: even(tier) };
      const long = even(tier * 16 / 9);
      return t.preset === "landscape" ? { w: long, h: even(tier) } : { w: even(tier), h: long };
    },
    // Kick off the build in the BACKGROUND. The job survives closing the studio: it
    // shows in the activity tab and fires a notification when done (Python side).
    _tlBuild() {
      const t = this._tl;
      if (t.building || !t.clips.length) return;
      for (const c of t.clips) { if (c.end != null && c.end <= (c.start || 0)) { this._note("tl-note", "a clip's OUT must be after its IN", true); return; } }
      this._tlStop();
      const size = this._tlResolveSize();
      const submit = (outPath) => {
        t.building = true;
        const btn = q("#tl-build");
        if (btn) { btn.dataset.lbl = btn.innerHTML; btn.disabled = true; btn.innerHTML = (t.dest === "file" ? "exporting…" : "building…"); }
        const prog = q("#tl-progress"); if (prog) prog.style.display = "";
        const bar = q("#tl-bar"); if (bar) bar.style.width = "0%";
        this._note("tl-note", (t.dest === "file" ? "exporting" : "building") + " in the background — you can close this; it'll appear in notifications.");
        call("timeline_build", {
          clips: t.clips, overlays: t.overlays, preset: t.preset, fps: t.fps, w: size.w, h: size.h,
          transition: t.transition, transition_dur: t.transitionDur,
          audio_tracks: (t.audioTracks || []).map((a) => ({ path: a.path, vol: a.vol, duck: a.duck, loop: a.loop,
            start: a.start, end: a.end, tlStart: a.tlStart, fadein: a.fadein, fadeout: a.fadeout })),
          timeline_dur: this._tlTotalDur(),
          background: t.background, fit: t.fit,
          dest: t.dest, format: t.format, quality: t.quality,
          out: outPath || null, name: (t.name || "reel"),
        }).then((r) => {
          if (r && r.ok === false && !r.async) this._onReelBuilt(r);   // a SYNC failure
        });
      };
      if (t.dest === "file") {
        call("timeline_pick_export", { format: t.format, name: t.name || "reel" }).then((r) => {
          if (r && r.ok && r.path) submit(r.path);
          else this._note("tl-note", "export cancelled");
        });
      } else submit(null);
    },
    _onReelDone() {
      const t = this._tl; t.building = false;
      const btn = q("#tl-build");
      if (btn) { btn.disabled = !(t.clips && t.clips.length); if (btn.dataset.lbl) { btn.innerHTML = btn.dataset.lbl; delete btn.dataset.lbl; } }
      const prog = q("#tl-progress"); if (prog) prog.style.display = "none";
    },
    _onReelBuilt(r) {
      this._onReelDone();
      if (!r || !r.ok) { this._note("tl-note", (r && r.error) || "build failed", true); return; }
      const pnl = document.getElementById("panel");
      const open = pnl.classList.contains("on");
      const here = (titleEl().textContent === "reel studio" || titleEl().textContent === "reel maker");
      if (r.dest === "file") {
        this._note("tl-note", "exported ✓ — saved to disk (see notifications)");
        toast("Reel exported ✓");
      } else if (open && here) {
        this._note("tl-note", "reel built ✓ — opening the captions editor…");
        pnl.classList.remove("studio");
        this.openCaptionsEditor(false);   // transcribe + caption the stitched reel
      } else {
        toast("Reel ready ✓ — see notifications");
      }
    },
    _onReelCancelled() { this._onReelDone(); this._note("tl-note", "build cancelled"); },

    // ── CAPTIONS EDITOR (Stage 2) ────────────────────────────────────────────────
    _capDefaults() {
      return { type: "highlight", font: "Arial", size: 54, primary: "#FFFFFF",
               highlight: "#C1F73A", position: "bottom", anim: "fade",
               uppercase: false, box: false, box_color: "#000000", outline_width: 3,
               letter_spacing: 0, italic: false, underline: false, pos: null };
    },
    // entrance/emphasis animation presets — value → [label, preview-duration-ms]
    _capAnims() {
      return { none: ["none", 0], fade: ["fade", 250], pop: ["pop", 300], bounce: ["bounce", 420],
               zoom: ["zoom", 340], pulse: ["pulse", 0], "slide-up": ["slide up", 340],
               "slide-down": ["slide down", 340], "slide-left": ["slide left", 340],
               "slide-right": ["slide right", 340] };
    },
    // a rich set of Windows-bundled fonts (no download needed) — good display faces for
    // social captions up top. (Bundling trendy display fonts like Bebas/Anton is a
    // follow-up: needs the font files + a libass fontsdir + an @font-face for the preview.)
    _capFonts() {
      return ["Impact", "Arial", "Arial Black", "Bahnschrift", "Bebas Neue", "Anton",
              "Segoe UI", "Segoe UI Black", "Tahoma", "Verdana", "Trebuchet MS",
              "Franklin Gothic Medium", "Calibri", "Georgia", "Times New Roman",
              "Courier New", "Comic Sans MS", "Gabriola"];
    },
    // One-click "looks" — preset bundles of style opts (the Submagic / CapCut /
    // Hormozi vibes). Clicking one merges its opts into c.opts and re-renders.
    _capLooks() {
      return {
        clean: { label: "clean", opts: { type: "highlight", font: "Arial", size: 54, primary: "#FFFFFF", highlight: "#C1F73A", uppercase: false, box: false, outline_width: 3, anim: "fade" } },
        bold: { label: "bold", opts: { type: "highlight", font: "Impact", size: 64, primary: "#FFFFFF", highlight: "#FFE03D", uppercase: true, box: false, outline_width: 5, anim: "pop" } },
        boxed: { label: "boxed", opts: { type: "sentence", font: "Arial", size: 48, primary: "#FFFFFF", highlight: "#C1F73A", uppercase: false, box: true, box_color: "#000000", outline_width: 3, anim: "fade" } },
        pop: { label: "pop", opts: { type: "word", font: "Impact", size: 72, primary: "#FFFFFF", highlight: "#C1F73A", uppercase: true, box: false, outline_width: 5, anim: "bounce" } },
      };
    },
    _capFmt(s) {
      s = Math.max(0, s || 0);
      const m = Math.floor(s / 60), ss = Math.floor(s % 60);
      return m + ":" + (ss < 10 ? "0" : "") + ss;
    },
    openCaptionsEditor(resume) {
      titleEl().textContent = "captions editor";
      document.getElementById("panel").classList.add("cap-wide");
      this._cap = { name: "", opts: this._capDefaults(), segments: [],
                    speakers: [{ name: "Speaker 1", color: "#C1F73A" }],
                    dims: { w: 1920, h: 1080, duration: 5 }, t: 1.0, loaded: false, _pt: null };
      openPanel();
      // reopened from the "Captions ready" notification → load the finished result
      if (resume) {
        body().innerHTML = '<p class="muted">opening…</p>';
        call("captions_resume").then((r) => {
          if (titleEl().textContent !== "captions editor") return;
          if (r && r.ok) { this._onCaptionsLoaded(r); }
          else { body().innerHTML = '<p class="muted">' + esc((r && r.error) || "nothing to reopen") + "</p>"; }
        });
        return true;
      }
      body().innerHTML = '<div class="cap-load"><div class="cap-spin"></div>' +
        '<p class="muted">transcribing the video…<br>' +
        '<span id="cap-spin-msg" style="font-size:11px">listening to the audio — this runs on your PC</span>' +
        '<br><span id="cap-spin-el" class="mono" style="font-size:11px;opacity:.6"></span>' +
        '<br><span style="font-size:11px;opacity:.5">you can close this — it keeps working in notifications</span></p></div>';
      this._startCapSpin();
      call("captions_transcribe").then((r) => {
        if (r && !r.ok && titleEl().textContent === "captions editor") {
          this._stopCapSpin();
          body().innerHTML = '<p class="muted">' + esc(r.error || "couldn’t start") + "</p>";
        }
      });
      return true;
    },
    _startCapSpin() {
      this._stopCapSpin();
      const t0 = Date.now();
      this._capSpinTimer = setInterval(() => {
        const el = document.getElementById("cap-spin-el");
        const msg = document.getElementById("cap-spin-msg");
        if (!el) { this._stopCapSpin(); return; }
        const s = Math.round((Date.now() - t0) / 1000);
        el.textContent = s + "s elapsed";
        // reassure once it's clearly a longer (CPU) run or a first-time model fetch
        if (msg && s === 25) msg.textContent = "still working — longer clips take a bit on CPU";
        if (msg && s === 75) msg.textContent = "first run also fetches the speech model — hang tight";
      }, 1000);
    },
    _stopCapSpin() {
      if (this._capSpinTimer) { clearInterval(this._capSpinTimer); this._capSpinTimer = null; }
    },
    _onCaptionsCancelled() {
      this._stopCapSpin();
      if (titleEl().textContent !== "captions editor") return;
      if (this._cap && this._cap.loaded) return;          // already showing results
      body().innerHTML = '<p class="muted">transcription cancelled.</p>';
    },
    _onCaptionsLoaded(payload) {
      this._stopCapSpin();
      if (titleEl().textContent !== "captions editor") return;
      if (!payload || !payload.ok) {
        body().innerHTML = '<p class="muted">' + esc((payload && payload.error) || "transcription failed") + "</p>";
        return;
      }
      const c = this._cap;
      c.segments = (payload.segments || []).map((s) => ({
        start: s.start, end: s.end, text: (s.text || "").trim(), words: s.words || [], speaker: 0 }));
      c.clipMarks = (payload.clip_marks && payload.clip_marks.length) ? payload.clip_marks : null;
      const last = c.segments.length ? c.segments[c.segments.length - 1].end : 5;
      c.dims = { w: payload.w || 1920, h: payload.h || 1080, duration: payload.duration || last || 5 };
      c.name = payload.name || "";
      c.seg = this._capSegDefaults();
      this._capResegment();            // split the raw transcript into caption-sized lines
      c.t = c.segments.length ? Math.min(c.dims.duration, c.segments[0].start + 0.4) : 1.0;
      c.loaded = true;
      this._capRenderEditor();         // builds the editor + loads the video player
    },
    _capOpts() {
      const c = this._cap, o = Object.assign({}, c.opts);
      o.speakers = c.speakers.map((s) => ({ highlight: s.color, position: c.opts.position }));
      return o;
    },
    // mode-aware "refresh the preview": in video mode just redraw the HTML caption
    // overlay (instant); in fallback frame mode re-burn a frame with ffmpeg (debounced).
    _capRefresh() {
      if (this._cap.mode === "frame") this._capPreview();
      else this._capRenderOverlay(this._cap.t);
    },
    // seek the preview to a specific time (used by the clickable segment timestamps):
    // moves the playhead, the seek bar + label, and the video / frame preview.
    _capSeekTo(t) {
      const c = this._cap;
      t = Math.max(0, Math.min(c.dims.duration || t || 0, t || 0));
      c.t = t;
      const slider = q("#cap-time"); if (slider) slider.value = t.toFixed(2);
      const lab = q("#cap-tlabel"); if (lab) lab.textContent = this._capFmt(t);
      if (c.mode === "video") {
        const v = q("#cap-video");
        if (v) { c._seeking = true; try { v.currentTime = t; } catch (_) { /* */ } c._seeking = false; }
        this._capRenderOverlay(t);
      } else this._capPreview();
    },
    // ── live video player + HTML caption overlay ─────────────────────────────
    _capLoadVideo() {
      const c = this._cap, self = this;
      const v = q("#cap-video"); if (!v) return;
      c.mode = "loading"; c._proxyTried = false;
      call("captions_video_url").then((r) => {
        // early orientation safety net from ffprobe DISPLAY dims (rotation-aware) — so a
        // vertical clip lays out two columns immediately, before the proxy is built.
        if (r && r.ok && r.w && r.h && !c._urlDimsApplied) {
          c._urlDimsApplied = true;
          const flip = (r.h > r.w) !== ((c.dims.h || 0) > (c.dims.w || 0));
          c.dims.w = r.w; c.dims.h = r.h; if (r.duration) c.dims.duration = r.duration;
          if (flip) { self._capRenderEditor(); return; }   // re-render → calls _capLoadVideo again
        }
        // ALWAYS preview via a small transcoded proxy delivered as a Blob. A raw file://
        // <video> doesn't play in WebView2 (the UI itself is a file:// page), and the
        // proxy also normalises the codec (8-bit 4:2:0) + rotation so it's watchable.
        self._capLoadProxy("preparing the preview…");
      });
    },
    // build (or reuse a cached) web-playable proxy, decode its base64 bytes into a Blob
    // URL (file:// video won't play in WebView2), then play THAT.
    _capLoadProxy(msg) {
      const c = this._cap, self = this;
      const v = q("#cap-video"); if (!v) return;
      c._proxyTried = true; c.mode = "loading";
      v.style.display = "";
      const im = q("#cap-prev"); if (im) im.style.display = "none";
      const warn = q("#cap-prevwarn"); if (warn) warn.textContent = msg || "preparing a preview you can play…";
      const gen = (c._proxyGen = (c._proxyGen || 0) + 1);
      call("captions_proxy").then((r) => {
        if (gen !== c._proxyGen) return;                       // superseded by a newer load
        if (!r || !r.ok || (!r.b64 && !r.url)) { self._capFallbackFrame((r && r.error) || "couldn’t prepare a preview"); return; }
        let src = r.url || "";
        if (r.b64) {                                           // base64 → Blob URL (plays in WebView2)
          try {
            if (c._blobUrl) { URL.revokeObjectURL(c._blobUrl); c._blobUrl = null; }
            const bin = atob(r.b64), bytes = new Uint8Array(bin.length);
            for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
            src = c._blobUrl = URL.createObjectURL(new Blob([bytes], { type: r.mime || "video/mp4" }));
          } catch (e) { src = r.url || ""; }
        }
        if (!src) { self._capFallbackFrame("couldn’t prepare a preview"); return; }
        const w2 = q("#cap-prevwarn"); if (w2) w2.textContent = "";
        self._capAttachSrc(src);
      });
    },
    // wire the <video> handlers for ONE load generation and attach a source URL.
    _capAttachSrc(url) {
      const c = this._cap, self = this;
      const v = q("#cap-video"); if (!v) return;
      const seekTo = Math.max(0, c.t || 0);    // captured NOW so a stray event can't move it
      const token = (c._vload = (c._vload || 0) + 1);   // invalidate handlers/timers from a prior load
      const stale = function () { return token !== c._vload; };
      c.mode = "loading"; c._vinit = false; v.muted = false; v.style.display = "";
      if (c._vpoll) { clearInterval(c._vpoll); c._vpoll = null; }
      const target = function () { return Math.min(seekTo, v.duration || seekTo); };
      // Only seek once the source can ACTUALLY seek there — some sources (blob/http
      // without range) report canplay before `seekable` covers the target, and a
      // premature seek silently clamps to 0. file:// is seekable immediately.
      const seekable = function () {
        return v.duration > 0 && v.seekable && v.seekable.length &&
               v.seekable.end(v.seekable.length - 1) >= target() - 0.05;
      };
      const initSeek = function () {
        if (stale() || c._vinit || !seekable()) return;
        c._vinit = true;
        if (c._vpoll) { clearInterval(c._vpoll); c._vpoll = null; }
        try { v.currentTime = target(); } catch (e) { /* */ }
        self._capRenderOverlay(target());
        if (c._wasPlaying) v.play().catch(() => {});
      };
      v.onloadedmetadata = function () {
        if (stale()) return;
        const vw = v.videoWidth, vh = v.videoHeight;
        if (vw && vh) {
          const flip = (vh > vw) !== ((c.dims.h || 0) > (c.dims.w || 0));
          c.dims.w = vw; c.dims.h = vh;
          c.dims.duration = v.duration || c.dims.duration;
          if (flip) { self._capRenderEditor(); return; }   // re-apply the correct layout
        }
        c.mode = "video";
        const sl = q("#cap-time"); if (sl) sl.max = (v.duration || c.dims.duration || 5).toFixed(2);
        initSeek();
      };
      v.oncanplay = function () { if (stale()) return; c.mode = "video"; initSeek(); };
      v.onseeked = function () { if (stale()) return; c.t = v.currentTime; self._capRenderOverlay(v.currentTime); };
      v.ontimeupdate = function () {
        if (stale() || c._seeking || !c._vinit) return;   // ignore until the initial seek has landed
        c.t = v.currentTime;
        const sl = q("#cap-time"); if (sl) sl.value = c.t;
        const lb = q("#cap-tlabel"); if (lb) lb.textContent = self._capFmt(c.t);
        self._capRenderOverlay(c.t);
      };
      v.onplay = function () { if (stale()) return; c._wasPlaying = true; self._capSetPlayUI(true); };
      v.onpause = function () { if (stale()) return; c._wasPlaying = false; self._capSetPlayUI(false); };
      v.onerror = function () {
        if (stale()) return;
        // the raw file wouldn't decode → try a transcoded proxy once before giving up.
        if (!c._proxyTried) { self._capLoadProxy("optimizing this clip for preview… (one-time)"); return; }
        self._capFallbackFrame("live playback unavailable — scrubbing frames");
      };
      // poll for seekability (blob / range-less http lag canplay); stop after the seek
      // lands, when superseded, or after a hard cap so we never spin forever.
      c._vpoll = setInterval(function () {
        if (stale() || c._vinit) { clearInterval(c._vpoll); c._vpoll = null; return; }
        initSeek();
      }, 120);
      setTimeout(function () {
        if (c._vpoll) { clearInterval(c._vpoll); c._vpoll = null; }
        if (!stale() && !c._vinit) { c._vinit = true; self._capRenderOverlay(seekTo); }
      }, 3000);
      v.src = url; v.load();
    },
    // toggle the play/pause UI in both places: the in-preview overlay button and
    // the seek-bar button (kept in sync so either control reflects the state).
    _capSetPlayUI(playing) {
      const b = q("#cap-play"); if (b) b.innerHTML = playing ? "&#10073;&#10073;" : "&#9654;";
      const ov = q("#cap-playover"); if (ov) ov.classList.toggle("playing", playing);
      const ic = q(".cap-povicon"); if (ic) ic.innerHTML = playing ? "&#10073;&#10073;" : "&#9654;";
    },
    _capFallbackFrame(msg) {
      const c = this._cap; c.mode = "frame";
      const v = q("#cap-video"); if (v) { try { v.pause(); } catch (e) { /* */ } v.style.display = "none"; }
      const ov = q("#cap-overlay"); if (ov) ov.style.display = "none";
      const pb = q("#cap-play"); if (pb) pb.style.display = "none";
      const po = q("#cap-playover"); if (po) po.style.display = "none";   // no live playback to control
      const im = q("#cap-prev"); if (im) im.style.display = "";
      const warn = q("#cap-prevwarn"); if (warn) warn.textContent = msg || "";
      this._capPreview();
    },
    // Drag the caption anywhere on the preview → store a normalized {x,y} position
    // (mapped to ASS \an5\pos on export). Grabbing the caption text drags it; clicking
    // empty preview area still toggles play (the play button sits beneath the caption).
    _capWireDrag() {
      const c = this._cap, self = this;
      const wrap = q(".cap-prevwrap"); if (!wrap) return;
      wrap.addEventListener("pointerdown", (e) => {
        if (!e.target.closest(".cap-ovline")) return;        // not on the caption → let the play button handle it
        e.preventDefault();
        const rect = wrap.getBoundingClientRect();
        const move = (ev) => {
          c.opts.pos = { x: Math.min(0.98, Math.max(0.02, (ev.clientX - rect.left) / rect.width)),
                         y: Math.min(0.98, Math.max(0.02, (ev.clientY - rect.top) / rect.height)) };
          self._capRenderOverlay(c.t);
        };
        const up = () => {
          document.removeEventListener("pointermove", move);
          document.removeEventListener("pointerup", up);
          // reflect "custom position" in the controls without a full video reload
          const note = q(".cap-posnote"); if (note) note.textContent = "custom position — drag on the preview";
          const row = q(".cap-typorow");
          if (row && !q("#cap-posreset")) {
            const b = document.createElement("button");
            b.className = "pbtn sm"; b.id = "cap-posreset"; b.textContent = "reset position";
            b.addEventListener("click", () => { c.opts.pos = null; self._capRenderEditor(); });
            row.appendChild(b);
          }
        };
        document.addEventListener("pointermove", move);
        document.addEventListener("pointerup", up);
        move(e);                                              // jump the caption to where you grabbed
      });
    },
    // Draw the captions as an HTML overlay synced to the video time (matches the
    // chosen type/position/font/size/colours; the ffmpeg burn is the exact export).
    _capRenderOverlay(t) {
      const c = this._cap, o = c.opts;
      const ov = q("#cap-overlay"); if (!ov) return;
      const wrap = q(".cap-prevwrap");
      const H = (wrap && wrap.clientHeight) || 360;
      const fontPx = Math.max(9, Math.round((o.size || 54) * H / (c.dims.h || 1080)));
      ov.style.justifyContent = o.position === "top" ? "flex-start" : (o.position === "center" ? "center" : "flex-end");
      // match the burn's vertical margin (libass marginV ≈ 7% of HEIGHT). CSS % padding
      // is width-relative, so set the top/bottom offset in px of the preview's height.
      const mvPx = Math.round(0.07 * H);
      ov.style.paddingTop = mvPx + "px"; ov.style.paddingBottom = mvPx + "px";
      const seg = (c.segments || []).find((s) => t >= s.start - 0.03 && t < s.end + 0.05);
      if (!seg) { ov.innerHTML = ""; return; }
      const spk = (c.speakers[seg.speaker] && c.speakers[seg.speaker].color) || o.highlight || "#C1F73A";
      const base = o.primary || "#FFFFFF";
      const hl = o.highlight || spk;
      const tok = (s) => esc((s || "").trim());
      let inner = "";
      const ws = (seg.words && seg.words.length) ? seg.words : null;
      if (o.type === "word" && ws) {
        const w = ws.find((x) => t >= x.start - 0.03 && t < x.end + 0.03) || ws[ws.length - 1];
        inner = '<span style="color:' + base + '">' + tok(w.word) + "</span>";
      } else if (o.type === "highlight" && ws) {
        inner = ws.map((w) => '<span style="color:' + (t >= w.start - 0.03 ? hl : base) + '">' + tok(w.word) + "</span>").join(" ");
      } else {
        inner = '<span style="color:' + base + '">' + tok(seg.text) + "</span>";
      }
      ov.style.display = "flex";
      // mirror the burned look: weight/italic/underline, letter spacing, ALL CAPS, a
      // solid box, or an outline whose strength tracks outline_width (burn is exact).
      const ow = (o.outline_width != null ? +o.outline_width : 3);
      const lsPx = (+o.letter_spacing || 0) * H / (c.dims.h || 1080);   // px → preview scale
      let lineStyle = "font-family:'" + (o.font || "Arial") + "',sans-serif;font-size:" + fontPx + "px";
      lineStyle += ";font-weight:" + (o.bold === false ? "400" : "800");
      if (o.italic) lineStyle += ";font-style:italic";
      if (o.underline) lineStyle += ";text-decoration:underline";
      if (lsPx) lineStyle += ";letter-spacing:" + lsPx.toFixed(2) + "px";
      if (o.uppercase) lineStyle += ";text-transform:uppercase";
      // free position (drag): place the line absolutely; else the flex column positions it
      if (o.pos) {
        lineStyle += ";position:absolute;left:" + (o.pos.x * 100).toFixed(2) + "%;top:" +
          (o.pos.y * 100).toFixed(2) + "%;transform:translate(-50%,-50%);margin:0;z-index:4";
      } else {
        lineStyle += ";position:relative;z-index:4";
      }
      if (o.box) {
        lineStyle += ";display:inline-block;background:" + (o.box_color || "#000000") +
          ";padding:.06em .4em;border-radius:.16em;text-shadow:none";
      } else if (ow > 0) {
        const sh = [];
        for (let k = 0, n = Math.max(1, Math.round(ow / 1.5)); k < n; k++) sh.push("0 0 2px #000");
        lineStyle += ";text-shadow:" + sh.join(",") + ",0 2px 5px rgba(0,0,0,.85)";
      }
      // the caption itself is grabbable (above the play button); empty preview area still toggles play
      lineStyle += ";pointer-events:auto;cursor:move";
      // animation wrapper: per-caption KEYFRAMES (JS-interpolated, scrubs exactly) OR a
      // preset CSS animation seeked to the caption's local time via a negative delay.
      let animStyle = "display:inline-block";
      if (seg.kf && seg.kf.length >= 2 && o.type !== "word") {
        const dur = Math.max(0.05, seg.end - seg.start);
        const f = this._capKfAt(seg.kf, Math.max(0, Math.min(1, (t - seg.start) / dur)));
        if (f) animStyle += ";transform:scale(" + (f.sx / 100).toFixed(3) + "," + (f.sy / 100).toFixed(3) +
          ") rotate(" + (f.rot || 0).toFixed(1) + "deg);opacity:" + f.op.toFixed(3);
      } else {
        const an = o.anim || "fade", adur = (this._capAnims()[an] || [null, 0])[1];
        if (an === "pulse") animStyle += ";animation:cap-anim-pulse 900ms ease-in-out infinite";
        else if (adur && an !== "none") {
          const elapsedMs = Math.round(Math.max(0, (t - seg.start) * 1000));
          const ease = an === "bounce" ? "cubic-bezier(.34,1.56,.5,1)" : "ease-out";
          animStyle += ";animation:cap-anim-" + an + " " + adur + "ms " + ease + " both;animation-delay:-" + elapsedMs + "ms";
        }
      }
      ov.innerHTML = '<div class="cap-ovline" style="' + lineStyle + '"><span class="cap-ovanim" style="' + animStyle + '">' + inner + "</span></div>";
    },
    _capPreview() {
      const c = this._cap;
      clearTimeout(c._pt);
      c._pt = setTimeout(() => {
        const img = q("#cap-prev"); if (img) img.classList.add("loading");
        const warn = q("#cap-prevwarn"); if (warn) warn.textContent = "";
        call("captions_preview", { segments: c.segments, opts: this._capOpts(), t: c.t }).then((r) => {
          const im = q("#cap-prev"); if (!im) return; im.classList.remove("loading");
          if (r && r.ok && r.thumb) { im.src = r.thumb; im.style.visibility = "visible"; }
          else {
            im.removeAttribute("src"); im.style.visibility = "hidden";
            const w = q("#cap-prevwarn"); if (w) w.textContent = (r && r.error) || "preview unavailable";
          }
        });
      }, 320);
    },
    _capSegDefaults() {
      // social-caption defaults: keep sentences together but cap their length so the
      // segment list is short, punchy lines instead of one run-on paragraph.
      return { maxWords: 6, maxChars: 0, maxDur: 4, gap: 0.65, breakPunct: true };
    },
    // Flatten every word (with its source speaker) back into one ordered stream, so
    // re-segmentation is non-destructive and survives edits/translations.
    _capChunkWords() {
      const out = [];
      (this._cap.segments || []).forEach((s) => {
        const ws = (s.words && s.words.length) ? s.words
          : [{ start: s.start, end: s.end, word: s.text || "" }];
        ws.forEach((w) => { if ((w.word || "").trim()) out.push({ start: w.start, end: w.end, word: w.word, speaker: s.speaker || 0 }); });
      });
      return out;
    },
    // Re-chunk the word stream into caption-sized segments by the current rules
    // (max words / chars / seconds, break on sentence punctuation, break on pauses,
    // break on speaker change). This is the "how long should a caption be" control
    // that tools like Premiere/CapCut/Submagic expose.
    _capResegment() {
      const o = this._cap.seg || this._capSegDefaults();
      const words = this._capChunkWords();
      if (!words.length) return;
      const maxW = o.maxWords | 0, maxC = o.maxChars | 0, maxD = +o.maxDur || 0;
      const gap = +o.gap || 0, brk = o.breakPunct !== false;
      const segs = []; let cur = null;
      // trim+join each token so spacing is always correct, even when re-chunking
      // segments whose word tokens lost their leading spaces in a previous split.
      const txtOf = (ws) => ws.map((x) => (x.word || "").trim()).filter(Boolean).join(" ");
      const flush = () => { if (cur && cur.words.length) { cur.text = txtOf(cur.words); segs.push(cur); } cur = null; };
      for (let i = 0; i < words.length; i++) {
        const w = words[i];
        if (cur && cur.words.length) {
          if (w.speaker !== cur.speaker) flush();
          else if (gap && (w.start - cur.end) > gap) flush();
        }
        if (!cur) cur = { start: w.start, end: w.end, words: [], speaker: w.speaker || 0 };
        cur.words.push({ start: w.start, end: w.end, word: w.word });
        cur.end = w.end;
        const txt = txtOf(cur.words);
        const endsSentence = brk && /[.!?…]["')\]]?$/.test((w.word || "").trim());
        if ((maxW && cur.words.length >= maxW) || (maxC && txt.length >= maxC) ||
            (maxD && (cur.end - cur.start) >= maxD) || endsSentence) flush();
      }
      flush();
      this._cap.segments = segs;
    },
    _capRedistribute(seg) {
      const words = (seg.text || "").trim().split(/\s+/).filter(Boolean);
      if (!words.length) { seg.words = []; return; }
      const dur = Math.max(0.2, seg.end - seg.start), step = dur / words.length;
      seg.words = words.map((w, i) => ({ start: seg.start + i * step, end: seg.start + (i + 1) * step, word: (i ? " " : "") + w }));
    },
    // ── motion keyframes (advanced): scale / rotation / opacity over a caption ──
    _capEase(p, name) {
      switch (name || "linear") {
        case "ease-in": return p * p;
        case "ease-out": return 1 - (1 - p) * (1 - p);
        case "ease": return p * p * (3 - 2 * p);
        default: return p;
      }
    },
    // interpolate {sx,sy,rot,op} at local time lt (0..1) across the keyframes
    _capKfAt(kf, lt) {
      const ks = kf.slice().sort((a, b) => (a.t || 0) - (b.t || 0));
      if (!ks.length) return null;
      const g = (k, n, d) => (k[n] == null ? d : k[n]);
      if (lt <= ks[0].t) return { sx: g(ks[0], "sx", 100), sy: g(ks[0], "sy", 100), rot: g(ks[0], "rot", 0), op: g(ks[0], "op", 1) };
      const last = ks[ks.length - 1];
      if (lt >= last.t) return { sx: g(last, "sx", 100), sy: g(last, "sy", 100), rot: g(last, "rot", 0), op: g(last, "op", 1) };
      for (let i = 0; i < ks.length - 1; i++) {
        const a = ks[i], b = ks[i + 1];
        if (lt >= a.t && lt <= b.t) {
          let p = (lt - a.t) / ((b.t - a.t) || 1e-6);
          p = this._capEase(p, b.ease);
          const L = (n, d) => g(a, n, d) + (g(b, n, d) - g(a, n, d)) * p;
          return { sx: L("sx", 100), sy: L("sy", 100), rot: L("rot", 0), op: L("op", 1) };
        }
      }
      return { sx: 100, sy: 100, rot: 0, op: 1 };
    },
    _capKfEditorHTML() {
      const c = this._cap, si = c.selSeg;
      const head = '<div class="sectlabel" style="margin-top:14px">motion keyframes <span class="muted" style="font-size:10px">(advanced — scale · rotation · opacity)</span></div>';
      if (si == null || !c.segments[si]) {
        return head + '<p class="muted" style="font-size:11px">click a caption above to keyframe its scale / rotation / opacity over time.</p>';
      }
      const seg = c.segments[si], kf = seg.kf || [];
      const dots = kf.map((k, i) => '<button class="cap-kfdot' + (i === c.selKf ? " on" : "") +
        '" data-kf="' + i + '" style="left:' + ((k.t || 0) * 100).toFixed(1) + '%" title="' + Math.round((k.t || 0) * 100) + '%"></button>').join("");
      let edit = "";
      if (c.selKf != null && kf[c.selKf]) {
        const k = kf[c.selKf];
        const easeSel = ["linear", "ease-in", "ease-out", "ease"].map((x) =>
          '<option' + (x === (k.ease || "ease-out") ? " selected" : "") + ">" + x + "</option>").join("");
        edit = '<div class="cap-kfedit">' +
          '<label>scale %<input type="number" id="cap-kf-s" min="0" max="400" value="' + (k.sx == null ? 100 : k.sx) + '"></label>' +
          '<label>rotation°<input type="number" id="cap-kf-r" min="-180" max="180" value="' + (k.rot || 0) + '"></label>' +
          '<label>opacity %<input type="number" id="cap-kf-o" min="0" max="100" value="' + Math.round((k.op == null ? 1 : k.op) * 100) + '"></label>' +
          '<label>ease<select id="cap-kf-e">' + easeSel + "</select></label>" +
          '<button class="pbtn sm" id="cap-kf-del">delete keyframe</button></div>';
      }
      return head +
        '<div class="cap-kfcap muted">“' + esc((seg.text || "").slice(0, 40)) + '”</div>' +
        '<div class="cap-kftrack" id="cap-kftrack">' + (dots || '<span class="cap-kfempty">no keyframes — add one at the playhead</span>') + "</div>" +
        '<div class="cap-kfbtns"><button class="pbtn sm" id="cap-kfadd">+ keyframe @ playhead</button>' +
        (kf.length ? '<button class="pbtn sm" id="cap-kfclear">clear</button>' : "") + "</div>" + edit;
    },
    _capSelectSeg(i) {
      this._cap.selSeg = i; this._cap.selKf = null;
      qa(".cap-seg").forEach((el) => el.classList.toggle("sel", +el.dataset.si === i));
      this._capKfRefresh();
    },
    // re-render ONLY the keyframe editor (no video reload) + refresh the preview
    _capKfRefresh() {
      const host = q("#cap-kfhost"); if (!host) return;
      host.innerHTML = this._capKfEditorHTML();
      this._capWireKf();
      this._capRefresh();
    },
    _capWireKf() {
      const c = this._cap, on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      const seg = c.segments[c.selSeg];
      qa(".cap-kfdot").forEach((el) => el.addEventListener("click", () => { c.selKf = +el.dataset.kf; this._capKfRefresh(); }));
      on("#cap-kfadd", "click", () => {
        if (!seg) return;
        const dur = Math.max(0.05, seg.end - seg.start);
        const t = Math.max(0, Math.min(1, (c.t - seg.start) / dur));
        seg.kf = (seg.kf || []).concat([{ t: t, sx: 100, sy: 100, rot: 0, op: 1, ease: "ease-out" }]);
        seg.kf.sort((a, b) => a.t - b.t);
        c.selKf = seg.kf.findIndex((k) => k.t === t);
        this._capKfRefresh();
      });
      on("#cap-kfclear", "click", () => { if (seg) seg.kf = []; c.selKf = null; this._capKfRefresh(); });
      const upd = (n, v) => { if (seg && seg.kf && seg.kf[c.selKf]) { seg.kf[c.selKf][n] = v; this._capRefresh(); } };
      on("#cap-kf-s", "input", (e) => { const v = Math.max(0, parseInt(e.target.value, 10) || 100); seg.kf[c.selKf].sx = v; seg.kf[c.selKf].sy = v; this._capRefresh(); });
      on("#cap-kf-r", "input", (e) => upd("rot", parseFloat(e.target.value) || 0));
      on("#cap-kf-o", "input", (e) => upd("op", Math.max(0, Math.min(100, parseInt(e.target.value, 10) || 0)) / 100));
      on("#cap-kf-e", "change", (e) => upd("ease", e.target.value));
      on("#cap-kf-del", "click", () => { if (seg && seg.kf) { seg.kf.splice(c.selKf, 1); c.selKf = null; this._capKfRefresh(); } });
    },
    _capRenderEditor() {
      const c = this._cap, o = c.opts, fmt = (s) => this._capFmt(s);
      const fonts = this._capFonts();
      const anims = this._capAnims();
      const animOpts = Object.keys(anims).map((k) => [k, anims[k][0]]);
      const sel = (id, opts, val) => '<select id="' + id + '">' +
        opts.map((p) => '<option value="' + p[0] + '"' + (p[0] === val ? " selected" : "") + ">" + p[1] + "</option>").join("") + "</select>";
      const selF = (id, arr, val) => '<select id="' + id + '">' +
        arr.map((f) => "<option" + (f === val ? " selected" : "") + ">" + f + "</option>").join("") + "</select>";
      const sg = c.seg || (c.seg = this._capSegDefaults());
      // one segment row. The timestamp is clickable → seeks the preview to that point.
      const segRow = (s, i) =>
        '<div class="cap-seg' + (i === c.selSeg ? " sel" : "") + '" data-si="' + i + '">' +
          '<button class="cap-t mono" data-seek="' + i + '" title="jump to ' + fmt(s.start) + ' in the preview">' + fmt(s.start) + '</button>' +
          '<input class="cap-text" data-i="' + i + '" value="' + esc(s.text) + '">' +
          (c.speakers.length > 1 ? '<select class="cap-spk" data-i="' + i + '">' +
            c.speakers.map((sp, j) => '<option value="' + j + '"' + (j === s.speaker ? " selected" : "") + ">" + esc(sp.name) + "</option>").join("") + "</select>" : "") +
          '<button class="cap-del" data-i="' + i + '" title="delete">&#10005;</button></div>';
      // group the segment list by source clip when the video is a built multi-clip reel,
      // so you can see which captions belong to which clip (and which clips had no speech).
      let segRows;
      const marks = c.clipMarks;
      if (marks && marks.length > 1) {
        const clipOf = (t) => { let idx = 0; for (let k = 0; k < marks.length; k++) { if (t >= (marks[k].start || 0) - 0.04) idx = k; } return idx; };
        const groups = marks.map(() => []);
        c.segments.forEach((s, i) => groups[clipOf(s.start)].push(i));
        segRows = marks.map((m, ci) =>
          '<div class="cap-cliphdr"><span class="cap-clipnum mono">' + (ci + 1) + '</span>' +
            '<span class="cap-clipname">' + esc((m.name || ("clip " + (ci + 1))).replace(/\.[^.]+$/, "")) + '</span>' +
            (groups[ci].length ? '<span class="cap-clipcount muted">' + groups[ci].length + ' caption' + (groups[ci].length === 1 ? '' : 's') + '</span>'
                               : '<span class="cap-clipnone muted">no speech</span>') + '</div>' +
          groups[ci].map((i) => segRow(c.segments[i], i)).join("")).join("");
      } else {
        segRows = c.segments.map((s, i) => segRow(s, i)).join("");
      }
      // preview keeps the video's true aspect ratio (vertical stays vertical)
      const ar = (c.dims.w && c.dims.h) ? (c.dims.w + " / " + c.dims.h) : "16 / 9";
      const previewHTML =
        '<div class="cap-prevwrap" style="aspect-ratio:' + ar + '">' +
          '<video id="cap-video" class="cap-prev" playsinline preload="metadata"></video>' +
          '<img id="cap-prev" class="cap-prev" alt="" style="display:none">' +
          '<div id="cap-overlay" class="cap-overlay"></div>' +
          '<button id="cap-playover" class="cap-playover" title="play / pause" aria-label="play / pause">' +
            '<span class="cap-povicon">&#9654;</span></button>' +
          '<span id="cap-prevwarn" class="cap-prevwarn"></span></div>' +
        '<div class="cap-playbar">' +
          '<button class="cap-playbtn" id="cap-play" title="play / pause">&#9654;</button>' +
          '<input type="range" id="cap-time" min="0" max="' + (c.dims.duration || 5).toFixed(2) + '" step="0.03" value="' + c.t.toFixed(2) + '">' +
          '<span class="mono" id="cap-tlabel">' + fmt(c.t) + "</span></div>";
      const presets = [["1", "1 word"], ["3", "punchy"], ["6", "balanced"], ["0", "sentences"]];
      const looks = this._capLooks();
      const lookRow =
        '<div class="sectlabel" style="margin-top:6px">looks <span class="muted" style="font-size:10px">one-click style presets</span></div>' +
        '<div class="cap-looks">' + Object.keys(looks).map((k) =>
          '<button class="pbtn sm cap-look" data-look="' + k + '">' + esc(looks[k].label) + "</button>").join("") + "</div>";
      const controlsHTML =
        '<div class="cap-translate"><span class="muted" style="font-size:11px">captions language:</span>' +
          '<button class="pbtn sm cap-tr" data-lang="en">→ English</button>' +
          '<button class="pbtn sm cap-tr" data-lang="es">→ Spanish</button></div>' +
        lookRow +
        '<div class="sectlabel" style="margin-top:12px">style</div>' +
        '<div class="cap-grid">' +
          "<label>type" + sel("cap-type", [["highlight", "word highlight"], ["word", "one word"], ["sentence", "sentences"]], o.type) + "</label>" +
          "<label>position" + sel("cap-pos", [["bottom", "bottom"], ["center", "center"], ["top", "top"]], o.position) + "</label>" +
          "<label>font" + selF("cap-font", fonts, o.font) + "</label>" +
          '<label>size<input type="number" id="cap-size" min="14" max="160" value="' + o.size + '"></label>' +
          '<label>text<input type="color" id="cap-primary" value="' + o.primary + '"></label>' +
          '<label>highlight<input type="color" id="cap-hl" value="' + o.highlight + '"></label>' +
          '<label>outline<input type="number" id="cap-ow" min="0" max="12" step="0.5" value="' + (o.outline_width != null ? o.outline_width : 3) + '"></label>' +
          '<label>letter spacing<input type="number" id="cap-ls" min="0" max="40" step="0.5" value="' + (+o.letter_spacing || 0) + '"></label>' +
          "<label>animation" + sel("cap-anim", animOpts, o.anim || "fade") + "</label>" +
        "</div>" +
        // B / I / U weight toggles + the free-position controls
        '<div class="cap-typorow">' +
          '<button class="cap-tg' + (o.bold !== false ? " on" : "") + '" id="cap-bold" title="bold" style="font-weight:800">B</button>' +
          '<button class="cap-tg' + (o.italic ? " on" : "") + '" id="cap-italic" title="italic" style="font-style:italic">I</button>' +
          '<button class="cap-tg' + (o.underline ? " on" : "") + '" id="cap-underline" title="underline" style="text-decoration:underline">U</button>' +
          '<span class="cap-posnote muted">' + (o.pos ? "custom position — drag on the preview" : "drag the caption on the preview to move it") + "</span>" +
          (o.pos ? '<button class="pbtn sm" id="cap-posreset">reset position</button>' : "") +
        "</div>" +
        '<div class="cap-checks">' +
          '<label class="cap-check"><input type="checkbox" id="cap-upper"' + (o.uppercase ? " checked" : "") + "> ALL CAPS</label>" +
          '<label class="cap-check"><input type="checkbox" id="cap-box"' + (o.box ? " checked" : "") + "> background box</label>" +
          '<label class="cap-check sm">box color<input type="color" id="cap-boxcol" value="' + (o.box_color || "#000000") + '"></label>' +
        "</div>" +
        '<div class="sectlabel" style="margin-top:12px">speakers</div>' +
        '<div id="cap-spklist">' + this._capSpeakerRows() + "</div>" +
        '<button class="pbtn sm" id="cap-addspk">+ add speaker</button>' +
        '<div class="sectlabel" style="margin-top:14px">segments <span class="muted" style="font-size:10px">(' + c.segments.length + ')</span></div>' +
        '<div class="cap-segtools">' +
          '<div class="cap-presets">' + presets.map((p) =>
            '<button class="pbtn sm cap-preset' + ((+p[0] === (sg.maxWords | 0)) ? " on" : "") + '" data-w="' + p[0] + '">' + p[1] + "</button>").join("") + "</div>" +
          '<div class="cap-seground">' +
            '<label>max words<input type="number" id="cap-maxw" min="0" max="20" value="' + (sg.maxWords | 0) + '"></label>' +
            '<label>max secs<input type="number" id="cap-maxd" min="0" max="15" step="0.5" value="' + (+sg.maxDur || 0) + '"></label>' +
            '<label class="cap-check sm"><input type="checkbox" id="cap-brk"' + (sg.breakPunct !== false ? " checked" : "") + "> split on punctuation</label>" +
          "</div></div>" +
        '<div class="cap-seglist">' + (segRows || '<p class="muted">no speech detected</p>') + "</div>" +
        '<div id="cap-kfhost">' + this._capKfEditorHTML() + "</div>" +
        '<button class="pbtn primary big" id="cap-export" style="margin-top:14px">&#127916; burn captions into video</button>';
      // vertical video → put the (tall) preview on the LEFT, controls on the RIGHT;
      // horizontal video → keep the stacked single-column layout.
      const vertical = (c.dims.h || 0) > (c.dims.w || 0);
      const panel = document.getElementById("panel");
      if (panel) panel.classList.toggle("cap-vert", vertical);
      body().innerHTML = vertical
        ? '<div class="cap-cols"><div class="cap-col-l">' + previewHTML + '</div>' +
          '<div class="cap-col-r">' + controlsHTML + "</div></div>"
        : previewHTML + controlsHTML;
      this._capWire();
      this._capLoadVideo();
    },
    _capSpeakerRows() {
      return this._cap.speakers.map((s, i) =>
        '<div class="cap-spkrow"><input type="color" class="cap-spkcol" data-i="' + i + '" value="' + s.color + '">' +
        '<input class="cap-spkname" data-i="' + i + '" value="' + esc(s.name) + '">' +
        (this._cap.speakers.length > 1 ? '<button class="cap-spkdel" data-i="' + i + '">&#10005;</button>' : "") + "</div>").join("");
    },
    _capWire() {
      const c = this._cap, upd = () => this._capRefresh();
      const setOpt = (k, v) => { c.opts[k] = v; upd(); };
      const on = (id, ev, fn) => { const el = q(id); if (el) el.addEventListener(ev, fn); };
      on("#cap-time", "input", (e) => {
        const t = parseFloat(e.target.value) || 0; c.t = t;
        const l = q("#cap-tlabel"); if (l) l.textContent = this._capFmt(t);
        if (c.mode === "video") {
          const v = q("#cap-video"); if (v) { c._seeking = true; try { v.currentTime = t; } catch (_) { /* */ } c._seeking = false; }
          this._capRenderOverlay(t);
        } else this._capPreview();
      });
      const togglePlay = () => {
        const v = q("#cap-video"); if (!v || c.mode !== "video") return;
        if (v.paused) v.play().catch(() => {}); else v.pause();
      };
      on("#cap-play", "click", togglePlay);
      on("#cap-playover", "click", togglePlay);   // click anywhere on the preview
      on("#cap-type", "change", (e) => setOpt("type", e.target.value));
      on("#cap-pos", "change", (e) => {
        c.opts.position = e.target.value;
        if (c.opts.pos) { c.opts.pos = null; this._capRenderEditor(); }   // a preset overrides a custom drag
        else this._capRefresh();
      });
      on("#cap-font", "change", (e) => setOpt("font", e.target.value));
      on("#cap-size", "change", (e) => setOpt("size", parseInt(e.target.value, 10) || 54));
      on("#cap-primary", "input", (e) => setOpt("primary", e.target.value));
      on("#cap-hl", "input", (e) => setOpt("highlight", e.target.value));
      on("#cap-ls", "change", (e) => setOpt("letter_spacing", Math.max(0, parseFloat(e.target.value) || 0)));
      const tg = (id, key, dflt) => on(id, "click", (e) => {
        const cur = (c.opts[key] === undefined ? dflt : c.opts[key]);
        c.opts[key] = !cur; e.currentTarget.classList.toggle("on", !!c.opts[key]); this._capRefresh();
      });
      tg("#cap-bold", "bold", true);
      tg("#cap-italic", "italic", false);
      tg("#cap-underline", "underline", false);
      on("#cap-posreset", "click", () => { c.opts.pos = null; this._capRenderEditor(); });
      on("#cap-anim", "change", (e) => setOpt("anim", e.target.value));
      on("#cap-upper", "change", (e) => setOpt("uppercase", e.target.checked));
      on("#cap-box", "change", (e) => setOpt("box", e.target.checked));
      on("#cap-boxcol", "input", (e) => setOpt("box_color", e.target.value));
      on("#cap-ow", "change", (e) => setOpt("outline_width", Math.max(0, parseFloat(e.target.value) || 0)));
      this._capWireDrag();
      qa(".cap-look").forEach((el) => el.addEventListener("click", (e) => {
        const look = this._capLooks()[e.currentTarget.getAttribute("data-look")];
        if (look) { Object.assign(c.opts, look.opts); this._capRenderEditor(); }
      }));
      qa(".cap-spkcol").forEach((el) => el.addEventListener("input", (e) => { c.speakers[+e.target.dataset.i].color = e.target.value; upd(); }));
      qa(".cap-spkname").forEach((el) => el.addEventListener("input", (e) => { c.speakers[+e.target.dataset.i].name = e.target.value; }));
      qa(".cap-spkdel").forEach((el) => el.addEventListener("click", (e) => {
        c.speakers.splice(+e.currentTarget.dataset.i, 1);
        c.segments.forEach((s) => { if (s.speaker >= c.speakers.length) s.speaker = 0; });
        this._capRenderEditor();
      }));
      on("#cap-addspk", "click", () => {
        const cols = ["#C1F73A", "#6AA0FF", "#F5A623", "#E0533A", "#A78BFA"];
        c.speakers.push({ name: "Speaker " + (c.speakers.length + 1), color: cols[c.speakers.length % cols.length] });
        this._capRenderEditor();
      });
      qa(".cap-text").forEach((el) => el.addEventListener("input", (e) => {
        const i = +e.target.dataset.i; c.segments[i].text = e.target.value; this._capRedistribute(c.segments[i]); this._capRefresh();
      }));
      qa(".cap-spk").forEach((el) => el.addEventListener("change", (e) => { c.segments[+e.target.dataset.i].speaker = +e.target.value; this._capRefresh(); }));
      qa(".cap-del").forEach((el) => el.addEventListener("click", (e) => {
        c.segments.splice(+e.currentTarget.dataset.i, 1);
        if (c.selSeg === +e.currentTarget.dataset.i) { c.selSeg = null; c.selKf = null; }
        this._capRenderEditor();
      }));
      qa(".cap-t[data-seek]").forEach((el) => el.addEventListener("click", (e) => {
        e.stopPropagation();                                     // don't also select the row
        const s = c.segments[+e.currentTarget.dataset.seek];
        if (s) this._capSeekTo(s.start);
      }));
      qa(".cap-seg").forEach((el) => el.addEventListener("click", (e) => {
        if (e.target.closest("input,select,button")) return;     // editing the row, not selecting it
        this._capSelectSeg(+el.dataset.si);
      }));
      this._capWireKf();
      qa(".cap-tr").forEach((el) => el.addEventListener("click", (e) => {
        const lang = e.currentTarget.getAttribute("data-lang");
        e.currentTarget.textContent = "translating…"; e.currentTarget.disabled = true;
        call("captions_translate", { segments: c.segments, lang: lang }).then((r) => {
          if (r && r.ok && r.segments) c.segments = r.segments;
          this._capRenderEditor();
        });
      }));
      on("#cap-export", "click", () => { call("captions_export", { segments: c.segments, opts: this._capOpts() }); });
      // ── segmentation (how long each caption is) ──
      const resplit = () => { this._capResegment(); this._capRenderEditor(); };
      qa(".cap-preset").forEach((el) => el.addEventListener("click", (e) => {
        const w = +e.currentTarget.getAttribute("data-w");
        c.seg.maxWords = w;                       // 0 = sentences (rely on punctuation/pauses)
        c.seg.breakPunct = true;
        c.seg.maxDur = w === 0 ? 6 : 4;
        resplit();
      }));
      on("#cap-maxw", "change", (e) => { c.seg.maxWords = Math.max(0, parseInt(e.target.value, 10) || 0); resplit(); });
      on("#cap-maxd", "change", (e) => { c.seg.maxDur = Math.max(0, parseFloat(e.target.value) || 0); resplit(); });
      on("#cap-brk", "change", (e) => { c.seg.breakPunct = e.target.checked; resplit(); });
    },

    // ── MEMORY ──────────────────────────────────────────────────────────────────
    _mem: { search: "", editId: null },

    openMemory() {
      titleEl().textContent = "memory";
      this._mem = { search: "", editId: null };
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._reloadMemory();
      return true;
    },

    _reloadMemory() {
      call("cfg_memory_get", this._mem.search || "").then((d) => { this._mem.data = d || { facts: [], topics: [] }; this._renderMemory(); });
    },

    _renderMemory() {
      const d = this._mem.data || { facts: [], topics: [] };
      const facts = d.facts || [], topics = d.topics || [];
      const factRows = facts.map((f) =>
        '<div class="item" data-fid="' + f.id + '">' +
          '<div class="it-main"><div class="it-name">' + esc(f.key) + '</div>' +
          '<div class="it-sub" style="max-width:230px">' + esc(f.value) + '</div></div>' +
          '<div class="it-actions"><span class="imp">' + (f.importance || 5) + '</span>' +
          '<button class="iconbtn-sm danger" data-fdel="' + f.id + '" title="Delete">&#128465;</button></div>' +
        '</div>').join("") || '<p class="muted">No stored facts' + (this._mem.search ? " match." : " yet. Add one below, or just tell Wivexx “remember that …”.") + '</p>';
      body().innerHTML =
        '<div class="field"><div class="with-btn"><input id="mem-search" placeholder="search facts…" value="' + esc(this._mem.search) + '"><button class="pbtn" id="mem-searchbtn">search</button></div></div>' +
        '<div class="sectlabel">what Wivexx remembers about you</div>' +
        '<div class="itemlist">' + factRows + '</div>' +
        '<div class="formcard">' +
          '<div class="field"><label id="mem-formlabel">add a fact</label><input id="mem-key" autocomplete="off" placeholder="key (e.g. camera)"></div>' +
          '<div class="field"><label>value</label><input id="mem-val" autocomplete="off" placeholder="owns a Sony Alpha mirrorless"></div>' +
          '<div class="field"><label>importance (1–10)</label><input id="mem-imp" type="number" min="1" max="10" value="5" style="max-width:90px"></div>' +
          '<div class="btnrow"><button class="pbtn primary" id="mem-save">add</button><button class="pbtn" id="mem-new" style="display:none">clear</button></div>' +
          '<div class="savednote" id="mem-note"></div>' +
        '</div>' +
        (topics.length ? '<div class="sectlabel" style="margin-top:18px">session topics</div><div class="itemlist">' +
          topics.map((t) => '<div class="item" style="cursor:default"><div class="it-main"><div class="it-sub" style="max-width:280px;color:var(--stone)">' + esc(t.text) + '</div></div>' +
          '<div class="it-actions"><button class="iconbtn-sm danger" data-tdel="' + t.id + '" title="Delete">&#128465;</button></div></div>').join("") + '</div>' : "");

      if (this._mem.editId != null) {
        const f = facts.find((x) => x.id === this._mem.editId);
        if (f) {
          q("#mem-key").value = f.key; q("#mem-val").value = f.value; q("#mem-imp").value = f.importance || 5;
          q("#mem-formlabel").textContent = "edit fact"; q("#mem-save").textContent = "save"; q("#mem-new").style.display = "";
        }
      }
      qa(".item[data-fid]").forEach((it) => it.addEventListener("click", (e) => {
        if (e.target.closest("[data-fdel]")) return;
        this._mem.editId = parseInt(it.getAttribute("data-fid"), 10); this._renderMemory();
      }));
      qa("[data-fdel]").forEach((b) => b.addEventListener("click", (e) => {
        e.stopPropagation();
        this._armDel(b, () => call("cfg_fact_delete", b.getAttribute("data-fdel")).then(() => this._reloadMemory()));
      }));
      qa("[data-tdel]").forEach((b) => b.addEventListener("click", () =>
        this._armDel(b, () => call("cfg_topic_delete", b.getAttribute("data-tdel")).then(() => this._reloadMemory()))));
      const doSearch = () => { this._mem.search = q("#mem-search").value.trim(); this._mem.editId = null; this._reloadMemory(); };
      q("#mem-searchbtn").addEventListener("click", doSearch);
      q("#mem-search").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
      q("#mem-new").addEventListener("click", () => { this._mem.editId = null; this._renderMemory(); });
      q("#mem-save").addEventListener("click", () => {
        const key = q("#mem-key").value.trim(), val = q("#mem-val").value.trim(), imp = q("#mem-imp").value;
        if (!key || !val) { this._note("mem-note", "key and value are required", true); return; }
        const done = (r) => { if (r && r.ok === false) this._note("mem-note", r.error || "couldn't save", true); else { this._mem.editId = null; this._reloadMemory(); } };
        if (this._mem.editId != null) call("cfg_fact_update", this._mem.editId, key, val, imp).then(done);
        else call("cfg_fact_add", key, val, imp).then(done);
      });
    },

    // ── CAPTION STUDIO ─────────────────────────────────────────────────────────
    _cap: { media: [], pack: "", busy: false },

    openCaptions() {
      titleEl().textContent = "caption studio";
      this._cap = { media: [], pack: "", busy: false };
      call("cap_clear_media");                 // reset any stale pick in Python
      body().innerHTML =
        '<div class="sectlabel">1 · the content</div>' +
        '<div class="formcard">' +
          '<div class="media-pick" id="cap-media">' +
            '<button class="pbtn" id="cap-pick">📎 add photos <span class="muted">(carousel ok)</span></button>' +
            '<div class="media-info" id="cap-info"><span class="muted">no photos — I&rsquo;ll work from your description</span></div>' +
          '</div>' +
          '<div class="field" style="margin-top:12px"><label>what is the post about?</label><input id="cap-topic" autocomplete="off" placeholder="behind-the-scenes from today&rsquo;s shoot"></div>' +
        '</div>' +
        '<div class="sectlabel">2 · who is it for <span class="muted">(optional — I study the profile&rsquo;s voice)</span></div>' +
        '<div class="formcard">' +
          '<div class="field2">' +
            '<div class="field"><label>name</label><input id="cap-name" autocomplete="off"></div>' +
            '<div class="field"><label>IG handle</label><input id="cap-handle" autocomplete="off" placeholder="@username"></div>' +
          '</div>' +
        '</div>' +
        '<div class="sectlabel">3 · platforms <span class="muted">(hashtags researched per platform)</span></div>' +
        '<div class="formcard">' +
          '<label class="chk"><input type="checkbox" id="cap-ig" checked> 📷 Instagram</label>' +
          '<label class="chk"><input type="checkbox" id="cap-tt"> 🎵 TikTok</label>' +
        '</div>' +
        '<div class="btnrow"><button class="pbtn primary" id="cap-go">⚡ generate captions</button></div>' +
        '<div class="savednote" id="cap-note"></div>' +
        '<div id="cap-results"></div>';
      openPanel();
      this._renderCapMedia();
      q("#cap-pick").addEventListener("click", () => {
        call("cap_pick_media").then((r) => { if (r && r.ok) { this._cap.media = r.items || []; this._renderCapMedia(); } });
      });
      q("#cap-go").addEventListener("click", () => this._capGenerate(false));
      q("#cap-topic").addEventListener("keydown", (e) => { if (e.key === "Enter") this._capGenerate(false); });
      // restore the last pack — so captions that finished while this panel was
      // closed are still here when you come back (incl. from an activity link)
      call("cap_last").then((d) => {
        if (d && d.text && titleEl().textContent === "caption studio") {
          this._cap.pack = d.text;
          this._note("cap-note", "↑ your most recent pack");
          this._renderCapResults((d.opts) || { platforms: ["instagram"] });
        }
      });
    },

    _renderCapMedia() {
      const info = q("#cap-info"); if (!info) return;
      const m = this._cap.media || [];
      if (!m.length) { info.innerHTML = '<span class="muted">no photos — I&rsquo;ll work from your description</span>'; return; }
      const chips = m.map((it, i) => {
        const t = it.thumb ? '<img src="' + it.thumb + '" alt="">'
          : '<span class="cap-glyph">' + (it.kind === "video" ? "🎬" : "🖼") + "</span>";
        return '<span class="cap-chip">' + t +
          '<button type="button" class="cap-chipx" data-capx="' + i + '" aria-label="Remove">&#10005;</button></span>';
      }).join("");
      const label = m.length > 1 ? ("carousel · " + m.length + " photos — analysed in order")
                                 : (m[0].name || "1 photo");
      info.innerHTML = '<div class="cap-strip">' + chips + "</div>" +
        '<span class="muted cap-striplabel">' + esc(label) + "</span>";
      qa("[data-capx]").forEach((b) => b.addEventListener("click", () => {
        call("cap_remove_media", parseInt(b.getAttribute("data-capx"), 10)).then((r) => {
          this._cap.media = (r && r.items) || []; this._renderCapMedia();
        });
      }));
    },

    _capGenerate(isRevise, feedback) {
      if (this._cap.busy) return;
      const topic = (q("#cap-topic") && q("#cap-topic").value || "").trim();
      if (!isRevise && !topic && !(this._cap.media && this._cap.media.length)) { this._note("cap-note", "add the photo(s) or give me the topic", true); return; }
      const platforms = [];
      if (q("#cap-ig") && q("#cap-ig").checked) platforms.push("instagram");
      if (q("#cap-tt") && q("#cap-tt").checked) platforms.push("tiktok");
      if (!platforms.length) { this._note("cap-note", "pick at least one platform", true); return; }
      const payload = {
        topic: topic, platforms: platforms,
        client_name: (q("#cap-name") && q("#cap-name").value || "").trim(),
        handle: (q("#cap-handle") && q("#cap-handle").value || "").trim().replace(/^@/, ""),
      };
      if (isRevise) { payload.feedback = feedback; payload.previous = this._cap.pack; }
      this._cap.busy = true;
      this._note("cap-note", isRevise ? "revising the pack…" : "analysing the content…");
      call("cap_generate", payload).then((r) => {
        this._cap.busy = false;
        if (r && r.ok) { this._cap.pack = r.text || ""; this._note("cap-note", isRevise ? "revised ✓" : "done ✓"); this._renderCapResults(payload); }
        else this._note("cap-note", (r && r.error) || "generation failed", true);
      });
    },

    _capParseOptions(t) {
      const out = {};
      const re = /OPTION\s+(\d)[^\n]*\n([\s\S]*?)(?=\n\s*OPTION\s+\d|\n\s*[A-Z][A-Z ]*HASHTAGS:|\n\s*WHY THESE TAGS:|\n\s*POSTING TIP:|$)/g;
      let m; while ((m = re.exec(t))) out[parseInt(m[1], 10)] = m[2].trim();
      return out;
    },
    _capTagLines(t) {
      const lines = []; const re = /^\s*([A-Z][A-Z ]*?)\s*HASHTAGS:\s*(.+)$/gm;
      let m; while ((m = re.exec(t))) lines.push(m[1].trim() + ": " + m[2].trim());
      return lines.join("\n");
    },

    _renderCapResults(payload) {
      const t = this._cap.pack || "";
      const opts = this._capParseOptions(t);
      const tags = this._capTagLines(t);
      const meta = ((payload.platforms || []).map((p) => p[0].toUpperCase() + p.slice(1)).join(" + ")) +
        (payload.handle ? "  ·  @" + esc(payload.handle) : "");
      const copyBtns = [1, 2, 3].filter((n) => opts[n]).map((n) =>
        '<button class="pbtn" data-copt="' + n + '">📋 option ' + n + "</button>").join("");
      // highlight headers in the rendered pack
      const html = t.split("\n").map((line) =>
        /^\s*(OPTION\s+\d|[A-Z][A-Z ]*(?:HASHTAGS|ALT TAGS):|WHY THESE TAGS:|CAROUSEL TIPS:|POSTING TIP:)/.test(line)
          ? '<span class="cap-hdr">' + esc(line) + "</span>" : esc(line)).join("\n");
      q("#cap-results").innerHTML =
        '<div class="sectlabel" style="margin-top:18px">the pack <span class="muted">· ' + meta + "</span></div>" +
        '<pre class="cap-pack">' + html + "</pre>" +
        '<div class="btnrow">' + copyBtns +
          (tags ? '<button class="pbtn" data-ctags="1">📋 hashtags</button>' : "") +
          '<button class="pbtn" data-call="1">📋 everything</button></div>' +
        '<div class="field" style="margin-top:12px"><label>not quite right? tell me what to change — I&rsquo;ll give 3 new options</label>' +
          '<div class="with-btn"><input id="cap-fb" autocomplete="off" placeholder="punchier, add a question, less salesy…"><button class="pbtn primary" id="cap-revise">🔁 revise</button></div></div>' +
        '<div class="savednote" id="cap-rnote"></div>';
      const copy = (s, note) => call("copy_text", s).then((r) => this._note("cap-rnote", (r && r.ok) ? "📋 " + note + " copied" : "couldn't copy", !(r && r.ok)));
      qa("[data-copt]").forEach((b) => b.addEventListener("click", () => {
        const n = parseInt(b.getAttribute("data-copt"), 10);
        copy(opts[n] + (tags ? "\n\n" + tags : ""), "option " + n + " + hashtags");
      }));
      const tb = q("[data-ctags]"); if (tb) tb.addEventListener("click", () => copy(tags, "hashtags"));
      q("[data-call]").addEventListener("click", () => copy(t, "everything"));
      q("#cap-revise").addEventListener("click", () => {
        const fb = (q("#cap-fb").value || "").trim(); if (fb) this._capGenerate(true, fb);
      });
      q("#cap-fb").addEventListener("keydown", (e) => { if (e.key === "Enter") { const fb = e.target.value.trim(); if (fb) this._capGenerate(true, fb); } });
    },

    // ── DELIVER STUDIO ─────────────────────────────────────────────────────────
    _dlv: { src: null, count: 0, busy: false },

    openDeliver() {
      titleEl().textContent = "client delivery";
      this._dlv = { src: null, count: 0, busy: false };
      body().innerHTML =
        '<div class="sectlabel">1 · source folder</div>' +
        '<div class="formcard">' +
          '<div class="with-btn"><button class="pbtn" id="dl-pick">📂 choose folder</button><span class="muted" id="dl-src" style="align-self:center">no folder chosen</span></div>' +
          '<div class="field" style="margin-top:12px">' +
            '<label>what to send</label>' +
            '<select id="dl-source">' +
              '<option value="jpg" selected>JPG / PNG — develop the RAWs into finished JPGs (recommended)</option>' +
              '<option value="both">RAW + JPG — developed JPGs plus the original RAW files</option>' +
              '<option value="raw">RAW only — the untouched originals, no developing</option>' +
            '</select>' +
          '</div>' +
        '</div>' +
        '<div id="dl-convsec">' +
        '<div class="sectlabel">2 · develop <span class="muted">(.ARW → full-quality JPG)</span></div>' +
        '<div class="formcard">' +
          '<div class="field2">' +
            '<div class="field"><label>JPEG quality</label><input id="dl-q" type="number" min="70" max="100" value="95"></div>' +
            '<div class="field"><label>brightness</label><input id="dl-bright" type="number" min="0.5" max="2" step="0.05" value="1.0"></div>' +
          '</div>' +
          '<label class="chk" style="margin-top:8px"><input type="checkbox" id="dl-auto" checked> auto-brighten</label>' +
        '</div></div>' +
        '<div class="sectlabel">3 · deliver to google drive</div>' +
        '<div class="formcard">' +
          '<div class="field"><label>gallery / folder name <span class="muted">(blank = only convert)</span></label><input id="dl-gallery" autocomplete="off"></div>' +
          '<div class="field"><label>photo name sequence <span class="muted">(blank = keep original names)</span></label><input id="dl-seq" autocomplete="off" placeholder="smith-wedding"></div>' +
          '<div class="muted" id="dl-seqprev" style="margin:-4px 0 8px"></div>' +
          '<label class="chk"><input type="checkbox" id="dl-public" checked> make the link public (anyone with the link)</label>' +
        '</div>' +
        '<div class="btnrow"><button class="pbtn primary" id="dl-go">⚡ convert &amp; deliver</button></div>' +
        '<div class="savednote" id="dl-note"></div>' +
        '<div id="dl-results"></div>';
      openPanel();
      q("#dl-pick").addEventListener("click", () => {
        call("dlv_pick_folder").then((r) => {
          if (!r || !r.ok) return;
          this._dlv.src = r.path; this._dlv.count = r.count || 0;
          q("#dl-src").textContent = r.name + "  ·  " + r.count + " RAW photo" + (r.count === 1 ? "" : "s") + " found";
          if (!q("#dl-gallery").value.trim()) q("#dl-gallery").value = r.name;
          this._dlvSeqPreview();
        });
      });
      const srcSel = q("#dl-source");
      const syncConv = () => { q("#dl-convsec").style.display = (srcSel.value === "raw") ? "none" : ""; };
      srcSel.addEventListener("change", syncConv); syncConv();
      q("#dl-seq").addEventListener("keyup", () => this._dlvSeqPreview());
      q("#dl-go").addEventListener("click", () => this._dlvRun());
    },

    _dlvSeqPreview() {
      const seq = (q("#dl-seq").value || "").trim();
      const el = q("#dl-seqprev"); if (!el) return;
      if (!seq) { el.textContent = ""; return; }
      const n = Math.max(this._dlv.count || 0, 1);
      const w = Math.max(3, String(n).length);
      const pad = (x) => String(x).padStart(w, "0");
      el.textContent = "→ " + seq + "-" + pad(1) + ".jpg … " + seq + "-" + pad(n) + ".jpg";
    },

    _dlvRun() {
      if (this._dlv.busy) return;
      if (!this._dlv.src) { this._note("dl-note", "choose the shoot folder first", true); return; }
      const source = (q("#dl-source") && q("#dl-source").value) || "jpg";
      const raw = source === "raw";
      const gallery = (q("#dl-gallery").value || "").trim();
      const opts = {
        src: this._dlv.src,
        source: source,                 // jpg | both | raw
        convert: !raw,                  // develop unless RAW-only
        raw_only: raw,
        mode: "develop",
        quality: parseInt(q("#dl-q").value, 10) || 95,
        exposure: parseFloat(q("#dl-bright").value) || 1.0,
        auto_bright: !!(q("#dl-auto") && q("#dl-auto").checked),
        include_jpegs: !raw,            // jpg & both pass existing JPG/PNG through
        gallery: gallery,
        sequence: (q("#dl-seq").value || "").trim(),
        public: !!(q("#dl-public") && q("#dl-public").checked),
      };
      if (!opts.convert && !opts.gallery) { this._note("dl-note", "nothing to do — convert, or give a gallery name to upload", true); return; }
      this._dlv.busy = true;
      q("#dl-go").disabled = true;
      this._note("dl-note", "starting…");
      q("#dl-results").innerHTML = "";
      // live progress into the note while it runs (the floating HUD/card also show it)
      if (this._dlvPoll) clearInterval(this._dlvPoll);
      this._dlvPoll = setInterval(() => {
        if (!this._dlv.busy) { clearInterval(this._dlvPoll); this._dlvPoll = null; return; }
        call("transfer_status").then((d) => {
          if (!this._dlv.busy || !d || d.kind !== "deliver" || !document.getElementById("dl-note")) return;
          if (d.paused) { this._note("dl-note", "paused — resume from the progress bar"); return; }
          const ct = d.total ? (d.copied + "/" + d.total) : "";
          this._note("dl-note", (d.stage || "working") + " " + ct + (d.percent ? " · " + d.percent + "%" : ""));
        });
      }, 600);
      call("dlv_run", opts).then((r) => {
        this._dlv.busy = false;
        if (this._dlvPoll) { clearInterval(this._dlvPoll); this._dlvPoll = null; }
        q("#dl-go").disabled = false;
        this._renderDlvResult(r || {});
      });
    },

    _renderDlvResult(r) {
      if (r.error && !(r.uploaded || r.converted)) { this._note("dl-note", r.error, true); return; }
      const phase = r.phase || (r.ok ? "done" : "");
      this._note("dl-note", phase === "cancelled" ? "cancelled" : (r.link ? "delivery complete ✓" : "conversion complete ✓"));
      const bits = [];
      if (r.converted) bits.push(r.converted + " developed");
      if (r.uploaded) bits.push(r.uploaded + " uploaded");
      const links = r.link
        ? '<div class="field" style="margin-top:10px"><label>public share link</label>' +
            '<div class="with-btn"><input id="dl-link" readonly value="' + esc(r.link) + '"><button class="pbtn primary" id="dl-copy">📋 copy</button><button class="pbtn" id="dl-open">🔗 open</button></div></div>'
        : "";
      q("#dl-results").innerHTML =
        '<div class="formcard" style="margin-top:14px"><div class="it-name">' +
          (r.link ? "delivery complete" : "conversion complete") + '</div>' +
          '<div class="muted" style="margin-top:4px">' + (bits.join("  ·  ") || esc(r.folder || "")) + "</div>" +
          links +
          '<div class="btnrow"><button class="pbtn" id="dl-folder">📁 open local JPG folder</button></div>' +
          '<div class="savednote" id="dl-rnote"></div></div>';
      const cp = q("#dl-copy"); if (cp) cp.addEventListener("click", () => call("copy_text", r.link).then((x) => this._note("dl-rnote", (x && x.ok) ? "📋 link copied" : "couldn't copy", !(x && x.ok))));
      const op = q("#dl-open"); if (op) op.addEventListener("click", () => call("open_url", r.link));
      const fo = q("#dl-folder"); if (fo) fo.addEventListener("click", () => call("open_folder", r.folder || ""));
    },

    // ── ACTIVITY / NOTIFICATIONS ───────────────────────────────────────────────
    openActivity() {
      titleEl().textContent = "activity";
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._stopActivity();
      this._loadActivity();
      this._actPoll = setInterval(() => {
        if (!document.getElementById("panel").classList.contains("on")) { this._stopActivity(); return; }
        this._loadActivity();
      }, 1500);
      this._actTick = setInterval(() => this._tickTimers(), 250);
      return true;
    },

    _stopActivity() {
      if (this._actPoll) { clearInterval(this._actPoll); this._actPoll = null; }
      if (this._actTick) { clearInterval(this._actTick); this._actTick = null; }
    },

    refreshActivity() { if (this._actPoll) this._loadActivity(); },

    _loadActivity() {
      call("notifs_get").then((d) => {
        if (titleEl().textContent !== "activity") return;   // navigated away mid-call
        this._renderActivity(d || { active: [], history: [] });
      });
    },

    _actIcon(kind) {
      return ({ deliver: "📤", import: "📥", caption: "✍", timer: "⏱", scan: "🧹", transcribe: "📝", reel: "🎬", shutdown: "⏻" })[kind] || "●";
    },
    _fmtCountdown(sec) {
      sec = Math.max(0, Math.round(sec));
      const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
      const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
      return h ? (h + ":" + mm + ":" + ss) : (mm + ":" + ss);
    },
    _fmtAgo(ts) {
      const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts || 0)));
      if (s < 60) return "just now";
      if (s < 3600) return Math.floor(s / 60) + "m ago";
      if (s < 86400) return Math.floor(s / 3600) + "h ago";
      return Math.floor(s / 86400) + "d ago";
    },

    _actActiveHTML(j) {
      const ic = this._actIcon(j.kind);
      if (j.shutdown) {
        return '<div class="act-item danger"><div class="act-ic">' + ic + '</div><div class="act-main">' +
          '<div class="act-title">' + esc(j.title) + '<span class="act-tag danger">danger</span></div>' +
          '<div class="act-sub">shutting down in <span class="act-time" data-ends="' + (j.ends || 0) + '">--:--</span> — cancel to stop it</div>' +
          '<div class="act-actions"><button class="act-btn danger" data-shutdowncancel="1">✕ cancel shutdown</button></div>' +
          '</div></div>';
      }
      if (j.timer) {
        return '<div class="act-item"><div class="act-ic">' + ic + '</div><div class="act-main">' +
          '<div class="act-title">' + esc(j.title) + '<span class="act-tag live">timer</span></div>' +
          '<div class="act-sub">ends in <span class="act-time" data-ends="' + (j.ends || 0) + '">--:--</span></div>' +
          '<div class="act-actions"><button class="act-btn danger" data-tcancel="' + esc(j.id) + '">cancel</button></div>' +
          '</div></div>';
      }
      const indet = !!j.indet;
      const pct = Math.max(0, Math.min(100, j.percent || 0));
      const track = '<div class="act-track"><div class="fill' + (indet ? ' indet' : '') +
        '" style="width:' + (indet ? 35 : pct) + '%"></div></div>';
      const controls = j.cancel_reel ?
        '<div class="act-actions"><button class="act-btn danger" data-reelcancel="' + esc(j.reelid) + '">cancel</button></div>'
        : j.cancel_cap ?
        '<div class="act-actions"><button class="act-btn danger" data-capcancel="' + esc(j.capid) + '">cancel</button></div>'
        : (j.controls ?
        '<div class="act-actions">' +
          '<button class="act-btn" data-taction="' + (j.paused ? "resume" : "pause") + '">' + (j.paused ? "resume" : "pause") + '</button>' +
          '<button class="act-btn danger" data-taction="cancel">cancel</button>' +
        '</div>' : "");
      const tag = j.paused ? "paused" : (j.controls ? "live" : "working");
      return '<div class="act-item"><div class="act-ic">' + ic + '</div><div class="act-main">' +
        '<div class="act-title">' + esc(j.title) + '<span class="act-tag live">' + tag + '</span></div>' +
        track +
        '<div class="act-sub">' + esc(j.sub || (indet ? "working…" : (pct + "%"))) + '</div>' +
        controls +
        '</div></div>';
    },

    _actHistHTML(n) {
      const cls = n.status === "failed" ? "failed" : (n.status === "danger" ? "danger" : "done");
      const tagText = cls === "failed" ? "failed" : (cls === "danger" ? "alert" : "done");
      const tag = '<span class="act-tag ' + cls + '">' + tagText + '</span>';
      const acts = [];
      if (n.link) {
        acts.push('<button class="act-btn primary" data-openurl="' + esc(n.link) + '">🔗 open link</button>');
        acts.push('<button class="act-btn" data-copy="' + esc(n.copy || n.link) + '">📋 copy</button>');
      }
      if (n.folder) acts.push('<button class="act-btn" data-folder="' + esc(n.folder) + '">📁 folder</button>');
      if (n.open) acts.push('<button class="act-btn primary" data-openpanel="' + esc(n.open) + '">' + esc(n.open_label || ("open " + n.open)) + '</button>');
      if (n.file) acts.push('<button class="act-btn primary" data-openfile="' + esc(n.file) + '">' + esc(n.file_label || "📝 open transcript") + '</button>');
      return '<div class="act-item ' + cls + '"><div class="act-ic">' + this._actIcon(n.kind) + '</div><div class="act-main">' +
        '<div class="act-title">' + esc(n.title) + tag +
          '<button class="act-x tc-x" data-dismiss="' + n.id + '" aria-label="Dismiss" style="margin-left:auto">&#10005;</button></div>' +
        (n.sub ? '<div class="act-sub">' + esc(n.sub) + "  ·  " + this._fmtAgo(n.ts) + '</div>'
               : '<div class="act-sub">' + this._fmtAgo(n.ts) + '</div>') +
        (n.link ? '<div class="act-link">' + esc(n.link) + '</div>' : "") +
        (acts.length ? '<div class="act-actions">' + acts.join("") + '</div>' : "") +
        '</div></div>';
    },

    _renderActivity(d) {
      const active = d.active || [], history = d.history || [];
      const header = '<div class="act-toolbar">' +
        '<button class="pbtn act-notifybtn" id="act-notifyprefs">&#128276; recurring notifications</button></div>';
      const parts = [header];
      if (!active.length && !history.length) {
        parts.push('<div class="act-empty">nothing happening right now.<br>' +
          'imports, deliveries, captions &amp; timers show up here.</div>');
      } else {
        if (active.length) {
          parts.push('<div class="act-sec">in progress</div>');
          active.forEach((j) => parts.push(this._actActiveHTML(j)));
        }
        parts.push('<div class="act-head-row"><span class="act-sec">recent</span>' +
          (history.length ? '<button class="act-clear" id="act-clear">clear all</button>' : "") + "</div>");
        if (history.length) history.forEach((n) => parts.push(this._actHistHTML(n)));
        else parts.push('<div class="act-empty" style="padding:14px 0">no finished items yet.</div>');
      }
      body().innerHTML = parts.join("");
      this._tickTimers();
      this._wireActivity();
    },

    _tickTimers() {
      const now = Date.now() / 1000;
      qa(".act-time").forEach((el) => {
        el.textContent = this._fmtCountdown((parseFloat(el.getAttribute("data-ends")) || 0) - now);
      });
    },

    _wireActivity() {
      qa("[data-taction]").forEach((b) => b.addEventListener("click", () => {
        call("transfer_action", b.getAttribute("data-taction")).then(() => this._loadActivity());
      }));
      qa("[data-tcancel]").forEach((b) => b.addEventListener("click", () => {
        call("timer_cancel", parseInt(b.getAttribute("data-tcancel"), 10)).then(() => this._loadActivity());
      }));
      qa("[data-shutdowncancel]").forEach((b) => b.addEventListener("click", () => {
        b.disabled = true; b.textContent = "cancelling…";
        call("shutdown_cancel").then(() => this._loadActivity());
      }));
      qa("[data-capcancel]").forEach((b) => b.addEventListener("click", () => {
        call("captions_cancel", parseInt(b.getAttribute("data-capcancel"), 10)).then(() => this._loadActivity());
      }));
      qa("[data-reelcancel]").forEach((b) => b.addEventListener("click", () => {
        call("timeline_cancel", parseInt(b.getAttribute("data-reelcancel"), 10)).then(() => this._loadActivity());
      }));
      qa("[data-openurl]").forEach((b) => b.addEventListener("click", () => call("open_url", b.getAttribute("data-openurl"))));
      qa("[data-folder]").forEach((b) => b.addEventListener("click", () => call("open_folder", b.getAttribute("data-folder"))));
      qa("[data-copy]").forEach((b) => b.addEventListener("click", () => {
        call("copy_text", b.getAttribute("data-copy")).then((x) => toast((x && x.ok) ? "📋 link copied" : "couldn't copy"));
      }));
      qa("[data-openpanel]").forEach((b) => b.addEventListener("click", () => this.open(b.getAttribute("data-openpanel"))));
      qa("[data-openfile]").forEach((b) => b.addEventListener("click", () => call("open_file", b.getAttribute("data-openfile"))));
      qa("[data-dismiss]").forEach((b) => b.addEventListener("click", () => {
        call("notif_dismiss", parseInt(b.getAttribute("data-dismiss"), 10)).then(() => this._loadActivity());
      }));
      const clr = q("#act-clear");
      if (clr) clr.addEventListener("click", () => call("notif_clear").then(() => this._loadActivity()));
      const np = q("#act-notifyprefs");
      if (np) np.addEventListener("click", () => this.open("notifyprefs"));
    },

    // ── RECURRING NOTIFICATION TOGGLES ─────────────────────────────────────────
    openNotifyPrefs() {
      titleEl().textContent = "recurring notifications";
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._loadNotifyPrefs();
      return true;
    },

    _loadNotifyPrefs() {
      call("cfg_notify_get").then((d) => {
        if (titleEl().textContent !== "recurring notifications") return;
        this._renderNotifyPrefs((d && d.prefs) || []);
      });
    },

    _renderNotifyPrefs(prefs) {
      if (!prefs.length) {
        body().innerHTML = '<p class="muted">Notification settings unavailable. Restart Wivexx and try again.</p>';
        return;
      }
      const rows = prefs.map((p) =>
        '<div class="np-row">' +
          '<div class="np-main"><div class="np-name">' + esc(p.label) + '</div>' +
            '<div class="np-desc">' + esc(p.desc) + (p.live ? '' : ' &middot; <span class="np-tag">applies next launch</span>') + '</div></div>' +
          '<button class="np-switch' + (p.enabled ? ' on' : '') + '" data-nkey="' + esc(p.key) + '" role="switch" aria-checked="' + (p.enabled ? 'true' : 'false') + '"><span class="np-knob"></span></button>' +
        '</div>').join("");
      body().innerHTML =
        '<p class="muted" style="margin:0 0 14px">Turn Wivexx’s automatic spoken notifications on or off. Everything keeps working — Wivexx just won’t announce the ones you switch off.</p>' +
        '<div class="np-list">' + rows + '</div>';
      qa(".np-switch").forEach((sw) => sw.addEventListener("click", () => {
        const key = sw.getAttribute("data-nkey");
        const next = !sw.classList.contains("on");
        sw.classList.toggle("on", next);                 // optimistic
        sw.setAttribute("aria-checked", next ? "true" : "false");
        call("cfg_notify_set", key, next).then((r) => {
          if (!r || !r.ok) { sw.classList.toggle("on", !next); sw.setAttribute("aria-checked", (!next) ? "true" : "false"); }
        });
      }));
    },

    // ── SETTINGS — one panel; the Updates section sits at the very top ─────────
    openSettings() {
      titleEl().textContent = "settings";
      // grouped preference switches + a volume slider (wired in _loadSettings)
      const groups = [
        { sec: "general", items: [
          { key: "autostart", name: "Launch on startup", desc: "Open Wivexx automatically when Windows starts" },
          { key: "auto_update", name: "Auto-update on startup", desc: "Install new Wivexx versions automatically when it boots (your data is always kept)" },
          { key: "close_to_tray", name: "Close to tray", desc: "The ✕ button hides Wivexx instead of quitting it" },
          { key: "overlay_on_minimize", name: "Floating orb when minimized", desc: "Show the transparent orb overlay when you minimize" },
        ] },
        { sec: "voice & audio", items: [
          { key: "mute_voice", name: "Mute voice", desc: "Reply on-screen only — don’t speak out loud" },
          { key: "tts_volume", type: "slider", name: "Voice volume", desc: "How loudly Wivexx speaks" },
          { key: "wake_acoustic", name: "Best-in-class wake detection", desc: "Use a dedicated on-device model to hear the wake word — far fewer accidental activations (recommended)" },
          { key: "wake_require_voice", name: "Only wake for my voice", desc: "Ignore the wake word unless it’s you — say “enroll my voice” first to set this up" },
        ] },
        { sec: "performance", items: [
          { key: "low_resource", name: "Low-resource mode", desc: "Lighter speech model + paused background work" },
          { key: "reduce_animations", name: "Reduce animations", desc: "A calmer orb for lower CPU/GPU use" },
          { key: "osd_in_gaming", name: "Overlay in optimized mode", desc: "Auto-show the transparent performance overlay in optimized/gaming mode (Ctrl+Shift+O toggles it anytime)" },
        ] },
        // 'notifications' section removed (v1.34) — it duplicated the
        // Notifications panel (Activity → recurring notifications).
      ];
      const links = [
        { key: "capabilities", act: "panel", ic: "✦", name: "what I can do", desc: "everything Wivexx can do right now" },
        { key: "commands", act: "panel", ic: "⌘", name: "commands", desc: "your custom voice & text commands" },
        { key: "shortcuts", act: "panel", ic: "⌨", name: "shortcuts", desc: "global keyboard hotkeys" },
        { key: "clients", act: "tool", ic: "📱", name: "phones & clients", desc: "devices connected to Wivexx" },
        // v1.57: the first-run tour is replayable from here (closes the P1 loop)
        { key: "welcometour", act: "welcome", ic: "✨", name: "welcome tour", desc: "replay the quick 3-step intro" },
      ];
      const toggleHtml = (it) =>
        '<button class="cfg-row cfg-toggle" data-skey="' + esc(it.key) + '" role="switch" aria-checked="false">' +
          '<span class="set-main"><span class="set-name">' + esc(it.name) + '</span>' +
          '<span class="set-desc">' + esc(it.desc) + '</span></span>' +
          '<span class="cfg-sw"><span class="cfg-knob"></span></span></button>';
      const sliderHtml = (it) =>
        '<div class="cfg-row cfg-sliderrow">' +
          '<span class="set-main"><span class="set-name">' + esc(it.name) + '</span>' +
          '<span class="set-desc">' + esc(it.desc) + '</span></span>' +
          '<span class="cfg-sliderwrap"><input type="range" class="cfg-slider" data-skey="' + esc(it.key) +
          '" min="0" max="100" value="100"><span class="cfg-val" data-for="' + esc(it.key) + '">100%</span></span></div>';
      const groupHtml = (g) =>
        '<div class="set-sec">' + esc(g.sec) + '</div><div class="cfg-list">' +
        g.items.map((it) => (it.type === "slider" ? sliderHtml(it) : toggleHtml(it))).join("") + '</div>';
      const linkHtml = (it) =>
        '<button class="set-row" data-lkey="' + esc(it.key) + '" data-lact="' + it.act + '">' +
          '<span class="set-ic">' + it.ic + '</span>' +
          '<span class="set-main"><span class="set-name">' + esc(it.name) + '</span>' +
          '<span class="set-desc">' + esc(it.desc) + '</span></span>' +
          '<span class="set-arrow">&rsaquo;</span></button>';
      body().innerHTML =
        // Updates FIRST — an inline section at the very top, before language.
        '<div class="set-sec">updates</div>' +
        '<div class="set-mic-wrap">' +
          '<div id="up-box" style="background:var(--graphite-2,#262624);border:1px solid rgba(255,255,255,.12);' +
            'border-radius:12px;padding:16px;text-align:center">' +
            '<div id="up-state" class="muted">Checking…</div>' +
            '<div id="up-actions" style="margin-top:12px"></div>' +
          '</div>' +
        '</div>' +
        '<div class="set-sec">language</div>' +
        '<div class="set-mic-wrap">' +
          '<select id="set-lang" class="set-mic"><option value="en">loading…</option></select>' +
          '<p class="muted" style="font-size:11px;margin-top:6px">Choose Wivexx’s interface language. It automatically understands whatever language you speak.</p>' +
        '</div>' +
        '<div class="set-sec">microphone</div>' +
        '<div class="set-mic-wrap">' +
          '<select id="set-mic" class="set-mic"><option value="default">loading microphones…</option></select>' +
          '<p class="muted" style="font-size:11px;margin-top:6px">Choose which microphone Wivexx listens to.</p>' +
        '</div>' +
        groups.map(groupHtml).join("") +
        '<div class="set-sec">manage</div><div class="set-list">' + links.map(linkHtml).join("") + '</div>';
      openPanel();
      qa("[data-lkey]").forEach((b) => b.addEventListener("click", () => {
        const k = b.getAttribute("data-lkey"), act = b.getAttribute("data-lact");
        if (act === "welcome") { UI().hidePanel && UI().hidePanel(); if (window.wivexx && window.wivexx.showWelcome) window.wivexx.showWelcome(); return; }
        if (act === "panel") this.open(k);
        else call("tool", k).then((d) => { if (d && d.title && window.wivexx) window.wivexx.showPanel(d.title, d.html || ""); });
      }));
      this._wireSettingsUpdate();
      this._loadMics();
      this._loadLanguage();
      this._loadSettings();
      return true;
    },

    // populate + wire the inline Updates section at the top of Settings
    _wireSettingsUpdate() {
      const setState = (h) => { const e = q("#up-state"); if (e) e.innerHTML = h; };
      const setActions = (h) => { const e = q("#up-actions"); if (e) e.innerHTML = h; };
      const recheck = () => { setState("Checking…"); setActions(""); this._wireSettingsUpdate(); };
      call("check_update").then((r) => {
        if (!q("#up-state")) return;                 // panel changed while checking
        if (!r || (!r.ok && !r.update)) {
          setState(r && r.error ? ("Couldn't check for updates.<br><span style='font-size:12px'>" + esc(r.error) + "</span>")
                                 : "Couldn't check for updates right now.");
          setActions('<button class="pbtn sm" id="up-retry">Try again</button>');
          const b = q("#up-retry"); if (b) b.addEventListener("click", recheck);
          return;
        }
        if (r.update) {
          setState("<b style='color:var(--volt,#c1f73a);font-size:18px'>Update available</b><br>" +
            "<span class='muted'>v" + esc(r.current) + " → v" + esc(r.latest) + "</span>" +
            (r.notes ? "<div style='margin-top:10px;text-align:left;font-size:13px;white-space:pre-wrap'>" + esc(r.notes) + "</div>" : ""));
          setActions('<button class="pbtn" id="up-go" style="background:var(--volt,#c1f73a);color:#0d0d0d;font-weight:600">Update &amp; relaunch</button>');
          const b = q("#up-go"); if (b) b.addEventListener("click", () => {
            setState("Downloading update…"); setActions("");
            call("apply_update", { url: r.url }).then((res) => {
              if (res && res.ok) setState("Update downloaded — Wivexx is relaunching…");
              else { setState("Update failed.<br><span style='font-size:12px'>" + esc((res && res.error) || "") + "</span>");
                     setActions('<button class="pbtn sm" id="up-retry2">Try again</button>');
                     const rb = q("#up-retry2"); if (rb) rb.addEventListener("click", recheck); }
            });
          });
        } else {
          setState("✅ You're on the latest version<br><span class='muted'>v" + esc(r.current) + "</span>");
          setActions("");
        }
      }).catch(() => setState("Couldn't check for updates right now."));
    },

    // populate + wire the Settings toggle switches + the volume slider
    _loadSettings() {
      // settings whose BACKEND default is ON (an unset key must show as on)
      const ON_BY_DEFAULT = { osd_in_gaming: true, wake_acoustic: true };
      call("get_settings").then((d) => {
        const s = (d && d.ok && d.settings) ? d.settings : {};
        qa(".cfg-toggle").forEach((b) => {
          const k = b.getAttribute("data-skey");
          const on = (k in s) ? !!s[k] : !!ON_BY_DEFAULT[k];
          b.classList.toggle("on", on);
          b.setAttribute("aria-checked", on ? "true" : "false");
          b.addEventListener("click", () => {
            const next = !b.classList.contains("on");
            b.classList.toggle("on", next);
            b.setAttribute("aria-checked", next ? "true" : "false");
            call("set_setting", { key: k, value: next });
            if (k === "reduce_animations" && window.wivexx && window.wivexx.setReduceMotion)
              window.wivexx.setReduceMotion(next);
            toast(next ? "On" : "Off");
          });
        });
        qa(".cfg-slider").forEach((sl) => {
          const k = sl.getAttribute("data-skey");
          const v = (s[k] == null ? 100 : s[k]);
          sl.value = v;
          const val = body().querySelector('.cfg-val[data-for="' + k + '"]');
          if (val) val.textContent = v + "%";
          sl.addEventListener("input", () => { if (val) val.textContent = sl.value + "%"; });
          sl.addEventListener("change", () => { call("set_setting", { key: k, value: parseInt(sl.value, 10) }); });
        });
      }).catch(() => {});
    },

    // populate + wire the Settings → language picker
    _loadLanguage() {
      const sel = body() && body().querySelector("#set-lang");
      if (!sel) return;
      call("get_language").then((d) => {
        if (!d || !d.ok) { sel.innerHTML = '<option value="en">English</option>'; return; }
        const cur = d.current || "en";
        sel.innerHTML = (d.options || []).map((o) =>
          '<option value="' + o.code + '"' + (o.code === cur ? " selected" : "") + '>' +
          esc(o.label) + '</option>').join("");
        sel.value = cur;
        sel.addEventListener("change", () => {
          const code = sel.value;
          if (window.WivexxI18n) window.WivexxI18n.setLang(code);   // instant UI switch
          call("set_language", { language: code });
          toast("Language updated");
        });
      }).catch(() => { sel.innerHTML = '<option value="en">English</option>'; });
    },

    // populate + wire the Settings → microphone picker (live device list)
    _loadMics() {
      const sel = body() && body().querySelector("#set-mic");
      if (!sel) return;
      call("list_mics").then((d) => {
        if (!d || !d.ok) { sel.innerHTML = '<option value="default">System default</option>'; return; }
        const cur = d.current;
        let html = '<option value="default">System default microphone</option>';
        const sd = (window.WivexxI18n ? WivexxI18n.t("system default") : "system default");
        (d.devices || []).forEach((m) => {
          const isCur = (cur !== null && cur !== undefined && String(cur) === String(m.index));
          html += '<option value="' + m.index + '"' + (isCur ? " selected" : "") + '>' +
                  esc(m.name) + (m.default ? "  (" + sd + ")" : "") + '</option>';
        });
        sel.innerHTML = html;
        if (cur === null || cur === undefined) sel.value = "default";
        sel.addEventListener("change", () => {
          const v = sel.value;
          call("set_mic", { device: v === "default" ? null : v });
          toast("Microphone updated");
        });
      }).catch(() => { sel.innerHTML = '<option value="default">System default</option>'; });
    },

    // ── DRIVE CLEANUP (large stale files from the drive scan) ──────────────────
    openDiskResults() {
      titleEl().textContent = "drive cleanup";
      body().innerHTML = '<p class="muted">loading…</p>';
      openPanel();
      this._loadDiskResults();
      return true;
    },

    _loadDiskResults() {
      call("disk_results").then((d) => {
        if (titleEl().textContent !== "drive cleanup") return;   // navigated away
        this._renderDiskResults(d || { files: [] });
      });
    },

    _renderDiskResults(d) {
      const files = d.files || [];
      if (!files.length) {
        body().innerHTML = '<div class="act-empty">no analysis yet.<br>' +
          'say &ldquo;analyse my drives&rdquo; and the big, untouched files land here.</div>';
        return;
      }
      const gb = (d.total_gb || 0).toFixed(1);
      const head =
        '<div class="dr-head">' +
          '<div class="dr-sum">' + files.length + ' large file' + (files.length === 1 ? '' : 's') +
            ' &middot; <strong>' + gb + ' GB</strong> reclaimable</div>' +
          '<div class="dr-meta">untouched 3+ months' +
            (d.drives ? ' &middot; ' + esc(d.drives) : '') +
            (d.generated ? ' &middot; ' + esc(d.generated) : '') + '</div>' +
          '<div class="dr-bar">' +
            '<label class="chk dr-allwrap"><input type="checkbox" id="dr-all"> select all</label>' +
            '<button class="pbtn danger" id="dr-delall" disabled>🗑 delete checked (<span id="dr-n">0</span>)</button>' +
          '</div>' +
          '<div class="dr-hint">deletes go to the Recycle Bin — recoverable.</div>' +
          '<div class="savednote" id="dr-note"></div>' +
        '</div>';
      const rows = files.map((f) =>
        '<div class="dr-row" data-path="' + esc(f.path) + '">' +
          '<label class="dr-chkwrap"><input type="checkbox" class="dr-chk"></label>' +
          '<div class="dr-info">' +
            '<div class="dr-name" title="' + esc(f.path) + '">' + esc(f.name) + '</div>' +
            '<div class="dr-path">' + esc(f.folder) + '</div>' +
          '</div>' +
          '<div class="dr-size">' + esc(f.size_str) +
            '<span class="dr-last">opened ' + esc(f.last) + '</span></div>' +
          '<div class="dr-acts">' +
            '<button class="act-btn dr-open" title="show in Explorer">📁</button>' +
            '<button class="act-btn danger dr-del" title="delete">🗑</button>' +
          '</div>' +
        '</div>').join("");
      body().innerHTML = head + '<div class="dr-list">' + rows + '</div>';
      this._wireDiskResults();
    },

    _wireDiskResults() {
      const upd = () => {
        const checks = qa(".dr-chk");
        const n = checks.filter((c) => c.checked).length;
        const ns = q("#dr-n"); if (ns) ns.textContent = n;
        const btn = q("#dr-delall"); if (btn && btn.getAttribute("data-armed") !== "1") btn.disabled = !n;
        const all = q("#dr-all"); if (all) all.checked = n > 0 && n === checks.length;
      };
      qa(".dr-chk").forEach((c) => c.addEventListener("change", upd));
      const all = q("#dr-all");
      if (all) all.addEventListener("change", () => {
        qa(".dr-chk").forEach((c) => { c.checked = all.checked; }); upd();
      });
      qa(".dr-open").forEach((b) => b.addEventListener("click", () => {
        const p = b.closest(".dr-row").getAttribute("data-path");
        call("disk_reveal", p).then((r) => { if (!r || !r.ok) toast((r && r.error) || "couldn't open folder"); });
      }));
      qa(".dr-del").forEach((b) => b.addEventListener("click", () => this._armDel(b, () => {
        const p = b.closest(".dr-row").getAttribute("data-path");
        call("disk_delete", [p]).then((r) => {
          if (r && r.ok && r.deleted) { toast("🗑 moved to Recycle Bin"); this._loadDiskResults(); }
          else this._note("dr-note", (r && (r.error || (r.errors || [])[0])) || "couldn't delete that file", true);
        });
      })));
      const delall = q("#dr-delall");
      if (delall) delall.addEventListener("click", () => this._armDel(delall, () => {
        const paths = qa(".dr-chk").filter((c) => c.checked)
          .map((c) => c.closest(".dr-row").getAttribute("data-path"));
        if (!paths.length) return;
        delall.disabled = true;
        call("disk_delete", paths).then((r) => {
          if (r && r.ok) { toast("🗑 " + (r.deleted || 0) + " moved to Recycle Bin"); this._loadDiskResults(); }
          else { this._note("dr-note", (r && r.error) || "couldn't delete", true); delall.disabled = false; }
        });
      }));
    },

    // ── shared helpers ─────────────────────────────────────────────────────────
    _note(id, text, err) {
      const n = document.getElementById(id);
      if (!n) return;
      n.textContent = text || ""; n.classList.toggle("err", !!err);
    },
    // inline two-click confirm (no native confirm() — unreliable in WebView2
    // and it would break the minimal look). First click arms, second deletes.
    _armDel(btn, fn) {
      if (btn.getAttribute("data-armed") === "1") { fn(); return; }
      const prev = btn.innerHTML;
      btn.setAttribute("data-armed", "1");
      btn.innerHTML = "&#10003;?";
      btn.title = "click again to delete";
      setTimeout(() => {
        if (btn.isConnected && btn.getAttribute("data-armed") === "1") {
          btn.setAttribute("data-armed", ""); btn.innerHTML = prev; btn.title = "Delete";
        }
      }, 2600);
    },
  };

  window.WivexxPanels = Panels;
})();
