"""wivexx_webgui.py — Wivexx v1.20 web UI engine (Edge WebView2 via pywebview).

The visible control-room window is HTML/CSS/JS (the ``ui/`` folder) rendered in
the native Edge WebView2 runtime — so it looks pixel-identical to wivexx.com.
This module is the Python side of the bridge:

  • Python → page :  WebGui.js("<fn>", *args)  ->  window.wivexx.<fn>(...)
  • page → Python :  pywebview.api.<fn>()       ->  JsApi.<fn>(...)

The heavy *native* tools (caption / deliver studios, command / shortcut editors,
the transparent click-overlay, the media-device prompt) stay in tkinter and are
hosted by a withdrawn ``WivexxGUI`` "sidecar" running on a background thread —
see wivexx_gui._start_tk_sidecar(). pywebview owns the main thread.

Nothing here touches the mic / TTS / intent pipeline; it only renders state that
wivexx_api already pushes and forwards typed input back through the existing
``submit_text`` integration — the same path the old tkinter input bar used.
"""

from __future__ import annotations

import html as _html
import json
import logging
import os
import threading
import time
from typing import Any, Optional

try:
    import webview                      # pywebview
    _WEBVIEW_OK = True
except Exception:                       # pragma: no cover
    webview = None                      # type: ignore
    _WEBVIEW_OK = False

# Logging — the bridge logs failures from background threads / drop handlers. A child of
# wivexx_api's "jarvis" logger, so it inherits that logger's file handler/level by
# propagation with no wiring; defaulting to a real logger means these calls never raise.
# wivexx_api can still share its exact logger via set_logger() if desired.
_logger = logging.getLogger("jarvis.webgui")


def set_logger(logger) -> None:
    """Route this module's logging through wivexx_api's shared logger (optional)."""
    global _logger
    if logger is not None:
        _logger = logger


def available() -> bool:
    """True when pywebview (and therefore the WebView2 backend) can be used."""
    return _WEBVIEW_OK


def _ui_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "index.html")


def _overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "overlay.html")


def _goals_overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "goals_overlay.html")


def _status_overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "status_overlay.html")


def _transfer_overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "transfer_overlay.html")


def _timer_overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "timer_overlay.html")


def _timer_alert_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "timer_alert_overlay.html")


def _perf_overlay_index() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "perf_overlay.html")


# ── Performance HUD config (shared with the tk fallback via wivexx_hud.json) ──
def _perf_cfg_path() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx_hud.json")


_PERF_METRICS_ALL = ["cpu", "gpu", "vram", "ram", "disk", "net", "speed", "fps"]


def _load_perf_metrics() -> list:
    try:
        with open(_perf_cfg_path(), encoding="utf-8") as f:
            m = json.load(f).get("metrics", [])
        m = [x for x in m if x in _PERF_METRICS_ALL]
        if m:
            return m
    except Exception:
        pass
    return ["cpu", "gpu", "vram", "ram", "net", "speed"]


def _save_perf_metrics(metrics: list) -> None:
    try:
        cfg = {}
        try:
            with open(_perf_cfg_path(), encoding="utf-8") as f:
                cfg = json.load(f)
        except Exception:
            cfg = {}
        cfg["metrics"] = [x for x in metrics if x in _PERF_METRICS_ALL] or ["cpu", "gpu", "ram"]
        with open(_perf_cfg_path(), "w", encoding="utf-8") as f:
            json.dump(cfg, f)
    except Exception:
        pass


# minimized-overlay window size (logical px) — small DOT card, bottom-right
OVERLAY_W = 220
OVERLAY_H = 216
# (the minimalist "bare orb" form is NOT a resized WebView2 window — WebView2
# can't be made transparent on Win11; it's a separate transparent tk overlay,
# wivexx_ball_overlay, shown via wivexx_gui.show_ball_overlay)
# goals "today's focus" overlay size — top-right
GOALS_OV_W = 304
GOALS_OV_H = 388
# status overlay size — top-left
STATUS_OV_W = 264
STATUS_OV_H = 252
# transfer (import/deliver progress) overlay — bottom-center
TRANSFER_OV_W = 320
TRANSFER_OV_H = 244
# live timer countdown overlay — top-center (height grows with timer count)
TIMER_OV_W = 280
TIMER_OV_ROW = 46
TIMER_OV_HEAD = 42
# timer completion alert — screen-centered
TIMER_ALERT_W = 380
TIMER_ALERT_H = 200
# performance HUD overlay — top-right; height auto-fits its content via resize()
PERF_OV_W = 300
PERF_OV_H = 280


# ── shared always-on-top overlay-window helpers (Win32) ──────────────────────
# ── v1.58: win32 functions resolved ONCE at import (main thread) with explicit
# prototypes. These used to be looked up lazily via ctypes.windll from whatever
# BACKGROUND thread fired an overlay (timer/goal reminders!) — the windll lazy
# attribute cache is not thread-safe and the calls had no restype/argtypes
# (64-bit HWND truncation), which produced a REAL crash: WER 2026-06-29,
# 0xc0000005 in _ctypes.pyd, "reminder crashes Wivexx". Resolving + typing them
# here removes both hazards.
try:
    import ctypes as _ct
    _U32 = _ct.WinDLL("user32", use_last_error=True)
    _DWM = _ct.WinDLL("dwmapi", use_last_error=True)
    _U32.FindWindowW.restype = _ct.c_void_p
    _U32.FindWindowW.argtypes = (_ct.c_wchar_p, _ct.c_wchar_p)
    _U32.GetWindowLongW.restype = _ct.c_long
    _U32.GetWindowLongW.argtypes = (_ct.c_void_p, _ct.c_int)
    _U32.SetWindowLongW.restype = _ct.c_long
    _U32.SetWindowLongW.argtypes = (_ct.c_void_p, _ct.c_int, _ct.c_long)
    _DWM.DwmSetWindowAttribute.restype = _ct.c_long
    _DWM.DwmSetWindowAttribute.argtypes = (_ct.c_void_p, _ct.c_uint,
                                           _ct.c_void_p, _ct.c_uint)
except Exception:
    _U32 = _DWM = None


def _find_hwnd(title: str) -> int:
    try:
        if _U32 is None:
            return 0
        return int(_U32.FindWindowW(None, title) or 0)
    except Exception:
        return 0


def _style_overlay_hwnd(hwnd: int) -> None:
    """Round corners (Win11 DWM) + hide from taskbar/Alt-Tab (WS_EX_TOOLWINDOW)."""
    try:
        if _U32 is None or _DWM is None or not hwnd:
            return
        import ctypes
        val = ctypes.c_int(2)   # DWMWCP_ROUND
        _DWM.DwmSetWindowAttribute(
            hwnd, 33, ctypes.byref(val), ctypes.sizeof(val))   # CORNER_PREFERENCE
        cur = _U32.GetWindowLongW(hwnd, -20)                    # GWL_EXSTYLE
        _U32.SetWindowLongW(hwnd, -20, cur | 0x00000080)        # WS_EX_TOOLWINDOW
    except Exception:
        pass


class _WinDispatcher:
    """v1.58 (review-hardened): ONE serialized worker per pywebview window.
    pywebview's evaluate_js/show/hide are SYNCHRONOUS — against a HIDDEN
    WebView2 window (whose renderer Windows may have suspended) they can wedge
    forever; that froze reminder/poller threads until the whole app 'stopped
    interacting with Windows' (WER AppHangB1, 2026-07-09). The first fix ran a
    throwaway thread PER CALL with a join timeout, which (a) still blocked the
    producer for the round-trip and (b) leaked one thread per push under a
    wedge — a 20 Hz level meter would leak thousands. Now every window op is
    posted fire-and-forget to that window's single worker: producers never
    block, a wedge pins exactly one thread, the bounded deque drops the OLDEST
    pushes under backlog (they're stale anyway), and show/hide/push for one
    window are strictly ordered — which also removes the fire/dismiss race on
    the timer alert."""

    def __init__(self, name: str) -> None:
        import collections
        self._q = collections.deque(maxlen=64)
        self._evt = threading.Event()
        threading.Thread(target=self._run, daemon=True,
                         name=f"wivexx-ui-{name}").start()

    def post(self, fn) -> None:
        self._q.append(fn)
        self._evt.set()

    def _run(self) -> None:
        while True:
            self._evt.wait()
            self._evt.clear()
            while True:
                try:
                    fn = self._q.popleft()
                except IndexError:
                    break
                try:
                    fn()
                except Exception:
                    pass


_dispatchers: dict = {}
_dispatchers_lock = threading.Lock()


def _dispatch(window, fn) -> None:
    """Post *fn* to *window*'s serialized UI worker (created on first use)."""
    if window is None:
        return
    key = id(window)
    with _dispatchers_lock:
        d = _dispatchers.get(key)
        if d is None:
            d = _WinDispatcher(str(len(_dispatchers) + 1))
            _dispatchers[key] = d
    d.post(fn)


def _safe_eval_js(window, script: str) -> None:
    """Fire-and-forget evaluate_js on the window's serialized worker."""
    if window is None:
        return

    def _run() -> None:
        try:
            window.evaluate_js(script)
        except Exception:
            pass

    _dispatch(window, _run)


def _img_thumb_data_url(path: str, max_px: int = 200) -> str:
    """Small base64 JPEG thumbnail as a data: URL for the attachment preview.
    Returns "" if the file isn't an image / Pillow isn't installed (the page
    then shows a named file chip instead)."""
    try:
        import base64
        import io
        from PIL import Image
        with Image.open(path) as im:
            im = im.convert("RGB")
            im.thumbnail((max_px, max_px), Image.LANCZOS)
            buf = io.BytesIO()
            im.save(buf, format="JPEG", quality=72)
        b64 = base64.standard_b64encode(buf.getvalue()).decode()
        return "data:image/jpeg;base64," + b64
    except Exception:
        return ""


def _native_drag_hwnd(hwnd: int) -> bool:
    """Drag a frameless window by following the cursor while the left mouse
    button is held.

    pywebview 6.x runs every js_api call on a *fresh worker thread* (util.py:
    `Thread(target=_call)`), so the classic `ReleaseCapture()` +
    `SendMessageW(WM_NCLBUTTONDOWN, HTCAPTION)` modal-loop trick can't work here:
    ReleaseCapture is thread-local and the WebView2 child (on the UI thread)
    keeps the mouse capture, so the modal loop never tracks the cursor.

    Instead we poll the *global* cursor position and button state and
    SetWindowPos directly. Everything is in physical pixels (GetCursorPos /
    GetWindowRect / SetWindowPos all agree), so it's DPI-correct on any monitor,
    and it doesn't matter which thread we're on."""
    if not hwnd:
        return False
    try:
        import time
        import ctypes
        from ctypes import wintypes
        u = ctypes.windll.user32
        VK_LBUTTON = 0x01
        # SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE — move only, keep z-order
        # (preserves an on_top overlay's topmost) and don't steal activation.
        SWP = 0x0001 | 0x0004 | 0x0010
        pt = wintypes.POINT()
        rect = wintypes.RECT()
        if not u.GetCursorPos(ctypes.byref(pt)):
            return False
        u.GetWindowRect(hwnd, ctypes.byref(rect))
        off_x, off_y = pt.x - rect.left, pt.y - rect.top
        deadline = time.monotonic() + 30.0        # safety cap; release ends it
        while (u.GetAsyncKeyState(VK_LBUTTON) & 0x8000) and time.monotonic() < deadline:
            u.GetCursorPos(ctypes.byref(pt))
            u.SetWindowPos(hwnd, 0, pt.x - off_x, pt.y - off_y, 0, 0, SWP)
            time.sleep(0.008)                      # ~120 Hz, smooth + cheap
        return True
    except Exception:
        return False


def _native_resize_hwnd(hwnd: int, edge: str, min_w: int = 380,
                        min_h: int = 480) -> bool:
    """Resize a frameless window by following the cursor while LBUTTON is held —
    the same worker-thread-safe trick as `_native_drag_hwnd` (the WM_NCHITTEST /
    WS_THICKFRAME native path never reaches us because WebView2 owns the whole
    client area). `edge` ∈ {l,r,t,b,tl,tr,bl,br}: which sides track the cursor.
    All physical pixels, so it's DPI-correct on any monitor."""
    if not hwnd or not edge:
        return False
    try:
        import time
        import ctypes
        from ctypes import wintypes
        u = ctypes.windll.user32
        VK_LBUTTON = 0x01
        SWP = 0x0004 | 0x0010                      # SWP_NOZORDER | SWP_NOACTIVATE
        pt = wintypes.POINT()
        r0 = wintypes.RECT()
        if not u.GetCursorPos(ctypes.byref(pt)):
            return False
        u.GetWindowRect(hwnd, ctypes.byref(r0))
        sx, sy = pt.x, pt.y
        L0, T0, R0, B0 = r0.left, r0.top, r0.right, r0.bottom
        west = "l" in edge
        east = "r" in edge
        north = "t" in edge
        south = "b" in edge
        deadline = time.monotonic() + 30.0
        while (u.GetAsyncKeyState(VK_LBUTTON) & 0x8000) and time.monotonic() < deadline:
            u.GetCursorPos(ctypes.byref(pt))
            dx, dy = pt.x - sx, pt.y - sy
            left, top, right, bottom = L0, T0, R0, B0
            if east:
                right = max(L0 + min_w, R0 + dx)
            if west:
                left = min(R0 - min_w, L0 + dx)
            if south:
                bottom = max(T0 + min_h, B0 + dy)
            if north:
                top = min(B0 - min_h, T0 + dy)
            u.SetWindowPos(hwnd, 0, left, top, right - left, bottom - top, SWP)
            time.sleep(0.008)
        return True
    except Exception:
        return False


# api state vocabulary  →  the 4 visual states the DOT/UI understands
_STATE_MAP = {
    "standby": "idle", "idle": "idle", "active": "idle",
    "mic_muted": "idle", "audio_muted": "idle",
    "listening": "listening",
    "searching": "processing", "processing": "processing",
    "speaking": "speaking",
}
_ROLE_MAP = {"you": "user", "user": "user", "wivexx": "assistant",
             "assistant": "assistant", "system": "system"}

# fallback status line per UI state — used on first paint when the assistant's
# own idle status raced ahead of the window being ready (so it's never stuck on
# the "starting…" placeholder). Live status pushes from wivexx_api override these.
_STATUS_FOR_STATE = {
    "idle": "● Idle",
    "listening": "● Listening...",
    "processing": "● Thinking...",
    "speaking": "● Speaking...",
}


def _esc(s: Any) -> str:
    return _html.escape("" if s is None else str(s))


def _fmt_secs(secs) -> str:
    """Compact human duration: 45s · 3m 20s · 1h 5m."""
    try:
        secs = max(0, int(secs))
    except Exception:
        return ""
    h, rem = divmod(secs, 3600)
    m, s = divmod(rem, 60)
    if h:
        return f"{h}h {m}m"
    if m:
        return f"{m}m {s}s" if s else f"{m}m"
    return f"{s}s"


# ── apps.json access (shared with the native editor via _INTEGRATIONS) ───────
def _apps_path() -> str:
    try:
        import wivexx_gui
        return wivexx_gui._INTEGRATIONS.get("apps_json_path", "") or ""
    except Exception:
        return ""


def _read_apps() -> dict:
    path = _apps_path()
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
    except Exception:
        data = {}
    data.setdefault("apps", {})
    data.setdefault("environments", {})
    data.setdefault("audio_commands", {})
    return data


def _write_apps(data: dict) -> bool:
    """Write apps.json and hot-reload it so new commands work immediately."""
    path = _apps_path()
    if not path:
        return False
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    except Exception:
        return False
    try:
        import wivexx_gui
        fn = wivexx_gui._INTEGRATIONS.get("reload_apps")
        if callable(fn):
            fn()
    except Exception:
        pass
    return True


def _env_line(item) -> str:
    return json.dumps(item) if isinstance(item, dict) else str(item)


def _line_to_item(line: str):
    s = (line or "").strip()
    if s.startswith("{"):
        try:
            return json.loads(s)
        except Exception:
            return s
    return s


# ─────────────────────────────────────────────────────────────────────────────
# JS API  — methods callable from the page as  pywebview.api.<name>(...)
# ─────────────────────────────────────────────────────────────────────────────
class JsApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui
        self._pending_attach: Optional[str] = None
        self._cap_media: list = []                    # caption studio picked files (carousel)
        self._cap_video: Optional[str] = None         # the video the captions EDITOR is on
        self._reel_clip_marks: Optional[dict] = None  # {path, marks}: per-clip time-marks of a built reel

    # the page booted and the bridge is live
    def ready(self) -> None:
        self._gui._on_ready()
        try:                       # optional personal add-on (module never ships)
            import wivexx_rephotos
            wivexx_rephotos.on_ready(self._gui)
        except ImportError:
            pass
        except Exception:
            pass

    def re_photos(self, method=None, payload=None) -> dict:
        """Bridge for the optional personal photos add-on; inert when absent."""
        try:
            import wivexx_rephotos
        except ImportError:
            return {"ok": False, "off": True}
        try:
            return wivexx_rephotos.dispatch(self._gui, method, payload)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def send_text(self, text: str) -> None:
        import wivexx_gui
        text = (text or "").strip()
        att = self._pending_attach
        self._pending_attach = None
        # A dropped/attached AUDIO or VIDEO file is handled by the transcription
        # system — but ONLY when the user asks ("transcribe"). Otherwise we keep it
        # attached (it's not something Claude's vision can read) + hint.
        if att and self._is_media(att):
            if self._wants_transcribe(text):
                self._transcribe_attachment(att)
                return
            self._pending_attach = att                 # keep it pending for next send
            try:
                self._gui.js("attachChip", self._chip_for(att))
                self._gui.toast('Type "transcribe" to transcribe that file.')
            except Exception:
                pass
            return
        if not text and not att:
            return
        submit = wivexx_gui._INTEGRATIONS.get("submit_text")
        if submit is None:
            self._gui.toast("Text input not ready yet — try again in a moment.")
            return
        try:
            submit(text, att)                          # att = image path (or None) → vision
        except Exception:
            pass

    # ── attachment helpers (shared by the picker, drag-drop, and transcribe) ──
    @staticmethod
    def _is_media(path: str) -> bool:
        try:
            import wivexx_transcribe as _wt
            return bool(path) and _wt.is_media(path)
        except Exception:
            return False

    @staticmethod
    def _wants_transcribe(text: str) -> bool:
        return "transcri" in (text or "").lower()      # transcribe / transcript / transcription

    @staticmethod
    def _chip_for(path: str) -> dict:
        thumb = _img_thumb_data_url(path)
        return {"ok": True, "name": os.path.basename(path),
                "kind": "image" if thumb else "file", "thumb": thumb}

    def _transcribe_attachment(self, path: str) -> None:
        try:
            self._gui.show_main()
        except Exception:
            pass

        def _go():
            try:
                import wivexx_api
                import wivexx_gui
                wivexx_api._do_transcribe(path, wivexx_gui, "transcribe (attachment)")
            except Exception as _te:
                _logger.warning("attachment transcription failed: %s", _te)
        threading.Thread(target=_go, daemon=True, name="wivexx-attach-transcribe").start()

    # ── media tools on the current attachment (wivexx_media) ──────────────────
    def media_caps(self) -> dict:
        """What kind of file is attached + which operations apply (drives the UI)."""
        p = self._pending_attach
        if not p:
            return {"ok": False, "kind": "none"}
        import wivexx_media as mm
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        if ext in mm.VIDEO_FORMATS:
            kind = "video"
        elif ext in mm.AUDIO_FORMATS:
            kind = "audio"
        elif ext in mm.IMAGE_WRITE or ext in mm.IMAGE_READ_EXTRA:
            kind = "image"
        else:
            kind = "file"
        return {"ok": True, "kind": kind, "name": os.path.basename(p), "ffmpeg": mm.has_ffmpeg(),
                "image_formats": ["png", "jpg", "webp", "tiff", "bmp", "gif", "ico"],
                "video_formats": ["mp4", "mkv", "mov", "webm", "gif", "avi"],
                "audio_formats": ["mp3", "wav", "flac", "aac", "m4a", "ogg", "opus"]}

    def media_palette(self, n: int = 6) -> dict:
        p = self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach an image first."}
        try:
            import wivexx_media as mm
            return mm.palette(p, int(n or 6))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def media_run(self, op: str, params: Optional[dict] = None) -> dict:
        """Run convert / resize / compress on the attachment. Images are quick →
        synchronous; audio/video can take a while → background thread + toast/notif."""
        p = self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach a file first."}
        import wivexx_media as mm
        params = params or {}
        q = params.get("quality", "high")
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        is_image = ext in mm.IMAGE_WRITE or ext in mm.IMAGE_READ_EXTRA

        def _do_image():
            if op == "convert":
                return mm.convert_image(p, params.get("fmt", "png"), quality=q)
            if op == "resize":
                return mm.resize_image(p, width=params.get("width"), height=params.get("height"),
                                       scale=params.get("scale"), max_side=params.get("max_side"), quality=q)
            if op == "compress":
                return mm.compress_image(p, target_kb=params.get("target_kb"),
                                         quality=params.get("quality", "medium"))
            return {"ok": False, "error": "Unknown op"}

        def _do_media():
            if op == "convert":
                return mm.convert_media(p, params.get("fmt", "mp4"), quality=q)
            if op == "resize":
                return mm.resize_video(p, width=params.get("width"), height=params.get("height"),
                                       scale=params.get("scale"), max_side=params.get("max_side"), quality=q)
            if op == "compress":
                return mm.compress_video(p, target_mb=params.get("target_mb"),
                                         quality=params.get("quality", "medium"))
            return {"ok": False, "error": "Unknown op"}

        if is_image:
            r = _do_image()
            if r.get("ok"):
                self._gui.toast("Saved → " + os.path.basename(r["path"]))
            else:
                self._gui.toast("Failed: " + str(r.get("error", ""))[:70])
            return r

        if not mm.has_ffmpeg():
            return {"ok": False, "error": "Video tools need ffmpeg (not found)."}

        def _go():
            r = _do_media()
            try:
                if r.get("ok"):
                    self._gui.toast("Saved → " + os.path.basename(r["path"]))
                    self._gui._add_notif("media", "Media saved", sub=os.path.basename(r["path"]),
                                         status="done", file=r.get("path"))
                else:
                    self._gui.toast("Media failed: " + str(r.get("error", ""))[:70])
                    self._gui._add_notif("media", "Media op failed",
                                         sub=str(r.get("error", ""))[:80], status="failed")
            except Exception:
                pass
        self._gui.toast("Working on it…")
        threading.Thread(target=_go, daemon=True, name="wivexx-media-op").start()
        return {"ok": True, "async": True}

    def transcribe_attachment(self) -> dict:
        p = self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach an audio or video file first."}
        if not self._is_media(p):
            return {"ok": False, "error": "That file isn't audio or video."}
        self._transcribe_attachment(p)
        return {"ok": True, "async": True}

    def copy_clip(self, text: str) -> dict:
        """Copy text (e.g. a hex code) to the Windows clipboard via clip.exe."""
        try:
            import subprocess
            subprocess.run(["clip"], input=str(text), text=True,
                           creationflags=0x08000000)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def make_captions(self, opts: Optional[dict] = None) -> dict:
        """Auto-caption the attached video (transcribe → styled ASS → burn). Runs in
        the background; opts = {type:'highlight|word|sentence', position, primary,
        highlight, translate_to:'en|es'}. Toasts + notifies on completion."""
        p = self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach a video first."}
        import wivexx_media as mm
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        if ext not in mm.VIDEO_FORMATS or ext == "gif":
            return {"ok": False, "error": "Attach a video file."}
        if not mm.has_ffmpeg():
            return {"ok": False, "error": "Captions need ffmpeg (not found)."}

        def _go():
            try:
                import wivexx_api
                import wivexx_gui
                wivexx_api._do_make_captions(p, opts or {}, wivexx_gui)
            except Exception as e:
                try:
                    self._gui.toast("Captions failed: " + str(e)[:60])
                except Exception:
                    pass
        self._gui.toast("Working on captions…")
        threading.Thread(target=_go, daemon=True, name="wivexx-captions").start()
        return {"ok": True, "async": True}

    def mascot_export(self, payload: Optional[dict] = None) -> dict:
        """MASCOT STUDIO: assemble captured dot frames (RGBA PNG data-URLs from
        WivexxDot.captureClip) into a transparent overlay clip. payload = {frames:[dataURL],
        fps, formats:['prores'|'pngseq'], name, audio?}. Returns {ok, outputs:{fmt:path}}."""
        payload = payload or {}
        frames = payload.get("frames") or []
        if not frames:
            return {"ok": False, "error": "no frames captured"}
        try:
            import wivexx_mascot as ms
            base = os.path.dirname(os.path.abspath(__file__))
            out_dir = os.path.join(base, "mascot_clips")
            audio_b64 = payload.get("audio")
            sfx = payload.get("sfx")                       # bundled sound effect name
            if sfx and sfx != "none":
                sfx_path = os.path.join(base, "mascot_sfx", str(sfx) + ".m4a")
                if os.path.isfile(sfx_path):
                    import base64 as _b64
                    audio_b64 = "data:audio/mp4;base64," + _b64.b64encode(open(sfx_path, "rb").read()).decode()
            r = ms.render_overlay(frames, fps=int(payload.get("fps") or 30),
                                  formats=payload.get("formats") or ["prores"],
                                  audio_b64=audio_b64,
                                  out_dir=out_dir, name=payload.get("name") or "mascot")
        except Exception as e:
            return {"ok": False, "error": str(e)}
        if r.get("ok"):
            try:
                self._gui.toast("Mascot clip exported")
                if getattr(self._gui, "_web", None):
                    p = next(iter((r.get("outputs") or {}).values()), None)
                    self._gui._web._add_notif("media", "Mascot clip exported",
                                              sub=(os.path.basename(p) if p else ""), status="done",
                                              folder=os.path.dirname(p) if p else None, file=p)
            except Exception:
                pass
        return r

    def send_report(self, payload: Optional[dict] = None) -> dict:
        """Send a user report / feedback (with optional small photos) to the wivexx.com
        admin panel. payload = {category, message, contact, photos:[data-URL]}."""
        payload = payload or {}
        msg = (payload.get("message") or "").strip()
        photos = [p for p in (payload.get("photos") or [])
                  if isinstance(p, str) and p.startswith("data:image/")][:3]
        if not msg and not photos:
            return {"ok": False, "error": "Write a message or attach a photo first."}
        try:
            import json as _json
            import urllib.request as _rq
            import platform as _pf
            try:
                import wivexx_api as _wa
                ver = getattr(_wa, "WIVEXX_VERSION", "")
            except Exception:
                ver = ""
            body = {
                "category": payload.get("category") or "feedback",
                "message": msg[:4000],
                "contact": (payload.get("contact") or "")[:200],
                "app_version": ver,
                "os": f"{_pf.system()} {_pf.release()}",
                "photos": photos,
            }
            req = _rq.Request("https://wivexx.com/api/reports",
                              data=_json.dumps(body).encode("utf-8"),
                              headers={"Content-Type": "application/json"}, method="POST")
            with _rq.urlopen(req, timeout=20) as resp:
                out = _json.loads(resp.read().decode("utf-8"))
            if out.get("ok"):
                try:
                    self._gui.toast("Report sent — thank you!")
                except Exception:
                    pass
                return {"ok": True}
            return {"ok": False, "error": out.get("error") or "send failed"}
        except Exception as e:
            _logger.warning("send_report failed: %s", e)
            return {"ok": False, "error": "Couldn't reach wivexx.com — check your connection."}

    # ── Microphone selection (Settings → Microphone) ─────────────────────────
    def list_mics(self) -> dict:
        """Selectable input devices + the current selection, for the Settings
        microphone picker. Enumerated live via sounddevice, so it's PC-independent."""
        devices, seen = [], set()
        try:
            import sounddevice as _sd
            try:
                default_in = _sd.default.device[0]
            except Exception:
                default_in = None
            for i, d in enumerate(_sd.query_devices()):
                try:
                    if d.get("max_input_channels", 0) <= 0:
                        continue
                    name = (d.get("name") or "").strip()
                    if not name or name.lower() in seen:
                        continue
                    seen.add(name.lower())
                    devices.append({"index": i, "name": name,
                                    "default": (i == default_in)})
                except Exception:
                    continue
        except Exception as e:
            return {"ok": False, "error": str(e), "devices": [], "current": None}
        current = None
        try:
            import wivexx_settings as _ws
            current = _ws.get("mic_device", None)
        except Exception:
            current = None
        return {"ok": True, "devices": devices, "current": current}

    def set_mic(self, payload=None) -> dict:
        """Persist the chosen microphone. payload {"device": <index|null>}; null /
        "default" clears the override (system default). Applies to the next capture."""
        dev = payload.get("device", None) if isinstance(payload, dict) else None
        if dev in ("", "default", None):
            value = None
        else:
            try:
                value = int(dev)
            except Exception:
                value = str(dev)
        try:
            import wivexx_settings as _ws
            _ws.set("mic_device", value)
        except Exception as e:
            return {"ok": False, "error": str(e)}
        try:
            _logger.info("Microphone set to %r via Settings", value)
        except Exception:
            pass
        return {"ok": True, "current": value}

    # ── Language (Settings → Language) ───────────────────────────────────────
    def get_language(self) -> dict:
        """Current language + the options offered by the picker."""
        cur = "en"
        try:
            import wivexx_i18n as _i18n
            cur = _i18n.current_language()
        except Exception:
            cur = "en"
        # v1.51: interface language ONLY (en/es). Spoken-language detection is
        # always automatic and no longer a menu choice — so no "auto" option here.
        return {"ok": True, "current": cur,
                "options": [
                    {"code": "en", "label": "English"},
                    {"code": "es", "label": "Spanish"},
                ]}

    def set_language(self, payload=None) -> dict:
        """Persist the chosen language and apply it. UI + voice + replies switch
        immediately; the voice-INPUT model reloads in the background so spoken
        commands in the new language are understood."""
        code = payload.get("language", None) if isinstance(payload, dict) else None
        code = (str(code or "en")).lower()
        if code not in ("en", "es"):   # v1.51: "auto" retired → interface is en/es only
            code = "en"
        try:
            import wivexx_settings as _ws
            _ws.set("language", code)
        except Exception as e:
            return {"ok": False, "error": str(e)}
        # push the new language to the page immediately (UI re-translates)
        try:
            self._gui.js("setLanguage", code)
        except Exception:
            pass
        # reload the speech model so the listener understands the new language
        reloaded = False
        try:
            import wivexx_gui
            fn = wivexx_gui._INTEGRATIONS.get("reload_stt")
            if callable(fn):
                fn()
                reloaded = True
        except Exception as e:
            _logger.debug("reload_stt after language switch failed: %s", e)
        # v1.58: first time this language is used → offer the guided wake-word
        # tuning IN that language (builds its per-language wake profile).
        try:
            import wivexx_gui
            fn = wivexx_gui._INTEGRATIONS.get("wake_enroll_check")
            if callable(fn):
                fn(code)
        except Exception as e:
            _logger.debug("wake enroll check after language switch failed: %s", e)
        try:
            _logger.info("Language set to %s via Settings (stt reload=%s)", code, reloaded)
        except Exception:
            pass
        return {"ok": True, "current": code, "stt_reloaded": reloaded}

    # ── General customization settings (Settings menu) ───────────────────────
    _SETTING_DEFAULTS = {
        "autostart": None,            # None → fall back to cfg.AUTOSTART_ENABLED
        "auto_update": False,         # v1.34: hands-free update on boot
        "mute_voice": False,
        "tts_volume": 100,
        "overlay_on_minimize": True,
        "low_resource": False,
        "close_to_tray": True,
        "reduce_animations": False,
        "osd_in_gaming": True,        # v1.34: perf overlay auto-shows in optimized mode
        "welcome_done": False,        # v1.57: first-run tour seen (hidden — no Settings row)
        "wake_require_voice": False,  # v1.57 Tier 2: only wake for the enrolled owner's voice
        "wake_acoustic": True,        # v1.57 Tier 3: use the openWakeWord acoustic detector when available
        # NOTE: startup_greeting + disk_warnings were removed from the Settings
        # menu (v1.34) — they duplicated the Notifications panel (boot_greeting /
        # drive_full in wivexx_notify_prefs), which is now their single home.
    }

    def get_settings(self) -> dict:
        """Every customizable setting + its current value, for the Settings menu."""
        try:
            import wivexx_settings as _ws
        except Exception:
            _ws = None
        auto_default = True
        try:
            import wivexx_config as _c
            auto_default = bool(getattr(_c, "AUTOSTART_ENABLED", True))
        except Exception:
            pass
        out = {}
        for k, d in self._SETTING_DEFAULTS.items():
            v = None
            try:
                v = _ws.get(k, None) if _ws else None
            except Exception:
                v = None
            if v is None:
                v = auto_default if k == "autostart" else d
            out[k] = int(v) if k == "tts_volume" else bool(v)
        return {"ok": True, "settings": out}

    def set_setting(self, payload=None) -> dict:
        """Persist a setting + apply its effect. Most are read live where they're
        used; autostart / low_resource / overlay need an action on toggle."""
        key = payload.get("key") if isinstance(payload, dict) else None
        value = payload.get("value") if isinstance(payload, dict) else None
        if key not in self._SETTING_DEFAULTS:
            return {"ok": False, "error": "unknown setting"}
        if key == "tts_volume":
            try:
                value = max(0, min(100, int(value)))
            except Exception:
                value = 100
        else:
            value = bool(value)
        try:
            import wivexx_settings as _ws
            _ws.set(key, value)
        except Exception as e:
            return {"ok": False, "error": str(e)}
        try:
            import wivexx_gui
            ints = getattr(wivexx_gui, "_INTEGRATIONS", {}) or {}
            if key == "autostart":
                fn = ints.get("apply_autostart")
                if callable(fn):
                    fn(value)
            elif key == "low_resource":
                fn = ints.get("apply_low_resource")
                if callable(fn):
                    fn(value)
            elif key == "overlay_on_minimize":
                # reflect immediately if the main window is already hidden/minimized
                if not value:
                    try:
                        wivexx_gui.hide_ball_overlay()
                    except Exception:
                        pass
                elif getattr(self._gui, "_min_hidden", False) or getattr(self._gui, "_hidden", False):
                    try:
                        wivexx_gui.show_ball_overlay("idle")
                    except Exception:
                        pass
        except Exception as e:
            _logger.debug("set_setting(%s) apply failed: %s", key, e)
        try:
            _logger.info("Setting %s = %r via Settings", key, value)
        except Exception:
            pass
        return {"ok": True, "key": key, "value": value}

    def check_update(self) -> dict:
        """Ask wivexx.com whether a newer Wivexx build exists.
        Returns {ok, update, current, latest, notes, url}."""
        try:
            import wivexx_update as up
            return up.check()
        except Exception as e:
            _logger.warning("check_update failed: %s", e)
            return {"ok": False, "update": False, "error": str(e)}

    def apply_update(self, payload: Optional[dict] = None) -> dict:
        """Download the latest build, stage it, launch the detached updater, then quit
        so it can swap files in and relaunch. payload = {url?}."""
        payload = payload or {}
        try:
            import wivexx_update as up
            url = payload.get("url")
            if not url:
                info = up.check()
                if not info.get("update"):
                    return {"ok": False, "error": "You're already on the latest version."}
                url = info.get("url")
            zip_path = up.download(url)
            r = up.apply_update(zip_path)
            if r.get("ok"):
                try:
                    self._gui.toast("Updating Wivexx — it'll relaunch in a moment…")
                except Exception:
                    pass
                import threading as _th
                import time as _t

                def _quit():
                    _t.sleep(1.4)        # let the toast show + the updater start waiting
                    os._exit(0)          # hard exit so the in-use files unlock for the copy
                _th.Thread(target=_quit, daemon=True, name="wivexx-update-quit").start()
            return r
        except Exception as e:
            _logger.warning("apply_update failed: %s", e)
            return {"ok": False, "error": str(e)}

    def make_captions_multi(self, payload: Optional[dict] = None) -> dict:
        """MULTI-LANGUAGE auto-captions in the background: auto-detect the spoken language
        (per-segment, so mid-video switches transcribe right), translate into each requested
        track language, and burn every language track at once. payload = {tracks:[{lang, pos,
        size, primary, highlight, type, font, uppercase, ...}], source_lang?, out?}."""
        p = payload.get("path") if payload else None
        p = p or self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach or pick a video first."}
        import wivexx_media as mm
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        if ext not in mm.VIDEO_FORMATS or ext == "gif":
            return {"ok": False, "error": "That isn't a video."}
        if not mm.has_ffmpeg():
            return {"ok": False, "error": "Captions need ffmpeg (not found)."}
        opts = dict(payload or {})
        opts.pop("path", None)

        def _go():
            try:
                import wivexx_api
                import wivexx_gui
                wivexx_api._do_make_captions_multi(p, opts, wivexx_gui)
            except Exception as e:
                try:
                    self._gui.toast("Captions failed: " + str(e)[:60])
                except Exception:
                    pass
        self._gui.toast("Working on multi-language captions…")
        threading.Thread(target=_go, daemon=True, name="wivexx-captions-multi").start()
        return {"ok": True, "async": True}

    # ── captions editor (Stage 2) ────────────────────────────────────────────
    def _attached_video(self) -> Optional[str]:
        p = self._pending_attach
        if not p:
            return None
        import wivexx_media as mm
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        return p if (ext in mm.VIDEO_FORMATS and ext != "gif") else None

    def captions_transcribe(self) -> dict:
        """Transcribe the attached video (word timing) in the BACKGROUND. The job
        keeps running even if the editor is closed: it shows in the activity tab with
        a Cancel button, and a 'Captions ready' notification (open editor) fires when
        done — so closing the tab mid-transcribe never loses the work."""
        p = self._attached_video()
        if not p:
            return {"ok": False, "error": "Attach a video file."}
        self._cap_video = p                  # the editor's video (preview/export use this)
        g = self._gui
        # reuse an in-flight job for the same file instead of starting a second one
        with g._notif_lock:
            for cj in g._cap_jobs.values():
                if cj.get("path") == p:
                    return {"ok": True, "async": True, "job": cj["id"]}
            g._cap_seq += 1
            jid = g._cap_seq
            ev = threading.Event()
            g._cap_jobs[jid] = {"id": jid, "path": p, "name": os.path.basename(p),
                                "event": ev, "title": "Transcribing captions…",
                                "sub": "“%s”" % os.path.basename(p)}
        try:
            g.js("activityChanged")
        except Exception:
            pass

        def _go():
            cancelled = False
            payload = {"ok": False}
            try:
                import wivexx_captions as cap
                import wivexx_media as mm
                tr = cap.transcribe_segments(p, cancel=ev.is_set)
                if ev.is_set() or tr.get("cancelled"):
                    cancelled = True
                else:
                    info = mm.media_info(p)
                    rc = self._reel_clip_marks
                    marks = rc.get("marks") if (rc and rc.get("path") == p) else None
                    payload = {"ok": tr.get("ok"), "error": tr.get("error"),
                               "segments": tr.get("segments", []), "language": tr.get("language"),
                               "w": info.get("w"), "h": info.get("h"), "duration": info.get("duration"),
                               "name": os.path.basename(p), "path": p, "clip_marks": marks}
            except Exception as e:
                if ev.is_set():
                    cancelled = True
                else:
                    payload = {"ok": False, "error": str(e)}
            finally:
                with g._notif_lock:
                    g._cap_jobs.pop(jid, None)
            if cancelled:
                for fn in ("activityChanged", "captionsCancelled"):
                    try:
                        g.js(fn)
                    except Exception:
                        pass
                return
            try:
                g.js("captionsLoaded", payload)          # update the editor if it's open
            except Exception:
                pass
            if payload.get("ok"):
                g._cap_ready = payload                   # stash for reopen-from-notification
                g._add_notif("caption", "Captions ready — " + payload.get("name", ""),
                             sub="transcribed — tap to open the editor", status="done",
                             open="captionseditor", open_label="open editor")
            else:
                g._add_notif("caption", "Captions failed — " + os.path.basename(p),
                             sub=(payload.get("error") or "")[:140], status="failed")
            try:
                g.js("activityChanged")
            except Exception:
                pass

        g.toast("Transcribing captions…")
        threading.Thread(target=_go, daemon=True, name="wivexx-cap-transcribe").start()
        return {"ok": True, "async": True, "job": jid}

    def captions_cancel(self, jid=None) -> dict:
        """Cancel a running captions transcription (from the activity tab)."""
        g = self._gui
        with g._notif_lock:
            if jid is None:
                targets = list(g._cap_jobs.values())
            else:
                try:
                    j = g._cap_jobs.get(int(jid))
                except Exception:
                    j = None
                targets = [j] if j else []
        for j in targets:
            try:
                j["event"].set()
            except Exception:
                pass
        return {"ok": True}

    def captions_resume(self) -> dict:
        """Return the last completed editor payload (for reopening from the 'Captions
        ready' notification) and restore the attachment so preview/export still work."""
        g = self._gui
        payload = g._cap_ready
        if not payload:
            return {"ok": False, "error": "No finished captions to reopen."}
        if payload.get("path"):
            self._pending_attach = payload["path"]
            self._cap_video = payload["path"]
        return payload

    def _cap_video_path(self) -> Optional[str]:
        """The video the captions editor is working on. Prefer the editor's own
        remembered path (set when transcription started / on reopen) so a live preview
        or export never breaks just because the chat attachment got cleared."""
        p = self._cap_video
        if p and os.path.exists(p):
            return p
        return self._attached_video()

    def captions_video_url(self) -> dict:
        """A loadable file:// URL for the editor's video, so the page can play it in a
        <video> element (live HTML caption overlay) instead of only burning frames.
        Reports DISPLAY dims (rotation-aware) + whether the raw file can decode in the
        webview; if not, the page asks for a transcoded proxy (captions_proxy)."""
        p = self._cap_video_path()
        if not p:
            return {"ok": False, "error": "No video."}
        try:
            import pathlib
            info = {}
            try:
                import wivexx_media as mm
                info = mm.media_info(p)
            except Exception:
                pass
            return {"ok": True, "url": pathlib.Path(p).as_uri(),
                    "w": info.get("w"), "h": info.get("h"), "duration": info.get("duration"),
                    "rotation": info.get("rotation", 0),
                    "playable": bool(info.get("web_playable", True))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def captions_proxy(self) -> dict:
        """Build (or reuse a cached) small, web-playable H.264 proxy of the editor's
        video and hand back its BYTES as base64 — so the page can play it from a Blob
        URL. We can't use a file:// URL: Wivexx's UI is itself loaded via file:// and
        WebView2 won't play a file:// <video> (that's why preview frames are base64
        too). The proxy also normalises the codec (8-bit 4:2:0) + rotation. Runs on a
        pywebview worker thread, so the transcode just resolves the JS promise."""
        p = self._cap_video_path()
        if not p:
            return {"ok": False, "error": "No video."}
        try:
            import base64
            import wivexx_captions as cap
            r = cap.make_preview_proxy(p)
            if not r.get("ok"):
                return {"ok": False, "error": r.get("error", "couldn’t build a preview")}
            with open(r["path"], "rb") as fh:
                b64 = base64.standard_b64encode(fh.read()).decode("ascii")
            return {"ok": True, "b64": b64, "mime": "video/mp4"}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def captions_preview(self, payload: Optional[dict] = None) -> dict:
        """Render one frame with the current captions burned → base64 (live preview)."""
        p = self._cap_video_path()
        if not p:
            return {"ok": False, "error": "No video."}
        payload = payload or {}
        try:
            import wivexx_captions as cap
            return cap.preview_frame(p, payload.get("segments", []), payload.get("opts", {}),
                                     float(payload.get("t", 1.0)))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def captions_export(self, payload: Optional[dict] = None) -> dict:
        """Burn the edited captions into the video (background) + toast/notify."""
        p = self._cap_video_path()
        if not p:
            return {"ok": False, "error": "No video."}
        payload = payload or {}

        def _go():
            try:
                import wivexx_captions as cap
                r = cap.render(p, payload.get("segments", []), payload.get("opts", {}))
                if r.get("ok"):
                    self._gui.toast("Captioned → " + os.path.basename(r["path"]))
                    self._gui._add_notif("media", "Captions exported",
                                         sub=os.path.basename(r["path"]), status="done", file=r["path"])
                else:
                    self._gui.toast("Export failed: " + str(r.get("error", ""))[:60])
            except Exception as e:
                self._gui.toast("Export failed: " + str(e)[:60])
        self._gui.toast("Rendering captions…")
        threading.Thread(target=_go, daemon=True, name="wivexx-cap-export").start()
        return {"ok": True, "async": True}

    def captions_translate(self, payload: Optional[dict] = None) -> dict:
        """Translate the given caption segments (EN↔ES) and return them (the editor
        replaces its list). Word timing is cleared → they render at sentence level."""
        payload = payload or {}
        segs = payload.get("segments") or []
        lang = "es" if str(payload.get("lang", "en")).lower().startswith("es") else "en"
        try:
            import wivexx_api
            return {"ok": True, "segments": wivexx_api._translate_caption_segments(segs, lang)}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ── reel timeline (Phase 3): stitch several clips → one video → captions ──────
    def timeline_drop_add(self) -> dict:
        """A file was DROPPED onto the reel studio → drain the native path(s) and return
        the media items (video/image + duration) so the page adds them to the timeline."""
        files = self._gui._drain_dropped_files()
        if not files:
            return {"ok": False}
        import wivexx_media as mm
        vid_ext = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".ts", ".m2ts",
                   ".mts", ".mpg", ".mpeg", ".wmv")
        img_ext = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif", ".heic", ".heif")
        items = []
        for p in files:
            ext = os.path.splitext(p)[1].lower()
            kind = "video" if ext in vid_ext else ("image" if ext in img_ext else None)
            if not kind:
                continue
            dur = 0.0
            it = {"path": p, "name": os.path.basename(p), "kind": kind, "dur": 0.0}
            if kind == "video":
                try:
                    dur = float(mm.media_info(p).get("duration") or 0)
                except Exception:
                    pass
                try:                                     # auto-detect flat/log footage → flag for the LUT fix
                    import wivexx_timeline as tl
                    if tl.detect_log(p).get("is_log"):
                        it["log"] = True
                        it["logAuto"] = True
                except Exception:
                    pass
            it["dur"] = round(dur, 2)
            items.append(it)
        return {"ok": bool(items), "items": items}

    def timeline_pick_clips(self) -> dict:
        """Pick one or MORE videos/images to add to the reel timeline; probe each for
        duration so the editor can show + trim them."""
        if webview is None or self._gui._window is None:
            return {"ok": False, "error": "No window."}
        try:
            res = self._gui._window.create_file_dialog(
                webview.OPEN_DIALOG, allow_multiple=True,
                file_types=("Video or photo (*.mp4;*.mov;*.avi;*.mkv;*.webm;*.m4v;"
                            "*.jpg;*.jpeg;*.png;*.webp;*.heic;*.bmp;*.gif)",
                            "All files (*.*)"))
        except Exception:
            res = None
        if not res:
            return {"ok": False}
        import wivexx_media as mm
        paths = list(res) if isinstance(res, (list, tuple)) else [res]
        vid_ext = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".ts",
                   ".m2ts", ".mts", ".mpg", ".mpeg", ".wmv")
        items = []
        for p in paths:
            kind = "video" if os.path.splitext(p)[1].lower() in vid_ext else "image"
            dur = 0.0
            try:
                dur = float(mm.media_info(p).get("duration") or 0)
            except Exception:
                pass
            it = {"path": p, "name": os.path.basename(p), "kind": kind, "dur": round(dur, 2)}
            if kind == "video":                          # auto-detect flat/log footage → flag for the LUT fix
                try:
                    import wivexx_timeline as tl
                    if tl.detect_log(p).get("is_log"):
                        it["log"] = True
                        it["logAuto"] = True
                except Exception:
                    pass
            items.append(it)
        return {"ok": True, "items": items}

    def timeline_pick_music(self) -> dict:
        """Pick ONE audio file for the reel's music bed."""
        if webview is None or self._gui._window is None:
            return {"ok": False}
        try:
            res = self._gui._window.create_file_dialog(
                webview.OPEN_DIALOG, allow_multiple=False,
                file_types=("Audio (*.mp3;*.m4a;*.wav;*.aac;*.flac;*.ogg;*.opus;*.wma)",
                            "All files (*.*)"))
        except Exception:
            res = None
        if not res:
            return {"ok": False}
        p = res[0] if isinstance(res, (list, tuple)) else res
        dur = 0.0
        try:
            import wivexx_media as mm
            dur = float(mm.media_info(p).get("duration") or 0)
        except Exception:
            pass
        return {"ok": True, "path": p, "name": os.path.basename(p), "dur": dur}

    def timeline_pick_overlay(self) -> dict:
        """Pick one or MORE images/clips to overlay (logos, stickers, B-roll)."""
        if webview is None or self._gui._window is None:
            return {"ok": False}
        try:
            res = self._gui._window.create_file_dialog(
                webview.OPEN_DIALOG, allow_multiple=True,
                file_types=("Image or video (*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp;"
                            "*.mp4;*.mov;*.webm;*.m4v)", "All files (*.*)"))
        except Exception:
            res = None
        if not res:
            return {"ok": False}
        import wivexx_media as mm
        paths = list(res) if isinstance(res, (list, tuple)) else [res]
        vid_ext = (".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv")
        items = []
        for p in paths:
            kind = "video" if os.path.splitext(p)[1].lower() in vid_ext else "image"
            dur = 0.0
            if kind == "video":
                try:
                    dur = float(mm.media_info(p).get("duration") or 0)
                except Exception:
                    pass
            items.append({"path": p, "name": os.path.basename(p), "kind": kind, "dur": round(dur, 2)})
        return {"ok": True, "items": items}

    def timeline_build(self, payload: Optional[dict] = None) -> dict:
        """Stitch the timeline's clips into ONE normalised reel (crossfade, overlays,
        ducked music, voiceover) — then either hand it to the captions editor OR export
        it to a chosen file/codec. Runs in the BACKGROUND on a daemon thread, so closing
        the reel studio never loses the work: it shows in the activity tab with a Cancel
        button and fires a 'Reel ready' notification when done. Returns immediately."""
        payload = payload or {}
        clips = payload.get("clips") or []
        if not clips:
            return {"ok": False, "error": "Add at least one clip."}
        try:
            import wivexx_timeline as tl
        except Exception as e:
            return {"ok": False, "error": "Timeline engine unavailable: %s" % e}
        w, h = tl.resolve_size(payload.get("preset"), payload.get("w"), payload.get("h"))
        fps = int(payload.get("fps") or 30)
        dest = (payload.get("dest") or "captions").lower()      # "captions" | "file"
        out_path = payload.get("out") or None
        fmt = (payload.get("format") or "h264").lower()
        quality = (payload.get("quality") or "balanced").lower()
        name = payload.get("name") or "reel"
        g = self._gui

        with g._notif_lock:
            g._reel_seq += 1
            jid = g._reel_seq
            ev = threading.Event()
            g._reel_jobs[jid] = {"id": jid, "name": name, "event": ev, "percent": 0,
                                 "title": "Building reel…", "sub": "normalising clips…",
                                 "dest": dest}
        try:
            g.js("activityChanged")
        except Exception:
            pass

        def _setprog(pct, sub=None):
            with g._notif_lock:
                j = g._reel_jobs.get(jid)
                if j:
                    j["percent"] = max(0, min(100, int(pct)))
                    if sub:
                        j["sub"] = sub

        def _prog(fr):
            _setprog(round(float(fr) * 100))
            try:
                g.js("timelineProgress", round(float(fr), 3))
            except Exception:
                pass
            try:
                g.js("activityChanged")
            except Exception:
                pass

        def _go():
            r = {"ok": False}
            try:
                r = tl.build_timeline(
                    clips, w=w, h=h, fps=fps,
                    transition=payload.get("transition", "none"),
                    transition_dur=float(payload.get("transition_dur") or 0.5),
                    overlays=payload.get("overlays") or [],
                    music=(payload.get("music") or None),
                    music_vol=float(payload.get("music_vol") or 0.5),
                    duck=payload.get("duck", True) is not False,
                    background=payload.get("background", "black"),
                    fit=payload.get("fit", "fit"),
                    voiceover=(payload.get("voiceover") or None),
                    voiceover_vol=float(payload.get("voiceover_vol") or 1.0),
                    audio_tracks=(payload.get("audio_tracks") or None),
                    timeline_dur=(payload.get("timeline_dur") or None),
                    progress=_prog, cancel=ev.is_set)
                # ── export to a chosen file (codec / quality / container) ──
                if r.get("ok") and dest == "file" and out_path and not ev.is_set():
                    _setprog(96, "encoding the export…")
                    try:
                        g.js("activityChanged")
                    except Exception:
                        pass
                    ex = tl.export_encode(r["path"], out_path, fmt=fmt, quality=quality, fps=fps)
                    if ex.get("ok"):
                        r["path"] = ex["path"]
                        r["kb"] = ex.get("kb") or r.get("kb")
                    else:
                        r = {"ok": False, "error": ex.get("error") or "export failed"}
            except Exception as e:
                r = {"ok": False, "error": str(e)}
            finally:
                with g._notif_lock:
                    g._reel_jobs.pop(jid, None)

            if ev.is_set() or r.get("cancelled"):
                for fn in ("activityChanged", "reelCancelled"):
                    try:
                        g.js(fn)
                    except Exception:
                        pass
                return

            if r.get("ok"):
                g._reel_ready = dict(r, name=name, dest=dest)
                if dest == "file":
                    g._add_notif("reel", "Reel exported — " + name,
                                 sub="saved to " + os.path.basename(out_path or r["path"]),
                                 status="done", folder=os.path.dirname(r["path"]),
                                 file=r["path"], file_label="▶ open video")
                else:
                    self._pending_attach = r["path"]      # hand the reel to captions
                    self._cap_video = r["path"]
                    self._reel_clip_marks = {"path": r["path"], "marks": r.get("clip_marks") or []}
                    g._add_notif("reel", "Reel ready — " + name,
                                 sub="built — tap to add captions", status="done",
                                 open="captionseditor", open_label="add captions")
                try:
                    g.js("reelBuilt", {"ok": True, "dest": dest, "path": r["path"],
                                       "name": name, "w": r.get("w"), "h": r.get("h"),
                                       "duration": r.get("duration"), "kb": r.get("kb")})
                except Exception:
                    pass
            else:
                g._add_notif("reel", "Reel build failed — " + name,
                             sub=(r.get("error") or "")[:140], status="failed")
                try:
                    g.js("reelBuilt", {"ok": False, "error": r.get("error")})
                except Exception:
                    pass
            try:
                g.js("activityChanged")
            except Exception:
                pass

        g.toast("Building reel…")
        threading.Thread(target=_go, daemon=True, name="wivexx-reel-build").start()
        return {"ok": True, "async": True, "job": jid}

    def timeline_cancel(self, jid=None) -> dict:
        """Cancel a running reel build (from the activity tab or the studio)."""
        g = self._gui
        with g._notif_lock:
            if jid is None:
                targets = list(g._reel_jobs.values())
            else:
                try:
                    j = g._reel_jobs.get(int(jid))
                except Exception:
                    j = None
                targets = [j] if j else []
        for j in targets:
            try:
                j["event"].set()
            except Exception:
                pass
        return {"ok": True}

    def timeline_auto_edit(self, payload: Optional[dict] = None) -> dict:
        """AI auto-edit: take the chosen clip paths (IN ORDER), transcribe each, cut the
        silences (speech-aware), and return a tight, dynamic clip list for V1. Runs in the
        background (transcription is slow) + shows in the activity tab; on completion the
        page gets autoEditDone({ok, clips, ...})."""
        payload = payload or {}
        paths = [p for p in (payload.get("paths") or []) if p]
        if not paths:
            return {"ok": False, "error": "Select at least one clip in the media bin."}
        g = self._gui
        with g._notif_lock:
            g._reel_seq += 1
            jid = g._reel_seq
            ev = threading.Event()
            g._reel_jobs[jid] = {"id": jid, "name": "auto-edit", "event": ev, "percent": 0,
                                 "title": "Auto-editing…", "sub": "listening to your clips…", "dest": "autoedit"}
        try:
            g.js("activityChanged")
        except Exception:
            pass

        def _prog(fr):
            with g._notif_lock:
                j = g._reel_jobs.get(jid)
                if j:
                    j["percent"] = max(0, min(100, int(float(fr) * 100)))
            for fn, a in (("autoEditProgress", round(float(fr), 3)), ("activityChanged", None)):
                try:
                    g.js(fn, a) if a is not None else g.js(fn)
                except Exception:
                    pass

        def _go():
            r = {"ok": False}
            try:
                import wivexx_autoedit as ae
                r = ae.auto_edit(paths, payload.get("settings") or {}, progress=_prog, cancel=ev.is_set)
            except Exception as e:
                r = {"ok": False, "error": str(e)}
            finally:
                with g._notif_lock:
                    g._reel_jobs.pop(jid, None)
            if ev.is_set() or r.get("cancelled"):
                try:
                    g.js("activityChanged")
                except Exception:
                    pass
                return
            if r.get("ok"):
                g._add_notif("reel", "Auto-edit ready",
                             sub="%d cuts · %ss of silence removed" % (r.get("count", 0), r.get("removed_s", 0)),
                             status="done")
            else:
                g._add_notif("reel", "Auto-edit failed", sub=(r.get("error") or "")[:140], status="failed")
            for fn in (("autoEditDone", r), ("activityChanged", None)):
                try:
                    g.js(fn[0], fn[1]) if fn[1] is not None else g.js(fn[0])
                except Exception:
                    pass

        g.toast("Auto-editing your clips…")
        threading.Thread(target=_go, daemon=True, name="wivexx-auto-edit").start()
        return {"ok": True, "async": True, "job": jid}

    def timeline_pick_export(self, payload: Optional[dict] = None) -> dict:
        """Save-dialog for 'export reel to file' — defaults the name + extension to the
        chosen format."""
        if webview is None or self._gui._window is None:
            return {"ok": False, "error": "No window."}
        payload = payload or {}
        try:
            import wivexx_timeline as tl
            ext = tl.export_ext(payload.get("format"))
        except Exception:
            ext = ".mp4"
        base = (payload.get("name") or "reel").strip() or "reel"
        base = "".join(ch for ch in base if ch not in '\\/:*?"<>|').strip() or "reel"
        try:
            res = self._gui._window.create_file_dialog(
                webview.SAVE_DIALOG, save_filename=base + ext,
                file_types=("Video (*%s)" % ext, "All files (*.*)"))
        except Exception:
            res = None
        if not res:
            return {"ok": False}
        p = res[0] if isinstance(res, (list, tuple)) else res
        if not os.path.splitext(p)[1]:
            p += ext
        return {"ok": True, "path": p, "name": os.path.basename(p)}

    def timeline_waveform(self, payload: Optional[dict] = None) -> dict:
        """Render a waveform image (base64 PNG) for an audio file — for the canvas lanes."""
        payload = payload or {}
        p = payload.get("path")
        if not p or not os.path.isfile(p):
            return {"ok": False, "error": "no audio"}
        try:
            import wivexx_timeline as tl, wivexx_media as mm
            r = tl.waveform(p, width=int(payload.get("width") or 600),
                            height=int(payload.get("height") or 38),
                            color=payload.get("color") or "6AA0FF")
            if isinstance(r, dict) and r.get("ok"):
                try:
                    r["dur"] = float(mm.media_info(p).get("duration") or 0)
                except Exception:
                    pass
            return r
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def timeline_frame(self, payload: Optional[dict] = None) -> dict:
        """A framed preview frame at timeline time `t` — powers the canvas scrub playhead."""
        payload = payload or {}
        try:
            import wivexx_timeline as tl
            w, h = tl.resolve_size(payload.get("preset"), payload.get("w"), payload.get("h"))
            return tl.preview_frame_at(payload.get("clips") or [], payload.get("t", 0), w=w, h=h,
                                       background=payload.get("background", "black"),
                                       fit=payload.get("fit", "fit"))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def timeline_prep(self, payload: Optional[dict] = None) -> dict:
        """Background: build the small preview proxies for the timeline's clips so canvas
        scrubbing is fast (proxies decode ~10× quicker than 4K 10-bit sources). Cached +
        idempotent — fire-and-forget; pushes 'timelineProxyReady' when done."""
        payload = payload or {}
        clips = payload.get("clips") or []
        if not clips:
            return {"ok": True, "skipped": True}
        g = self._gui

        def _go():
            try:
                import wivexx_timeline as tl
                r = tl.build_proxies(clips)
                try:
                    g.js("timelineProxyReady", r)
                except Exception:
                    pass
            except Exception:
                pass

        threading.Thread(target=_go, daemon=True, name="wivexx-tl-prep").start()
        return {"ok": True, "async": True}

    # ── saved reel projects (create / save / load) ───────────────────────────────
    def project_list(self) -> dict:
        try:
            import wivexx_projects as pj
            return {"ok": True, "projects": pj.list_projects()}
        except Exception as e:
            return {"ok": False, "error": str(e), "projects": []}

    def project_save(self, payload: Optional[dict] = None) -> dict:
        payload = payload or {}
        try:
            import wivexx_projects as pj
            pid, name = pj.save_project(payload.get("name"), payload.get("data") or {}, payload.get("id"))
            return {"ok": True, "id": pid, "name": name}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def project_load(self, pid) -> dict:
        try:
            import wivexx_projects as pj
            data = pj.load_project(pid)
            return {"ok": data is not None, "data": data} if data is not None \
                else {"ok": False, "error": "project not found"}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def project_delete(self, pid) -> dict:
        try:
            import wivexx_projects as pj
            return {"ok": pj.delete_project(pid)}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def translate_image(self, lang: str = "en") -> dict:
        """Translate the text inside the attached image (EN↔ES) and recompose it in
        place. Runs in the background (Claude vision + Pillow); toasts on done."""
        p = self._pending_attach
        if not p:
            return {"ok": False, "error": "Attach an image first."}
        import wivexx_media as mm
        ext = os.path.splitext(p)[1].lower().lstrip(".")
        if not (ext in mm.IMAGE_WRITE or ext in mm.IMAGE_READ_EXTRA):
            return {"ok": False, "error": "That isn't an image."}
        lang = "es" if str(lang).lower().startswith("es") else "en"

        def _go():
            try:
                import wivexx_api
                import wivexx_gui
                wivexx_api._do_translate_image(p, lang, wivexx_gui, "translate image")
            except Exception as e:
                try:
                    self._gui.toast("Translate failed: " + str(e)[:60])
                except Exception:
                    pass
        self._gui.toast("Reading the image…")
        threading.Thread(target=_go, daemon=True, name="wivexx-translate-image").start()
        return {"ok": True, "async": True}

    def eyedropper_pick(self) -> dict:
        """Arm a one-shot eyedropper: the NEXT click anywhere samples that pixel's
        hex and copies it (for invoking from the command palette / UI). The instant
        sample-under-cursor variant runs off the global hotkey instead."""
        try:
            import wivexx_eyedropper as _eye

            def _notify(hexv):
                self._gui.toast(("copied " + hexv) if hexv else "couldn't read that colour")
            if _eye.arm_pick(notify=_notify):
                self._gui.toast("Eyedropper armed — click any colour…")
                return {"ok": True, "armed": True}
            return {"ok": False, "error": "Eyedropper needs pynput."}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def attach_image(self) -> dict:
        """Open a native file picker; remember the path for the next send and
        return a preview chip {ok, name, kind, thumb} so the page can show it
        with a remove button before sending."""
        if webview is None or self._gui._window is None:
            return {"ok": False}
        try:
            # the control room accepts ANY file (image → vision, audio/video →
            # transcribe/captions/media tools, anything else → attach), so default
            # the picker to all files rather than only photos.
            res = self._gui._window.create_file_dialog(
                webview.OPEN_DIALOG, allow_multiple=False,
                file_types=("All files (*.*)",
                            "Images (*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.gif;*.heic)",
                            "Video (*.mp4;*.mov;*.mkv;*.webm;*.m4v;*.avi;*.wmv)",
                            "Audio (*.mp3;*.wav;*.m4a;*.flac;*.aac;*.ogg;*.opus)"))
        except Exception:
            res = None
        if res:
            path = res[0] if isinstance(res, (list, tuple)) else res
            self._pending_attach = path
            thumb = _img_thumb_data_url(path)
            return {"ok": True, "name": os.path.basename(path),
                    "kind": "image" if thumb else "file", "thumb": thumb}
        return {"ok": False}

    def drop_notify(self) -> dict:
        """The page calls this on a file drop (it also fires WebView2's native
        FilesDropped to capture the real path). We drain the captured path(s) and
        attach the first — robust even if pywebview's own drop callback never fires."""
        try:
            self._gui._drain_dropped_paths()
        except Exception as e:
            _logger.warning("drop_notify drain failed: %s", e)
        return {"ok": True}

    def clear_attachment(self) -> dict:
        """Drop the pending attachment (the × on the preview chip)."""
        self._pending_attach = None
        return {"ok": True}

    # ── caption studio (in-page panel) ───────────────────────────────────────
    def cap_pick_media(self) -> dict:
        """Pick one or MORE photos/videos the captions are written for. Multiple
        images = an Instagram CAROUSEL, analysed in order as one set. Picking adds
        to the set (de-duped); the × on each thumb removes just that one."""
        if webview is None or self._gui._window is None:
            return self._cap_items()
        try:
            res = self._gui._window.create_file_dialog(
                webview.OPEN_DIALOG, allow_multiple=True,
                file_types=("Video or photo (*.mp4;*.mov;*.avi;*.mkv;*.webm;*.m4v;"
                            "*.jpg;*.jpeg;*.png;*.webp;*.heic;*.bmp;*.gif)",
                            "All files (*.*)"))
        except Exception:
            res = None
        if not res:
            return self._cap_items()
        paths = list(res) if isinstance(res, (list, tuple)) else [res]
        vid_ext = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".ts",
                   ".m2ts", ".mts", ".mpg", ".mpeg", ".wmv")
        have = {m.get("path") for m in self._cap_media}
        for path in paths:
            if path in have:
                continue
            kind = "video" if os.path.splitext(path)[1].lower() in vid_ext else "image"
            self._cap_media.append({"path": path, "kind": kind})
        return self._cap_items()

    def _cap_items(self) -> dict:
        """The current carousel set, with thumbnails, for the UI strip."""
        items = [{"name": os.path.basename(m["path"]), "kind": m["kind"],
                  "thumb": "" if m["kind"] == "video" else _img_thumb_data_url(m["path"])}
                 for m in self._cap_media]
        return {"ok": True, "items": items, "count": len(items)}

    def cap_remove_media(self, idx) -> dict:
        try:
            i = int(idx)
            if 0 <= i < len(self._cap_media):
                self._cap_media.pop(i)
        except Exception:
            pass
        return self._cap_items()

    def cap_clear_media(self) -> dict:
        self._cap_media = []
        return {"ok": True, "items": [], "count": 0}

    def cap_generate(self, payload) -> dict:
        """Generate (or revise) a caption pack. Runs on a pywebview worker
        thread, so the heavy Claude call just resolves the JS promise when done.
        Video frames / image bytes are built here from the picked file."""
        import wivexx_gui
        ints = wivexx_gui._INTEGRATIONS
        gen = ints.get("caption_generate")
        if gen is None:
            return {"ok": False, "error": "Caption engine not available yet."}
        payload = dict(payload or {})
        payload.setdefault("frames_b64", [])
        media = list(self._cap_media or [])
        if media:
            imgs = [m for m in media if m.get("kind") == "image"]
            vids = [m for m in media if m.get("kind") == "video"]
            payload["file_name"] = os.path.basename(media[0]["path"])
            payload["n_images"] = len(imgs)
            payload["media_kind"] = ("carousel" if len(imgs) > 1
                                     else ("video" if vids and not imgs else "image"))
            frames = []
            try:
                enc = ints.get("encode_image")
                for m in imgs[:10]:          # every carousel slide, in order
                    b = enc(m["path"]) if enc else None
                    if b:
                        frames.append(b)
                if vids and not imgs:        # a single video → sample frames
                    vf = ints.get("video_frames")
                    if vf:
                        frames = vf(vids[0]["path"], 3)
            except Exception:
                pass
            payload["frames_b64"] = frames[:10]
        self._gui._cap_running = True
        try:
            self._gui.js("activityChanged")     # show "drafting…" live if open
        except Exception:
            pass
        try:
            res = gen(payload)
        except Exception as e:
            res = {"ok": False, "error": str(e)}
        finally:
            self._gui._cap_running = False
        # persist the pack so closing the panel mid-run never loses it, and log
        # an activity notification that re-opens the captions panel on the result
        if isinstance(res, dict) and res.get("ok") and res.get("text"):
            label = (payload.get("topic") or payload.get("file_name")
                     or "your post")
            self._gui._last_caption = {"text": res.get("text", ""),
                                       "opts": payload, "ts": time.time()}
            if not payload.get("feedback"):     # only the first draft notifies
                self._gui._add_notif("caption", "Captions ready",
                                     sub=str(label)[:80], status="done",
                                     open="captions")
        return res

    def cap_last(self) -> dict:
        """The last caption pack (so re-opening the panel restores it even if it
        was closed before generation finished)."""
        d = self._gui._last_caption
        return d if isinstance(d, dict) else {}

    # ── activity / notifications center ───────────────────────────────────────
    def notifs_get(self) -> dict:
        return self._gui.notifs_get()

    def notif_dismiss(self, nid) -> dict:
        return self._gui.notif_dismiss(nid)

    def notif_clear(self) -> dict:
        return self._gui.notif_clear()

    def timer_cancel(self, tid) -> list:
        """Cancel a running timer from the activity panel."""
        try:
            import wivexx_gui
            fn = wivexx_gui._INTEGRATIONS.get("cancel_timer")
            if callable(fn):
                fn(tid)
        except Exception:
            pass
        return self._gui._timer_data()

    def shutdown_cancel(self) -> dict:
        """Cancel the armed system shutdown from the activity panel (the danger countdown)."""
        try:
            import wivexx_gui
            fn = wivexx_gui._INTEGRATIONS.get("cancel_shutdown")
            ok = bool(fn()) if callable(fn) else False
        except Exception:
            ok = False
        return {"ok": ok}

    def open_panel(self, name: str) -> None:
        """Raise the control room and open an in-page panel (used by activity
        'open' actions, e.g. jump to the finished captions)."""
        try:
            self._gui.open_panel(name)
        except Exception:
            pass

    # ── deliver studio (in-page panel; progress via the transfer overlay) ─────
    def dlv_pick_folder(self) -> dict:
        if webview is None or self._gui._window is None:
            return {"ok": False}
        try:
            res = self._gui._window.create_file_dialog(webview.FOLDER_DIALOG)
        except Exception:
            res = None
        if not res:
            return {"ok": False}
        path = res[0] if isinstance(res, (list, tuple)) else res
        import wivexx_gui
        dlv = wivexx_gui._INTEGRATIONS.get("deliver")
        n = 0
        try:
            if dlv:
                n = dlv.count_raws(path)
        except Exception:
            n = 0
        return {"ok": True, "path": path,
                "name": os.path.basename(path.rstrip("\\/")) or path, "count": n}

    def dlv_auth_status(self) -> str:
        import wivexx_gui
        dlv = wivexx_gui._INTEGRATIONS.get("deliver")
        try:
            return dlv.drive_auth_status() if dlv else "need_credentials"
        except Exception:
            return "need_credentials"

    def dlv_run(self, opts) -> dict:
        """Run convert+upload. Blocks this worker thread until done (progress is
        shown live via the transfer overlay/card); returns the result."""
        return self._gui.run_delivery(dict(opts or {}))

    def copy_text(self, text: str) -> dict:
        """Put text on the Windows clipboard (caption-pack copy buttons)."""
        try:
            import wivexx_clipboard
            return {"ok": bool(wivexx_clipboard.set_text(text or ""))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def open_url(self, url: str) -> dict:
        """Open a link in the default browser (delivery share link)."""
        try:
            import webbrowser
            webbrowser.open(url)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def open_folder(self, path: str) -> dict:
        """Reveal a local folder (the developed-JPG output)."""
        try:
            import os as _os
            _os.startfile(path)            # noqa: S606 (Windows-only, user path)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def open_file(self, path: str) -> dict:
        """Open a local file in its default app (e.g. a finished transcript)."""
        try:
            import os as _os
            _os.startfile(path)            # noqa: S606 (Windows-only, user path)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ── drive cleanup tab (large-stale-file results) ─────────────────────────
    def disk_results(self) -> dict:
        """Structured file list from the last drive scan, for the cleanup tab."""
        try:
            import wivexx_disk
            return wivexx_disk.last_results()
        except Exception as e:
            return {"files": [], "error": str(e)}

    def disk_reveal(self, path: str) -> dict:
        """Open Explorer with the file selected (so you can see it in context)."""
        try:
            import os as _os, subprocess
            if not path or not _os.path.exists(path):
                return {"ok": False, "error": "that file is no longer there"}
            subprocess.Popen(["explorer", "/select,", _os.path.normpath(path)])
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def disk_delete(self, paths) -> dict:
        """Send the selected large files to the Recycle Bin (recoverable)."""
        try:
            import wivexx_disk
            r = wivexx_disk.move_to_recycle(paths or [])
            return {"ok": True, **r}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def tool(self, name: str) -> dict:
        """A tool chip was clicked. Native editors open a window and return
        {native:true}; data tools return {title, html} for the slide-over."""
        try:
            return self._gui.build_tool(name)
        except Exception as e:
            return {"title": name, "html": "<p class='muted'>Couldn't open: "
                    + _esc(e) + "</p>"}

    def open_native(self, name: str) -> None:
        import wivexx_gui
        try:
            wivexx_gui.open_native_tool(name)
        except Exception:
            pass

    def set_toggle(self, name: str, value: bool) -> bool:
        """Flip an on/off mode and return the resulting real state (so the UI
        switch always reflects what actually happened)."""
        try:
            return self._gui.set_toggle(name, value)
        except Exception:
            return bool(value)

    def toggle_perf_hud(self) -> bool:
        """v1.34: ONE overlay everywhere — the titlebar button now toggles the
        same TRANSPARENT click-through OSD used by optimized mode, the voice
        command and Ctrl+Shift+O (the old opaque WebView2 HUD window is retired;
        two different overlays confused everyone). Returns the new visibility so
        the button's lit state stays in sync."""
        try:
            import wivexx_gui as _wg
            want = not _wg.perf_osd_shown()
            _wg.toggle_perf_osd(want)
            return want
        except Exception:
            try:
                return bool(self._gui.toggle_perf_overlay())   # headless fallback
            except Exception:
                return False

    # ── config editors (commands + goals) — in-page HTML panels ──────────────
    def pick_path(self, folder: bool = False) -> str:
        """Native file/folder picker; returns the chosen path (or "")."""
        if webview is None or self._gui._window is None:
            return ""
        try:
            if folder:
                res = self._gui._window.create_file_dialog(webview.FOLDER_DIALOG)
            else:
                res = self._gui._window.create_file_dialog(
                    webview.OPEN_DIALOG, allow_multiple=False,
                    file_types=("Programs (*.exe;*.bat;*.lnk)", "All files (*.*)"))
        except Exception:
            res = None
        if res:
            return res[0] if isinstance(res, (list, tuple)) else res
        return ""

    # ----- commands (apps.json) -----
    def cfg_apps_get(self) -> dict:
        d = _read_apps()
        apps = []
        for key, rec in sorted(d.get("apps", {}).items()):
            exes = rec.get("exes", [])
            aliases = [v for v in rec.get("variants", []) if v != key.lower()]
            apps.append({"name": key, "path": exes[0] if exes else "",
                         "aliases": ", ".join(aliases)})
        envs = []
        for key, rec in sorted(d.get("environments", {}).items()):
            aliases = [v for v in rec.get("variants", []) if v != key.lower()]
            envs.append({"name": key, "aliases": ", ".join(aliases),
                         "open": "\n".join(_env_line(i) for i in rec.get("open", [])),
                         "close": "\n".join(_env_line(i) for i in rec.get("close", [])),
                         "open_n": len(rec.get("open", []))})
        audio = []
        for phrase, rec in sorted(d.get("audio_commands", {}).items()):
            audio.append({"phrase": phrase,
                          "device_name": rec.get("device_name", ""),
                          "device_id": rec.get("device_id", "")})
        return {"apps": apps, "environments": envs, "audio": audio}

    def cfg_app_save(self, name: str, path: str, aliases: str) -> dict:
        name = (name or "").strip().lower()
        path = (path or "").strip()
        if not name or not path:
            return {"ok": False, "error": "Spoken name and app path are both required."}
        al = [a.strip().lower() for a in (aliases or "").split(",") if a.strip()]
        d = _read_apps()
        d["apps"][name] = {"display_name": name.title(), "exes": [path],
                           "variants": [name] + [a for a in al if a != name],
                           "categories": []}
        return {"ok": _write_apps(d)}

    def cfg_app_delete(self, name: str) -> dict:
        d = _read_apps()
        d.get("apps", {}).pop((name or "").strip().lower(), None)
        return {"ok": _write_apps(d)}

    def cfg_env_save(self, name: str, aliases: str,
                     open_text: str, close_text: str) -> dict:
        name = (name or "").strip().lower()
        if not name:
            return {"ok": False, "error": "Environment name is required."}
        open_items = [_line_to_item(l) for l in (open_text or "").splitlines() if l.strip()]
        close_items = [_line_to_item(l) for l in (close_text or "").splitlines() if l.strip()]
        if not open_items:
            return {"ok": False, "error": "Add at least one item to open."}
        al = [a.strip().lower() for a in (aliases or "").split(",") if a.strip()]
        d = _read_apps()
        d["environments"][name] = {"display_name": name.title(),
                                   "variants": [name] + [a for a in al if a != name],
                                   "open": open_items, "close": close_items}
        return {"ok": _write_apps(d)}

    def cfg_env_delete(self, name: str) -> dict:
        d = _read_apps()
        d.get("environments", {}).pop((name or "").strip().lower(), None)
        return {"ok": _write_apps(d)}

    def cfg_audio_devices(self) -> list:
        try:
            import wivexx_audio_devices as _ad
            devs = sorted(_ad.list_output_devices(), key=lambda x: x["name"].lower())
            return [{"name": d["name"], "id": d.get("id", "")} for d in devs]
        except Exception:
            return []

    def cfg_audio_save(self, phrase: str, device_name: str) -> dict:
        phrase = (phrase or "").strip().lower()
        device_name = (device_name or "").strip()
        if not phrase or not device_name:
            return {"ok": False, "error": "Type a phrase and pick an output device."}
        dev_id = ""
        try:
            for d in self.cfg_audio_devices():
                if d["name"] == device_name:
                    dev_id = d["id"]
                    break
        except Exception:
            pass
        d = _read_apps()
        d.setdefault("audio_commands", {})[phrase] = {
            "device_name": device_name, "device_id": dev_id}
        return {"ok": _write_apps(d)}

    def cfg_audio_delete(self, phrase: str) -> dict:
        d = _read_apps()
        d.get("audio_commands", {}).pop((phrase or "").strip().lower(), None)
        return {"ok": _write_apps(d)}

    # ----- storage devices / drive nicknames (wivexx_media_import.py) -----
    def cfg_devices_get(self) -> dict:
        """Known import drives + their policy/nickname/tag, for the device
        manager (Commands ▸ storage)."""
        try:
            import wivexx_media_import as mi
            out = []
            for serial, rec in mi.get_devices().items():
                out.append({
                    "serial": serial,
                    "label": rec.get("label", ""),
                    "nickname": rec.get("nickname", ""),
                    "policy": rec.get("policy", ""),
                    "tag": rec.get("tag", ""),
                    "last_import": rec.get("last_import", ""),
                    "name": mi.display_name(rec),
                })
            out.sort(key=lambda d: (d["name"] or d["serial"]).lower())
            return {"ok": True, "devices": out,
                    "dest": getattr(__import__("wivexx_config"),
                                    "MEDIA_IMPORT_DEST", "")}
        except Exception as e:
            return {"ok": False, "error": str(e), "devices": []}

    def cfg_device_save(self, serial: str, policy: str, tag: str = "",
                        nickname: str = "") -> dict:
        try:
            import wivexx_media_import as mi
            policy = "allow" if (policy or "").startswith("allow") else "deny"
            mi.set_device(serial, policy, tag or "", nickname=nickname or "")
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_device_forget(self, serial: str) -> dict:
        try:
            import wivexx_media_import as mi
            mi.forget_device(serial)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- wake words (wivexx_api.py) -----
    def cfg_wake_get(self) -> dict:
        try:
            import wivexx_api
            return wivexx_api.wake_words_info()
        except Exception as e:
            return {"error": str(e), "wake_word": "", "display": "",
                    "defaults": [], "custom": []}

    def cfg_wake_add(self, word: str) -> dict:
        try:
            import wivexx_api
            return wivexx_api.add_wake_word(word)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_wake_delete(self, word: str) -> dict:
        try:
            import wivexx_api
            return wivexx_api.remove_wake_word(word)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- goals (wivexx_goals.py) -----
    def cfg_goals_get(self) -> dict:
        try:
            import wivexx_goals as g
            return {"goals": g.list_goals(include_done=True), "stats": g.stats(),
                    "priorities": list(g.PRIORITIES), "reminders": list(g.REMINDERS)}
        except Exception as e:
            return {"goals": [], "stats": {}, "priorities": ["high", "normal", "low"],
                    "reminders": ["none", "hourly", "daily", "weekly"], "error": str(e)}

    def cfg_goal_add(self, title: str, priority: str = "normal",
                     due: str = "", reminder: str = "none",
                     remind_time: str = "09:00") -> dict:
        try:
            import wivexx_goals as g
            g.add_goal((title or "").strip(), priority=priority or "normal",
                       due=(due or None), reminder=reminder or "none",
                       remind_time=remind_time or "09:00")
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_goal_update(self, goal_id, fields: dict) -> dict:
        try:
            import wivexx_goals as g
            return {"ok": bool(g.update_goal(int(goal_id), **(fields or {})))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_goal_complete(self, goal_id, done: bool = True) -> dict:
        try:
            import wivexx_goals as g
            ok = g.complete_goal(int(goal_id)) if done else g.reopen_goal(int(goal_id))
            return {"ok": bool(ok)}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_goal_delete(self, goal_id) -> dict:
        try:
            import wivexx_goals as g
            return {"ok": bool(g.delete_goal(int(goal_id)))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- shortcuts (wivexx_hotkeys.py) -----
    def cfg_hotkeys_get(self) -> dict:
        try:
            import wivexx_hotkeys as hk
            if not hk.available():
                return {"available": False, "bindings": [], "customs": []}
            binds = hk.get_bindings()
            rows = [{"action": a, "label": hk.ACTION_LABELS.get(a, a),
                     "keys": binds.get(a, "—")} for a in hk.ACTION_LABELS]
            customs = [{"keys": c.get("keys", ""), "command": c.get("command", "")}
                       for c in hk.get_customs()]
            return {"available": True, "bindings": rows, "customs": customs}
        except Exception as e:
            return {"available": False, "bindings": [], "customs": [], "error": str(e)}

    def cfg_hotkey_capture(self) -> str:
        """Blocking: wait for the user to press a key/mouse combo (≤8s)."""
        try:
            import wivexx_hotkeys as hk
            return hk.capture_combo(timeout=8.0) or ""
        except Exception:
            return ""

    def cfg_hotkey_set(self, action: str, keys: str) -> dict:
        try:
            import wivexx_hotkeys as hk
            if keys:
                hk.set_binding(action, keys)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_hotkey_reset(self) -> dict:
        try:
            import wivexx_hotkeys as hk
            hk.reset_defaults()
            try:
                hk.apply()
            except Exception:
                pass
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_custom_add(self, command: str, keys: str) -> dict:
        command = (command or "").strip()
        keys = (keys or "").strip()
        if not command or not keys:
            return {"ok": False, "error": "Capture a key combo and enter a command."}
        try:
            import wivexx_hotkeys as hk
            hk.add_custom(command, keys, command)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_custom_delete(self, index) -> dict:
        try:
            import wivexx_hotkeys as hk
            hk.remove_custom(int(index))
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- autoclicker & key inputs (wivexx_autoclicker.py) -----
    def cfg_autoclicker_get(self) -> dict:
        try:
            import wivexx_autoclicker as ac
            return ac.get_config()
        except Exception as e:
            return {"available": False, "autoclicker": {}, "inputs": [],
                    "key_choices": [], "button_choices": [], "mode_choices": [],
                    "fire_choices": [], "error": str(e)}

    def cfg_autoclicker_set(self, cps=None, button=None, hotkey=None,
                            mode=None, enabled=None) -> dict:
        try:
            import wivexx_autoclicker as ac
            return ac.set_autoclicker(cps=cps, button=button, hotkey=hotkey,
                                      mode=mode, enabled=enabled)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_input_add(self, name="", key="", cps=10, hotkey="",
                      mode="toggle", fire="repeat") -> dict:
        try:
            import wivexx_autoclicker as ac
            return ac.add_input(name=name, key=key, cps=cps, hotkey=hotkey,
                                mode=mode, fire=fire)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_input_update(self, input_id: str, fields: dict) -> dict:
        try:
            import wivexx_autoclicker as ac
            return ac.update_input(input_id, **(fields or {}))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_input_delete(self, input_id: str) -> dict:
        try:
            import wivexx_autoclicker as ac
            return ac.delete_input(input_id)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_autoclicker_stop(self) -> dict:
        try:
            import wivexx_autoclicker as ac
            ac.stop_all()
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- action recorder / looping player (wivexx_macro.py) -----
    def macro_status(self) -> dict:
        try:
            import wivexx_macro as mac
            return mac.status()
        except Exception as e:
            return {"available": False, "state": "idle", "error": str(e)}

    def macro_list(self) -> dict:
        try:
            import wivexx_macro as mac
            shortcut = play_shortcut = ""
            try:
                import wivexx_hotkeys as hk
                binds = hk.get_bindings()
                shortcut = binds.get("toggle_recording", "")
                play_shortcut = binds.get("play_active", "")
            except Exception:
                pass
            return {"available": mac.available(), "recordings": mac.list_recordings(),
                    "status": mac.status(), "shortcut": shortcut,
                    "play_shortcut": play_shortcut}
        except Exception as e:
            return {"available": False, "recordings": [], "error": str(e)}

    def macro_record_start(self, mode: str = "", name: str = "") -> dict:
        try:
            import wivexx_macro as mac
            return mac.start_recording(mode or "", name or "")
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_set_name(self, name: str = "") -> dict:
        """Name the NEXT take before it starts (so a shortcut-started take is named)."""
        try:
            import wivexx_macro as mac
            return mac.set_pending_name(name or "")
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_record_stop(self, name: str = "") -> dict:
        try:
            import wivexx_macro as mac
            return mac.stop_recording(name)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_record_cancel(self) -> dict:
        try:
            import wivexx_macro as mac
            return mac.cancel_recording()
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_play(self, name: str, loops=0, speed=1.0) -> dict:
        try:
            import wivexx_macro as mac
            return mac.play(name, loops=int(loops or 0), speed=float(speed or 1.0))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_stop(self) -> dict:
        try:
            import wivexx_macro as mac
            return mac.stop_playback()
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_delete(self, name: str) -> dict:
        try:
            import wivexx_macro as mac
            return mac.delete_recording(name)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_rename(self, old: str, new: str) -> dict:
        try:
            import wivexx_macro as mac
            return mac.rename_recording(old, new)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_set_panic(self, key: str) -> dict:
        try:
            import wivexx_macro as mac
            return mac.set_panic_key(key)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_set_sensitivity(self, mult) -> dict:
        try:
            import wivexx_macro as mac
            return mac.set_game_sensitivity(mult)
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_set_active(self, name: str = "") -> dict:
        try:
            import wivexx_macro as mac
            return mac.set_active(name or "")
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def macro_play_active(self) -> dict:
        try:
            import wivexx_macro as mac
            return mac.play_active()
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- social media trackers (wivexx_socials.py) -----
    def cfg_socials_get(self) -> dict:
        try:
            import wivexx_socials as s
            return {"ok": True, "accounts": s.report_all(),
                    "trackers": s.list_trackers()}
        except Exception as e:
            return {"ok": False, "accounts": [], "trackers": [], "error": str(e)}

    def cfg_socials_history(self, platform: str, handle: str, days=31) -> dict:
        try:
            import wivexx_socials as s
            return {"ok": True,
                    "points": s.history(platform or "instagram", handle, int(days or 31))}
        except Exception as e:
            return {"ok": False, "points": [], "error": str(e)}

    def cfg_socials_add(self, handle: str, platform: str = "instagram") -> dict:
        try:
            import wivexx_socials as s
            acct = s.add_tracker(platform or "instagram", handle)
            return {"ok": True, "handle": acct.get("handle", "")}
        except ValueError as ve:
            return {"ok": False, "error": str(ve)}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_socials_remove(self, handle: str, platform: str = "instagram") -> dict:
        try:
            import wivexx_socials as s
            return {"ok": bool(s.remove_tracker(platform or "instagram", handle))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_socials_refresh(self, platform: str, handle: str) -> dict:
        try:
            import wivexx_socials as s
            snap = s.take_snapshot(platform or "instagram", handle, force=True)
            return {"ok": snap is not None}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- recurring notification toggles (wivexx_notify_prefs.py) -----
    def cfg_notify_get(self) -> dict:
        try:
            import wivexx_notify_prefs as np
            return {"ok": True, "prefs": np.list_prefs()}
        except Exception as e:
            return {"ok": False, "prefs": [], "error": str(e)}

    def cfg_notify_set(self, key: str, enabled: bool) -> dict:
        try:
            import wivexx_notify_prefs as np
            return np.set_enabled(key, bool(enabled))
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- memory (wivexx_memory_store.py) -----
    def cfg_memory_get(self, search: str = "") -> dict:
        try:
            import wivexx_memory_store as ms
            facts = [{"id": f.get("id"), "key": f.get("key", ""),
                      "value": f.get("value", ""), "importance": f.get("importance", 5),
                      "source": f.get("source", "")} for f in ms.list_facts(search or "")]
            topics = []
            try:
                for t in ms.list_topics():
                    topics.append({"id": t.get("id"),
                                   "text": t.get("summary") or t.get("topic")
                                   or t.get("text") or ""})
            except Exception:
                topics = []
            return {"facts": facts, "topics": topics}
        except Exception as e:
            return {"facts": [], "topics": [], "error": str(e)}

    def cfg_fact_add(self, key: str, value: str, importance=5) -> dict:
        try:
            import wivexx_memory_store as ms
            ok = ms.add_fact((key or "").strip(), (value or "").strip(),
                             int(importance or 5), source="manual")
            return {"ok": bool(ok),
                    "error": None if ok else "Rejected — empty, reserved, or looks like a question."}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_fact_update(self, fact_id, key: str, value: str, importance) -> dict:
        try:
            import wivexx_memory_store as ms
            imp = int(importance) if importance not in (None, "") else None
            return {"ok": bool(ms.update_fact(int(fact_id), key=key or None,
                                              value=value or None, importance=imp))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_fact_delete(self, fact_id) -> dict:
        try:
            import wivexx_memory_store as ms
            return {"ok": bool(ms.delete_fact(int(fact_id)))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def cfg_topic_delete(self, topic_id) -> dict:
        try:
            import wivexx_memory_store as ms
            return {"ok": bool(ms.delete_topic(int(topic_id)))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ----- capabilities catalog (wivexx_capabilities.py) -----
    def cfg_capabilities_get(self) -> dict:
        try:
            import wivexx_capabilities as caps
            return {"version": caps.VERSION, "categories": caps.categories()}
        except Exception as e:
            return {"version": "", "categories": [], "error": str(e)}

    def minimize(self) -> None:
        # v1.20: minimize shows the transparent orb overlay + hides the window.
        # v1.29: the Settings "overlay_on_minimize" toggle (default on) controls it;
        # off → a plain taskbar minimize.
        overlay = True
        try:
            import wivexx_settings as _ws
            v = _ws.get("overlay_on_minimize", None)
            overlay = True if v is None else bool(v)
        except Exception:
            overlay = True
        try:
            if overlay:
                self._gui.minimize_to_overlay()
            else:
                self._gui._window.minimize()
        except Exception:
            try:
                self._gui._window.minimize()
            except Exception:
                pass

    def start_drag(self) -> bool:
        """Drag the frameless control-room window by following the cursor.
        Runs the shared poll-based move loop (see `_native_drag_hwnd`) — the
        WM_NCLBUTTONDOWN modal trick can't work from pywebview's worker thread.
        Returns False if the window handle couldn't be resolved, so the JS side
        can fall back to per-frame move_window."""
        return _native_drag_hwnd(self._gui.native_hwnd())

    def move_window(self, x, y) -> None:
        """Per-frame reposition fallback (used only if start_drag can't resolve
        the window handle). pywebview 6.x's move() does work (it calls
        SetWindowPos); x/y are logical pixels — it scales to physical itself."""
        win = self._gui._window
        if win is None:
            return
        try:
            win.move(int(x), int(y))
        except Exception:
            pass

    # ── window sizing (frameless: edges + maximize + fullscreen) ──────────────
    def start_resize(self, edge: str) -> bool:
        """Drag a window edge/corner to resize (frameless → no native borders).
        edge ∈ {l,r,t,b,tl,tr,bl,br}."""
        self._gui._maximized = False      # manual sizing leaves the maxed state
        self._gui._fullscreen = False
        return _native_resize_hwnd(self._gui.native_hwnd(), edge)

    def toggle_maximize(self) -> bool:
        """Fill the monitor work area (taskbar still visible) / restore. Returns
        the resulting maximized flag so the page can swap the button glyph."""
        return self._gui.toggle_maximize()

    def toggle_fullscreen(self) -> bool:
        """True fullscreen over the whole monitor (covers the taskbar) / restore.
        Returns the resulting fullscreen flag (F11 / the ⤢ button)."""
        return self._gui.toggle_fullscreen()

    def window_state(self) -> dict:
        return {"maximized": self._gui._maximized,
                "fullscreen": self._gui._fullscreen}

    def fit_on_screen(self) -> dict:
        """Snap the window back fully into the monitor work area (and shrink it
        if it's bigger than the screen) — the 'something went off-screen' fix."""
        return self._gui.fit_on_screen()

    def close(self) -> None:
        # ✕ hides to tray (assistant keeps running); tray "Close Wivexx" quits.
        # v1.29: Settings "close_to_tray" (default on). Off → the ✕ quits Wivexx.
        tray = True
        try:
            import wivexx_settings as _ws
            v = _ws.get("close_to_tray", None)
            tray = True if v is None else bool(v)
        except Exception:
            tray = True
        try:
            self._gui._window.hide()
        except Exception:
            pass
        if not tray:
            import threading as _th
            import time as _t

            def _bye():
                _t.sleep(0.4)
                os._exit(0)              # same clean hard-exit the updater uses
            _th.Thread(target=_bye, daemon=True, name="wivexx-quit").start()

    # ── transfer controls (control-room panel mirrors the overlay buttons) ────
    def transfer_action(self, action: str) -> dict:
        """pause | resume | cancel the running transfer (delivery if one is
        running, otherwise the drive import)."""
        a = (action or "").lower()
        try:
            # a delivery in progress takes precedence
            if self._gui._deliver_cancel is not None:
                ev_c = self._gui._deliver_cancel
                ev_p = self._gui._deliver_pause
                if a == "pause" and ev_p is not None:
                    ev_p.set()
                    self._gui._deliver_set(paused=True)
                elif a == "resume" and ev_p is not None:
                    ev_p.clear()
                    self._gui._deliver_set(paused=False)
                elif a == "cancel":
                    if ev_c is not None:
                        ev_c.set()
                    if ev_p is not None:
                        ev_p.clear()
                self._gui.push_transfer_overlay()
                return self._gui._deliver_snapshot()
            import wivexx_media_import as mi
            if a == "pause":
                mi.pause_import()
            elif a == "resume":
                mi.resume_import()
            elif a == "cancel":
                mi.cancel_import()
            self._gui.push_transfer_overlay()
            return mi.get_import_status()
        except Exception as e:
            return {"error": str(e)}

    def transfer_status(self) -> Optional[dict]:
        return self._gui._transfer_data()


# ─────────────────────────────────────────────────────────────────────────────
# OverlayApi — methods the minimized-overlay page calls (pywebview.api.<name>)
# ─────────────────────────────────────────────────────────────────────────────
class OverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        # push the current state + the remembered minimal/full form once live
        self._gui.overlay_js("setState", self._gui._last_state)
        self._gui.overlay_js("applyMinimal", self._gui._overlay_minimal)

    def restore_main(self) -> None:
        self._gui.restore_from_overlay()

    def start_drag(self) -> bool:
        return self._gui.start_overlay_drag()

    # ── right-click menu actions (overlay.js context menu) ────────────────────
    def hide_overlay(self) -> None:
        """Hide the floating widget — Wivexx keeps running in the tray; minimize
        the control room again to bring it back."""
        self._gui.hide_overlay()

    def set_minimal(self, on) -> bool:
        """Toggle the bare-DOT minimalist form (resizes the overlay window)."""
        return self._gui.set_overlay_minimal(bool(on))

    def quit_app(self) -> None:
        """Close Wivexx entirely (same as the tray 'Close Wivexx')."""
        try:
            import wivexx_gui
            wivexx_gui._force_close()
        except Exception:
            pass


# ─────────────────────────────────────────────────────────────────────────────
# GoalsOverlayApi — the "today's focus" to-do overlay (shortcut-triggered)
# ─────────────────────────────────────────────────────────────────────────────
class GoalsOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.push_goals_overlay()

    def close(self) -> None:
        self._gui.hide_goals_overlay()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._goals_ov_hwnd())

    def toggle(self, goal_id) -> list:
        try:
            import wivexx_goals as g
            cur = next((x for x in g.list_goals(include_done=True)
                        if x.get("id") == int(goal_id)), None)
            if cur is not None:
                (g.reopen_goal if cur.get("done") else g.complete_goal)(int(goal_id))
        except Exception:
            pass
        return self._gui._today_focus()

    def open_full(self) -> None:
        self._gui.open_goals_full()


# ─────────────────────────────────────────────────────────────────────────────
# StatusOverlayApi — the small always-on-top status widget
# ─────────────────────────────────────────────────────────────────────────────
class StatusOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.push_status_overlay()

    def close(self) -> None:
        self._gui.hide_status_overlay()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._status_ov_hwnd())


# ─────────────────────────────────────────────────────────────────────────────
# PerfOverlayApi — the live hardware HUD (CPU/GPU/RAM…) widget
# ─────────────────────────────────────────────────────────────────────────────
class PerfOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.perf_overlay_js("setMetrics", self._gui._perf_metrics)
        self._gui.push_perf_overlay()

    def close(self) -> None:
        self._gui.hide_perf_overlay()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._perf_ov_hwnd())

    def optimize(self):
        return self._gui.perf_optimize()

    def set_metrics(self, metrics) -> None:
        self._gui.set_perf_metrics(metrics)

    def resize(self, height) -> None:
        self._gui.resize_perf_overlay(height)

    def speedtest(self) -> None:
        try:
            import wivexx_sysmon
            wivexx_sysmon.speedtest_async()
        except Exception:
            pass


# ─────────────────────────────────────────────────────────────────────────────
# TransferOverlayApi — the import/deliver progress HUD (pause/cancel/close)
# ─────────────────────────────────────────────────────────────────────────────
class TransferOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.push_transfer_overlay()

    def close(self) -> None:
        self._gui.dismiss_transfer_overlay()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._transfer_ov_hwnd())

    def pause(self) -> None:
        try:
            import wivexx_media_import as mi
            mi.pause_import()
        except Exception:
            pass
        self._gui.push_transfer_overlay()

    def resume(self) -> None:
        try:
            import wivexx_media_import as mi
            mi.resume_import()
        except Exception:
            pass
        self._gui.push_transfer_overlay()

    def cancel(self) -> None:
        try:
            import wivexx_media_import as mi
            mi.cancel_import()
        except Exception:
            pass
        self._gui.push_transfer_overlay()


# ─────────────────────────────────────────────────────────────────────────────
# TimerOverlayApi — the live running-countdown widget
# ─────────────────────────────────────────────────────────────────────────────
class TimerOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.push_timer_overlay()

    def close(self) -> None:
        self._gui.dismiss_timer_overlay()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._timer_ov_hwnd())

    def cancel(self, tid) -> list:
        try:
            import wivexx_gui
            fn = wivexx_gui._INTEGRATIONS.get("cancel_timer")
            if callable(fn):
                fn(tid)
        except Exception:
            pass
        return self._gui._timer_data()


# ─────────────────────────────────────────────────────────────────────────────
# TimerAlertOverlayApi — the timer-completion popup (beeps until dismissed)
# ─────────────────────────────────────────────────────────────────────────────
class TimerAlertOverlayApi:
    def __init__(self, gui: "WebGui") -> None:
        self._gui = gui

    def ready(self) -> None:
        self._gui.push_timer_alert()

    def dismiss(self) -> None:
        self._gui.dismiss_timer_alert()

    def start_drag(self) -> bool:
        return _native_drag_hwnd(self._gui._timer_alert_hwnd())


# ─────────────────────────────────────────────────────────────────────────────
# WebGui — owns the window + pushes state into the page
# ─────────────────────────────────────────────────────────────────────────────
class WebGui:
    def __init__(self, config: Optional[dict] = None) -> None:
        self._cfg = config or {}
        self._api = JsApi(self)
        self._window = None
        self._ready = False
        self._taskbar_hidden = False      # tray-only mode (no taskbar button)
        self._lock = threading.Lock()
        self._last_level_t = 0.0
        # buffered until the page signals ready
        self._buf_turns: list = []
        self._last_state = "idle"
        self._last_status = ""
        self._last_mem = bool(self._cfg.get("_mem_active", False))
        self._last_power = False
        self._hwnd_cache = 0          # resolved top-level HWND (native drag)
        self._maximized = False       # filled-to-work-area (taskbar visible)
        self._fullscreen = False      # whole-monitor fullscreen
        self._win_prev_rect = None    # rect to restore from max/fullscreen
        # ── minimized overlay (secondary WebGL DOT window) ───────────────────
        self._overlay = None
        self._overlay_api = OverlayApi(self)
        self._overlay_shown = False
        self._overlay_hwnd_cache = 0
        self._overlay_corner_done = False
        self._overlay_minimal = False     # bare-DOT form (overlay right-click)
        # ── goals "today's focus" overlay ────────────────────────────────────
        self._goals_ov = None
        self._goals_ov_api = GoalsOverlayApi(self)
        self._goals_ov_shown = False
        self._goals_ov_hwnd_cache = 0
        self._goals_ov_corner_done = False
        # ── status overlay ───────────────────────────────────────────────────
        self._status_ov = None
        self._status_ov_api = StatusOverlayApi(self)
        self._status_ov_shown = False
        self._status_ov_hwnd_cache = 0
        self._status_ov_corner_done = False
        # ── performance HUD overlay (live hardware stats) ────────────────────
        self._perf_ov = None
        self._perf_ov_api = PerfOverlayApi(self)
        self._perf_ov_shown = False
        self._perf_ov_hwnd_cache = 0
        self._perf_ov_corner_done = False
        self._perf_metrics = _load_perf_metrics()
        self._perf_optimizing = False
        # ── transfer (import/deliver) progress overlay ───────────────────────
        self._transfer_ov = None
        self._transfer_ov_api = TransferOverlayApi(self)
        self._transfer_ov_shown = False
        self._transfer_ov_hwnd_cache = 0
        self._transfer_ov_corner_done = False
        self._transfer_last_active = False
        self._transfer_hide_at = 0.0
        self._transfer_done_notified = False   # one-shot per transfer run
        self._transfer_ov_dismissed = False    # user closed the HUD this run
        self._transfer_seen_started = 0.0      # detect a new transfer run
        # ── live timer countdown overlay ─────────────────────────────────────
        self._timer_ov = None
        self._timer_ov_api = TimerOverlayApi(self)
        self._timer_ov_shown = False
        self._timer_ov_hwnd_cache = 0
        self._timer_ov_corner_done = False
        self._timer_ov_count = -1
        self._timer_ov_dismissed = False
        # ── timer completion alert overlay ───────────────────────────────────
        self._timer_alert = None
        self._timer_alert_api = TimerAlertOverlayApi(self)
        self._timer_alert_hwnd_cache = 0
        self._timer_alert_corner_done = False
        self._timer_alert_lock = threading.Lock()
        self._fired_timers: list = []
        self._beep_stop = None
        # delivery progress (drives the same transfer surfaces as import)
        self._deliver_lock = threading.Lock()
        self._deliver_cancel = None
        self._deliver_pause = None
        self._deliver_status: dict = {
            "active": False, "kind": "deliver", "phase": "idle", "stage": "",
            "label": "", "total": 0, "copied": 0, "skipped": 0, "failed": 0,
            "bytes_done": 0, "bytes_total": 0, "eta": None, "current": "",
            "paused": False, "started": 0.0,
        }
        # ── activity / notifications center ──────────────────────────────────
        self._notifs: list = []           # finished / notable events (capped)
        self._notif_lock = threading.Lock()
        self._notif_seq = 0
        self._notif_unread = 0
        self._transcribe_active: dict = {}  # job_id -> {title, sub, percent}
        self._cap_running = False         # caption job in flight (web panel)
        self._last_caption = None         # last caption result {text, opts, ts}
        self._cap_jobs: dict = {}         # id -> caption-EDITOR transcription job (runs in bg)
        self._cap_seq = 0
        self._cap_ready = None            # last completed editor payload (for reopen)
        self._reel_jobs: dict = {}        # id -> reel BUILD job (runs in bg, survives panel close)
        self._reel_seq = 0
        self._reel_ready = None           # last finished reel result (for reopen / hand-off)

    # ── lifecycle ───────────────────────────────────────────────────────────
    def run(self) -> None:
        if not _WEBVIEW_OK:
            raise RuntimeError("pywebview unavailable")
        # Give the process an explicit taskbar identity (an AppUserModelID) so
        # Windows groups Wivexx under its own button + icon and lets you pin it —
        # without this the running app is just "pythonw" and won't pin properly.
        try:
            import ctypes
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Wivexx.AI")
        except Exception:
            pass
        w = int(self._cfg.get("GUI_WINDOW_W", 980))
        h = int(self._cfg.get("GUI_WINDOW_H", 720))
        self._window = webview.create_window(
            "wivexx",
            url=_ui_index(),
            js_api=self._api,
            width=w, height=h, min_size=(380, 520),
            frameless=True, easy_drag=False, text_select=True,
            background_color="#0d0d0d", shadow=True,
            hidden=bool(self._cfg.get("GUI_WEB_HIDDEN", False)),  # verification hook
        )
        # secondary, hidden: the minimized overlay (same WebGL DOT). Positioned
        # bottom-right; shown on minimize. Created before start() so it's ready.
        try:
            ox, oy = self._overlay_start_pos(OVERLAY_W, OVERLAY_H)
            self._overlay = webview.create_window(
                "wivexx-overlay-hud",
                url=_overlay_index(),
                js_api=self._overlay_api,
                width=OVERLAY_W, height=OVERLAY_H, x=ox, y=oy,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._overlay = None
        # secondary, hidden: the "today's focus" goals overlay (top-right)
        try:
            gx, gy = self._overlay_start_pos(GOALS_OV_W, GOALS_OV_H, corner="tr")
            self._goals_ov = webview.create_window(
                "wivexx-goals-overlay",
                url=_goals_overlay_index(),
                js_api=self._goals_ov_api,
                width=GOALS_OV_W, height=GOALS_OV_H, x=gx, y=gy,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._goals_ov = None
        # secondary, hidden: the status overlay (top-left)
        try:
            sx, sy = self._overlay_start_pos(STATUS_OV_W, STATUS_OV_H, corner="tl")
            self._status_ov = webview.create_window(
                "wivexx-status-overlay",
                url=_status_overlay_index(),
                js_api=self._status_ov_api,
                width=STATUS_OV_W, height=STATUS_OV_H, x=sx, y=sy,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._status_ov = None
        # secondary, hidden: the performance HUD (top-LEFT; auto-fits height)
        try:
            px, py_ = self._overlay_start_pos(PERF_OV_W, PERF_OV_H, corner="tl")
            self._perf_ov = webview.create_window(
                "wivexx-perf-overlay",
                url=_perf_overlay_index(),
                js_api=self._perf_ov_api,
                width=PERF_OV_W, height=PERF_OV_H, x=px, y=py_,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._perf_ov = None
        # secondary, hidden: the import/deliver transfer HUD (bottom-center)
        try:
            tx, ty = self._overlay_start_pos(TRANSFER_OV_W, TRANSFER_OV_H, corner="bc")
            self._transfer_ov = webview.create_window(
                "wivexx-transfer-overlay",
                url=_transfer_overlay_index(),
                js_api=self._transfer_ov_api,
                width=TRANSFER_OV_W, height=TRANSFER_OV_H, x=tx, y=ty,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._transfer_ov = None
        # secondary, hidden: the live timer countdown (top-center)
        try:
            kx, ky = self._overlay_start_pos(TIMER_OV_W, TIMER_OV_HEAD + TIMER_OV_ROW,
                                             corner="tc")
            self._timer_ov = webview.create_window(
                "wivexx-timer-overlay",
                url=_timer_overlay_index(),
                js_api=self._timer_ov_api,
                width=TIMER_OV_W, height=TIMER_OV_HEAD + TIMER_OV_ROW + 16,
                x=kx, y=ky, frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._timer_ov = None
        # secondary, hidden: the timer-completion alert (screen-centered)
        try:
            ax, ay = self._overlay_start_pos(TIMER_ALERT_W, TIMER_ALERT_H,
                                             corner="center")
            self._timer_alert = webview.create_window(
                "wivexx-timer-alert",
                url=_timer_alert_index(),
                js_api=self._timer_alert_api,
                width=TIMER_ALERT_W, height=TIMER_ALERT_H, x=ax, y=ay,
                frameless=True, easy_drag=False, on_top=True,
                background_color="#0d0d0d", hidden=True,
            )
        except Exception:
            self._timer_alert = None
        # background pollers: transfer HUD + live timer countdown + perf HUD
        threading.Thread(target=self._transfer_poll_loop, daemon=True,
                         name="wivexx-transfer-poll").start()
        threading.Thread(target=self._timer_poll_loop, daemon=True,
                         name="wivexx-timer-poll").start()
        threading.Thread(target=self._perf_poll_loop, daemon=True,
                         name="wivexx-perf-poll").start()
        webview.start(http_server=True,
                      debug=bool(self._cfg.get("GUI_WEB_DEBUG", False)))

    def _on_ready(self) -> None:
        self._ready = True
        # give the window its Wivexx taskbar + titlebar icon (not the bare pythonw)
        self._set_window_icon()
        # close the launch splash now that the window is painted
        try:
            import wivexx_gui
            wivexx_gui._signal_splash_close()
        except Exception:
            pass
        # flush the snapshot the page missed while it was loading
        self.js("setState", self._last_state)
        # always push a status so the "starting…" placeholder is cleared even if
        # the assistant's idle status raced ahead of the window being ready.
        status = self._last_status or _STATUS_FOR_STATE.get(self._last_state, "")
        self._last_status = status
        self.js("setStatus", status)
        self.js("setMemory", self._last_mem)
        self.js("setPower", self._last_power)
        # push the chosen language so the UI renders translated from the first paint
        try:
            import wivexx_i18n as _i18n
            self.js("setLanguage", _i18n.current_language())
        except Exception:
            pass
        self.refresh_integrations()
        self.refresh_modes()
        # honour a persisted "hide from taskbar" (tray-only) preference
        try:
            if self._load_ui_settings().get("hide_taskbar"):
                self._taskbar_hidden = True
                self._apply_taskbar_style(True)
        except Exception:
            pass
        for (role, text, atts) in self._buf_turns:
            self.js("logTurn", role, text, atts)
        self._buf_turns = []
        # make sure we never open partly off-screen (smaller monitor / moved)
        try:
            self.fit_on_screen()
        except Exception:
            pass
        # enable drag-and-drop of a media file onto the control room → transcribe it
        try:
            self._enable_file_drop()
        except Exception as _dde:
            _logger.debug("file-drop setup failed: %s", _dde)

    def _enable_file_drop(self) -> None:
        """Make dragging ANY file onto the control room attach it. The page (app.js)
        does the capture: on drop it posts WebView2's `FilesDropped` message (so the
        host can read each File's real .Path — the page itself never sees the path) and
        then calls drop_notify(). pywebview only records those paths when
        _dnd_state['num_listeners'] > 0, so all we must do here is raise that counter.

        We deliberately do NOT use pywebview's `window.dom.document.on('drop', …)` —
        that API is version-flaky (6.2.1's call raised before the counter got set, which
        is why drops silently stopped attaching). The page-side handler is sufficient
        and the explicit counter makes it reliable across pywebview versions."""
        if getattr(self, "_drop_armed", False):
            return
        try:
            import webview.dom as _wd
            if _wd._dnd_state.get("num_listeners", 0) < 1:
                _wd._dnd_state["num_listeners"] = 1
            self._drop_armed = True
            _logger.info("file-drop: armed (num_listeners=%s)",
                         _wd._dnd_state.get("num_listeners"))
        except Exception as e:
            _logger.warning("file-drop: arm failed — %s", e)

    def _on_file_drop(self, _e) -> None:
        self._drain_dropped_paths()

    def _drain_dropped_files(self) -> list:
        """Drain the natively-captured dropped path(s) and RETURN them (no attach) — the
        reel studio adds dropped media to the timeline rather than attaching to the room.
        Shares `_drop_lock` so it never races the room-attach drain."""
        lock = getattr(self, "_drop_lock", None)
        if lock is None:
            lock = self._drop_lock = threading.Lock()
        if not lock.acquire(blocking=False):
            return []
        try:
            paths = []
            try:
                import webview.dom as _wd
                for _ in range(40):
                    got = _wd._dnd_state.get("paths") or []
                    if got:
                        time.sleep(0.08)
                        paths = list(_wd._dnd_state.get("paths") or got)
                        _wd._dnd_state["paths"] = []
                        break
                    time.sleep(0.04)
            except Exception:
                pass
            files = []
            for p in paths:
                f = p[1] if isinstance(p, (tuple, list)) else p
                if f and f not in files:
                    files.append(f)
            try:
                _logger.info("studio-drop: drained %d path(s)", len(files))
            except Exception:
                pass
            return files
        finally:
            lock.release()

    def _drain_dropped_paths(self) -> None:
        """Pull the natively-captured dropped path(s) out of _dnd_state and attach the
        first. Called from BOTH pywebview's drop callback and the page's drop_notify —
        a non-blocking lock makes sure only one of them actually attaches."""
        lock = getattr(self, "_drop_lock", None)
        if lock is None:
            lock = self._drop_lock = threading.Lock()
        if not lock.acquire(blocking=False):
            return
        try:
            paths = []
            try:
                import webview.dom as _wd
                for _ in range(40):             # up to ~1.6s; the native msg can lag
                    got = _wd._dnd_state.get("paths") or []
                    if got:
                        time.sleep(0.08)        # let the duplicate FilesDropped land too
                        paths = list(_wd._dnd_state.get("paths") or got)
                        _wd._dnd_state["paths"] = []
                        break
                    time.sleep(0.04)
            except Exception:
                pass
            files = []                          # de-dup (page + pywebview both fire FilesDropped)
            for p in paths:
                f = p[1] if isinstance(p, (tuple, list)) else p
                if f and f not in files:
                    files.append(f)
            try:
                _logger.info("file-drop: drained %d path(s)", len(files))
            except Exception:
                pass
            if not files:
                return
            path = files[0]                     # attach the first dropped file (any type)
            try:
                self.show_main()                # bring the window forward
            except Exception:
                pass
            self._api._pending_attach = path
            try:
                self.js("attachChip", self._api._chip_for(path))
            except Exception:
                pass
            try:
                if self._api._is_media(path):
                    self.js("toast", 'Attached — type "transcribe" or pick a tool.')
                else:
                    self.js("toast", "Attached " + os.path.basename(path))
            except Exception:
                pass
        finally:
            lock.release()

    # ── Python → page ─────────────────────────────────────────────────────────
    def js(self, fn: str, *args) -> None:
        if not self._ready or self._window is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            # v1.58 review fix: the MAIN window is routinely hidden too
            # (close-to-tray / minimize-to-overlay) — a suspended renderer
            # wedging a synchronous evaluate_js here froze the WAKE LOOP via
            # push_level. Same serialized-worker treatment as the overlays.
            _safe_eval_js(self._window,
                          f"window.wivexx&&wivexx.{fn}({payload})")
        except Exception:
            pass

    # ── contract surface (called from wivexx_gui module fns) ─────────────────
    def set_state(self, state: str) -> None:
        ui = _STATE_MAP.get(str(state).strip().lower(), "idle")
        self._last_state = ui
        self.js("setState", ui)
        if self._overlay_shown:
            self.overlay_js("setState", ui)     # mirror onto the minimized DOT
        if self._status_ov_shown:
            self.push_status_overlay()          # keep the status widget live
        try:
            import wivexx_gui
            wivexx_gui.feed_overlay_state(ui)   # keep the tk HUD fallback in sync
            if self._overlay_minimal:
                wivexx_gui.feed_ball_state(ui)  # drive the transparent tk orb
        except Exception:
            pass

    def set_status(self, text: str) -> None:
        self._last_status = text or ""
        self.js("setStatus", self._last_status)

    def push_level(self, rms: float) -> None:
        now = time.time()
        if now - self._last_level_t < 0.05:      # throttle to ~20 Hz
            return
        self._last_level_t = now
        try:
            self.js("level", float(rms))
        except Exception:
            pass
        if self._overlay_shown:
            self.overlay_js("setLevel", float(rms))     # drive the minimized DOT
        try:
            import wivexx_gui
            wivexx_gui.feed_overlay_level(float(rms))   # tk HUD fallback
            if self._overlay_minimal:
                wivexx_gui.feed_ball_level(float(rms))  # drive the tk orb
        except Exception:
            pass

    def log_turn(self, role: str, text: str, atts=None) -> None:
        r = _ROLE_MAP.get(str(role).strip().lower(), "assistant")
        # forward attachment objects {name, kind, thumb} so the bubble can show
        # an actual image preview (not just a "📎 name" line).
        items = []
        for a in (atts or []):
            if isinstance(a, dict):
                name = a.get("name") or os.path.basename(a.get("path", "")) or "attachment"
                thumb = a.get("thumb") or ""
                if thumb and not thumb.startswith("data:"):
                    thumb = "data:image/jpeg;base64," + thumb   # _make_chat_thumb is bare b64
                items.append({"name": name, "thumb": thumb,
                              "kind": a.get("kind") or ("image" if thumb else "file")})
            elif isinstance(a, str):
                items.append({"name": a, "thumb": "", "kind": "file"})
        if not self._ready:
            self._buf_turns.append((r, text, items))
            if len(self._buf_turns) > 60:
                self._buf_turns = self._buf_turns[-60:]
            return
        self.js("logTurn", r, text, items)

    def set_memory(self, active: bool) -> None:
        self._last_mem = bool(active)
        self.js("setMemory", self._last_mem)
        self.refresh_modes()                 # keep the top memory dot in sync

    def set_power(self, low: bool) -> None:
        self._last_power = bool(low)
        self.js("setPower", self._last_power)

    def set_activity(self, name: str) -> None:
        """Context-aware outfit: 'music'|'gaming'|'video'|'coding'|'' → the dot."""
        name = name or ""
        if name == getattr(self, "_activity", ""):
            return
        self._activity = name
        self.js("setActivity", name)

    def set_music(self, on: bool) -> None:           # back-compat
        self.set_activity("music" if on else "")

    def set_gadget(self, name: str) -> None:
        """App-state mascot pose: 'recording'|'clicker'|'playback'|'' → the dot.
        Separate channel from set_activity (it layers over outfits)."""
        name = name or ""
        if name == getattr(self, "_gadget", ""):
            return
        self._gadget = name
        self.js("setGadget", name)

    def set_busy(self, key: str, text=None) -> None:
        self.js("setBusy", str(key), text)

    def toast(self, text: str) -> None:
        self.js("toast", text)

    def refresh_integrations(self) -> None:
        """Bottom-row integration dots. Phones / work mode / memory were removed
        (work mode now lives in the top behavior nodes; memory keeps its own green
        dot). Left as a hook for any future truthful indicator."""
        self.js("setIntegrations", {})

    def refresh_modes(self) -> None:
        """Push live behavior-control (mode) states to the top-of-room node cards.
        None → the module isn't available (renders as a disabled node)."""
        import wivexx_gui
        ints = wivexx_gui._INTEGRATIONS
        out: dict = {}
        out["memory"] = bool(self._last_mem)            # semantic memory active
        try:
            import wivexx_config as _c
            out["_workmode_available"] = bool(getattr(_c, "WORK_MODE_AVAILABLE", True))
        except Exception:
            out["_workmode_available"] = True
        try:
            wm = ints.get("workmode")
            out["workmode"] = bool(wm.is_enabled()) if (wm and hasattr(wm, "is_enabled")) else None
        except Exception:
            out["workmode"] = None
        try:
            gcm = ints.get("get_call_mode")
            out["callmode"] = bool(gcm()) if callable(gcm) else None
        except Exception:
            out["callmode"] = None
        try:
            ggm = ints.get("get_gaming_mode")
            out["gamingmode"] = bool(ggm()) if callable(ggm) else None
        except Exception:
            out["gamingmode"] = None
        self.js("setModes", out)
        try:                                  # tie on the minimalist ball too
            if out.get("workmode") is not None:
                wivexx_gui.set_ball_outfit(work=bool(out["workmode"]))
        except Exception:
            pass

    # ── native window handle (for the WM_NCLBUTTONDOWN drag) ─────────────────
    def native_hwnd(self) -> int:
        """Resolve & cache the top-level Win32 handle of the WebView2 window.
        Uses the foreground window (the user just clicked our title bar) and
        validates its title contains 'wivexx' before caching; FindWindow is the
        fallback."""
        if self._hwnd_cache:
            return self._hwnd_cache
        try:
            import ctypes
            u = ctypes.windll.user32
            GA_ROOT = 2

            def _title(h: int) -> str:
                buf = ctypes.create_unicode_buffer(256)
                u.GetWindowTextW(h, buf, 256)
                return (buf.value or "").lower()

            cand = u.GetForegroundWindow()
            if cand:
                cand = u.GetAncestor(cand, GA_ROOT) or cand
            ct = _title(cand) if cand else ""
            if not (cand and "wivexx" in ct and "overlay" not in ct):
                cand = 0
                for t in ("wivexx", "wivexx."):
                    h = u.FindWindowW(None, t)
                    if h:
                        cand = h
                        break
            self._hwnd_cache = int(cand or 0)
            return self._hwnd_cache
        except Exception:
            return 0

    def _set_window_icon(self) -> None:
        """Set the main window's taskbar + titlebar icon to wivexx.ico via
        WM_SETICON. ctypes prototypes are 64-bit-safe (HICON handles are pointer-
        sized — without restype/argtypes they'd truncate to 32 bits and load junk)."""
        try:
            import ctypes, os
            ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx.ico")
            if not os.path.exists(ico):
                return
            hwnd = self.native_hwnd()
            if not hwnd:
                return
            u = ctypes.windll.user32
            u.LoadImageW.restype = ctypes.c_void_p
            u.LoadImageW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_uint,
                                     ctypes.c_int, ctypes.c_int, ctypes.c_uint]
            u.SendMessageW.restype = ctypes.c_void_p
            u.SendMessageW.argtypes = [ctypes.c_void_p, ctypes.c_uint,
                                       ctypes.c_void_p, ctypes.c_void_p]
            IMAGE_ICON, LR_LOADFROMFILE, LR_DEFAULTSIZE = 1, 0x10, 0x40
            WM_SETICON, ICON_SMALL, ICON_BIG = 0x0080, 0, 1
            SM_CXSMICON, SM_CYSMICON = 49, 50
            big = u.LoadImageW(None, ico, IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE)
            small = u.LoadImageW(None, ico, IMAGE_ICON,
                                 u.GetSystemMetrics(SM_CXSMICON), u.GetSystemMetrics(SM_CYSMICON),
                                 LR_LOADFROMFILE)
            if big:
                u.SendMessageW(hwnd, WM_SETICON, ICON_BIG, big)
            u.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, small or big)
        except Exception:
            pass

    # ── taskbar icon: pin identity + hide-to-tray ────────────────────────────
    def _ui_settings_path(self) -> str:
        import os
        return os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            "wivexx_ui_settings.json")

    def _load_ui_settings(self) -> dict:
        import json, os
        try:
            p = self._ui_settings_path()
            if os.path.isfile(p):
                with open(p, "r", encoding="utf-8") as f:
                    return json.load(f) or {}
        except Exception:
            pass
        return {}

    def _save_ui_setting(self, key: str, value) -> None:
        import json
        try:
            s = self._load_ui_settings()
            s[key] = value
            with open(self._ui_settings_path(), "w", encoding="utf-8") as f:
                json.dump(s, f, indent=2)
        except Exception:
            pass

    def _apply_taskbar_style(self, hidden: bool) -> bool:
        """Hide/show the main window's taskbar button via WS_EX_TOOLWINDOW.
        The taskbar only re-evaluates the button while the window is hidden, so
        we toggle visibility around the style change (only if it's on screen)."""
        try:
            import ctypes
            from ctypes import wintypes
            hwnd = self.native_hwnd()
            if not hwnd:
                return False
            u = ctypes.windll.user32
            GWL_EXSTYLE = -20
            WS_EX_TOOLWINDOW = 0x00000080
            WS_EX_APPWINDOW = 0x00040000
            SW_HIDE, SW_SHOW = 0, 5
            getL = getattr(u, "GetWindowLongPtrW", None) or u.GetWindowLongW
            setL = getattr(u, "SetWindowLongPtrW", None) or u.SetWindowLongW
            getL.restype = ctypes.c_ssize_t
            getL.argtypes = [wintypes.HWND, ctypes.c_int]
            setL.restype = ctypes.c_ssize_t
            setL.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_ssize_t]
            ex = getL(hwnd, GWL_EXSTYLE)
            if hidden:
                ex = (ex & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW
            else:
                ex = (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
            vis = bool(u.IsWindowVisible(hwnd))
            if vis:
                u.ShowWindow(hwnd, SW_HIDE)
            setL(hwnd, GWL_EXSTYLE, ex)
            if vis:
                u.ShowWindow(hwnd, SW_SHOW)
            return True
        except Exception:
            return False

    def set_taskbar_hidden(self, hidden: bool) -> bool:
        """Toggle tray-only mode (no taskbar button). Persists the choice.
        Returns the state actually applied."""
        hidden = bool(hidden)
        if self._apply_taskbar_style(hidden):
            self._taskbar_hidden = hidden
            self._save_ui_setting("hide_taskbar", hidden)
            return hidden
        return self._taskbar_hidden

    def is_taskbar_hidden(self) -> bool:
        return bool(self._taskbar_hidden)

    # ── window sizing: maximize / fullscreen / fit-to-screen ─────────────────
    def _monitor_rect(self, hwnd: int, work: bool = True):
        """(left,top,right,bottom) of the monitor the window sits on — work area
        (excludes the taskbar) when work=True, else the full monitor."""
        import ctypes
        from ctypes import wintypes

        class _MI(ctypes.Structure):
            _fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT),
                        ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)]
        u = ctypes.windll.user32
        mon = u.MonitorFromWindow(hwnd, 2)        # MONITOR_DEFAULTTONEAREST
        mi = _MI(); mi.cbSize = ctypes.sizeof(_MI)
        u.GetMonitorInfoW(mon, ctypes.byref(mi))
        r = mi.rcWork if work else mi.rcMonitor
        return (r.left, r.top, r.right, r.bottom)

    def _set_rect(self, hwnd: int, l: int, t: int, r: int, b: int) -> None:
        import ctypes
        ctypes.windll.user32.SetWindowPos(hwnd, 0, l, t, r - l, b - t,
                                          0x0004 | 0x0010)   # NOZORDER|NOACTIVATE

    def _get_rect(self, hwnd: int):
        import ctypes
        from ctypes import wintypes
        r = wintypes.RECT()
        ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(r))
        return (r.left, r.top, r.right, r.bottom)

    def toggle_maximize(self) -> bool:
        hwnd = self.native_hwnd()
        if not hwnd:
            return self._maximized
        try:
            if self._maximized or self._fullscreen:
                if self._win_prev_rect:
                    self._set_rect(hwnd, *self._win_prev_rect)
                self._maximized = self._fullscreen = False
            else:
                self._win_prev_rect = self._get_rect(hwnd)
                self._set_rect(hwnd, *self._monitor_rect(hwnd, work=True))
                self._maximized = True
        except Exception:
            pass
        return self._maximized

    def toggle_fullscreen(self) -> bool:
        hwnd = self.native_hwnd()
        if not hwnd:
            return self._fullscreen
        try:
            if self._fullscreen:
                if self._win_prev_rect:
                    self._set_rect(hwnd, *self._win_prev_rect)
                self._fullscreen = self._maximized = False
            else:
                if not self._maximized:        # remember the real (restored) rect
                    self._win_prev_rect = self._get_rect(hwnd)
                self._set_rect(hwnd, *self._monitor_rect(hwnd, work=False))
                self._fullscreen = True
                self._maximized = False
        except Exception:
            pass
        return self._fullscreen

    def fit_on_screen(self) -> dict:
        """Clamp the window fully inside the monitor work area (shrink if it's
        bigger than the screen). The 'it went off-screen' safety net."""
        hwnd = self.native_hwnd()
        if not hwnd:
            return {"ok": False}
        try:
            l, t, r, b = self._get_rect(hwnd)
            wl, wt, wr, wb = self._monitor_rect(hwnd, work=True)
            w = min(r - l, wr - wl)
            h = min(b - t, wb - wt)
            x = max(wl, min(l, wr - w))
            y = max(wt, min(t, wb - h))
            if (x, y, x + w, y + h) != (l, t, r, b):
                self._set_rect(hwnd, x, y, x + w, y + h)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    # ── window controls ──────────────────────────────────────────────────────
    def show_main(self) -> None:
        try:
            self._window.show()
            self._window.restore()
        except Exception:
            pass

    def hide_main(self) -> None:
        try:
            self._window.hide()
        except Exception:
            pass

    def minimize_to_overlay(self) -> None:
        """Minimize → go STRAIGHT to the minimalist transparent tk ball (the full
        WebView2 card overlay was removed at the user's request), then hide the
        window. Falls back to the legacy tk HUD, then a plain taskbar minimize."""
        import wivexx_gui
        shown = False
        try:
            self._overlay_minimal = True
            self._overlay_shown = False
            shown = bool(wivexx_gui.show_ball_overlay(self._last_state))
        except Exception:
            shown = False
        if not shown:                              # fallback 2: the tk corner HUD
            try:
                shown = bool(wivexx_gui.show_minimized_overlay())
            except Exception:
                shown = False
        try:
            if shown:
                self._window.hide()
            else:
                self._window.minimize()            # fallback 3
        except Exception:
            pass

    def react(self, name: str, hold: float = 0.7) -> None:
        """Forward a transient mascot expression to the control-room dot (and the
        card overlay if it's up). The tk ball is reached separately via wivexx_gui."""
        self.js("react", name, hold)
        if self._overlay_shown:
            self.overlay_js("react", name, hold)

    # ── minimized overlay (secondary WebGL DOT window) ───────────────────────
    def overlay_js(self, fn: str, *args) -> None:
        if self._overlay is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._overlay,
                          f"window.wivexxOverlay&&wivexxOverlay.{fn}({payload})")
        except Exception:
            pass

    def _overlay_hwnd(self) -> int:
        """Resolve & cache the overlay window's top-level HWND (unique title)."""
        if self._overlay_hwnd_cache:
            return self._overlay_hwnd_cache
        try:
            import ctypes
            u = ctypes.windll.user32
            hwnd = u.FindWindowW(None, "wivexx-overlay-hud")
            self._overlay_hwnd_cache = int(hwnd or 0)
            return self._overlay_hwnd_cache
        except Exception:
            return 0

    def _overlay_start_pos(self, w: int, h: int, corner: str = "br"):
        """A work-area corner (so it never sits under the taskbar). corner:
        'br' = bottom-right (default), 'tr' = top-right, 'tl' = top-left,
        'bc' = bottom-center."""
        try:
            import ctypes
            from ctypes import wintypes
            u = ctypes.windll.user32
            try:
                scale = u.GetDpiForSystem() / 96.0
            except Exception:
                scale = 1.0
            rect = wintypes.RECT()
            SPI_GETWORKAREA = 0x0030
            u.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0)
            margin = int(24 * scale)
            if corner == "tl":
                x = int(rect.left + margin)
            elif corner in ("bc", "tc", "center"):
                x = int((rect.left + rect.right - w * scale) / 2)
            else:
                x = int(rect.right - w * scale - margin)
            if corner in ("tr", "tl", "tc"):
                y = int(rect.top + margin)
            elif corner == "center":
                y = int((rect.top + rect.bottom - h * scale) / 2)
            elif corner == "bc":
                y = int(rect.bottom - h * scale - int(64 * scale))   # float above taskbar
            else:
                y = int(rect.bottom - h * scale - margin)
            return max(0, x), max(0, y)
        except Exception:
            return {"tl": (40, 60), "tr": (1180, 60), "tc": (560, 60),
                    "bc": (560, 640), "center": (560, 360)}.get(corner, (1180, 700))

    def _apply_overlay_corners(self) -> None:
        """One-time overlay polish: Win11 rounded corners (DWM) + hide it from
        the taskbar / Alt-Tab (WS_EX_TOOLWINDOW) so it reads as a floating widget."""
        if self._overlay_corner_done:
            return
        try:
            import ctypes
            hwnd = self._overlay_hwnd()
            if not hwnd:
                return
            # rounded corners
            DWMWA_WINDOW_CORNER_PREFERENCE = 33
            DWMWCP_ROUND = 2
            val = ctypes.c_int(DWMWCP_ROUND)
            ctypes.windll.dwmapi.DwmSetWindowAttribute(
                hwnd, DWMWA_WINDOW_CORNER_PREFERENCE,
                ctypes.byref(val), ctypes.sizeof(val))
            # hide from taskbar + Alt-Tab
            u = ctypes.windll.user32
            GWL_EXSTYLE = -20
            WS_EX_TOOLWINDOW = 0x00000080
            cur = u.GetWindowLongW(hwnd, GWL_EXSTYLE)
            u.SetWindowLongW(hwnd, GWL_EXSTYLE, cur | WS_EX_TOOLWINDOW)
            self._overlay_corner_done = True
        except Exception:
            pass

    def show_overlay(self) -> bool:
        """Show the WebGL overlay window. Returns False if unavailable."""
        if self._overlay is None:
            return False
        try:
            # apply corners + TOOLWINDOW while still hidden (the ex-style must
            # precede visibility or the taskbar button lingers); FindWindow
            # resolves hidden windows fine.
            self._apply_overlay_corners()
            # always (re)show as the FULL card — clear any leftover body.minimal
            # CSS from a previous bare-orb toggle so the card isn't hidden
            self.overlay_js("applyMinimal", False)
            self.overlay_js("setState", self._last_state)
            self._overlay.show()
            self._overlay_shown = True
            return True
        except Exception:
            return False

    def hide_overlay(self) -> None:
        """Hide BOTH overlay forms (the WebView2 card and the tk ball)."""
        self._overlay_shown = False
        self._overlay_minimal = False
        try:
            import wivexx_gui
            wivexx_gui.hide_ball_overlay()
        except Exception:
            pass
        if self._overlay is None:
            return
        try:
            self._overlay.hide()
        except Exception:
            pass

    def restore_from_overlay(self) -> None:
        self.hide_overlay()
        self.show_main()

    def set_overlay_minimal(self, on: bool) -> bool:
        """Toggle the bare-orb minimalist form.

        IMPORTANT: a windowed WebView2 surface can't be made transparent or
        shaped on Windows 11 — DirectComposition ignores SetWindowRgn, layered
        color-key, DWM glass and blur-behind alike (all verified to leave an
        opaque box). So the minimalist orb is NOT the WebView2 overlay shrunk
        down; it's a genuinely transparent **tk** window (GDI-painted, so its
        `-transparentcolor` actually works) that renders the same cartoon orb
        (wivexx_ball_overlay). Minimal ⇒ hide the card window, show the tk ball;
        full ⇒ hide the ball, show the card window."""
        self._overlay_minimal = bool(on)
        try:
            import wivexx_gui
            if on:
                if self._overlay is not None:
                    try:
                        self._overlay.hide()
                    except Exception:
                        pass
                self._overlay_shown = False
                wivexx_gui.show_ball_overlay(self._last_state)
            else:
                wivexx_gui.hide_ball_overlay()
                self.show_overlay()
        except Exception:
            pass
        return self._overlay_minimal

    def start_overlay_drag(self) -> bool:
        return _native_drag_hwnd(self._overlay_hwnd())

    # ── goals "today's focus" overlay (shortcut-triggered) ───────────────────
    def goals_overlay_js(self, fn: str, *args) -> None:
        if self._goals_ov is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._goals_ov,
                          f"window.wivexxGoals&&wivexxGoals.{fn}({payload})")
        except Exception:
            pass

    def _goals_ov_hwnd(self) -> int:
        if not self._goals_ov_hwnd_cache:
            self._goals_ov_hwnd_cache = _find_hwnd("wivexx-goals-overlay")
        return self._goals_ov_hwnd_cache

    def _today_focus(self) -> list:
        try:
            import wivexx_goals as g
            return [{"id": x.get("id"), "title": x.get("title", ""),
                     "priority": x.get("priority", "normal"), "due": x.get("due"),
                     "done": bool(x.get("done"))}
                    for x in g.list_goals(include_done=False)][:8]
        except Exception:
            return []

    def push_goals_overlay(self) -> None:
        self.goals_overlay_js("setGoals", self._today_focus())

    def show_goals_overlay(self) -> bool:
        if self._goals_ov is None:
            return False
        try:
            if not self._goals_ov_corner_done:
                h = self._goals_ov_hwnd()
                if h:
                    _style_overlay_hwnd(h)
                    self._goals_ov_corner_done = True
            self.push_goals_overlay()
            self._goals_ov.show()
            self._goals_ov_shown = True
            return True
        except Exception:
            return False

    def hide_goals_overlay(self) -> None:
        self._goals_ov_shown = False
        try:
            if self._goals_ov is not None:
                self._goals_ov.hide()
        except Exception:
            pass

    def toggle_goals_overlay(self) -> None:
        if self._goals_ov_shown:
            self.hide_goals_overlay()
        else:
            self.show_goals_overlay()

    def open_goals_full(self) -> None:
        """From the overlay 'manage all →': raise the control room + open goals."""
        self.hide_goals_overlay()
        self.open_panel("goals")

    def open_panel(self, name: str) -> None:
        """Raise the control room and open an in-page editor panel (captions,
        deliver, commands, shortcuts, goals, memory, capabilities). Used by
        voice triggers so they land in the web UI, not the old tk windows."""
        self.show_main()
        try:
            self._window.evaluate_js(
                f"window.WivexxPanels&&WivexxPanels.open({json.dumps(name)})")
        except Exception:
            pass

    # ── status overlay (state + timers + today's API cost) ───────────────────
    def status_overlay_js(self, fn: str, *args) -> None:
        if self._status_ov is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._status_ov,
                          f"window.wivexxStatus&&wivexxStatus.{fn}({payload})")
        except Exception:
            pass

    def _status_ov_hwnd(self) -> int:
        if not self._status_ov_hwnd_cache:
            self._status_ov_hwnd_cache = _find_hwnd("wivexx-status-overlay")
        return self._status_ov_hwnd_cache

    def _status_data(self) -> dict:
        import wivexx_gui
        ints = wivexx_gui._INTEGRATIONS
        timers = []
        try:
            gt = ints.get("get_timers")
            raw = gt() if callable(gt) else []
            now = time.time()
            for t in raw:
                rem = max(0, int(t.get("ends", now) - now))
                mm, ss = divmod(rem, 60)
                hh, mm = divmod(mm, 60)
                tstr = (f"{hh}h " if hh else "") + f"{mm:02d}:{ss:02d}"
                timers.append(f"{t.get('label') or 'timer'} — {tstr}")
        except Exception:
            pass
        usage = {"cost": "$0.00", "calls": 0}
        try:
            gu = ints.get("get_usage")
            u = gu() if callable(gu) else {}
            if isinstance(u, dict):
                usage = {"cost": f"${float(u.get('cost', 0)):.2f}",
                         "calls": int(u.get("calls", 0))}
        except Exception:
            pass
        return {"state": self._last_state, "timers": timers, "usage": usage}

    def push_status_overlay(self) -> None:
        self.status_overlay_js("setStatus", self._status_data())

    def show_status_overlay(self) -> bool:
        if self._status_ov is None:
            return False
        try:
            if not self._status_ov_corner_done:
                h = self._status_ov_hwnd()
                if h:
                    _style_overlay_hwnd(h)
                    self._status_ov_corner_done = True
            self.push_status_overlay()
            self._status_ov.show()
            self._status_ov_shown = True
            return True
        except Exception:
            return False

    def hide_status_overlay(self) -> None:
        self._status_ov_shown = False
        try:
            if self._status_ov is not None:
                self._status_ov.hide()
        except Exception:
            pass

    def toggle_status_overlay(self, show=None) -> None:
        want = (not self._status_ov_shown) if show is None else bool(show)
        if want:
            self.show_status_overlay()
        else:
            self.hide_status_overlay()

    # ── performance HUD overlay (live hardware stats) ─────────────────────────
    def perf_overlay_js(self, fn: str, *args) -> None:
        if self._perf_ov is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._perf_ov,
                          f"window.wivexxPerf&&wivexxPerf.{fn}({payload})")
        except Exception:
            pass

    def _perf_ov_hwnd(self) -> int:
        if not self._perf_ov_hwnd_cache:
            self._perf_ov_hwnd_cache = _find_hwnd("wivexx-perf-overlay")
        return self._perf_ov_hwnd_cache

    def push_perf_overlay(self) -> None:
        snap = {}
        try:
            import wivexx_sysmon
            snap = wivexx_sysmon.snapshot()
        except Exception:
            pass
        self.perf_overlay_js("setData", snap)

    def show_perf_overlay(self) -> bool:
        if self._perf_ov is None:
            return False
        try:
            import wivexx_sysmon
            wivexx_sysmon.start(1.2)
            sp = wivexx_sysmon.last_speed()        # auto-run a speed test if none/stale
            if not sp or (time.time() - sp.get("ts", 0)) > 600:
                wivexx_sysmon.speedtest_async()
        except Exception:
            pass
        try:
            if not self._perf_ov_corner_done:
                h = self._perf_ov_hwnd()
                if h:
                    _style_overlay_hwnd(h)
                    self._perf_ov_corner_done = True
            self.perf_overlay_js("setMetrics", self._perf_metrics)
            self.push_perf_overlay()
            self._perf_ov.show()
            self._perf_ov_shown = True
            return True
        except Exception:
            return False

    def hide_perf_overlay(self) -> None:
        self._perf_ov_shown = False
        try:
            if self._perf_ov is not None:
                self._perf_ov.hide()
        except Exception:
            pass

    def toggle_perf_overlay(self, show=None) -> bool:
        want = (not self._perf_ov_shown) if show is None else bool(show)
        if want:
            self.show_perf_overlay()
        else:
            self.hide_perf_overlay()
        return self._perf_ov_shown

    def perf_overlay_visible(self) -> bool:
        return bool(self._perf_ov_shown)

    def resize_perf_overlay(self, height) -> None:
        try:
            h = max(120, min(700, int(height)))
            if self._perf_ov is not None:
                self._perf_ov.resize(PERF_OV_W, h)
        except Exception:
            pass

    def set_perf_metrics(self, metrics) -> None:
        try:
            m = [x for x in metrics if x in _PERF_METRICS_ALL]
            if m:
                self._perf_metrics = m
                _save_perf_metrics(m)
        except Exception:
            pass

    def perf_optimize(self) -> str:
        """⚡ from the HUD — trim RAM (off-thread is unnecessary; it's ~0.5 s) and
        return a short result the overlay flashes."""
        if self._perf_optimizing:
            return "already optimizing…"
        self._perf_optimizing = True
        try:
            import wivexx_optimize
            ram = (wivexx_optimize.optimize("ram").get("ram") or {})
            mb = ram.get("freed_mb")
            return ("freed %d MB RAM" % mb) if mb else "RAM already lean"
        except Exception:
            return "optimize failed"
        finally:
            self._perf_optimizing = False

    def _perf_poll_loop(self) -> None:
        """Push live telemetry to the perf HUD ~1×/s while it's shown."""
        while True:
            try:
                if self._perf_ov_shown:
                    self.push_perf_overlay()
            except Exception:
                pass
            time.sleep(1.0)

    # ── transfer overlay (import / deliver progress) ──────────────────────────
    def transfer_overlay_js(self, fn: str, *args) -> None:
        if self._transfer_ov is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._transfer_ov,
                          f"window.wivexxTransfer&&wivexxTransfer.{fn}({payload})")
        except Exception:
            pass

    def _transfer_ov_hwnd(self) -> int:
        if not self._transfer_ov_hwnd_cache:
            self._transfer_ov_hwnd_cache = _find_hwnd("wivexx-transfer-overlay")
        return self._transfer_ov_hwnd_cache

    _TERMINAL = ("done", "cancelled", "failed", "paused")

    def _deliver_snapshot(self) -> dict:
        """Delivery status with derived percent/elapsed (mirrors the import
        status shape so one renderer handles both)."""
        with self._deliver_lock:
            st = dict(self._deliver_status)
        total = st.get("total") or 0
        st["percent"] = min(100, int(st.get("copied", 0) * 100 / total)) if total else 0
        started = st.get("started") or 0.0
        st["elapsed"] = int(time.time() - started) if started else 0
        return st

    def _deliver_set(self, **kw) -> None:
        with self._deliver_lock:
            self._deliver_status.update(kw)

    def _transfer_data(self) -> Optional[dict]:
        """Whichever transfer is current — a drive import or a delivery. Returns
        the most-recently-started of the two (None when neither is going)."""
        cands = []
        try:
            import wivexx_media_import as mi
            st = mi.get_import_status()
            if st.get("active") or st.get("phase") in self._TERMINAL:
                cands.append(st)
        except Exception:
            pass
        try:
            ds = self._deliver_snapshot()
            if ds.get("active") or ds.get("phase") in self._TERMINAL:
                cands.append(ds)
        except Exception:
            pass
        try:
            import wivexx_disk as _wd
            ss = _wd.analysis_transfer_status()
            if ss.get("active") or ss.get("phase") in self._TERMINAL:
                cands.append(ss)
        except Exception:
            pass
        if not cands:
            return None
        return max(cands, key=lambda d: d.get("started") or 0.0)

    def push_transfer_overlay(self) -> None:
        d = self._transfer_data()
        if d is not None:
            self.transfer_overlay_js("setStatus", d)
        # also feed the in-page panel if it's listening
        try:
            self.js("setTransfer", d)
        except Exception:
            pass

    def show_transfer_overlay(self) -> bool:
        if self._transfer_ov is None:
            return False
        try:
            if not self._transfer_ov_corner_done:
                h = self._transfer_ov_hwnd()
                if h:
                    _style_overlay_hwnd(h)
                    self._transfer_ov_corner_done = True
            self.push_transfer_overlay()
            self._transfer_ov.show()
            self._transfer_ov_shown = True
            return True
        except Exception:
            return False

    def hide_transfer_overlay(self) -> None:
        self._transfer_ov_shown = False
        try:
            if self._transfer_ov is not None:
                self._transfer_ov.hide()
        except Exception:
            pass

    def dismiss_transfer_overlay(self) -> None:
        """User clicked × on the floating HUD — keep it hidden for the rest of
        THIS transfer (the poll loop would otherwise re-show it next tick); a
        new transfer run re-arms it."""
        self._transfer_ov_dismissed = True
        self.hide_transfer_overlay()

    def _transfer_poll_loop(self) -> None:
        """Watch the import/deliver status and drive both progress surfaces:
        the floating HUD when the control room is minimized, and the in-page
        card when it's open. Keeps a finished summary up for a few seconds,
        then hides. Pushes live status every tick."""
        import wivexx_media_import as _mi_probe  # noqa: F401  (ensure importable)
        while True:
            time.sleep(0.4)
            try:
                d = self._transfer_data()
                active = bool(d and d.get("active"))
                # the floating HUD is for when the control room is hidden; while
                # it's open the richer in-page card carries the progress instead.
                minimized = bool(self._overlay_shown)
                if active or self._transfer_last_active:
                    self.push_transfer_overlay()
                if active:
                    # a new transfer run (different start time) re-arms the HUD
                    started = float(d.get("started") or 0.0)
                    if started != self._transfer_seen_started:
                        self._transfer_seen_started = started
                        self._transfer_ov_dismissed = False
                    if minimized and not self._transfer_ov_shown and not self._transfer_ov_dismissed:
                        self.show_transfer_overlay()
                    elif (not minimized or self._transfer_ov_dismissed) and self._transfer_ov_shown:
                        self.hide_transfer_overlay()
                    self._transfer_hide_at = 0.0
                    self._transfer_last_active = True
                    self._transfer_done_notified = False   # arm for this run
                elif self._transfer_last_active:
                    # just finished — log an activity notification once (imports
                    # and drive scans; deliveries are notified from _deliver_finish).
                    if not self._transfer_done_notified and d:
                        if d.get("kind") == "import":
                            self._notify_import_done(d)
                        elif d.get("kind") == "scan":
                            self._add_notif("scan", "Drive analysis complete",
                                            sub=(d.get("summary") or "")[:80],
                                            status="done", open="diskresults",
                                            open_label="review files")
                    self._transfer_done_notified = True
                    # keep the right surface up briefly, then hide it (respect a
                    # user dismiss — don't pop the finished summary back up)
                    if minimized and not self._transfer_ov_shown and not self._transfer_ov_dismissed:
                        self.show_transfer_overlay()
                    elif (not minimized or self._transfer_ov_dismissed) and self._transfer_ov_shown:
                        self.hide_transfer_overlay()
                    if not self._transfer_hide_at:
                        self._transfer_hide_at = time.time() + 7.0
                    if time.time() >= self._transfer_hide_at:
                        self.hide_transfer_overlay()
                        self._transfer_last_active = False
                        self._transfer_hide_at = 0.0
                        try:
                            self.js("setTransfer", None)
                        except Exception:
                            pass
            except Exception:
                pass

    # ── delivery orchestration (convert → upload, progress on the HUD/card) ───
    def run_delivery(self, opts: dict) -> dict:
        """Develop RAWs (optional) then upload to Drive, driving the transfer
        overlay/card the whole way. Returns the result for the deliver panel."""
        import os as _os
        import wivexx_gui
        dlv = wivexx_gui._INTEGRATIONS.get("deliver")
        if dlv is None:
            return {"ok": False, "error": "Delivery engine not available yet."}
        src = opts.get("src")
        if not src or not _os.path.isdir(src):
            return {"ok": False, "error": "Choose a valid source folder, sir."}
        gallery = (opts.get("gallery") or "").strip()
        # what to send: jpg (develop only) | raw (originals) | both (JPG + RAW)
        source = (opts.get("source") or "").lower()
        if source == "raw":
            raw_only = True
        elif source in ("jpg", "both"):
            raw_only = False
        else:
            raw_only = bool(opts.get("raw_only"))             # legacy callers
        also_raw = (source == "both")                         # JPGs + RAW originals
        do_convert = bool(opts.get("convert", True)) and not raw_only
        if not do_convert and not gallery:
            return {"ok": False,
                    "error": "Nothing to do — convert, or give a gallery name to upload."}

        cancel = threading.Event()
        pause = threading.Event()
        self._deliver_cancel = cancel
        self._deliver_pause = pause
        label = gallery or _os.path.basename(src.rstrip("\\/")) or "delivery"
        self._deliver_set(active=True, kind="deliver", phase="copying",
                          stage="preparing", label=label, total=0, copied=0,
                          skipped=0, failed=0, bytes_done=0, bytes_total=0,
                          eta=None, current="", paused=False, started=time.time())

        def _prog(stage_name: str, phase: str):
            def _cb(done, total, name, eta=None):
                self._deliver_set(phase=phase, stage=stage_name, copied=done,
                                  total=total, current=name, eta=eta,
                                  paused=pause.is_set())
            return _cb

        result = {"ok": False, "folder": src, "link": "", "conv": None,
                  "up": None, "error": ""}
        try:
            folder = src
            if do_convert:
                conv = dlv.convert_folder(
                    src, mode=opts.get("mode", "develop"),
                    quality=int(opts.get("quality", 95) or 95),
                    include_jpegs=bool(opts.get("include_jpegs")),
                    auto_bright=bool(opts.get("auto_bright", True)),
                    exposure=float(opts.get("exposure", 1.0) or 1.0),
                    progress=_prog("developing", "copying"), cancel_evt=cancel)
                result["conv"] = conv
                if conv.get("cancelled") and not conv.get("converted"):
                    return self._deliver_finish("cancelled", result)
                if not conv.get("ok"):
                    result["error"] = conv.get("error") or "Conversion produced nothing."
                    return self._deliver_finish("failed", result)
                folder = conv["out_dir"]
            result["folder"] = folder

            if gallery and not cancel.is_set():
                st = dlv.drive_auth_status()
                if st == "need_credentials":
                    result["error"] = dlv.drive_setup_help()
                    return self._deliver_finish("failed", result)
                if st == "need_consent":
                    self._deliver_set(stage="opening browser to sign in…")
                up = dlv.upload_folder(
                    folder, gallery,
                    name_sequence=(opts.get("sequence") or None),
                    make_public=bool(opts.get("public", True)),
                    include_raw=(raw_only or also_raw),
                    extra_raw_dir=(src if also_raw else None),
                    progress=_prog("uploading", "uploading"),
                    cancel_evt=cancel, pause_evt=pause)
                result["up"] = up
                if up.get("ok"):
                    result["link"] = up.get("link", "")
                elif up.get("error"):
                    result["error"] = up["error"]
        except Exception as e:
            result["error"] = str(e)
            return self._deliver_finish("failed", result)

        conv = result.get("conv") or {}
        up = result.get("up") or {}
        phase = "cancelled" if (conv.get("cancelled") or up.get("cancelled")) else "done"
        return self._deliver_finish(phase, result)

    def _deliver_finish(self, phase: str, result: dict) -> dict:
        conv = result.get("conv") or {}
        up = result.get("up") or {}
        self._deliver_set(
            active=False, paused=False, phase=phase, current="",
            copied=up.get("uploaded", conv.get("converted", 0)),
            skipped=conv.get("skipped", 0),
            failed=(up.get("failed", 0) + conv.get("failed", 0)))
        self._deliver_cancel = None
        self._deliver_pause = None
        result["ok"] = bool(up.get("ok") or conv.get("ok")) and phase != "failed"
        result["phase"] = phase
        # counts + summary for the deliver panel result card
        result["converted"] = conv.get("converted", 0)
        result["uploaded"] = up.get("uploaded", 0)
        self.push_transfer_overlay()
        # ── activity notification: finished delivery (link + copy) ───────────
        label = self._deliver_snapshot().get("label") or "delivery"
        if phase == "done":
            n_up = result.get("uploaded", 0)
            n_cv = result.get("converted", 0)
            bits = []
            if n_up:
                bits.append(f"{n_up} uploaded")
            if n_cv:
                bits.append(f"{n_cv} developed")
            self._add_notif("deliver", f"Delivery finished — {label}",
                            sub=("  ·  ".join(bits) or "done"), status="done",
                            link=result.get("link", ""),
                            copy=result.get("link", ""),
                            folder=result.get("folder", ""))
        elif phase == "failed":
            self._add_notif("deliver", f"Delivery failed — {label}",
                            sub=(result.get("error") or "")[:120], status="failed")
        elif phase == "cancelled":
            self._add_notif("deliver", f"Delivery cancelled — {label}",
                            sub="you cancelled this delivery", status="failed")
        return result

    # ── activity / notifications center ───────────────────────────────────────
    def _add_notif(self, kind: str, title: str, sub: str = "",
                   status: str = "done", link: str = "", open: str = "",
                   copy: str = "", folder: str = "", open_label: str = "",
                   file: str = "", file_label: str = "") -> dict:
        """Record a finished/notable event for the activity tab + bell badge."""
        with self._notif_lock:
            self._notif_seq += 1
            item = {"id": self._notif_seq, "kind": kind, "title": title,
                    "sub": sub, "ts": time.time(), "status": status,
                    "link": link, "open": open, "copy": copy, "folder": folder,
                    "open_label": open_label, "file": file, "file_label": file_label}
            self._notifs.insert(0, item)        # newest first
            del self._notifs[40:]               # cap history
            self._notif_unread += 1
            unread = self._notif_unread
        try:
            self.js("bellCount", unread)
            self.js("activityChanged")
        except Exception:
            pass
        return item

    def _job_sub(self, t: dict) -> str:
        parts = []
        if t.get("total"):
            parts.append(f"{t.get('copied', 0)} of {t.get('total', 0)} files")
        if t.get("stage"):
            parts.append(str(t["stage"]))
        if t.get("eta"):
            parts.append("~" + _fmt_secs(t["eta"]) + " left")
        if t.get("paused"):
            parts.append("paused")
        return "  ·  ".join(p for p in parts if p)

    def _active_jobs(self) -> list:
        """Everything Wivexx is doing right now, for the activity tab."""
        jobs = []
        try:
            t = self._transfer_data()
            if t and t.get("active"):
                kind = t.get("kind", "import")
                verb = "Delivering" if kind == "deliver" else "Importing"
                label = t.get("label") or ("delivery" if kind == "deliver" else "drive")
                jobs.append({
                    "kind": kind, "title": f"{verb} — {label}",
                    "sub": self._job_sub(t), "percent": int(t.get("percent") or 0),
                    "indet": not t.get("total"), "paused": bool(t.get("paused")),
                    "controls": True})
        except Exception:
            pass
        try:
            for tm in self._timer_data():
                jobs.append({"kind": "timer", "id": tm.get("id"),
                             "title": tm.get("label") or "timer",
                             "ends": tm.get("ends", 0), "timer": True})
        except Exception:
            pass
        try:
            sd = self._shutdown_data()
            if sd and sd.get("armed"):
                jobs.insert(0, {"kind": "shutdown", "title": "Shutting down the PC",
                                "ends": sd.get("ends", 0), "shutdown": True, "danger": True,
                                "cancel_shutdown": True})
        except Exception:
            pass
        if self._cap_running:
            jobs.append({"kind": "caption", "title": "Drafting captions…",
                         "sub": "writing your caption pack", "indet": True})
        try:
            with self._notif_lock:
                capjobs = list(self._cap_jobs.values())
            for cj in capjobs:
                jobs.append({"kind": "caption", "capid": cj["id"], "cancel_cap": True,
                             "title": cj.get("title", "Transcribing captions…"),
                             "sub": cj.get("sub", "listening to the audio…"), "indet": True})
        except Exception:
            pass
        try:
            with self._notif_lock:
                reeljobs = list(self._reel_jobs.values())
            for rj in reeljobs:
                pct = int(rj.get("percent") or 0)
                jobs.append({"kind": "reel", "reelid": rj["id"], "cancel_reel": True,
                             "title": rj.get("title", "Building reel…"),
                             "sub": rj.get("sub", "stitching your clips…"),
                             "percent": pct, "indet": pct <= 0})
        except Exception:
            pass
        try:
            with self._notif_lock:
                tactive = list(self._transcribe_active.values())
            for tj in tactive:
                pct = tj.get("percent")
                jobs.append({"kind": "transcribe", "title": tj.get("title", "Transcribing…"),
                             "sub": tj.get("sub", "transcribing…"),
                             "percent": int(pct or 0), "indet": pct is None})
        except Exception:
            pass
        return jobs

    # ── transcription jobs (driven from wivexx_api's transcribe handler) ───────
    def transcribe_started(self, job_id, title: str, sub: str = "") -> None:
        with self._notif_lock:
            self._transcribe_active[job_id] = {"title": title, "sub": sub or "transcribing…",
                                               "percent": None}
            self._notif_unread += 1
            unread = self._notif_unread
        try:
            self.js("bellCount", unread)
            self.js("activityChanged")
        except Exception:
            pass

    def transcribe_progress(self, job_id, percent=None, sub: str = "") -> None:
        with self._notif_lock:
            t = self._transcribe_active.get(job_id)
            if not t:
                return
            if percent is not None:
                t["percent"] = max(0, min(100, int(percent)))
            if sub:
                t["sub"] = sub
        try:
            self.js("activityChanged")
        except Exception:
            pass

    def transcribe_finished(self, job_id, title: str, sub: str = "",
                            file: str = "", status: str = "done") -> None:
        with self._notif_lock:
            self._transcribe_active.pop(job_id, None)
        self._add_notif("transcribe", title, sub=sub, status=status, file=file,
                        folder=(os.path.dirname(file) if file else ""))

    def notifs_get(self) -> dict:
        """Active jobs + finished history. Reading it clears the unread badge."""
        with self._notif_lock:
            self._notif_unread = 0
            history = list(self._notifs)
        try:
            self.js("bellCount", 0)
        except Exception:
            pass
        return {"active": self._active_jobs(), "history": history}

    def notif_dismiss(self, nid) -> dict:
        with self._notif_lock:
            self._notifs = [n for n in self._notifs if n.get("id") != nid]
        return {"ok": True}

    def notif_clear(self) -> dict:
        with self._notif_lock:
            self._notifs = []
        return {"ok": True}

    def _notify_import_done(self, d: dict) -> None:
        """Log an activity notification when a drive import finishes."""
        label = d.get("label") or "drive"
        phase = d.get("phase") or "done"
        copied = d.get("copied", 0)
        failed = d.get("failed", 0)
        if phase == "done":
            sub = f"{copied} files copied" + (f"  ·  {failed} failed" if failed else "")
            self._add_notif("import", f"Import finished — {label}", sub=sub,
                            status="failed" if failed else "done")
        elif phase == "cancelled":
            self._add_notif("import", f"Import cancelled — {label}",
                            sub=f"{copied} files copied before cancel",
                            status="failed")
        else:   # failed
            self._add_notif("import", f"Import failed — {label}",
                            sub=str(d.get("current") or "")[:120], status="failed")

    # ── live timer countdown overlay ──────────────────────────────────────────
    def timer_overlay_js(self, fn: str, *args) -> None:
        if self._timer_ov is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._timer_ov,
                          f"window.wivexxTimers&&wivexxTimers.{fn}({payload})")
        except Exception:
            pass

    def _timer_ov_hwnd(self) -> int:
        if not self._timer_ov_hwnd_cache:
            self._timer_ov_hwnd_cache = _find_hwnd("wivexx-timer-overlay")
        return self._timer_ov_hwnd_cache

    def _timer_data(self) -> list:
        """Active timers for the countdown overlay (ends = epoch, so the page
        can tick locally between pushes)."""
        try:
            import wivexx_gui
            gt = wivexx_gui._INTEGRATIONS.get("get_timers")
            raw = gt() if callable(gt) else []
            return [{"id": t.get("id"), "label": t.get("label") or "timer",
                     "ends": t.get("ends", 0)} for t in raw]
        except Exception:
            return []

    def _shutdown_data(self) -> Optional[dict]:
        """The armed system shutdown (for the danger countdown in the activity tab)."""
        try:
            import wivexx_gui
            gs = wivexx_gui._INTEGRATIONS.get("get_shutdown")
            return gs() if callable(gs) else None
        except Exception:
            return None

    def push_timer_overlay(self) -> None:
        self.timer_overlay_js("setTimers", self._timer_data())

    def _resize_timer_overlay(self, count: int) -> None:
        n = max(1, min(int(count), 4))
        h = TIMER_OV_HEAD + n * TIMER_OV_ROW + 16
        try:
            self._timer_ov.resize(TIMER_OV_W, h)
        except Exception:
            pass

    def show_timer_overlay(self) -> bool:
        if self._timer_ov is None:
            return False
        try:
            if not self._timer_ov_corner_done:
                hwnd = self._timer_ov_hwnd()
                if hwnd:
                    _style_overlay_hwnd(hwnd)
                    self._timer_ov_corner_done = True
            self.push_timer_overlay()
            self._timer_ov.show()
            self._timer_ov_shown = True
            return True
        except Exception:
            return False

    def hide_timer_overlay(self) -> None:
        self._timer_ov_shown = False
        try:
            if self._timer_ov is not None:
                self._timer_ov.hide()
        except Exception:
            pass

    def dismiss_timer_overlay(self) -> None:
        """User closed the countdown widget — keep it hidden until the set of
        timers changes (so it doesn't immediately pop back)."""
        self._timer_ov_dismissed = True
        self.hide_timer_overlay()

    def toggle_timer_overlay(self) -> None:
        """v1.0 hotkey (Ctrl+Shift+T): show/hide the live timer countdown on
        screen. Hiding sticks (like a dismiss) until timers change or the user
        toggles it back."""
        if self._timer_ov_shown:
            self.dismiss_timer_overlay()
        else:
            self._timer_ov_dismissed = False
            self.show_timer_overlay()

    def _timer_poll_loop(self) -> None:
        """Auto-show the countdown while timers run; hide when none. Resizes the
        window to fit the visible timers (only when the count changes); a new
        timer un-dismisses a widget the user had closed."""
        while True:
            time.sleep(1.0)
            try:
                timers = self._timer_data()
                n = len(timers)
                if n:
                    if n != self._timer_ov_count:
                        self._resize_timer_overlay(n)
                        self._timer_ov_count = n
                        self._timer_ov_dismissed = False   # a change re-surfaces it
                    if self._timer_ov_dismissed:
                        continue
                    if not self._timer_ov_shown:
                        self.show_timer_overlay()
                    else:
                        self.push_timer_overlay()
                else:
                    if self._timer_ov_shown:
                        self.hide_timer_overlay()
                    self._timer_ov_count = -1
                    self._timer_ov_dismissed = False
            except Exception:
                pass

    # ── timer completion alert overlay (beeps until dismissed) ────────────────
    def timer_alert_js(self, fn: str, *args) -> None:
        if self._timer_alert is None:
            return
        try:
            payload = ",".join(json.dumps(a) for a in args)
            _safe_eval_js(self._timer_alert,
                          f"window.wivexxTimerAlert&&wivexxTimerAlert.{fn}({payload})")
        except Exception:
            pass

    def _timer_alert_hwnd(self) -> int:
        if not self._timer_alert_hwnd_cache:
            self._timer_alert_hwnd_cache = _find_hwnd("wivexx-timer-alert")
        return self._timer_alert_hwnd_cache

    def push_timer_alert(self) -> None:
        with self._timer_alert_lock:
            data = list(self._fired_timers)
        self.timer_alert_js("setAlert", data)

    def fire_timer_alert(self, label: str, seconds: int) -> None:
        with self._timer_alert_lock:
            self._fired_timers.append({"label": label, "seconds": int(seconds)})
        self._show_timer_alert()

    def fire_goal_alert(self, title: str) -> None:
        """Pop the same centered, beeping alarm for a GOAL reminder (kind=goal so
        the overlay reads 'Goal reminder' instead of timer text)."""
        with self._timer_alert_lock:
            self._fired_timers.append({"label": str(title), "kind": "goal", "seconds": 0})
        self._show_timer_alert()

    def _show_timer_alert(self) -> None:
        """v1.58: the whole firing sequence runs on its own daemon thread —
        reminder/timer producer threads return instantly and can never wedge on
        windowing. Order matters: show() FIRST (Windows suspends a hidden
        WebView2's renderer; showing resumes it so the evaluate_js that follows
        actually completes), then the one-time win32 styling, then the content
        push (timeout-guarded). The audible beep starts on the producer path
        regardless, so a wedged overlay can never silence a reminder."""
        def _pop() -> None:
            try:
                if self._timer_alert is not None:
                    self._timer_alert.show()
                    try:
                        self._timer_alert.on_top = True
                    except Exception:
                        pass
                if not self._timer_alert_corner_done:
                    hwnd = self._timer_alert_hwnd()
                    if hwnd:
                        _style_overlay_hwnd(hwnd)
                        self._timer_alert_corner_done = True
                self.push_timer_alert()
            except Exception:
                pass

        # v1.58 review fix: posted to the alert window's SERIALIZED worker (not
        # a free-floating thread) so a concurrent dismiss's hide() can never
        # interleave with show→style→push — fire and dismiss are now ordered.
        _dispatch(self._timer_alert, _pop)
        self._start_timer_beep()

    def dismiss_timer_alert(self) -> None:
        with self._timer_alert_lock:
            self._fired_timers.clear()
        self._stop_timer_beep()
        # v1.58: hide via the alert window's serialized worker — the beep is
        # already silenced above, a wedged hide() can't freeze the dismissing
        # (js_api) thread, and ordering vs a concurrent fire's show() is kept.
        def _hide() -> None:
            try:
                if self._timer_alert is not None:
                    self._timer_alert.hide()
            except Exception:
                pass
        _dispatch(self._timer_alert, _hide)

    def _start_timer_beep(self) -> None:
        # v1.58 review fix: atomic check-and-set under the alert lock — a timer
        # and a goal reminder firing together used to BOTH pass the None check,
        # orphaning one beep loop whose Event nobody could ever set (an
        # unstoppable beep every 1.2 s until restart).
        with self._timer_alert_lock:
            if self._beep_stop is not None:
                return                              # already ringing
            stop = threading.Event()
            self._beep_stop = stop

        def _loop() -> None:
            try:
                import winsound
                while not stop.is_set():
                    winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
                    stop.wait(1.2)
            except Exception:
                pass
        threading.Thread(target=_loop, daemon=True,
                         name="wivexx-timer-beep").start()

    def _stop_timer_beep(self) -> None:
        with self._timer_alert_lock:
            stop, self._beep_stop = self._beep_stop, None
        if stop is not None:
            stop.set()

    def destroy(self) -> None:
        self._stop_timer_beep()
        for w in (self._overlay, self._goals_ov, self._status_ov, self._perf_ov,
                  self._transfer_ov, self._timer_ov, self._timer_alert):
            try:
                if w is not None:
                    w.destroy()
            except Exception:
                pass
        try:
            self._window.destroy()
        except Exception:
            pass

    def set_toggle(self, name: str, value: bool) -> bool:
        """Apply a mode toggle through the real assistant functions (not text
        parsing) and return the resulting state."""
        import wivexx_gui
        ints = wivexx_gui._INTEGRATIONS
        name = (name or "").lower()
        value = bool(value)
        try:
            if name == "workmode":
                wm = ints.get("workmode")
                if wm is not None and hasattr(wm, "set_enabled"):
                    wm.set_enabled(value)            # the watcher is already running
                    self.refresh_modes()
                    return bool(wm.is_enabled()) if hasattr(wm, "is_enabled") else value
            elif name == "callmode":
                gcm = ints.get("get_call_mode")
                tcm = ints.get("toggle_call_mode")
                cur = bool(gcm()) if callable(gcm) else None
                if callable(tcm) and (cur is None or cur != value):
                    # toggle_call_mode announces via TTS, which can block for
                    # seconds on a bad audio device — run it off the bridge thread
                    # so set_toggle returns instantly and the button updates now
                    # (it used to stay stuck until the tab was reopened).
                    import threading as _t

                    def _flip():
                        try:
                            tcm()
                        finally:
                            self.refresh_modes()      # push the settled state
                    _t.Thread(target=_flip, daemon=True, name="wivexx-callmode").start()
                return value
            elif name == "gamingmode":
                ggm = ints.get("get_gaming_mode")
                tgm = ints.get("toggle_gaming_mode")
                cur = bool(ggm()) if callable(ggm) else None
                if callable(tgm) and (cur is None or cur != value):
                    # suspends many procs + announces over TTS → off the bridge
                    # thread so the button flips instantly, then settle the state
                    import threading as _t

                    def _flip_gm():
                        try:
                            tgm()
                        finally:
                            self.refresh_modes()
                    _t.Thread(target=_flip_gm, daemon=True,
                              name="wivexx-gamingmode").start()
                return value
        except Exception:
            pass
        return value

    def show_panel(self, name: str) -> None:
        """Build a data panel and push it into the slide-over (used by the
        voice-triggered toggle_goals_overlay / toggle_status_overlay)."""
        try:
            d = self.build_tool(name)
            if d.get("native"):
                return
            self.js("showPanel", d.get("title", ""), d.get("html", ""))
        except Exception:
            pass

    # ── slide-over panels (data tools) + native tools ────────────────────────
    def build_tool(self, name: str) -> dict:
        import wivexx_gui
        name = (name or "").lower()
        ints = wivexx_gui._INTEGRATIONS

        if name in ("captions", "deliver", "commands", "shortcuts"):
            wivexx_gui.open_native_tool(name)
            return {"native": True}

        if name == "goals":
            return {"title": "goals", "html": self._goals_html()}
        if name == "status":
            return {"title": "status", "html": self._status_html(ints)}
        if name == "clients":
            return {"title": "connected clients", "html": self._clients_html(ints)}
        if name == "memory":
            return {"title": "memory", "html": self._memory_html()}
        if name == "modes":
            return {"title": "modes", "html": self._modes_html(ints)}
        return {"title": name, "html": "<p class='muted'>Nothing here yet.</p>"}

    # ── panel builders ────────────────────────────────────────────────────────
    def _goals_html(self) -> str:
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            "wivexx_goals.json")
        try:
            data = json.load(open(path, encoding="utf-8"))
            goals = [g for g in data.get("goals", []) if not g.get("done")]
        except Exception:
            goals = []
        rows = []
        for g in goals:
            meta = []
            if g.get("priority") and g["priority"] != "normal":
                meta.append(g["priority"])
            if g.get("due"):
                meta.append("due " + str(g["due"]))
            sub = " · ".join(meta)
            rows.append(f"<div class='row'><div class='v'>{_esc(g.get('title',''))}</div>"
                        + (f"<div class='muted'>{_esc(sub)}</div>" if sub else "") + "</div>")
        body = "".join(rows) or "<p class='muted'>No open goals. Say “add a goal …”.</p>"
        body += ("<div class='cmd' data-native='goals'>manage goals →</div>")
        return body

    def _status_html(self, ints) -> str:
        rows = [f"<div class='row'><div class='k'>state</div>"
                f"<div class='v'>{_esc(self._last_state)}</div></div>"]
        try:
            gt = ints.get("get_timers")
            timers = gt() if callable(gt) else []
            if timers:
                items = "".join(f"<div class='muted'>{_esc(t)}</div>" for t in timers)
                rows.append(f"<div class='row'><div class='k'>active timers</div>{items}</div>")
            else:
                rows.append("<div class='row'><div class='k'>active timers</div>"
                            "<div class='muted'>none</div></div>")
        except Exception:
            pass
        try:
            gu = ints.get("get_usage")
            usage = gu() if callable(gu) else None
            if isinstance(usage, dict):
                u = "".join(f"<div class='muted'>{_esc(k)}: {_esc(v)}</div>"
                            for k, v in usage.items())
                rows.append(f"<div class='row'><div class='k'>usage today</div>{u}</div>")
            elif usage:
                rows.append(f"<div class='row'><div class='k'>usage today</div>"
                            f"<div class='muted'>{_esc(usage)}</div></div>")
        except Exception:
            pass
        return "".join(rows)

    def _clients_html(self, ints) -> str:
        try:
            wc = ints.get("web_clients")
            clients = wc() if callable(wc) else []
        except Exception:
            clients = []
        if not clients:
            return ("<p class='muted'>No phones connected right now.<br>"
                    "Open <b>wivexx.com</b> on your phone (signed in as the owner) "
                    "to control Wivexx remotely.</p>")
        rows = []
        for c in clients:
            name = c.get("name") or c.get("ip") or "device"
            rows.append(f"<div class='row'><div class='v'>{_esc(name)}</div>"
                        f"<div class='muted'>{_esc(c.get('ip',''))}</div></div>")
        return "".join(rows)

    def _memory_html(self) -> str:
        on = self._last_mem
        head = (f"<div class='row'><div class='k'>semantic memory</div>"
                f"<div class='v'>{'active' if on else 'off'}</div></div>")
        tips = ("<h3>try</h3>"
                "<div class='cmd' data-cmd='what do you remember about me?'>what do you remember about me?</div>"
                "<div class='cmd' data-cmd='remember that '>remember that …</div>")
        return head + tips

    def _toggle_row(self, name: str, title: str, desc: str, on: Optional[bool]) -> str:
        cls = "switch on" if on else "switch"
        if on is None:                              # module unavailable → disabled look
            cls = "switch busy"
        return (f"<div class='row toggle-row'>"
                f"<div class='label'><div class='v'>{_esc(title)}</div>"
                f"<div class='muted'>{_esc(desc)}</div></div>"
                f"<button class='{cls}' data-toggle='{_esc(name)}' "
                f"aria-pressed='{'true' if on else 'false'}'></button></div>")

    def _modes_html(self, ints) -> str:
        # work mode
        try:
            wm = ints.get("workmode")
            work_on = bool(wm.is_enabled()) if (wm and hasattr(wm, "is_enabled")) else None
        except Exception:
            work_on = None
        # call mode
        try:
            gcm = ints.get("get_call_mode")
            call_on = bool(gcm()) if callable(gcm) else None
        except Exception:
            call_on = None
        return (
            self._toggle_row("workmode", "work mode",
                             "Auto-extract finished Downloads zips into their folders.", work_on)
            + self._toggle_row("callmode", "call mode",
                               "Wake word off — push-to-talk only (for calls/games).", call_on)
        )
