/* ───────────────────────────────────────────────────────────────────────────
   Wivexx control room — UI logic + the bridge surface.
   Python drives the UI through `window.wivexx.*`; the UI calls back into Python
   through `pywebview.api.*`. Keep the two contracts stable.
   ─────────────────────────────────────────────────────────────────────────── */
(function () {
  "use strict";

  const $ = (s) => document.querySelector(s);
  const CAPTIONS = { idle: "ready", listening: "listening…", processing: "thinking…", speaking: "speaking" };

  let _state = "idle";
  let _statusText = "";
  const _busy = {};
  let _pyReady = false;
  const _queue = [];

  // ── call into Python (queues until the bridge is ready) ──────────────────
  function py(method, ...args) {
    const run = () => {
      try { return window.pywebview.api[method](...args); }
      catch (e) { console.warn("py call failed:", method, e); }
    };
    if (_pyReady && window.pywebview && window.pywebview.api) return run();
    _queue.push(run);
  }
  function flushQueue() {
    _pyReady = true;
    while (_queue.length) { try { _queue.shift()(); } catch (e) {} }
  }
  // run `fn` once the bridge is live (immediately if it already is) — for boot
  // reads that need a REAL promise back from py(), not a queued void call
  function whenBridge(fn) {
    if (_pyReady && window.pywebview && window.pywebview.api) { try { fn(); } catch (e) {} }
    else _queue.push(fn);
  }

  // ── transcript ───────────────────────────────────────────────────────────
  const convo = $("#convo");
  const emptyHint = $("#empty-hint");
  let thinkingRow = null;

  function scrollConvo() { convo.scrollTop = convo.scrollHeight; }

  function attEl(x) {
    const name = typeof x === "string" ? x.split(/[\\/]/).pop() : ((x && x.name) || "attachment");
    const thumb = (x && typeof x === "object") ? x.thumb : "";
    if (thumb) {
      const img = document.createElement("img");
      img.className = "att-thumb";
      img.src = thumb; img.alt = name; img.title = name; img.loading = "lazy";
      return img;
    }
    const chip = document.createElement("span");
    chip.className = "att-file";
    chip.textContent = "📎 " + name;
    return chip;
  }

  function addBubble(role, text, atts) {
    if (emptyHint) emptyHint.style.display = "none";
    const b = document.createElement("div");
    b.className = "bubble " + (role === "user" ? "user" : role === "system" ? "system" : "assistant");
    // hover timestamp (shown via CSS ::after — locale digits, no i18n needed)
    b.setAttribute("data-t", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
    // hide the server's "[N file(s)]" placeholder when we have real previews
    let shown = text == null ? "" : String(text);
    if (atts && atts.length && /^\[\d+ file\(s\)\]$/.test(shown.trim())) shown = "";
    if (shown) b.textContent = shown;
    if (atts && atts.length) {
      const a = document.createElement("div");
      a.className = "atts";
      atts.forEach((x) => a.appendChild(attEl(x)));
      b.appendChild(a);
    }
    // keep the thinking row at the bottom
    if (thinkingRow && thinkingRow.parentNode === convo) convo.insertBefore(b, thinkingRow);
    else convo.appendChild(b);
    scrollConvo();
  }

  function setThinking(on) {
    if (on) {
      if (!thinkingRow) {
        thinkingRow = document.createElement("div");
        thinkingRow.className = "thinking-row";
        thinkingRow.innerHTML = "wivexx is thinking <span class='d'></span><span class='d'></span><span class='d'></span>";
      }
      if (thinkingRow.parentNode !== convo) convo.appendChild(thinkingRow);
      thinkingRow.classList.add("on");
      scrollConvo();
    } else if (thinkingRow) {
      thinkingRow.classList.remove("on");
    }
  }

  // ── status line (status text, overridden by any active busy text) ────────
  function renderStatus() {
    const keys = Object.keys(_busy);
    const el = $("#status");
    if (keys.length) { el.textContent = _busy[keys[keys.length - 1]]; el.classList.add("volt"); }
    else { el.textContent = _statusText; el.classList.remove("volt"); }
  }

  // ── the surface Python calls ─────────────────────────────────────────────
  const wivexx = {
    setState(state) {
      if (!CAPTIONS[state]) return;
      _state = state;
      window.WivexxDot && window.WivexxDot.setState(state);
      $("#caption").textContent = CAPTIONS[state];
      $("#caption").style.color = (state === "idle") ? "var(--bone)" : "var(--volt)";
      // mirror the mood into the top strip's live state chip
      const tst = $("#topstate-text"), ts = $("#topstate");
      if (tst) tst.textContent = CAPTIONS[state];
      if (ts) ts.classList.toggle("idle", state === "idle");
      setThinking(state === "processing");
    },
    react(name, hold) { window.WivexxDot && window.WivexxDot.react(name, hold); },
    setStatus(text) { _statusText = text || ""; renderStatus(); },
    // show a pending-attachment chip (e.g. after a file is dropped onto the room)
    attachChip(att) { if (att && (att.name || att.thumb)) showAttachChip(att); },
    // captions editor: Python pushes transcribed segments back to the panel
    captionsLoaded(payload) { if (window.WivexxPanels && window.WivexxPanels._onCaptionsLoaded) window.WivexxPanels._onCaptionsLoaded(payload); },
    captionsCancelled() { if (window.WivexxPanels && window.WivexxPanels._onCaptionsCancelled) window.WivexxPanels._onCaptionsCancelled(); },
    timelineProgress(frac) { if (window.WivexxPanels && window.WivexxPanels._onTimelineProgress) window.WivexxPanels._onTimelineProgress(frac); },
    timelineProxyReady(r) { if (window.WivexxPanels && window.WivexxPanels._onTimelineProxyReady) window.WivexxPanels._onTimelineProxyReady(r); },
    reelBuilt(r) { if (window.WivexxPanels && window.WivexxPanels._onReelBuilt) window.WivexxPanels._onReelBuilt(r); },
    reelCancelled() { if (window.WivexxPanels && window.WivexxPanels._onReelCancelled) window.WivexxPanels._onReelCancelled(); },
    autoEditDone(r) { if (window.WivexxPanels && window.WivexxPanels._onAutoEditDone) window.WivexxPanels._onAutoEditDone(r); },
    autoEditProgress(fr) { if (window.WivexxPanels && window.WivexxPanels._onAutoEditProgress) window.WivexxPanels._onAutoEditProgress(fr); },
    logTurn(role, text, atts) { addBubble(role, text, atts); },
    level(rms) { window.WivexxDot && window.WivexxDot.setLevel(rms); },
    setBusy(key, text) {
      if (text === null || text === undefined || text === "") delete _busy[key];
      else _busy[key] = text;
      renderStatus();
    },
    setMemory(active) {
      const el = $("#ind-memory");      // legacy bottom dot (memory now lives up top)
      if (el) {
        el.classList.toggle("on", !!active);
        el.lastChild.textContent = active ? " memory" : " memory off";
      }
    },
    setPower(low) {
      const el = $("#ind-power");
      el.classList.toggle("warn", !!low);
      el.classList.toggle("on", !low);
      el.classList.toggle("quiet", !low);         // exception-based: only speak when LOW
      el.lastChild.textContent = low ? " low power" : " full power";
      this.setAccessory("cap", !!low);            // 😴 low power → sleepy cap
    },
    setIntegrations(obj) {
      const el = $("#ind-integrations");
      el.innerHTML = "";
      if (!obj) return;
      Object.keys(obj).forEach((k) => {
        const s = document.createElement("span");
        s.className = "it" + (obj[k] ? " on" : "");
        s.textContent = (obj[k] ? "● " : "○ ") + k;
        el.appendChild(s);
      });
    },
    // behavior controls — backgroundless status dots at the top of the room.
    // memory is a live status (click → memory panel); work/call mode toggle.
    setModes(obj) {
      const el = $("#behaviors");
      if (!el) return;
      // work mode is an owner-only photographer feature; the backend sends
      // _workmode_available:false in the public build → hide the chip entirely.
      const workOK = !obj || obj._workmode_available !== false;
      const defs = [
        { key: "memory", name: "memory", cat: "#c1f73a", act: "panel:memory", title: "semantic memory · open" },
        { key: "workmode", name: "work mode", cat: "#f5a623", act: "toggle", title: "auto-extract finished Downloads zips" },
        { key: "callmode", name: "call mode", cat: "#6aa0ff", act: "toggle", title: "wake word off · push-to-talk" },
        { key: "gamingmode", name: "performance mode", cat: "#a78bfa", act: "toggle", title: "freeze background apps + high-performance power" },
      ].filter((d) => d.key !== "workmode" || workOK);
      el.innerHTML = defs.map((d) => {
        const st = obj ? obj[d.key] : null;
        const on = st === true, busy = (st === null || st === undefined);
        return '<button type="button" class="bctl' + (on ? " on" : "") + (busy ? " busy" : "") +
          '" data-mode="' + d.key + '" data-act="' + d.act + '" style="--bcat:' + d.cat +
          '" aria-pressed="' + (on ? "true" : "false") + '" title="' + d.title + '">' +
          '<span class="bdot"></span><span class="blabel">' + d.name + '</span></button>';
      }).join("");
      el.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => {
        const act = b.getAttribute("data-act") || "";
        if (act.indexOf("panel:") === 0) { if (window.WivexxPanels) window.WivexxPanels.open(act.slice(6)); }
        else this._toggleNode(b);
      }));
      this.setAccessory("tie", !!(obj && obj.workmode === true));   // 👔 work mode → tie
    },
    // backend pushes the chosen language here (on boot + when changed in Settings)
    setLanguage(code) {
      try { if (window.WivexxI18n) window.WivexxI18n.setLang(code); } catch (e) {}
    },
    // Settings → reduce animations (calmer orb + a calmer WHOLE UI, lower CPU/GPU)
    setReduceMotion(on) {
      try { document.body.classList.toggle("reduce-motion", !!on); } catch (e) {}
      try { if (window.WivexxDot && window.WivexxDot.setReduceMotion) window.WivexxDot.setReduceMotion(on); } catch (e) {}
    },
    // fire a mascot REACTION on an app event — Python calls window.wivexx.emote(...):
    //   "celebrate" | "cheer" | "oops" | "nodYes" | "shakeNo"  → a scripted reaction
    //   any EXPR name ("happy","surprised","love",…)           → a quick expression
    emote(name, hold) {
      try {
        const d = window.WivexxDot; if (!d || !name) return;
        if (typeof d[name] === "function") d[name]();
        else if (d.react) d.react(name, hold || 0.9);
      } catch (e) {}
    },
    _toggleNode(sw) {
      if (sw.classList.contains("busy")) return;
      const name = sw.getAttribute("data-mode");
      const want = !sw.classList.contains("on");
      const apply = (on) => { sw.classList.toggle("on", !!on); sw.setAttribute("aria-pressed", String(!!on)); };
      // Optimistic: flip immediately, then reconcile with the real state (call
      // mode announces over TTS, so the bridge can take a beat to settle).
      apply(want);
      sw.classList.add("busy");
      const r = py("set_toggle", name, want);
      const done = (state) => { apply(!!state); sw.classList.remove("busy"); };
      if (r && r.then) r.then(done).catch(() => done(want));
      else done(want);
    },
    // mascot outfits — show/hide an accessory over the orb (tie/cap/headphones).
    // modes that can stack on top of an activity: tie (work) + cap (low power)
    setAccessory(name, on) {
      const fx = $("#mascot-fx");
      if (!fx) return;
      const cls = { tie: "fx-tie", cap: "fx-cap" }[name];
      if (cls) fx.classList.toggle(cls, !!on);
    },
    // What you're DOING on the PC → layered outfits by BODY SLOT, so several can
    // stack at once (e.g. gaming + a Discord call → controller + headset) without
    // doubling up on a slot. `names` is a comma-string (or array) of activities:
    //   head 🎧 music · 🎙️ call   ·   face 🤓 coding   ·   hand 🎮 gaming · 🍿 video
    //   · 🎨 design · 🛍️ shopping.  One winner per slot (by priority) → no duplicates.
    setActivity(names) {
      const fx = $("#mascot-fx");
      if (!fx) return;
      const acts = Array.isArray(names) ? names.slice()
        : (names ? String(names).split(/[,\s]+/).filter(Boolean) : []);
      const ACT_ITEM = {                       // activity → the prop it wears
        music: "hp", call: "headset", coding: "glasses",
        gaming: "controller", video: "popcorn", design: "brush", shopping: "bag",
      };
      const ITEM_SLOT = {                       // prop → body slot (one prop per slot)
        hp: "head", headset: "head", glasses: "face",
        controller: "hand", popcorn: "hand", brush: "hand", bag: "hand",
      };
      const PRI = {                             // when two want a slot, higher wins
        call: 3, music: 1, gaming: 4, video: 3, design: 2, shopping: 1, coding: 1,
      };
      const win = {};                           // slot → { item, pri }
      acts.forEach((a) => {
        const item = ACT_ITEM[a]; if (!item) return;
        const slot = ITEM_SLOT[item], pri = PRI[a] || 0;
        if (!win[slot] || pri > win[slot].pri) win[slot] = { item, pri };
      });
      const show = new Set(Object.values(win).map((w) => w.item));
      ["hp", "headset", "glasses", "controller", "popcorn", "brush", "bag"]
        .forEach((it) => fx.classList.toggle("show-" + it, show.has(it)));
      // any active outfit means you're clearly NOT idle → hide the sleepy low-power cap
      fx.classList.toggle("has-activity", acts.length > 0);
      // one body-language behaviour for the whole pose (priority: gaming > call > music)
      const dw = document.querySelector(".dot-wrap");
      if (dw) {
        dw.classList.remove("bop", "game-focus", "call-nod");
        if (acts.includes("gaming")) dw.classList.add("game-focus"); // leans in, tense
        else if (acts.includes("call")) dw.classList.add("call-nod"); // gentle talking nod
        else if (acts.includes("music")) dw.classList.add("bop");     // head-bobs to the beat
      }
    },
    setMusic(on) { this.setActivity(on ? "music" : ""); },        // back-compat
    // A sustained app-STATE pose, separate from activity outfits:
    //   "recording" → grabs a camcorder + squints through the viewfinder
    //   "clicker"   → frantic mouse + jabbing finger, the whole orb shakes
    //   "playback"  → a floating keyboard whose keys ripple as the macro types
    //   ""          → clear, back to normal.  Hides activity props while active.
    setGadget(name) {
      const fx = $("#mascot-fx");
      const dw = document.querySelector(".dot-wrap");
      const map = { recording: "camera", clicker: "clicker", playback: "keys" };
      const bodyMap = { recording: "gad-rec", clicker: "gad-click", playback: "gad-play" };
      const item = map[name] || "";
      if (fx) {
        ["camera", "clicker", "keys"].forEach((it) => fx.classList.toggle("show-" + it, it === item));
        fx.classList.toggle("has-gadget", !!item);
      }
      if (dw) {
        ["gad-rec", "gad-click", "gad-play"].forEach((c) => dw.classList.remove(c));
        if (bodyMap[name]) dw.classList.add(bodyMap[name]);
      }
      if (window.WivexxDot && window.WivexxDot.gadget) window.WivexxDot.gadget(item ? name : "");
    },
    showPanel(title, html) {
      $("#panel-title").textContent = title || "";
      $("#panel-body").innerHTML = html || "";
      // command rows: clicking sends them to the assistant
      $("#panel-body").querySelectorAll("[data-cmd]").forEach((n) => {
        n.addEventListener("click", () => { sendText(n.getAttribute("data-cmd")); hidePanel(); });
      });
      // rows that open a native editor window
      $("#panel-body").querySelectorAll("[data-native]").forEach((n) => {
        n.addEventListener("click", () => { py("open_native", n.getAttribute("data-native")); hidePanel(); });
      });
      // on/off toggles (e.g. modes) — flip live via the bridge, sync to the real state
      $("#panel-body").querySelectorAll("[data-toggle]").forEach((sw) => {
        sw.addEventListener("click", () => {
          if (sw.classList.contains("busy")) return;
          const name = sw.getAttribute("data-toggle");
          const want = !sw.classList.contains("on");
          // Optimistic: reflect the click IMMEDIATELY so the on/off state is
          // obvious the moment you click. Then reconcile with the real state when
          // the bridge responds — the toggle can be slow (e.g. call mode announces
          // over TTS, which previously left the button stuck until a tab reopen).
          sw.classList.toggle("on", want);
          sw.setAttribute("aria-pressed", String(want));
          sw.classList.add("busy");
          const r = py("set_toggle", name, want);
          const apply = (state) => {
            sw.classList.toggle("on", !!state);
            sw.setAttribute("aria-pressed", String(!!state));
            sw.classList.remove("busy");
          };
          if (r && r.then) r.then(apply).catch(() => apply(want));
          else apply(want);
        });
      });
      openPanel();
    },
    hidePanel() { hidePanel(); },
    toast(text) {
      const t = $("#toast");
      t.textContent = text || "";
      t.classList.add("on");
      clearTimeout(this._tt);
      this._tt = setTimeout(() => t.classList.remove("on"), 2600);
    },
    setTransfer(d) { renderTransfer(d); },
    bellCount(n) { setBell(n); },
    activityChanged() {
      if (window.WivexxPanels && WivexxPanels.refreshActivity) WivexxPanels.refreshActivity();
    },
    focusInput() { $("#text").focus(); },
  };
  window.wivexx = wivexx;

  // ── transfer card (import/deliver progress, richer than the floating HUD) ──
  let _tcDismissed = null;        // 'started' value the user dismissed locally
  let _tcPaused = false;

  function fmtBytes(n) {
    n = n || 0;
    if (n < 1024) return n + " B";
    const u = ["KB", "MB", "GB", "TB"]; let i = -1;
    do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
    return n.toFixed(n < 10 ? 1 : 0) + " " + u[i];
  }
  function fmtEta(sec) {
    if (sec == null || !isFinite(sec) || sec < 0) return "";
    sec = Math.round(sec);
    if (sec < 60) return "~" + sec + "s left";
    const m = Math.floor(sec / 60), s = sec % 60;
    if (m < 60) return "~" + m + "m " + (s ? s + "s " : "") + "left";
    return "~" + Math.floor(m / 60) + "h " + (m % 60) + "m left";
  }

  function renderTransfer(d) {
    const card = $("#transfer-card");
    if (!card) return;
    if (!d) { card.classList.add("hidden"); return; }
    if (d.started && d.started === _tcDismissed) { card.classList.add("hidden"); return; }

    const phase = d.phase || "copying";
    const terminal = (phase === "done" || phase === "cancelled" || phase === "failed");
    const running = (phase === "copying" || phase === "paused" || phase === "uploading");
    const isScan = (d.kind === "scan");
    _tcPaused = !!d.paused;

    $("#tc-kind").textContent = _tcPaused ? "paused"
      : terminal ? (phase === "done" ? "complete" : phase)
      : isScan ? "analysing"
      : (d.kind === "deliver" ? (d.stage || "delivering") : "importing");
    $("#tc-drive").textContent = d.label || "storage";
    $("#tc-dot").className = "tc-dot" + (_tcPaused ? " paused" : terminal ? " done" : "");

    const fill = $("#tc-fill");
    const pct = d.percent != null ? d.percent : 0;
    fill.classList.toggle("indet", phase === "scanning" || (isScan && !terminal));
    fill.style.width = (phase === "done" ? 100 : pct) + "%";

    const total = d.total || 0, copied = d.copied || 0;
    $("#tc-count").textContent = isScan
        ? ((d.scanned || 0).toLocaleString() + " files scanned"
           + (d.found ? " · " + d.found + " flagged" : ""))
      : phase === "scanning" ? "counting files…"
      : total ? (copied + " of " + total + " files") : (copied + " files");
    $("#tc-bytes").textContent = (d.bytes_total)
      ? (fmtBytes(d.bytes_done) + " / " + fmtBytes(d.bytes_total)) : "";
    $("#tc-pct").textContent = (isScan || phase === "scanning") ? "" : ((phase === "done" ? 100 : pct) + "%");

    let fileLine = "";
    if (terminal) {
      if (isScan) { fileLine = d.summary || ""; }
      else {
        const bits = [];
        if (d.skipped) bits.push(d.skipped + " skipped");
        if (d.failed) bits.push(d.failed + " failed");
        fileLine = bits.join(" · ");
      }
    } else if (isScan) {
      const p = d.current || "";                 // live path being scanned (trimmed)
      fileLine = p.length > 48 ? "…" + p.slice(-46) : p;
    } else if (phase === "scanning") {
      fileLine = "";
    } else {
      fileLine = _tcPaused ? "paused" : (d.current || fmtEta(d.eta) || "");
    }
    $("#tc-file").textContent = fileLine;

    const pause = $("#tc-pause"), cancel = $("#tc-cancel");
    pause.style.display = running ? "" : "none";
    pause.textContent = _tcPaused ? "resume" : "pause";
    cancel.textContent = running ? "cancel" : "dismiss";
    cancel.dataset.mode = running ? "cancel" : "dismiss";

    card.classList.remove("hidden");
  }

  function wireTransferCard() {
    const pause = $("#tc-pause"), cancel = $("#tc-cancel"), x = $("#tc-x");
    if (pause) pause.addEventListener("click", () => py("transfer_action", _tcPaused ? "resume" : "pause"));
    if (cancel) cancel.addEventListener("click", () => {
      if (cancel.dataset.mode === "cancel") py("transfer_action", "cancel");
      else { _dismissTransfer(); }
    });
    if (x) x.addEventListener("click", _dismissTransfer);
  }
  function _dismissTransfer() {
    // remember the current transfer so the poller doesn't re-show it
    const r = py("transfer_status");
    if (r && r.then) r.then((d) => { _tcDismissed = d ? d.started : Date.now(); });
    $("#transfer-card").classList.add("hidden");
  }

  // ── lightbox (click an attached image to view it full-size) ───────────────
  function openLightbox(src, alt) {
    const lb = $("#lightbox"); if (!lb) return;
    $("#lightbox-img").src = src;
    $("#lightbox-img").alt = alt || "";
    const cap = $("#lightbox-cap"); if (cap) cap.textContent = alt || "";   // filename caption
    lb.classList.remove("hidden");
  }
  function closeLightbox() {
    const lb = $("#lightbox"); if (!lb) return;
    lb.classList.add("hidden");
    $("#lightbox-img").src = "";
    const cap = $("#lightbox-cap"); if (cap) cap.textContent = "";
  }
  // Drag ANY file anywhere onto the control room to attach it. WebView2 hides the
  // real file PATH from the page, so on drop we fire WebView2's native FilesDropped
  // message (which captures the path into pywebview's _dnd_state) and then ask Python
  // to drain + attach it. Doing it here ourselves means attachment works even if
  // pywebview's own drop-callback routing doesn't fire.
  function wireDropZone() {
    let depth = 0;
    const hasFiles = (e) => e.dataTransfer &&
      Array.prototype.indexOf.call(e.dataTransfer.types || [], "Files") >= 0;
    const show = () => document.body.classList.add("dropping");
    const hide = () => { depth = 0; document.body.classList.remove("dropping"); };
    window.addEventListener("dragenter", (e) => { if (hasFiles(e)) { depth++; show(); } });
    window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
    window.addEventListener("dragleave", () => { if (--depth <= 0) hide(); });
    window.addEventListener("drop", (e) => {
      hide();
      if (!hasFiles(e)) return;
      e.preventDefault();
      try {
        const dt = e.dataTransfer;
        if (dt && dt.files && dt.files.length && window.chrome && window.chrome.webview &&
            typeof window.chrome.webview.postMessageWithAdditionalObjects === "function") {
          // hand WebView2 the real File objects so the host can read their .Path
          window.chrome.webview.postMessageWithAdditionalObjects("FilesDropped", dt.files);
        }
      } catch (err) { /* non-WebView2 host — pywebview's own handler covers it */ }
      // when the reel studio is open, a drop ADDS media to the timeline instead of
      // attaching it to the room
      const pnl = $("#panel");
      if (pnl && pnl.classList.contains("studio") && window.WivexxPanels && window.WivexxPanels._onTimelineDrop) {
        py("timeline_drop_add").then((r) => {
          if (r && r.ok && r.items) window.WivexxPanels._onTimelineDrop(r.items);
        });
      } else {
        py("drop_notify");
      }
    });
  }

  function wireLightbox() {
    convo.addEventListener("click", (e) => {
      const img = e.target.closest(".att-thumb");
      if (img) openLightbox(img.src, img.alt);
    });
    const lb = $("#lightbox");
    if (lb) lb.addEventListener("click", closeLightbox);
  }

  // ── "/" command palette ──────────────────────────────────────────────────
  // Type "/" in the composer to fuzzy-find a command. Each entry reuses an
  // EXISTING entry point (open a panel, click a mode/HUD toggle, or send a
  // voice-style command) — no new bridge surface.
  function _openCmdPanel(name) { cmdClose(); $("#text").value = ""; if (window.WivexxPanels) window.WivexxPanels.open(name); }
  function _clickSel(sel) { cmdClose(); $("#text").value = ""; const el = $(sel); if (el) el.click(); }
  function _runCmd(text) { cmdClose(); sendText(text); }
  const CMD_DEFS = [
    { label: "goals", hint: "today's goals", kw: "goals todo tasks", run: () => _openCmdPanel("goals") },
    { label: "social", hint: "tracking · captions · deliver", kw: "social media instagram followers", run: () => _openCmdPanel("social") },
    { label: "reel builder", hint: "stitch clips → captions", kw: "reel builder timeline editor stitch merge clips video edit", run: () => _openCmdPanel("timeline") },
    { label: "automations", hint: "autoclicker · recorder", kw: "automations actions", run: () => _openCmdPanel("actions") },
    { label: "recorder", hint: "record / replay actions", kw: "recorder record macro replay", run: () => _openCmdPanel("recorder") },
    { label: "autoclicker", hint: "auto-click / key turbo", kw: "autoclicker click turbo key", run: () => _openCmdPanel("autoinput") },
    { label: "memory", hint: "what Wivexx remembers", kw: "memory remember facts recall", run: () => _openCmdPanel("memory") },
    { label: "shortcuts", hint: "global hotkeys", kw: "shortcuts hotkeys keybinds", run: () => _openCmdPanel("shortcuts") },
    { label: "commands", hint: "custom voice / text", kw: "commands custom", run: () => _openCmdPanel("commands") },
    { label: "recurring notifications", hint: "briefings on / off", kw: "recurring notifications briefing", run: () => _openCmdPanel("notifyprefs") },
    { label: "settings", hint: "configure Wivexx", kw: "settings preferences config options", run: () => _openCmdPanel("settings") },
    { label: "work mode", hint: "toggle", kw: "work mode extract", run: () => _clickSel('.bctl[data-mode="workmode"]') },
    { label: "call mode", hint: "toggle", kw: "call mode push to talk", run: () => _clickSel('.bctl[data-mode="callmode"]') },
    { label: "performance mode", hint: "toggle", kw: "performance gaming mode freeze", run: () => _clickSel('.bctl[data-mode="gamingmode"]') },
    { label: "performance HUD", hint: "CPU · GPU · RAM", kw: "performance hud fps monitor stats", run: () => _clickSel("#btn-perf") },
    { label: "notifications", hint: "activity log", kw: "notifications activity bell alerts", run: () => _clickSel("#btn-activity") },
    { label: "eyedropper", hint: "pick a screen colour", kw: "eyedropper colour color pick hex pixel", run: () => { cmdClose(); $("#text").value = ""; py("eyedropper_pick"); } },
    { label: "translate image to english", hint: "needs an image attached", kw: "translate image english", run: () => { cmdClose(); $("#text").value = ""; py("translate_image", "en"); } },
    { label: "translate image to spanish", hint: "needs an image attached", kw: "translate image spanish español", run: () => { cmdClose(); $("#text").value = ""; py("translate_image", "es"); } },
    { label: "take a screenshot", hint: "run", kw: "screenshot capture screen", run: () => _runCmd("take a screenshot") },
    { label: "social media report", hint: "run", kw: "social report stats", run: () => _runCmd("social media report") },
    { label: "optimize my pc", hint: "run", kw: "optimize free ram boost performance", run: () => _runCmd("optimize my pc") },
    { label: "internet speed", hint: "run", kw: "internet speed test bandwidth", run: () => _runCmd("internet speed") },
  ];
  const _cmd = { open: false, items: [], active: 0 };
  function _cmdFilter(q) {
    q = (q || "").trim().toLowerCase();
    if (!q) return CMD_DEFS.slice();
    return CMD_DEFS.filter((c) => c.label.toLowerCase().includes(q) || (c.kw && c.kw.indexOf(q) >= 0));
  }
  function _cmdHighlight() {
    const els = $("#cmdpalette").querySelectorAll(".cmd-item");
    els.forEach((el, i) => el.classList.toggle("active", i === _cmd.active));
    const cur = els[_cmd.active]; if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: "nearest" });
  }
  function _cmdRender() {
    const box = $("#cmdpalette");
    if (!_cmd.items.length) { box.innerHTML = '<div class="cmd-empty">no matching command</div>'; return; }
    box.innerHTML = '<div class="cmd-hint">commands</div>' + _cmd.items.map((c, i) =>
      '<div class="cmd-item' + (i === _cmd.active ? " active" : "") + '" data-i="' + i + '">' +
      '<span class="cl"></span><span class="ch"></span></div>').join("");
    const els = box.querySelectorAll(".cmd-item");
    els.forEach((el, i) => {
      el.querySelector(".cl").textContent = _cmd.items[i].label;   // textContent = XSS-safe
      el.querySelector(".ch").textContent = _cmd.items[i].hint || "";
      el.addEventListener("mousedown", (e) => { e.preventDefault(); _cmd.items[i].run(); });
      el.addEventListener("mousemove", () => { if (_cmd.active !== i) { _cmd.active = i; _cmdHighlight(); } });
    });
  }
  function cmdOpen(q) { _cmd.items = _cmdFilter(q); _cmd.active = 0; _cmd.open = true; $("#cmdpalette").classList.remove("hidden"); _cmdRender(); }
  function cmdClose() { _cmd.open = false; const b = $("#cmdpalette"); if (b) b.classList.add("hidden"); }

  // ── panel open/close ───────────────────────────────────────────────────
  // The slide-over is `display:none` (.gone) when fully closed so WebView2
  // can't leave a composited ghost of its content over the composer; on open
  // we un-hide, force a reflow, then slide it in.
  function openPanel() {
    const p = $("#panel");
    clearTimeout(p._goneT);
    p.classList.remove("gone");
    void p.offsetWidth;                       // reflow → the slide-in animates
    p.classList.add("on"); $("#scrim").classList.add("on");
  }
  function hidePanel() {
    const p = $("#panel");
    p.classList.remove("on"); $("#scrim").classList.remove("on");
    clearTimeout(p._goneT);
    p._goneT = setTimeout(() => { if (!p.classList.contains("on")) p.classList.add("gone"); }, 360);
  }

  // shared surface the config-editor panels (panels.js) build on
  window.WivexxUI = {
    py: py, openPanel: openPanel, hidePanel: hidePanel,
    toast: (t) => wivexx.toast(t), sendText: (t) => sendText(t),
  };

  // ── custom title-bar drag ────────────────────────────────────────────────
  // WebView2 ignores pywebview's `.pywebview-drag-region`, so on mousedown we
  // ask Python to run a cursor-follow move loop (start_drag → SetWindowPos,
  // smooth + DPI-correct, no per-frame IPC). That promise stays pending until
  // the drag ends, so `nativeOk` is null for the whole successful drag and the
  // fallback stays off. If start_drag can't resolve the window handle it
  // resolves false immediately → we switch to the per-frame move_window path.
  function wireDrag() {
    const bar = $("#topbar");
    if (!bar) return;
    let dragging = false, grabX = 0, grabY = 0, nativeOk = null, pending = null, raf = 0;

    function flush() {
      raf = 0;
      if (dragging && nativeOk === false && pending) { py("move_window", pending.x, pending.y); pending = null; }
    }
    bar.addEventListener("mousedown", (e) => {
      if (e.button !== 0) return;               // left button only
      if (e.target.closest(".winbtn")) return;  // not the minimize/close buttons
      dragging = true;
      grabX = e.clientX;                         // grab point inside the window
      grabY = e.clientY;                         // (frameless: client == window origin)
      nativeOk = null;
      e.preventDefault();
      const p = py("start_drag");                // native cursor-follow loop (primary)
      if (p && p.then) p.then((ok) => { nativeOk = !!ok; }).catch(() => { nativeOk = false; });
      else nativeOk = false;                     // bridge not ready → fallback path
    });
    // fallback: only fires if the native loop couldn't start (nativeOk === false)
    document.addEventListener("mousemove", (e) => {
      if (!dragging) return;
      if (e.buttons === 0) { dragging = false; return; }   // button released
      if (nativeOk !== false) return;                       // native loop owns the drag
      pending = { x: Math.round(e.screenX - grabX), y: Math.round(e.screenY - grabY) };
      if (!raf) raf = requestAnimationFrame(flush);
    });
    document.addEventListener("mouseup", () => { dragging = false; });
    window.addEventListener("blur", () => { dragging = false; });
  }

  // ── pending attachment chip (preview + remove, before sending) ────────────
  let _hasAttachment = false;

  function showAttachChip(att) {
    const row = $("#attach-row");
    if (!row) return;
    _hasAttachment = true;
    const name = att.name || "attachment";
    row.innerHTML =
      '<div class="attach-chip">' +
        (att.thumb
          ? '<img class="ac-thumb" src="' + att.thumb + '" alt="">'
          : '<span class="ac-glyph">📎</span>') +
        '<span class="ac-name"></span>' +
        '<button type="button" class="ac-x" aria-label="Remove attachment">&#10005;</button>' +
      "</div>" +
      '<div class="attach-actions" id="attach-actions"></div>';
    row.querySelector(".ac-name").textContent = name;     // textContent = safe
    row.querySelector(".ac-x").addEventListener("click", () => clearAttachChip());
    row.classList.remove("hidden");
    loadAttachActions();                                  // type-aware tool chips
  }

  // ── media tools on the attachment (palette / convert / resize / compress) ──
  function loadAttachActions() {
    const box = $("#attach-actions");
    if (!box) return;
    Promise.resolve(py("media_caps")).then((caps) => {
      if (!caps || !caps.ok) { box.innerHTML = ""; return; }
      const ops = { image: ["palette", "translate", "convert", "resize", "compress"],
                    video: ["captions", "convert", "resize", "compress", "transcribe"],
                    audio: ["convert", "transcribe"] }[caps.kind];
      if (!ops) { box.innerHTML = ""; return; }
      const LABEL = { palette: "🎨 palette", translate: "translate", convert: "convert",
                      resize: "resize", compress: "compress", transcribe: "transcribe",
                      captions: "🎬 captions" };
      box.innerHTML =
        '<div class="ma-chips">' +
          ops.map((o) => '<button type="button" class="ma-chip" data-op="' + o + '">' + LABEL[o] + "</button>").join("") +
        '</div><div class="ma-panel" id="ma-panel"></div>';
      box.querySelectorAll(".ma-chip").forEach((b) =>
        b.addEventListener("click", () => onMediaChip(b.getAttribute("data-op"), caps)));
    }).catch(() => {});
  }

  function onMediaChip(op, caps) {
    const pn = $("#ma-panel");
    if (!pn) return;
    if (op === "palette") {
      pn.innerHTML = '<div class="ma-hint">reading colours…</div>';
      Promise.resolve(py("media_palette", 6)).then((r) => {
        if (!r || !r.ok) { pn.innerHTML = '<div class="ma-hint">' + ((r && r.error) || "couldn’t read colours") + "</div>"; return; }
        pn.innerHTML = '<div class="swatches">' + r.colors.map((c) =>
          '<button type="button" class="swatch" data-hex="' + c.hex + '" title="' + c.hex + " · " + c.pct + '%" style="background:' + c.hex + '"><span>' + c.hex + "</span></button>").join("") +
          '</div><div class="ma-hint">click a colour to copy its hex</div>';
        pn.querySelectorAll(".swatch").forEach((s) => s.addEventListener("click", () => {
          const hex = s.getAttribute("data-hex"); py("copy_clip", hex); wivexx.toast("copied " + hex);
        }));
      });
      return;
    }
    if (op === "transcribe") {
      Promise.resolve(py("transcribe_attachment")).then((r) =>
        wivexx.toast(r && r.ok ? "transcribing…" : ((r && r.error) || "can’t transcribe")));
      return;
    }
    if (op === "translate") {
      pn.innerHTML = '<div class="ma-row"><span class="ma-hint" style="margin:0 4px 0 0">translate text to:</span>' +
        '<button type="button" class="ma-go" data-lang="en">English</button>' +
        '<button type="button" class="ma-go" data-lang="es">Spanish</button></div>';
      pn.querySelectorAll(".ma-go").forEach((b) => b.addEventListener("click", () => {
        py("translate_image", b.getAttribute("data-lang")); pn.innerHTML = "";
      }));
      return;
    }
    if (op === "captions") {
      pn.innerHTML = "";
      if (window.WivexxPanels && window.WivexxPanels.openCaptionsEditor)
        window.WivexxPanels.openCaptionsEditor();   // full editor: transcribe → edit → style → burn
      return;
    }
    const kind = caps.kind, fmts = caps[kind + "_formats"] || [];
    if (op === "convert") {
      pn.innerHTML = '<div class="ma-row"><select class="ma-sel">' +
        fmts.map((f) => '<option value="' + f + '">' + f + "</option>").join("") +
        '</select><button type="button" class="ma-go">convert</button></div>';
      pn.querySelector(".ma-go").addEventListener("click", () =>
        runMedia("convert", { fmt: pn.querySelector(".ma-sel").value }));
    } else if (op === "resize") {
      const presets = (kind === "image")
        ? [["2048px", { max_side: 2048 }], ["1280px", { max_side: 1280 }], ["800px", { max_side: 800 }], ["50%", { scale: 0.5 }]]
        : [["1080p", { height: 1080 }], ["720p", { height: 720 }], ["480p", { height: 480 }], ["50%", { scale: 0.5 }]];
      pn.innerHTML = '<div class="ma-row">' +
        presets.map((p, i) => '<button type="button" class="ma-go" data-i="' + i + '">' + p[0] + "</button>").join("") + "</div>";
      pn.querySelectorAll(".ma-go").forEach((b) =>
        b.addEventListener("click", () => runMedia("resize", presets[+b.getAttribute("data-i")][1])));
    } else if (op === "compress") {
      const ph = kind === "image" ? "target e.g. 500kb (optional)" : "target e.g. 8mb (optional)";
      pn.innerHTML = '<div class="ma-row"><input class="ma-inp" placeholder="' + ph + '"><button type="button" class="ma-go">compress</button></div>';
      pn.querySelector(".ma-go").addEventListener("click", () => {
        const v = (pn.querySelector(".ma-inp").value || "").trim().toLowerCase();
        const params = {}; const m = v.match(/([\d.]+)\s*(kb|mb|gb|k|m|g)?/);
        if (m) {
          const num = parseFloat(m[1]), unit = m[2] || "";
          if (kind === "image") params.target_kb = unit.indexOf("m") === 0 ? num * 1024 : num;
          else params.target_mb = unit.indexOf("k") === 0 ? num / 1024 : num;
        }
        runMedia("compress", params);
      });
    }
  }

  function runMedia(op, params) {
    Promise.resolve(py("media_run", op, params)).then((r) => {
      if (r && !r.async && !r.ok) wivexx.toast((r && r.error) || "failed");  // sync errors
      const pn = $("#ma-panel"); if (pn) pn.innerHTML = "";                  // bridge toasts the result
    });
  }
  function clearAttachChip(serverToo) {
    const row = $("#attach-row");
    if (row) { row.innerHTML = ""; row.classList.add("hidden"); }
    _hasAttachment = false;
    if (serverToo !== false) py("clear_attachment");
  }

  // ── UI → Python ──────────────────────────────────────────────────────────
  function sendText(text) {
    const v = (text != null ? text : $("#text").value).trim();
    if (!v && !_hasAttachment) return;          // allow image-only sends
    if (text == null) $("#text").value = "";
    py("send_text", v);
    clearAttachChip(false);                     // server consumes it on send
  }

  // ── window state: maximize / fullscreen button glyphs ────────────────────
  function applyMaxState(isMax) {
    const b = $("#btn-max");
    if (b) { b.innerHTML = isMax ? "&#10064;" : "&#9633;"; b.title = isMax ? "Restore" : "Maximize"; }
  }
  function applyFullState(isFull) {
    document.body.classList.toggle("is-fullscreen", !!isFull);
    const b = $("#btn-full");
    if (b) b.title = isFull ? "Exit fullscreen (F11)" : "Fullscreen (F11)";
    if (isFull) applyMaxState(false);
  }
  function toggleMax() { const p = py("toggle_maximize"); if (p && p.then) p.then(applyMaxState); }
  function toggleFull() { const p = py("toggle_fullscreen"); if (p && p.then) p.then(applyFullState); }

  // ── frameless edge/corner resize (native cursor-follow loop in Python) ────
  function wireResize() {
    document.querySelectorAll(".rz").forEach((h) => {
      h.addEventListener("mousedown", (e) => {
        if (e.button !== 0) return;
        e.preventDefault();
        applyMaxState(false); applyFullState(false);   // manual sizing ⇒ not maxed
        py("start_resize", h.getAttribute("data-rz"));
      });
    });
  }

  // ── first-run welcome tour (one-time, skippable; persisted via welcome_done) ──
  const WL_STEPS = 3;
  let _wlStep = 0;
  function wlRender() {
    document.querySelectorAll("#welcome .wl-step").forEach((el) =>
      el.classList.toggle("on", +el.getAttribute("data-step") === _wlStep));
    const dots = $("#wl-dots");
    if (dots) dots.innerHTML = Array.from({ length: WL_STEPS }, (_, i) =>
      '<span class="wl-dot' + (i === _wlStep ? " on" : "") + '"></span>').join("");
    const next = $("#wl-next");
    if (next) next.textContent = _wlStep >= WL_STEPS - 1 ? "let’s go" : "next →";
  }
  function wlOpen() {
    _wlStep = 0; wlRender();
    const w = $("#welcome"); if (w) w.classList.remove("hidden");
  }
  function wlClose() {
    const w = $("#welcome");
    if (!w || w.classList.contains("hidden")) return false;
    w.classList.add("hidden");
    py("set_setting", { key: "welcome_done", value: true });
    return true;
  }
  function wireWelcome() {
    const skip = $("#wl-skip"), next = $("#wl-next");
    if (skip) skip.addEventListener("click", wlClose);
    if (next) next.addEventListener("click", () => {
      if (_wlStep >= WL_STEPS - 1) { wlClose(); return; }
      _wlStep++; wlRender();
    });
    // starter chips: the chip's visible (possibly translated) text IS the command
    document.querySelectorAll("#starter-chips .schip").forEach((c) =>
      c.addEventListener("click", () => sendText(c.textContent.trim())));
  }
  wivexx.showWelcome = wlOpen;   // replayable (Settings link / testing)

  // ── activity / notifications badge ───────────────────────────────────────
  let _bellUnread = 0;
  function setBell(n) {
    const _prevUnread = _bellUnread;
    _bellUnread = Math.max(0, n | 0);
    // a NEW notification arriving → the orb perks up (reacts to the event)
    if (_bellUnread > _prevUnread && window.WivexxDot && window.WivexxDot.react) {
      try { window.WivexxDot.react("alert", 0.9); } catch (e) {}
    }
    const badge = $("#bell-badge"), bell = $("#btn-activity");
    if (badge) {
      badge.textContent = _bellUnread > 99 ? "99+" : String(_bellUnread);
      badge.classList.toggle("hidden", _bellUnread === 0);
    }
    if (bell) bell.classList.toggle("has-unread", _bellUnread > 0);
  }
  function openActivity() {
    setBell(0);
    if (window.WivexxPanels) window.WivexxPanels.open("activity");
  }

  function wire() {
    $("#btn-send").addEventListener("click", () => sendText());
    const _textEl = $("#text");
    _textEl.addEventListener("input", () => {
      const v = _textEl.value;
      if (v.charAt(0) === "/") cmdOpen(v.slice(1)); else cmdClose();
    });
    _textEl.addEventListener("keydown", (e) => {
      if (_cmd.open) {
        if (e.key === "ArrowDown") { e.preventDefault(); _cmd.active = Math.min(_cmd.active + 1, _cmd.items.length - 1); _cmdHighlight(); return; }
        if (e.key === "ArrowUp")   { e.preventDefault(); _cmd.active = Math.max(_cmd.active - 1, 0); _cmdHighlight(); return; }
        if (e.key === "Enter")     { e.preventDefault(); const c = _cmd.items[_cmd.active]; if (c) c.run(); return; }
        if (e.key === "Escape")    { e.preventDefault(); e.stopPropagation(); cmdClose(); _textEl.value = ""; return; }
        if (e.key === "Tab")       { e.preventDefault(); const c = _cmd.items[_cmd.active]; if (c) c.run(); return; }
      }
      if (e.key === "Enter") { e.preventDefault(); sendText(); }
    });
    _textEl.addEventListener("blur", () => setTimeout(cmdClose, 120));
    $("#btn-attach").addEventListener("click", () => {
      const r = py("attach_image");
      if (r && r.then) r.then((att) => { if (att && att.ok) showAttachChip(att); });
    });
    $("#btn-min").addEventListener("click", () => py("minimize"));
    $("#btn-max").addEventListener("click", toggleMax);
    $("#btn-full").addEventListener("click", toggleFull);
    $("#btn-activity").addEventListener("click", openActivity);
    // 📊 Performance HUD — a standalone always-on-top hardware widget (NOT a mode):
    // titlebar toggle pops/hides the overlay; reconcile the lit state with the bridge.
    const perfBtn = $("#btn-perf");
    if (perfBtn) perfBtn.addEventListener("click", () => {
      const want = !perfBtn.classList.contains("on");
      perfBtn.classList.toggle("on", want);                 // optimistic
      perfBtn.setAttribute("aria-pressed", String(want));
      const r = py("toggle_perf_hud");
      const settle = (vis) => {
        const on = (vis === undefined || vis === null) ? want : !!vis;
        perfBtn.classList.toggle("on", on);
        perfBtn.setAttribute("aria-pressed", String(on));
      };
      if (r && r.then) r.then(settle).catch(() => settle(want));
    });
    $("#btn-settings").addEventListener("click", () => { if (window.WivexxPanels) window.WivexxPanels.open("settings"); });
    $("#btn-close").addEventListener("click", () => py("close"));
    $("#panel-close").addEventListener("click", hidePanel);
    $("#scrim").addEventListener("click", hidePanel);
    $("#topbar").addEventListener("dblclick", (e) => { if (!e.target.closest(".winbtn")) toggleMax(); });
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") { if (wlClose()) return; hidePanel(); closeLightbox(); }
      if (e.key === "F11") { e.preventDefault(); toggleFull(); }
    });
    wireDrag();
    wireResize();
    wireTransferCard();
    wireLightbox();
    wireDropZone();
    wireWelcome();

    // brand icons + plain-language tooltips on the hub chips, so a new user knows
    // what "goals / social / actions" actually hold before clicking
    const CHIP_META = {
      goals:   { ic: "target",    tip: "today's goals & to-dos" },
      social:  { ic: "megaphone", tip: "captions · tracking · deliver" },
      actions: { ic: "bolt",      tip: "autoclicker · macro recorder" },
    };
    document.querySelectorAll(".chip[data-tool]").forEach((btn) => {
      const m = CHIP_META[btn.getAttribute("data-tool")];
      if (m) {
        btn.title = m.tip;
        if (window.WivexxIcons) btn.insertAdjacentHTML("afterbegin", WivexxIcons.get(m.ic, 13));
      }
    });

    document.querySelectorAll(".chip[data-tool]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const tool = btn.getAttribute("data-tool");
        // these are full in-page editors (panels.js); the rest use the generic
        // bridge tool() flow.
        if (tool === "activity") { openActivity(); return; }
        if (window.WivexxPanels &&
            (tool === "commands" || tool === "goals" || tool === "shortcuts" ||
             tool === "memory" || tool === "capabilities" ||
             tool === "captions" || tool === "deliver" ||
             tool === "social" || tool === "actions")) {
          window.WivexxPanels.open(tool);
          return;
        }
        const r = py("tool", tool);
        if (r && r.then) {
          r.then((data) => {
            if (!data) return;
            if (data.native) return;                 // native window opened by Python
            if (data.title) wivexx.showPanel(data.title, data.html || "");
          }).catch(() => {});
        }
      });
    });

    // tell Python the UI is ready (close splash, push initial state)
    py("ready");
    setTimeout(() => $("#text").focus(), 300);
  }

  // ── mascot outfits (SVG props layered over the orb) ────────────────────────
  // (the old hand-written MASCOT_SVG accessories were removed — the control room now
  //  draws items from the SHARED spec ui/mascot_items.js via accessories.js. See buildMascotFx.)
  // z-order of the WORN/HELD outfits (drawn when no gadget pose is active). The tie
  // hangs behind the hand props; the sleepy cap + its zzz go on top.
  const OUTFIT_ITEMS = [
    ["fx-tie", "tie"], ["show-hp", "hp"], ["show-controller", "controller"],
    ["show-popcorn", "popcorn"], ["show-glasses", "glasses"], ["show-headset", "headset"],
    ["show-brush", "brush"], ["show-bag", "bag"], ["fx-cap", "cap"], ["fx-cap", "zzz"],
  ];
  // The control room draws its accessories on a CANVAS from the SHARED spec
  // (ui/mascot_items.js via accessories.js) — the SAME source the floating tk orb uses,
  // so a costume is designed ONCE. The trigger logic still toggles classes on #mascot-fx.
  function buildMascotFx() {
    const fx = $("#mascot-fx"), cv = $("#mascot-acc");
    if (!fx || !cv || !window.WivexxAccessories) return;
    const ctx = cv.getContext("2d");
    if (!ctx) return;
    const wrap = cv.parentElement;
    let lastDraw = 0;
    let wasEmpty = false;   // skip all canvas work while nothing is worn/held
    function tick() {
      requestAnimationFrame(tick);
      if (document.hidden) return;             // window hidden: zero work
      // honor Settings → reduce animations (cap to ~24fps, mirrors dot.js)
      if (window.WivexxDot && window.WivexxDot._reduceMotion) {
        const now = performance.now();
        if (now - lastDraw < 42) return;
        lastDraw = now;
      }
      const gad = ["camera", "clicker", "keys"].find((c) => fx.classList.contains("show-" + c));
      let anyOutfit = false;
      if (!gad) for (const [cls] of OUTFIT_ITEMS) { if (fx.classList.contains(cls)) { anyOutfit = true; break; } }
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const w = cv.clientWidth, h = cv.clientHeight;
      if (!w || !h) return;
      if (!gad && !anyOutfit) {                // bare orb (the common case): clear once, then idle
        if (!wasEmpty) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); wasEmpty = true; }
        return;
      }
      wasEmpty = false;
      if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
        cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
      }
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      const R = Math.min(w, h) * 0.33, cx = w / 2, cy = h / 2;
      const t = performance.now() / 1000;
      const lx = parseFloat(wrap.style.getPropertyValue("--dot-lx")) || 0;
      const ly = parseFloat(wrap.style.getPropertyValue("--dot-ly")) || 0;
      if (gad) {
        window.WivexxAccessories.draw(ctx, gad, cx, cy, R, t, lx, ly);
      } else {
        const activity = fx.classList.contains("has-activity");
        for (const [cls, item] of OUTFIT_ITEMS) {
          if (!fx.classList.contains(cls)) continue;
          if ((item === "cap" || item === "zzz") && activity) continue;
          window.WivexxAccessories.draw(ctx, item, cx, cy, R, t, lx, ly);
        }
      }
    }
    tick();
  }

  // ── boot ─────────────────────────────────────────────────────────────────
  function boot() {
    window.WivexxDot && window.WivexxDot.init($("#dot"));
    buildMascotFx();
    wivexx.setState("idle");
    wire();
    // once the bridge is live: apply persisted "reduce animations", and show the
    // one-time welcome tour to first-run users (welcome_done not yet set)
    whenBridge(() => {
      const r = py("get_settings");
      if (!r || !r.then) return;
      r.then((d) => {
        const s = d && d.settings;
        if (!s) return;
        if (s.reduce_animations) wivexx.setReduceMotion(true);
        if (!s.welcome_done) wlOpen();
      }).catch(() => {});
    });
  }

  if (window.pywebview && window.pywebview.api) { _pyReady = true; }
  window.addEventListener("pywebviewready", flushQueue);

  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
  else boot();
})();
