"""
wivexx_gui.py — Unified Wivexx GUI  (v1.3)
═══════════════════════════════════════════════════════
Single resizable window:
  • Top 70%  → geodesic sphere with ripple rings
  • Bottom 30% → scrolling conversation console
  • Status bar at bottom

Replaces wivexx_spectrogram.py, wivexx_hud.py, wivexx_waveform.py.

When the user presses ×:
  • Main window hides → 200×200 corner HUD appears
  • Minimize also shows HUD (<Unmap>/<Map> bindings)
  • Click HUD → restores main window
  • HUD is draggable; right-click for context menu
  • Tray "Close Wivexx" → actual exit

Public API (called from wivexx_api.py):
  start_gui(config)   → WivexxGUI
  log_turn(role, text)
  set_gui_state(state)   # "idle","standby","listening","speaking","processing","searching"
  push_level(rms)        # 0.0 – 1.0
  set_status(text)
  show_main()
  hide_main()
  _force_close()
"""

import json
import math
import os
import queue
import random
import re
import tempfile
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from typing import Optional

# v1.19: the centred 3D DOT is software-shaded with numpy + Pillow (both already
# required by the assistant).  Guarded so the GUI falls back to the 2D DOT if a
# slim environment is missing them.
try:
    import numpy as _np
    from PIL import Image as _PImage, ImageDraw as _PDraw, ImageFilter as _PFilter, ImageTk as _PTk
    _DOT3D_OK = True
except Exception:
    _DOT3D_OK = False

# ══════════════════════════════════════════════════════════════════════════════
#  Wivexx brand tokens (v1.18) — ink / graphite / volt / bone / stone
#  Brand rule: borderless, minimal, ONE volt accent per layout.  The assistant's
# "one volt element" is the DOT + its active state; everything else is bone/stone.
# ══════════════════════════════════════════════════════════════════════════════
INK       = "#0D0D0D"   # backgrounds (was teal #040e14)
GRAPHITE  = "#1C1C1A"   # cards / surfaces / buttons
HOVER     = "#2A2A27"   # button hover / active surface
LINE      = "#262624"   # hairline separators / borders
VOLT      = "#C1F73A"   # the single accent — the DOT, active state, links
BONE      = "#F2F0EA"   # primary text on dark (never pure white)
STONE     = "#8A8A85"   # secondary text / metadata
STONE_DIM = "#55554F"   # tertiary / inactive
AMBER     = "#E0A030"   # functional warning only (low-power)
DANGER    = "#E0553A"   # functional danger only (mute / stop)

# Back-compat: the rest of the file still references BG_HEX.
BG_HEX = INK
_BG_RGB = (13, 13, 13)       # RGB tuple for blending (toward ink)

# ── Brand fonts (resolved against installed families at runtime) ───────────────
# Brand = Space Grotesk (display) / Inter (UI) / Space Mono (mono).  These are
# Google Fonts and may not be installed on Windows, so each falls back cleanly.
WORDMARK_FONT = "Segoe UI"   # display — set by _resolve_fonts(root)
UI_FONT       = "Segoe UI"   # body / labels
MONO_FONT     = "Consolas"   # console / metadata (Space Mono stand-in)


def _resolve_fonts(root) -> None:
    """Pick brand fonts if installed, else keep the Windows fallbacks.  Needs a
    live Tk root (tkinter.font.families requires one)."""
    global WORDMARK_FONT, UI_FONT, MONO_FONT
    try:
        import tkinter.font as _tkfont
        fams = set(_tkfont.families(root))
        if "Space Grotesk" in fams:
            WORDMARK_FONT = "Space Grotesk"
        elif "Segoe UI Semibold" in fams:
            WORDMARK_FONT = "Segoe UI Semibold"
        if "Inter" in fams:
            UI_FONT = "Inter"
        if "Space Mono" in fams:
            MONO_FONT = "Space Mono"
    except Exception:
        pass

def _signal_splash_close() -> None:
    """Write the sentinel the launch splash (wivexx_splash.py) watches for, so it
    fades out the moment the real window is up.  Same path wivexx_api computes."""
    try:
        open(os.path.join(tempfile.gettempdir(), "wivexx_splash.close"), "w").close()
    except Exception:
        pass

# ── Integrations injected by wivexx_api.py (v1.6) ──────────────────────────────
# Keys: apps_json_path, reload_apps, toggle_call_mode, get_call_mode, hotkeys
_INTEGRATIONS: dict = {}

# ── Shared button styling ──────────────────────────────────────────────────────
_BTN_KW = dict(
    bg=GRAPHITE, fg=BONE, activebackground=HOVER,
    activeforeground=VOLT, relief=tk.FLAT, font=("Consolas", 9),
    padx=10, pady=3, cursor="hand2", borderwidth=0,
)

# ── Brand line-icons for tk (PNG rasters of the ui/icons.js glyphs — tk can't
#    render SVG). Same glyph set as the control room, so the windows match. ──────
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "icons_png")
_ICON_CACHE: dict = {}


def _icon(name: str, color: str = "volt"):
    """Cached tk PhotoImage of a brand glyph (~18px), or None if unavailable so the
    caller can fall back to text. color: 'volt' | 'bone'."""
    key = (name, color)
    if key in _ICON_CACHE:
        return _ICON_CACHE[key]
    path = os.path.join(_ICON_DIR, name + ("" if color == "volt" else "_b") + ".png")
    img = None
    try:
        img = tk.PhotoImage(file=path)
        try:
            img = img.subsample(2, 2)   # 36px raster → ~18px crisp
        except Exception:
            pass
    except Exception:
        img = None
    _ICON_CACHE[key] = img
    return img

# ── State colour map (R, G, B 0-255) — volt family; states differ by motion ────
_STATE_COLORS: dict[str, tuple[int, int, int]] = {
"idle":        (130, 160,  60),   # calm dim volt
"standby":     (90,  110,  45),   # dimmer volt
"listening":   (193, 247,  58),   # full volt
"active":      (180, 235,  70),   # volt
"speaking":    (210, 250, 110),   # bright volt
"processing":  (150, 170,  90),   # desaturated volt (thinking)
"searching":   (193, 247,  58),   # volt
"mic_muted":   (224,  85,  58),   # danger red
"audio_muted": (224, 160,  48),   # amber
}

# ── Status labels ─────────────────────────────────────────────────────────────
_STATUS_LABELS: dict[str, str] = {
"standby": "● Idle",
"idle": "● Idle",
"active": "● Active",
"listening": "● Listening...",
"searching": "● Searching...",
"processing": "● Thinking...",
"speaking": "● Speaking...",
"mic_muted": "● Mic muted",
"audio_muted":"● Voice paused",
}

# ── Pre-flight memory status (set before GUI is instantiated) ─────────────────
_INITIAL_MEM_STATUS: bool = False

# ── Ring limits per state ─────────────────────────────────────────────────────
_STATE_RING_PARAMS: dict[str, dict] = {
"idle":       {"interval": 4.0, "max_rings": 2, "speed": 0.7},
"standby":    {"interval": 4.0, "max_rings": 2, "speed": 0.7},
"listening":  {"interval": 0.8, "max_rings": 5, "speed": 1.4},
"speaking":   {"interval": 0.5, "max_rings": 6, "speed": 1.8},
"processing": {"interval": 1.5, "max_rings": 3, "speed": 1.3},
"searching":  {"interval": 1.0, "max_rings": 4, "speed": 1.5},
}


# ══════════════════════════════════════════════════════════════════════════════
#  Helpers
# ══════════════════════════════════════════════════════════════════════════════

def _dim_color(rgb: tuple[int, int, int], alpha: float) -> str:
    """Blend rgb toward the near-black background, return hex string."""
    r, g, b = rgb
    br, bg_, bb = _BG_RGB
    r2 = int(r * alpha + br * (1.0 - alpha))
    g2 = int(g * alpha + bg_ * (1.0 - alpha))
    b2 = int(b * alpha + bb * (1.0 - alpha))
    return f"#{r2:02x}{g2:02x}{b2:02x}"


def _lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


# ══════════════════════════════════════════════════════════════════════════════
#  The 3D DOT (v1.19) — software-shaded sphere (numpy + Pillow)
#  Real Lambert + Blinn-Phong specular + volt rim glow + a sweeping band for
#  visible rotation.  Geometry + glow are cached per render size (stable across
#  frames — breathing/pulse just resizes the rendered image), so the per-frame
#  cost is one vectorised shade + a few composites.  No per-frame blur.
# ══════════════════════════════════════════════════════════════════════════════
_VOLT_RGB = (193, 247, 58)
_DOT_GEOM: dict = {}     # Rfix -> shading terms + blurred glow
_DOT_BG: dict = {}       # S    -> blurred contact-shadow backdrop


def _dot_geom(Rfix: int):
    g = _DOT_GEOM.get(Rfix)
    if g is not None:
        return g
    ax = _np.linspace(-1.0, 1.0, Rfix)
    x, y = _np.meshgrid(ax, ax)
    rr = _np.sqrt(x * x + y * y)
    z = _np.sqrt(_np.clip(1.0 - x * x - y * y, 0.0, 1.0))
    alpha = _np.clip((1.0 - rr) * (Rfix * 0.5) / 1.5, 0.0, 1.0)
    L = _np.array([-0.45, -0.6, 0.66]); L /= _np.linalg.norm(L)
    H = L + _np.array([0.0, 0.0, 1.0]); H /= _np.linalg.norm(H)
    diff = _np.clip(x * L[0] + y * L[1] + z * L[2], 0.0, 1.0)
    spec = _np.clip(x * H[0] + y * H[1] + z * H[2], 0.0, 1.0) ** 55
    rim = _np.clip(1.0 - z, 0.0, 1.0) ** 2.6
    az = _np.arctan2(x, _np.maximum(z, 1e-3))
    disc = _PImage.fromarray((alpha * 255).astype("uint8"), "L")
    glow = disc.filter(_PFilter.GaussianBlur(Rfix * 0.14))
    g = {"alpha": alpha, "diff": diff, "spec": spec, "rim": rim, "az": az, "glow": glow}
    if len(_DOT_GEOM) > 4:
        _DOT_GEOM.clear()    # bound the cache across window resizes
    _DOT_GEOM[Rfix] = g
    return g


def _dot_backdrop(S: int, base: float):
    bg = _DOT_BG.get(S)
    if bg is not None:
        return bg
    img = _PImage.new("RGBA", (S, S), (0, 0, 0, 0))
    cx = cy = S / 2
    sh = _PImage.new("RGBA", (S, S), (0, 0, 0, 0))
    _PDraw.Draw(sh).ellipse(
        [cx - base * 0.62, cy + base * 1.00, cx + base * 0.62, cy + base * 1.00 + base * 0.22],
        fill=(0, 0, 0, 140))
    img = _PImage.alpha_composite(img, sh.filter(_PFilter.GaussianBlur(base * 0.06)))
    if len(_DOT_BG) > 4:
        _DOT_BG.clear()
    _DOT_BG[S] = img
    return img


def _dot_sphere_rgba(Rfix: int, base_rgb, t: float):
    g = _dot_geom(Rfix)
    band = 0.10 * _np.sin(g["az"] * 3.0 + t * 1.3)
    inten = _np.clip(0.24 + 0.92 * g["diff"] + band, 0.0, 1.5)
    col = _np.array(base_rgb, dtype=float)
    rgb = inten[..., None] * col[None, None, :]
    rgb += g["spec"][..., None] * _np.array([255.0, 255.0, 235.0]) * 0.8
    rgb += g["rim"][..., None] * _np.array(list(_VOLT_RGB), dtype=float) * 0.55
    rgb = _np.clip(rgb, 0, 255)
    out = _np.dstack([rgb, g["alpha"] * 255.0]).astype("uint8")
    return _PImage.fromarray(out, "RGBA")


def _dot_scene(S: int, state: str, cur_rgb, amp: float, t: float,
               glow: bool = True, cap: int = 320):
    """Compose the full DOT scene as an SxS transparent RGBA image."""
    base = S * 0.30
    Rfix = int(max(24, min(cap, base * 2)))
    pulse = 0.0
    if state == "speaking":
        pulse = 0.10 * abs(math.sin(t * 6.0)) + amp * 0.25
    elif state == "listening":
        pulse = 0.06
    breathe = 0.02 * math.sin(t * 1.1)
    dia = max(24, min(cap, int(Rfix * (1.0 + pulse + breathe))))

    img = _dot_backdrop(S, base).copy()
    cx = cy = S / 2
    rgb = (int(cur_rgb[0]), int(cur_rgb[1]), int(cur_rgb[2]))

    if state in ("listening", "speaking"):
        prog = (t * 0.6) % 1.0
        rad = base * (1.05 + prog * 0.9)
        ring = _PImage.new("RGBA", (S, S), (0, 0, 0, 0))
        _PDraw.Draw(ring).ellipse([cx - rad, cy - rad * 0.92, cx + rad, cy + rad * 0.92],
                                  outline=rgb + (int(150 * (1.0 - prog)),), width=3)
        img = _PImage.alpha_composite(img, ring)

    sphere = _dot_sphere_rgba(Rfix, rgb, t)
    if dia != Rfix:
        sphere = sphere.resize((dia, dia), _PImage.LANCZOS)
    sx, sy = int(cx - dia / 2), int(cy - dia / 2)

    if glow:
        gg = _dot_geom(Rfix)["glow"]
        if gg.size[0] != dia:
            gg = gg.resize((dia, dia), _PImage.LANCZOS)
        tint = _PImage.new("RGBA", gg.size, _VOLT_RGB + (0,))
        tint.putalpha(gg)
        layer = _PImage.new("RGBA", (S, S), (0, 0, 0, 0))
        layer.paste(tint, (sx, sy), tint)
        img = _PImage.alpha_composite(img, layer)

    img.paste(sphere, (sx, sy), sphere)

    if state in ("processing", "searching"):
        a = t * 2.2
        ox = cx + math.cos(a) * base * 1.25
        oy = cy + math.sin(a) * base * 0.55
        s = max(3.0, base * 0.07)
        lay = _PImage.new("RGBA", (S, S), (0, 0, 0, 0))
        d = _PDraw.Draw(lay)
        d.ellipse([ox - s * 2, oy - s * 2, ox + s * 2, oy + s * 2], fill=_VOLT_RGB + (70,))
        d.ellipse([ox - s, oy - s, ox + s, oy + s], fill=_VOLT_RGB + (255,))
        img = _PImage.alpha_composite(img, lay)

    return img


def _fibonacci_sphere(n: int = 70) -> list[tuple[float, float, float]]:
    """Return n unit-sphere points via Fibonacci lattice."""
    pts: list[tuple[float, float, float]] = []
    phi = math.pi * (3.0 - math.sqrt(5.0))
    for i in range(n):
        y = 1.0 - (i / (n - 1)) * 2.0
        r = math.sqrt(max(0.0, 1.0 - y * y))
        theta = phi * i
        pts.append((r * math.cos(theta), y, r * math.sin(theta)))
    return pts


def _project(x: float, y: float, z: float,
             cx: int, cy: int, scale: float,
             rx: float, ry: float) -> tuple[float, float, float]:
    """Rotate (rx=pitch, ry=yaw) then perspective-project to screen coords."""
    # Yaw
    x2 = x * math.cos(ry) - z * math.sin(ry)
    z2 = x * math.sin(ry) + z * math.cos(ry)
    # Pitch
    y2 = y * math.cos(rx) - z2 * math.sin(rx)
    z3 = y * math.sin(rx) + z2 * math.cos(rx)
    fov = 3.5
    pz = z3 + fov
    sx = cx + (x2 / pz) * scale
    sy = cy + (y2 / pz) * scale * 0.85  # slight vertical flatten
    return sx, sy, z3


def _precompute_edges(pts: list[tuple[float, float, float]],
                      thresh: float = 0.55) -> list[tuple[int, int]]:
    """Pre-compute index pairs whose Euclidean distance < thresh."""
    edges: list[tuple[int, int]] = []
    n = len(pts)
    for i in range(n):
        x1, y1, z1 = pts[i]
        for j in range(i + 1, n):
            x2, y2, z2 = pts[j]
            d = math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
            if d < thresh:
                edges.append((i, j))
    return edges


# ══════════════════════════════════════════════════════════════════════════════
#  Ripple ring
# ══════════════════════════════════════════════════════════════════════════════

class _RippleRing:
    __slots__ = ("cx", "cy", "r", "alpha", "speed", "max_r")

    def __init__(self, cx: int, cy: int, speed: float = 1.5,
                 max_r: float = 120.0) -> None:
        self.cx = cx
        self.cy = cy
        self.r = 0.0
        self.alpha = 1.0
        self.speed = speed
        self.max_r = max_r

    def step(self) -> None:
        self.r += self.speed
        self.alpha = max(0.0, 1.0 - self.r / self.max_r)

    @property
    def dead(self) -> bool:
        return self.alpha < 0.02

    def draw(self, canvas: tk.Canvas, color_rgb: tuple[int, int, int],
             amp: float) -> None:
        if self.dead:
            return
        base_r = self.r * (1.0 + amp * 0.3)
        r_x = base_r
        r_y = base_r * 0.55   # perspective flatten
        for width, factor in ((4, 0.3), (2, 0.6), (1, 1.0)):
            c = _dim_color(color_rgb, self.alpha * factor)
            canvas.create_oval(
                self.cx - r_x, self.cy - r_y,
                self.cx + r_x, self.cy + r_y,
                outline=c, width=width,
            )


# ══════════════════════════════════════════════════════════════════════════════
#  Main GUI class
# ══════════════════════════════════════════════════════════════════════════════

class WivexxGUI:
    """Single-window Wivexx GUI. All tkinter calls happen in _run() thread."""

    def __init__(self, config: Optional[dict] = None, start_thread: bool = True) -> None:
        self._cfg = config or {}

        # ── Queues (thread-safe input from wivexx_api.py) ──────────────────────
        self._state_q: queue.Queue = queue.Queue()
        self._level_q: queue.Queue = queue.Queue(maxsize=256)
        self._log_q: queue.Queue   = queue.Queue()
        self._status_q: queue.Queue = queue.Queue(maxsize=32)

        # ── Animation state ────────────────────────────────────────────────────
        self._state: str = "standby"
        self._prev_state: str = "standby"
        self._cur_color: list[float] = list(_STATE_COLORS["standby"])
        self._tgt_color: list[float] = list(_STATE_COLORS["standby"])
        self._amp: float   = 0.0
        self._ry: float    = 0.0   # yaw
        self._rx: float    = 0.12  # slight downward pitch
        self._speak_phase: float = 0.0
        self._scale_pulse: float = 0.0
        self._rings: list[_RippleRing] = []
        self._last_ring_t: float = 0.0

        # ── Proportional sizing (updated on <Configure>) ──────────────────────
        self._cx: int   = 300
        self._cy: int   = 245
        self._sphere_r: float  = 168.0   # min(600,700)*0.28
        self._ring_max_r: float = 276.0  # min(600,700)*0.46

        # ── Sphere geometry (pre-computed) ─────────────────────────────────────
        n_pts = int(self._cfg.get("GUI_SPHERE_POINTS", 70))
        self._sphere_pts = _fibonacci_sphere(n_pts)
        self._sphere_edges = _precompute_edges(self._sphere_pts, thresh=0.55)

        # ── FPS ────────────────────────────────────────────────────────────────
        self._fps = int(self._cfg.get("GUI_FPS", 30))
        self._frame_ms = max(16, int(1000 / self._fps))

        # ── HUD drag state ────────────────────────────────────────────────────
        self._hud_drag_dx: int = 0
        self._hud_drag_dy: int = 0
        self._pure_anim_mode: Optional[tk.BooleanVar] = None
        self._hud_labels: list = []          # tk.Label widgets in HUD (pure mode toggle)
        self._hud_pending: bool = False      # guard: prevents duplicate _create_hud calls
        self._is_dragging_hud: bool = False  # freeze HUD animation during drag

        # ── Memory engine state ────────────────────────────────────────────────
        # Read the pre-flight status so the indicator is correct from frame 1
        self._mem_active: bool = _INITIAL_MEM_STATUS

        # ── v1.6 state ─────────────────────────────────────────────────────────
        self._low_power: bool = False        # low-power mode indicator
        self._modes_btn: Optional[tk.Button] = None   # v1.11.1 modes dropdown
        self._call_poll_tick: int = 0        # frames between status polls
        self._hud_topmost_tick: int = 0      # frames between topmost re-asserts

        # ── v1.6.1: editor windows are single-instance ─────────────────────────
        self._commands_win: Optional[tk.Toplevel] = None
        self._shortcuts_win: Optional[tk.Toplevel] = None

        # ── v1.9: goals manager + always-on-top goals overlay ──────────────────
        self._goals_win: Optional[tk.Toplevel] = None
        self._goals_overlay: Optional[tk.Toplevel] = None
        self._goals_overlay_tick = 0
        self._status_overlay: Optional[tk.Toplevel] = None   # v1.16
        self._click_overlay: Optional[tk.Toplevel] = None     # v1.17
        self._perf_hud: Optional[tk.Toplevel] = None          # v1.30 perf HUD
        self._perf_cfg: Optional[dict] = None

        # ── v1.10: memory manager ──────────────────────────────────────────────
        self._memory_win: Optional[tk.Toplevel] = None

        # ── v1.13: caption studio + generic busy indicator ─────────────────────
        self._caption_win: Optional[tk.Toplevel] = None
        self._caption_res_win: Optional[tk.Toplevel] = None
        # ── v1.14: client photo delivery ───────────────────────────────────────
        self._deliver_win: Optional[tk.Toplevel] = None
        self._deliver_res_win: Optional[tk.Toplevel] = None
        self._busy_lock = threading.Lock()
        self._busy_jobs: dict = {}           # key → "working…" text
        self._busy_tick = 0

        # ── tkinter handles (all set in _run thread) ───────────────────────────
        self._root: Optional[tk.Tk] = None
        self._sphere_canvas: Optional[tk.Canvas] = None
        self._console_txt: Optional[tk.Text]   = None
        self._status_lbl: Optional[tk.Label]   = None
        self._hud_win: Optional[tk.Toplevel]   = None
        self._hud_canvas: Optional[tk.Canvas]  = None
        self._main_visible: bool = True
        # v1.20: in web mode this instance is the withdrawn tk *sidecar* — the
        # visible window is the WebView2 page.  When set, restoring from the
        # corner HUD calls this instead of deiconifying the (hidden) sidecar root.
        self._restore_override = None

        # ── Start GUI thread (skipped when run_gui_mainloop drives main thread) ──
        if start_thread:
            self._thread = threading.Thread(target=self._run, daemon=True)
            self._thread.start()
            time.sleep(0.4)   # give tkinter a moment to initialise

    # ──────────────────────────────────────────────────────────────────────────
    # tkinter thread entry point
    # ──────────────────────────────────────────────────────────────────────────

    def _run(self) -> None:
        try:
            self._build_main_window()
            self._root.mainloop()
        except Exception as _e:
            try:
                import logging
                logging.getLogger("jarvis").warning("GUI thread: %s", _e)
            except Exception:
                pass

    def _build_main_window(self) -> None:
        """Build all widgets. Called once from _run()."""
        w = int(self._cfg.get("GUI_WIDTH",  600))
        h = int(self._cfg.get("GUI_HEIGHT", 700))

        root = tk.Tk()
        self._root = root
        root.title("Wivexx")
        root.configure(bg=BG_HEX)
        root.minsize(360, 480)

        # v1.18: resolve the Wivexx brand fonts now that a Tk root exists.
        _resolve_fonts(root)

        # v1.18: borderless / minimalist window.  The brand is borderless, so we
        # drop the OS title bar and draw our own slim wivexx. bar (built below).
        # Minimise/close route to the corner HUD exactly as before (withdraw is
        # overrideredirect-safe).  Wrapped so a failure falls back to a normal
        # window rather than breaking the daily-driver assistant.
        self._borderless = False
        try:
            root.overrideredirect(True)
            self._borderless = True
        except Exception:
            self._borderless = False

        # v1.9: real app icon — AppUserModelID makes the Windows taskbar use
        # OUR icon instead of grouping under pythonw's default one.
        try:
            import ctypes
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
            "Wivexx.Assistant")
        except Exception:
            pass
        try:
            _here = os.path.dirname(os.path.abspath(__file__))
            # Prefer the Wivexx (DOT) icon; fall back to the old wivexx.ico.
            _ico = os.path.join(_here, "wivexx.ico")
            if not os.path.exists(_ico):
                _ico = os.path.join(_here, "wivexx.ico")
            if os.path.exists(_ico):
                root.iconbitmap(default=_ico)
        except Exception:
            pass

        # Centre on screen
        sw = root.winfo_screenwidth()
        sh = root.winfo_screenheight()
        root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")

        # X button → minimise to corner HUD, not exit
        root.protocol("WM_DELETE_WINDOW", self._minimize_to_hud)

        # Section 4a: also show HUD on OS minimize and hide it on restore
        root.bind("<Unmap>", lambda e: self._show_hud() if e.widget is root else None)
        root.bind("<Map>",   lambda e: self._hide_hud() if e.widget is root else None)

        # ── Grid: a centred content column (col 1) with gutters that absorb
        #    extra width on big screens — mirrors the website's max-width layout ─
        root.grid_columnconfigure(0, weight=0)   # left gutter (wide screens)
        root.grid_columnconfigure(1, weight=1)   # content column
        root.grid_columnconfigure(2, weight=0)   # right gutter
        root.grid_rowconfigure(0, weight=60)     # DOT hero
        root.grid_rowconfigure(1, weight=40)     # chat panel
        for _r in range(2, 7):                   # attach / input / tools / ind / status
            root.grid_rowconfigure(_r, weight=0)
        self._content_max = 900
        self._gutter = -1
        root.bind("<Configure>", self._on_root_resize)

        # ── DOT hero canvas (big, centred) ────────────────────────────────────
        self._sphere_canvas = tk.Canvas(root, bg=BG_HEX, highlightthickness=0)
        self._sphere_canvas.grid(row=0, column=1, sticky="nsew")
        self._sphere_canvas.bind("<Configure>", self._on_canvas_configure)

        # ── Chat panel (graphite card with a hairline border, like the site) ──
        console_frame = tk.Frame(root, bg=GRAPHITE,
                                 highlightbackground=LINE, highlightthickness=1)
        console_frame.grid(row=1, column=1, sticky="nsew", padx=14, pady=(0, 8))
        console_frame.grid_rowconfigure(0, weight=1)
        console_frame.grid_columnconfigure(0, weight=1)

        sb = tk.Scrollbar(console_frame)
        sb.grid(row=0, column=1, sticky="ns")

        txt = tk.Text(
            console_frame,
            bg=GRAPHITE, fg=BONE,
            font=(MONO_FONT, 10),
            state=tk.DISABLED,
            wrap=tk.WORD,
            relief=tk.FLAT,
            highlightthickness=0,
            padx=12, pady=10,
            yscrollcommand=sb.set,
        )
        txt.tag_configure("you",    foreground=BONE,      font=(MONO_FONT, 10))
        txt.tag_configure("jarvis", foreground=VOLT,      font=(MONO_FONT, 10))
        txt.tag_configure("status", foreground=STONE,     font=(MONO_FONT, 9, "italic"))
        txt.tag_configure("time",   foreground=STONE_DIM, font=(MONO_FONT, 9))
        txt.grid(row=0, column=0, sticky="nsew")
        sb.config(command=txt.yview)
        self._console_txt = txt
        self._chat_img_refs: list = []   # v1.12: keep inline thumbnails alive

        # ── Attachment preview (v1.6.2) — hidden until an image is attached ───
        self._attach_path: Optional[str] = None
        self._attach_thumb = None                      # keep PhotoImage ref alive
        af = tk.Frame(root, bg=GRAPHITE)
        af.grid(row=2, column=1, sticky="ew", padx=14, pady=(0, 0))
        af.grid_remove()
        self._attach_frame = af
        self._attach_img_lbl = tk.Label(af, bg=GRAPHITE)
        self._attach_img_lbl.pack(side=tk.LEFT, padx=6, pady=4)
        self._attach_name_lbl = tk.Label(af, bg=GRAPHITE, fg=BONE,
                                         font=(MONO_FONT, 9), anchor=tk.W)
        self._attach_name_lbl.pack(side=tk.LEFT, padx=4, fill=tk.X, expand=True)
        tk.Button(af, text="✕", command=self._clear_attachment, **_BTN_KW)\
            .pack(side=tk.RIGHT, padx=6, pady=4)

        # ── Input row —  + pill entry + volt "send →" (website style) ───────
        input_row = tk.Frame(root, bg=BG_HEX)
        input_row.grid(row=3, column=1, sticky="ew", padx=14, pady=(0, 6))
        input_row.grid_columnconfigure(1, weight=1)
        _clip = _icon("clip")
        tk.Button(input_row, command=self._on_attach_image, **_BTN_KW,
                  **({"image": _clip} if _clip is not None else {"text": "+"}))\
            .grid(row=0, column=0, padx=(0, 6))
        self._input_entry = tk.Entry(
            input_row, bg=GRAPHITE, fg=BONE, insertbackground=VOLT,
            relief=tk.FLAT, font=(UI_FONT, 11),
            highlightbackground=LINE, highlightcolor=VOLT, highlightthickness=1)
        self._input_entry.grid(row=0, column=1, sticky="ew", ipady=7)
        self._input_entry.bind("<Return>", lambda _e: self._on_send_text())
        # Ctrl+V pastes images as attachments (text pastes normally)
        self._input_entry.bind("<Control-v>", self._on_paste)
        self._input_entry.bind("<Control-V>", self._on_paste)
        tk.Button(input_row, text="send →", command=self._on_send_text,
                  bg=BG_HEX, fg=VOLT, activebackground=GRAPHITE, activeforeground=VOLT,
                  relief=tk.FLAT, font=(MONO_FONT, 10), cursor="hand2", bd=0,
                  highlightbackground=VOLT, highlightthickness=1, padx=16, pady=5)\
            .grid(row=0, column=2, padx=(6, 0))

        # ── Tool buttons — responsive flow bar that WRAPS into rows so the
        #    buttons never run off the edge or overlap (v1.19) ──────────────────
        btn_row = tk.Frame(root, bg=BG_HEX)
        btn_row.grid(row=4, column=1, sticky="ew", padx=10, pady=(0, 2))
        self._btn_row = btn_row
        _slim = {**_BTN_KW, "padx": 9, "pady": 4, "font": (MONO_FONT, 9)}
        def _toolbtn(glyph, label, cmd):
            ic = _icon(glyph)
            kw = dict(_slim)
            if ic is not None:
                return tk.Button(btn_row, image=ic, text=" " + label, compound="left", command=cmd, **kw)
            return tk.Button(btn_row, text=label, command=cmd, **kw)
        self._tool_buttons = [
            _toolbtn("gear", "commands", self._open_commands_editor),
            _toolbtn("keyboard", "shortcuts", self._open_shortcuts_editor),
            _toolbtn("target", "goals", self._open_goals_manager),
            _toolbtn("brain", "memory", self._open_memory_manager),
            _toolbtn("pencil", "captions", lambda: self._open_caption_studio()),
            _toolbtn("package", "deliver", lambda: self._open_deliver_studio()),
            _toolbtn("chartBar", "status", lambda: self._toggle_status_overlay()),
        ]
        self._modes_btn = tk.Button(btn_row, text="◉ modes",
                                    command=self._open_modes_menu, **_slim)
        self._tool_buttons.append(self._modes_btn)
        self._toolbar_w = -1
        btn_row.bind("<Configure>", self._layout_toolbar)

        # ── Activity indicators row (v1.11.1) — appears only when something
        #    long-running is happening, so the buttons row never overflows ────
        ind_row = tk.Frame(root, bg=BG_HEX)
        ind_row.grid(row=5, column=1, sticky="ew", padx=10)
        self._ind_row = ind_row

        # v1.8: storage-import indicator — appears only while copying; click cancels
        self._import_btn = tk.Button(ind_row, text="", command=self._on_cancel_import,
                                     **{**_BTN_KW, "fg": "#E0A030",
                                     "activeforeground": "#E0553A",
                                     "font": (MONO_FONT, 9)})
        self._import_btn_shown = False   # not packed until an import starts

        # v1.8.1: disk-analysis working indicator (read-only)
        self._analysis_lbl = tk.Label(ind_row, text="", bg=BG_HEX, fg=VOLT,
                                      font=(MONO_FONT, 9))
        self._analysis_lbl_shown = False

        # v1.13: generic busy indicator (socials checks, caption drafting, …)
        self._busy_lbl = tk.Label(ind_row, text="", bg=BG_HEX, fg=VOLT,
                                  font=(MONO_FONT, 9))
        self._busy_lbl_shown = False

        # ── Status bar — state (left) +  connected clients (right) ──────────
        status_row = tk.Frame(root, bg=BG_HEX)
        status_row.grid(row=6, column=1, sticky="ew")
        status_row.grid_columnconfigure(0, weight=1)
        self._status_lbl = tk.Label(
            status_row, text="● Idle",
            bg=BG_HEX, fg=STONE,
            font=(MONO_FONT, 9, "italic"),
            anchor=tk.W, padx=10, pady=4,
        )
        self._status_lbl.grid(row=0, column=0, sticky="ew")

        # v1.8: connected web clients indicator ( n) with hover details
        self._web_lbl = tk.Label(status_row, text="", bg=BG_HEX, fg=STONE,
                                 font=(MONO_FONT, 9), cursor="hand2", padx=10)
        self._web_lbl.grid(row=0, column=1, sticky="e")
        self._web_tip: Optional[tk.Toplevel] = None
        self._web_lbl.bind("<Enter>", self._show_web_clients_tip)
        self._web_lbl.bind("<Leave>", lambda _e: self._hide_web_clients_tip())

        # ── Borderless title bar (v1.18) ───────────────────────────────────────
        # A slim wivexx. bar placed over the top of the canvas (both ink, so it
        # reads as one surface).  Drag to move; ─ / ✕ minimise to the corner HUD.
        self._build_titlebar(root)

        # Borderless windows can open unfocused/behind — bring it forward once.
        try:
            root.after(60, lambda: (root.lift(), root.focus_force()))
        except Exception:
            pass

        # v1.18: signal the launch splash to close now that the window is up.
        try:
            root.after(120, _signal_splash_close)
        except Exception:
            pass

        # ── Start animation loop ───────────────────────────────────────────────
        root.after(self._frame_ms, self._animate)

        # v1.28: localize the tk fallback UI when a non-English language is chosen.
        # (English/auto → not scheduled, so English users see zero change/overhead.)
        try:
            import wivexx_i18n as _i18n
            if _i18n.current_language() not in ("en", "auto"):
                root.after(700, self._i18n_tick)
        except Exception:
            pass

    # ── Language localization for the tk fallback UI ───────────────────────────
    def _i18n_tick(self) -> None:
        """Re-localize the tk widget tree to the chosen language every ~1.2 s and
        queue any not-yet-cached strings for background (Claude) translation."""
        try:
            self._localize_ui()
        except Exception:
            pass
        try:
            if self._root is not None:
                self._root.after(1200, self._i18n_tick)
        except Exception:
            pass

    def _localize_ui(self) -> None:
        """Walk the widget tree, translating each widget's `text` (and the window
        title). Caches each widget's ORIGINAL English (`_i18n_en`) so it's
        idempotent + reversible. UI-thread lookups are cache-only (never block);
        misses are queued to a background thread that Claude-translates + caches."""
        try:
            import wivexx_i18n as _i18n
        except Exception:
            return
        root = self._root
        if root is None:
            return
        en_mode = _i18n.current_language() in ("en", "auto")
        todo = []

        def _walk(w):
            try:
                try:
                    cur = w.cget("text")
                except Exception:
                    cur = None
                if cur and isinstance(cur, str) and any(c.isalpha() for c in cur):
                    en = getattr(w, "_i18n_en", None)
                    if en is None:
                        w._i18n_en = en = cur
                    if en_mode:
                        new = en
                    else:
                        new = _i18n.ui(en, allow_remote=False)   # cache/dict only
                        if new == en:
                            todo.append(en)                      # needs remote translate
                    if new != cur:
                        try:
                            w.configure(text=new)
                        except Exception:
                            pass
            except Exception:
                pass
            try:
                for c in w.winfo_children():
                    _walk(c)
            except Exception:
                pass

        # window title
        try:
            ten = getattr(root, "_i18n_title_en", None)
            cur = root.title()
            if ten is None and cur:
                root._i18n_title_en = ten = cur
            if ten:
                root.title(ten if en_mode else _i18n.ui(ten, allow_remote=False))
        except Exception:
            pass
        _walk(root)     # covers the main window + any Toplevels (children of root)

        if todo and not en_mode:
            uniq = list(dict.fromkeys(todo))
            threading.Thread(target=lambda: _i18n.prewarm(uniq),
                             daemon=True, name="wivexx-tk-i18n").start()

    # ──────────────────────────────────────────────────────────────────────────
    # Section 3a: proportional resize handler
    # ──────────────────────────────────────────────────────────────────────────

    def _on_canvas_configure(self, event: tk.Event) -> None:
        """Update center and sphere/ring radii when canvas is resized."""
        w = event.width
        h = event.height
        self._cx = w // 2
        self._cy = h // 2
        self._sphere_r   = min(w, h) * 0.28
        self._ring_max_r = min(w, h) * 0.46
        self._precompute_sphere()

    def _precompute_sphere(self) -> None:
        """Called after resize. Currently a placeholder for future per-size caching."""
        pass

    # ──────────────────────────────────────────────────────────────────────────
    # Responsive layout (v1.19)
    # ──────────────────────────────────────────────────────────────────────────

    def _on_root_resize(self, event=None) -> None:
        """Keep the content column centred + capped (website-style max-width) by
        growing the side gutters on wide windows; full-width on small ones."""
        try:
            if event is not None and event.widget is not self._root:
                return
            w = self._root.winfo_width()
            gut = max(0, (w - getattr(self, "_content_max", 900)) // 2)
            if gut == getattr(self, "_gutter", -1):
                return
            self._gutter = gut
            self._root.grid_columnconfigure(0, minsize=gut)
            self._root.grid_columnconfigure(2, minsize=gut)
        except Exception:
            pass

    def _layout_toolbar(self, event=None) -> None:
        """Flow the tool buttons into as many rows as the width allows, so they
        never overlap or run off the edge at any window size."""
        bar = getattr(self, "_btn_row", None)
        btns = getattr(self, "_tool_buttons", None)
        if not bar or not btns:
            return
        try:
            w = bar.winfo_width()
            if w <= 1 or w == getattr(self, "_toolbar_w", -1):
                return
            self._toolbar_w = w
            pad = 4
            row = col = used = 0
            for b in btns:
                b.update_idletasks()
                bw = b.winfo_reqwidth() + pad * 2
                if col > 0 and used + bw > w:
                    row += 1
                    col = 0
                    used = 0
                b.grid(row=row, column=col, padx=pad, pady=3, sticky="w")
                used += bw
                col += 1
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # Borderless title bar (v1.18)
    # ──────────────────────────────────────────────────────────────────────────

    def _build_titlebar(self, root) -> None:
        """Slim borderless title bar placed over the top of the DOT canvas:
        the `wivexx.` wordmark (volt dot) on the left, drag-to-move anywhere on
        the bar, and ─ / ✕ controls that minimise to the corner HUD on the right.
        Uses .place() so it overlays without renumbering the grid rows."""
        bar = tk.Frame(root, bg=BG_HEX, height=34)
        bar.place(x=0, y=0, relwidth=1.0, height=34)
        self._titlebar = bar

        word = tk.Frame(bar, bg=BG_HEX)
        word.pack(side=tk.LEFT, padx=14)
        tk.Label(word, text="wivexx", bg=BG_HEX, fg=BONE,
                 font=(WORDMARK_FONT, 12, "bold")).pack(side=tk.LEFT)
        tk.Label(word, text=".", bg=BG_HEX, fg=VOLT,
                 font=(WORDMARK_FONT, 12, "bold")).pack(side=tk.LEFT)

        ctl = dict(bg=BG_HEX, fg=STONE, activebackground=GRAPHITE,
                   activeforeground=VOLT, relief=tk.FLAT, bd=0,
                   font=(MONO_FONT, 12), cursor="hand2", padx=8, pady=0)
        tk.Button(bar, text="✕", command=self._minimize_to_hud, **ctl)\
            .pack(side=tk.RIGHT, padx=(0, 8))
        tk.Button(bar, text="—", command=self._minimize_to_hud, **ctl)\
            .pack(side=tk.RIGHT)

        # Drag-to-move (borderless windows have no OS title bar to grab)
        for w in (bar, word) + tuple(word.winfo_children()):
            w.bind("<Button-1>", self._titlebar_press)
            w.bind("<B1-Motion>", self._titlebar_drag)

    def _titlebar_press(self, e) -> None:
        try:
            self._drag_off = (e.x_root - self._root.winfo_x(),
                              e.y_root - self._root.winfo_y())
        except Exception:
            self._drag_off = (0, 0)

    def _titlebar_drag(self, e) -> None:
        try:
            ox, oy = getattr(self, "_drag_off", (0, 0))
            self._root.geometry(f"+{e.x_root - ox}+{e.y_root - oy}")
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # Animation loop
    # ──────────────────────────────────────────────────────────────────────────

    def _animate(self) -> None:
        try:
            self._process_queues()
            use3d = getattr(self, "_dot3d", None)
            if use3d is None:
                use3d = _DOT3D_OK and bool(self._cfg.get("GUI_DOT_3D", True))
                self._dot3d = use3d
            if use3d:
                self._draw_dot3d_frame()
            else:
                self._draw_dot_frame()
        except Exception:
            pass
        try:
            self._root.after(self._frame_ms, self._animate)
        except Exception:
            pass

    def _process_queues(self) -> None:
        # State changes
        while not self._state_q.empty():
            s = self._state_q.get_nowait()
            if s != self._state:
                # Deliberately NOT clearing self._rings so existing waves
                # smoothly interpolate into the new state color.
                pass
            self._prev_state = self._state
            self._state = s
            self._tgt_color = list(_STATE_COLORS.get(s, _STATE_COLORS["standby"]))

        # Audio levels (decay when no new data)
        levels: list[float] = []
        while not self._level_q.empty():
            levels.append(self._level_q.get_nowait())
        if levels:
            tgt = min(1.0, max(levels) * 5.0)
            self._amp += (tgt - self._amp) * 0.35
        else:
            self._amp *= 0.82   # natural decay

        # Log entries
        while not self._log_q.empty():
            item = self._log_q.get_nowait()
            role, text = item[0], item[1]
            atts = item[2] if len(item) > 2 else None
            self._do_append_console(role, text, atts)

        # Status text
        while not self._status_q.empty():
            text = self._status_q.get_nowait()
            try:
                self._status_lbl.config(text=text)
            except Exception:
                pass

        # ◉ Modes button shows how many modes are active (poll ~2×/second —
        # modes can change by voice or hotkey at any moment)
        self._call_poll_tick += 1
        if self._call_poll_tick >= max(1, self._fps // 2):
            self._call_poll_tick = 0
            if self._modes_btn is not None:
                try:
                    n = sum(1 for _, on, _cb in self._mode_entries() if on)
                    label = f"◉ Modes ({n})" if n else "◉ Modes"
                    if self._modes_btn.cget("text") != label:
                        self._modes_btn.config(
                            text=label, fg="#C1F73A" if n else "#F2F0EA")
                except Exception:
                    pass

            # v1.8: storage-import progress indicator (click = cancel)
            media = _INTEGRATIONS.get("media")
            if media is not None and self._import_btn is not None:
                try:
                    st = media.get_import_status()
                    if st.get("active"):
                        self._import_btn.config(
                            text=f"Importing {st.get('label', '')} — "
                                 f"{st.get('copied', 0)} copied · click to cancel")
                        if not self._import_btn_shown:
                            self._import_btn.pack(side=tk.LEFT, padx=4, pady=4)
                            self._import_btn_shown = True
                    elif self._import_btn_shown:
                        self._import_btn.pack_forget()
                        self._import_btn_shown = False
                except Exception:
                    pass

            # v1.8.1: disk-analysis working indicator (read-only, no cancel)
            disk = _INTEGRATIONS.get("disk")
            if disk is not None and self._analysis_lbl is not None:
                try:
                    st = disk.analysis_status()
                    if st.get("active"):
                        self._analysis_lbl.config(
                            text=f"Analysing {st.get('drive', '')} — "
                                 f"{st.get('scanned', 0):,} files scanned, "
                                 f"{st.get('found', 0)} flagged")
                        if not self._analysis_lbl_shown:
                            self._analysis_lbl.pack(side=tk.LEFT, padx=6, pady=4)
                            self._analysis_lbl_shown = True
                    elif self._analysis_lbl_shown:
                        self._analysis_lbl.pack_forget()
                        self._analysis_lbl_shown = False
                except Exception:
                    pass

            # v1.13: generic busy jobs indicator (socials / caption studio)
            if self._busy_lbl is not None:
                try:
                    with self._busy_lock:
                        busy_texts = list(self._busy_jobs.values())
                    if busy_texts:
                        self._busy_tick = (self._busy_tick + 1) % 4
                        spin = "|/-\\"[self._busy_tick]
                        self._busy_lbl.config(
                            text=f" {spin} " + "   · ".join(busy_texts))
                        if not self._busy_lbl_shown:
                            self._busy_lbl.pack(side=tk.LEFT, padx=6, pady=4)
                            self._busy_lbl_shown = True
                    elif self._busy_lbl_shown:
                        self._busy_lbl.pack_forget()
                        self._busy_lbl_shown = False
                except Exception:
                    pass

            # v1.9: keep the goals overlay fresh (~30 s; goals can change
            # from voice or the website while the overlay is visible)
            self._goals_overlay_tick += 1
            if self._goals_overlay_tick >= 60:
                self._goals_overlay_tick = 0
                self._refresh_goals_overlay()

            # v1.8: connected web clients ( n)
            get_clients = _INTEGRATIONS.get("web_clients")
            if get_clients is not None and self._web_lbl is not None:
                try:
                    clients = get_clients() or []
                    txt = f"{len(clients)}" if clients else ""
                    if self._web_lbl.cget("text") != txt:
                        self._web_lbl.config(
                            text=txt, fg="#C1F73A" if clients else "#8A8A85")
                except Exception:
                    pass

    def _lerp_color(self) -> None:
        for i in range(3):
            self._cur_color[i] = _lerp(self._cur_color[i], self._tgt_color[i], 0.07)

    # ──────────────────────────────────────────────────────────────────────────
    # Sphere drawing (Section 3a/3b/3c)
    # ──────────────────────────────────────────────────────────────────────────

    def _draw_sphere_frame(self) -> None:
        canvas = self._sphere_canvas
        if canvas is None:
            return

        canvas.delete("all")
        self._lerp_color()

        cw = canvas.winfo_width()
        ch = canvas.winfo_height()
        if cw < 20 or ch < 20:
            return

        # Section 3a: use instance vars (updated by _on_canvas_configure)
        cx = self._cx
        cy = self._cy
        sphere_r  = self._sphere_r
        ring_max_r = self._ring_max_r

        state = self._state
        amp   = self._amp
        now   = time.time()
        color_rgb = (
            int(max(0, min(255, self._cur_color[0]))),
            int(max(0, min(255, self._cur_color[1]))),
            int(max(0, min(255, self._cur_color[2]))),
        )

        # ── Effective amplitude ───────────────────────────────────────────────
        if state == "speaking":
            self._speak_phase += 0.18   # FASTER PULSE
            eff_amp = 0.6 + 0.8 * abs(math.sin(self._speak_phase))  # EXAGGERATED
        else:
            self._speak_phase = 0.0
            eff_amp = amp

        # ── Per-state rotation speed and ring parameters (Section 3c) ─────────
        rp = _STATE_RING_PARAMS.get(state, _STATE_RING_PARAMS["standby"])
        ring_interval = rp["interval"]
        ring_max      = rp["max_rings"]
        ring_speed    = rp["speed"]

        if state == "listening":
            self._ry += 0.013 + amp * 0.025   # faster rotation with amp
            ring_speed += eff_amp * 1.2
        elif state == "speaking":
            self._ry += 0.015                 # faster rotation while speaking
            # v1.6.1: speech amplitude no longer drives the ripple rings —
            # only the sphere reacts to the voice; waves stay calm/steady.
        elif state == "processing":
            self._ry += 0.028
        elif state == "searching":
            self._ry += 0.020
        else:
            self._ry += 0.004

        # ── Spawn new rings (state-tiered max) ────────────────────────────────
        if now - self._last_ring_t > ring_interval and len(self._rings) < ring_max:
            self._rings.append(_RippleRing(cx, cy, speed=ring_speed, max_r=ring_max_r))
            self._last_ring_t = now
            if state == "processing" and len(self._rings) < ring_max:
                self._rings.append(_RippleRing(cx, cy, speed=ring_speed * 0.65, max_r=ring_max_r))

        # ── Step and cull rings ───────────────────────────────────────────────
        for ring in self._rings:
            ring.step()
        self._rings = [r for r in self._rings if not r.dead]

        # ── Draw rings ────────────────────────────────────────────────────────
        # v1.6.1: while speaking, rings ignore the voice amplitude (amp 0) so
        # they keep their smooth steady expansion; the sphere alone pulses.
        ring_amp = 0.0 if state == "speaking" else eff_amp
        for ring in self._rings:
            ring.draw(canvas, color_rgb, ring_amp)

        # ── Sphere scale (Section 3b/3c) ──────────────────────────────────────
        self._scale_pulse += 0.06
        if state == "speaking":
            # EXAGGERATED BOUNCE
            scale = sphere_r * (0.80 + 0.50 * abs(math.sin(self._speak_phase)) + amp * 0.3)
        elif state == "listening":
            scale = sphere_r * (1.0 + amp * 0.45)
        else:
            scale = sphere_r * (1.0 + 0.02 * math.sin(self._scale_pulse * 0.4))

        # ── Project sphere points ─────────────────────────────────────────────
        projected: list[tuple[float, float, float]] = []
        for (x, y, z) in self._sphere_pts:
            sx, sy, sz = _project(x, y, z, cx, cy, scale, self._rx, self._ry)
            projected.append((sx, sy, sz))

        # ── Draw edges ────────────────────────────────────────────────────────
        for (i, j) in self._sphere_edges:
            sx1, sy1, sz1 = projected[i]
            sx2, sy2, sz2 = projected[j]
            avg_z = (sz1 + sz2) * 0.5
            edge_alpha = 0.25 + 0.35 * (avg_z + 1.0) / 2.0
            ec = _dim_color(color_rgb, edge_alpha)
            canvas.create_line(sx1, sy1, sx2, sy2, fill=ec, width=1)

        # ── Draw dots at sphere vertices (Section 3b: larger, filled ovals) ───
        dot_r = max(3, int(sphere_r * 0.04))
        for (sx, sy, sz) in projected:
            depth_a = 0.35 + 0.65 * (sz + 1.0) / 2.0
            dot_a = min(1.0, depth_a * (0.7 + eff_amp * 0.3))
            dc = _dim_color(color_rgb, dot_a)
            canvas.create_oval(sx - dot_r, sy - dot_r,
                                sx + dot_r, sy + dot_r,
                                fill=dc, outline="")

        # Memory Indicator
        mem_color = "#C1F73A" if getattr(self, '_mem_active', False) else "#55554F"
        mem_text = "MEM: ON" if getattr(self, '_mem_active', False) else "MEM: OFF"
        canvas.create_text(cw - 50, 20, text=mem_text, fill=mem_color, font=("Consolas", 10, "bold"))

        # Low-power indicator (v1.6 — performance guard active)
        if getattr(self, '_low_power', False):
            canvas.create_text(cw - 50, 40, text="LOW POWER", fill="#E0A030",
                               font=("Consolas", 10, "bold"))

    # ──────────────────────────────────────────────────────────────────────────
    # The DOT (v1.18) — Wivexx's living mascot, replaces the geodesic sphere
    # ──────────────────────────────────────────────────────────────────────────

    def _draw_dot_frame(self) -> None:
        """The Wivexx DOT — a single volt dot on ink that feels alive.
          • idle:      breathing bob + squash/stretch, with the occasional blink,
                       sideways glance, and a rare little hop (alive & funny).
          • listening: perks up and emits volt ripple rings.
          • thinking:  wobbles side-to-side with a tiny satellite dot orbiting it.
          • speaking:  radius pulses to the voice amplitude.
        Reuses the same state / colour / ripple-ring machinery as the old sphere,
        so no API wiring changes — it's a renderer swap.  Mirrors the website's
        Float + ContactShadows motion (a floating dot grounded by a soft shadow)."""
        canvas = self._sphere_canvas
        if canvas is None:
            return
        canvas.delete("all")
        self._lerp_color()

        cw = canvas.winfo_width()
        ch = canvas.winfo_height()
        if cw < 20 or ch < 20:
            return

        cx = self._cx
        cy = self._cy
        base_r = self._sphere_r            # min(w,h)*0.28
        ring_max_r = self._ring_max_r
        state = self._state
        amp = self._amp
        now = time.time()
        color_rgb = (
            int(max(0, min(255, self._cur_color[0]))),
            int(max(0, min(255, self._cur_color[1]))),
            int(max(0, min(255, self._cur_color[2]))),
        )
        main = "#%02x%02x%02x" % color_rgb

        # ── personality timers (lazy-init via getattr — no __init__ changes) ──
        blink_until = getattr(self, "_dot_blink_until", 0.0)
        next_blink  = getattr(self, "_dot_next_blink", 0.0)
        glance      = getattr(self, "_dot_glance", 0.0)
        glance_tgt  = getattr(self, "_dot_glance_tgt", 0.0)
        next_glance = getattr(self, "_dot_next_glance", 0.0)
        hop_y       = getattr(self, "_dot_hop_y", 0.0)
        hop_vel     = getattr(self, "_dot_hop_vel", 0.0)
        next_hop    = getattr(self, "_dot_next_hop", 0.0)
        if next_blink == 0.0:                       # first frame — seed timers
            next_blink  = now + random.uniform(2.5, 5.0)
            next_glance = now + random.uniform(2.0, 4.0)
            next_hop    = now + random.uniform(8.0, 16.0)

        # ── breathing bob + squash/stretch ───────────────────────────────────
        self._scale_pulse += 0.045
        breathe = math.sin(self._scale_pulse)
        bob = breathe * base_r * 0.07
        squash = breathe * 0.05

        # ── speaking pulse ───────────────────────────────────────────────────
        if state == "speaking":
            self._speak_phase += 0.18
            pulse = 0.18 * abs(math.sin(self._speak_phase)) + amp * 0.25
        else:
            self._speak_phase = 0.0
            pulse = 0.0

        # ── listening perk / thinking wobble ─────────────────────────────────
        perk = (0.12 + amp * 0.35) if state == "listening" else 0.0
        wob_x = 0.0
        if state in ("processing", "searching"):
            wob_x = math.sin(now * 4.0) * base_r * 0.10

        # ── idle personality: blink / glance / hop (only when calm) ──────────
        calm = state in ("idle", "standby", "active")
        if calm:
            if now >= next_glance:
                glance_tgt = random.choice([-1, 0, 1]) * base_r * 0.18
                next_glance = now + random.uniform(2.0, 4.5)
            if now >= next_blink:
                blink_until = now + 0.12
                next_blink = now + random.uniform(2.5, 6.0)
            if now >= next_hop:
                hop_vel = -base_r * 0.16            # a little upward kick
                next_hop = now + random.uniform(10.0, 22.0)
        glance += (glance_tgt - glance) * 0.12
        hop_vel += base_r * 0.012                   # gravity
        hop_y += hop_vel
        if hop_y > 0:
            hop_y = 0.0
            hop_vel = 0.0

        # ── final geometry ───────────────────────────────────────────────────
        r = base_r * (1.0 + pulse + perk)
        rx = r * (1.0 + squash)
        ry = r * (1.0 - squash)
        dx = cx + glance + wob_x
        dy = cy + bob + hop_y

        # ── contact shadow / ground reflection (fakes the site ContactShadows) ─
        gr_w = r * 1.5
        gr_h = r * 0.30
        gy = cy + base_r * 1.05
        for k, a in ((1.0, 0.10), (0.7, 0.18)):
            col = _dim_color(color_rgb, a)
            canvas.create_oval(dx - gr_w * k, gy - gr_h * k,
                               dx + gr_w * k, gy + gr_h * k, fill=col, outline="")

        # ── ripple rings (reuse the ring machinery) for active states ────────
        rp = _STATE_RING_PARAMS.get(state, _STATE_RING_PARAMS["standby"])
        if state in ("listening", "speaking", "processing", "searching"):
            if now - self._last_ring_t > rp["interval"] and len(self._rings) < rp["max_rings"]:
                self._rings.append(_RippleRing(cx, cy, speed=rp["speed"], max_r=ring_max_r))
                self._last_ring_t = now
        for ring in self._rings:
            ring.step()
        self._rings = [rr for rr in self._rings if not rr.dead]
        ring_amp = 0.0 if state == "speaking" else amp
        for ring in self._rings:
            ring.draw(canvas, color_rgb, ring_amp)

        # ── soft outer glow around the dot (largest → smallest = gradient) ───
        for k, a in ((1.6, 0.06), (1.3, 0.10), (1.12, 0.18)):
            col = _dim_color(color_rgb, a)
            canvas.create_oval(dx - rx * k, dy - ry * k,
                               dx + rx * k, dy + ry * k, fill=col, outline="")

        # ── the DOT ──────────────────────────────────────────────────────────
        canvas.create_oval(dx - rx, dy - ry, dx + rx, dy + ry, fill=main, outline="")

        # ── specular highlight (its "gaze") or an ink eyelid while blinking ──
        if now >= blink_until:
            hl_r = rx * 0.26
            hlx = dx + glance * 1.4 - rx * 0.28
            hly = dy - ry * 0.32
            canvas.create_oval(hlx - hl_r, hly - hl_r, hlx + hl_r, hly + hl_r,
                               fill=_dim_color((242, 240, 234), 0.85), outline="")
        else:
            canvas.create_rectangle(dx - rx, dy - ry * 0.14, dx + rx, dy + ry * 0.14,
                                    fill=BG_HEX, outline="")

        # ── thinking satellite (a tiny volt dot orbiting the DOT) ────────────
        if state in ("processing", "searching"):
            ox = dx + math.cos(now * 3.0) * r * 1.35
            oy = dy + math.sin(now * 3.0) * r * 0.5
            sr = max(2, int(base_r * 0.06))
            canvas.create_oval(ox - sr, oy - sr, ox + sr, oy + sr, fill=main, outline="")

        # ── persist personality timers ──────────────────────────────────────
        self._dot_blink_until = blink_until
        self._dot_next_blink  = next_blink
        self._dot_glance      = glance
        self._dot_glance_tgt  = glance_tgt
        self._dot_next_glance = next_glance
        self._dot_hop_y       = hop_y
        self._dot_hop_vel     = hop_vel
        self._dot_next_hop    = next_hop

        # ── overlays: memory + low-power (bottom-right so they clear the bar) ─
        mem_color = "#C1F73A" if getattr(self, '_mem_active', False) else "#55554F"
        mem_text = "MEM: ON" if getattr(self, '_mem_active', False) else "MEM: OFF"
        canvas.create_text(cw - 54, ch - 16, text=mem_text, fill=mem_color,
                           font=("Consolas", 9, "bold"))
        if getattr(self, '_low_power', False):
            canvas.create_text(cw - 54, ch - 34, text="LOW POWER", fill="#E0A030",
                               font=("Consolas", 9, "bold"))

    # ──────────────────────────────────────────────────────────────────────────
    # The 3D DOT (v1.19) — software-shaded sphere blitted to the hero canvas
    # ──────────────────────────────────────────────────────────────────────────

    def _draw_dot3d_frame(self) -> None:
        canvas = self._sphere_canvas
        if canvas is None:
            return
        try:
            cw = canvas.winfo_width()
            ch = canvas.winfo_height()
            if cw < 40 or ch < 40:
                return
            self._lerp_color()
            now = time.time()
            cap = int(self._cfg.get("GUI_DOT_MAX_PX", 320))
            glow = bool(self._cfg.get("GUI_DOT_GLOW", True))
            # scene = the orb + glow + orbit, sized to the hero, capped for perf
            S = int(min(min(cw, ch) * 0.96, cap * 1.7))
            S = max(140, S)
            scene = _dot_scene(S, self._state, self._cur_color, self._amp, now,
                               glow=glow, cap=cap)
            photo = _PTk.PhotoImage(scene)
            self._dot_photo = photo            # keep a ref so Tk doesn't GC it
            canvas.delete("all")
            canvas.create_image(cw // 2, ch // 2, image=photo, anchor="center")

            cap = {"listening": "listening…", "processing": "thinking…",
            "searching": "searching…", "speaking": "speaking",
            "mic_muted": "mic muted", "audio_muted": "voice paused"}.get(
                       self._state, "ready")
            canvas.create_text(cw // 2, ch - 18, text=cap, fill=STONE,
                               font=(MONO_FONT, 10))

            mem_color = "#C1F73A" if getattr(self, '_mem_active', False) else "#55554F"
            mem_text = "MEM: ON" if getattr(self, '_mem_active', False) else "MEM: OFF"
            canvas.create_text(cw - 54, ch - 16, text=mem_text, fill=mem_color,
                               font=(MONO_FONT, 9, "bold"))
            if getattr(self, '_low_power', False):
                canvas.create_text(cw - 54, ch - 34, text="LOW POWER", fill="#E0A030",
                                   font=(MONO_FONT, 9, "bold"))
        except Exception:
            self._dot3d = False                # fall back to the 2D DOT for good
            try:
                self._draw_dot_frame()
            except Exception:
                pass

    # ──────────────────────────────────────────────────────────────────────────
    # Console
    # ──────────────────────────────────────────────────────────────────────────

    @staticmethod
    def _open_file(path: str) -> None:
        """v1.13: open a chat attachment in the default Windows viewer."""
        try:
            os.startfile(path)          # noqa: S606 — user-initiated preview
        except Exception:
            pass

    def _do_append_console(self, role: str, text: str,
                           atts: Optional[list] = None) -> None:
        """Called from _run() thread only (via _process_queues).
        v1.12: *atts* renders the actual image thumbnails / file chips that
        were sent with the message, inline in the transcript."""
        import datetime as _cdt
        txt = self._console_txt
        if txt is None:
            return
        try:
            txt.config(state=tk.NORMAL)
            ts = _cdt.datetime.now().strftime("%H:%M")
            tag = "you" if role.lower() in ("you", "user") else "jarvis"
            txt.insert(tk.END, f"[{ts}] ", "time")
            txt.insert(tk.END, "You " if tag == "you" else "WIVEXX ", tag)
            txt.insert(tk.END, text.strip() + "\n", tag)
            for att in (atts or []):
                if att.get("thumb"):
                    try:
                        import base64 as _b64
                        import io as _io
                        from PIL import Image as _PILImage, \
                            ImageTk as _PILImageTk
                        img = _PILImage.open(
                            _io.BytesIO(_b64.b64decode(att["thumb"])))
                        ph = _PILImageTk.PhotoImage(img)
                        # keep refs alive or tk garbage-collects the pixels
                        self._chat_img_refs.append(ph)
                        txt.insert(tk.END, " ", "time")
                        img_idx = txt.image_create(tk.END, image=ph,
                                                   padx=2, pady=3)
                        # v1.13: click the thumbnail → open the original file
                        path = att.get("path")
                        if path and os.path.exists(path):
                            tag = f"attimg{len(self._chat_img_refs)}"
                            txt.tag_add(tag, img_idx)
                            txt.tag_bind(
                                tag, "<Button-1>",
                                lambda _e, p=path: self._open_file(p))
                        txt.insert(tk.END, f"  {att.get('name', '')}\n", "time")
                        continue
                    except Exception:
                        pass
                path = att.get("path")
                if path and os.path.exists(path):
                    start = txt.index(tk.END + "-1c")
                    txt.insert(tk.END,
                               f"          {att.get('name', 'file')}"
                               f"  (click to open)\n", "time")
                    tag = f"attfile{start}"
                    txt.tag_add(tag, start, txt.index(tk.END + "-1c"))
                    txt.tag_bind(tag, "<Button-1>",
                                 lambda _e, p=path: self._open_file(p))
                else:
                    txt.insert(tk.END,
                               f"          {att.get('name', 'file')}\n",
                               "time")
            txt.see(tk.END)
            txt.config(state=tk.DISABLED)
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # HUD (corner mini-window shown when main window is hidden/minimized)
    # ──────────────────────────────────────────────────────────────────────────

    def _make_draggable(self, widget, on_click=None, window_to_move=None) -> None:
        """Bind drag-to-move to *widget*.  Fires on_click only on genuine clicks
        (movement < DRAG_THRESHOLD pixels).  window_to_move defaults to widget."""
        win = window_to_move or widget
        state = {"start_x": 0, "start_y": 0, "win_x": 0, "win_y": 0, "dragging": False}
        DRAG_THRESHOLD = 6

        def on_press(e: tk.Event) -> None:
            state["start_x"] = e.x_root
            state["start_y"] = e.y_root
            state["win_x"] = win.winfo_x()
            state["win_y"] = win.winfo_y()
            state["dragging"] = False

        def on_motion(e: tk.Event) -> None:
            dx = abs(e.x_root - state["start_x"])
            dy = abs(e.y_root - state["start_y"])
            if not state["dragging"] and (dx > DRAG_THRESHOLD or dy > DRAG_THRESHOLD):
                state["dragging"] = True
                self._is_dragging_hud = True   # freeze HUD animation
            if state["dragging"]:
                new_x = state["win_x"] + (e.x_root - state["start_x"])
                new_y = state["win_y"] + (e.y_root - state["start_y"])
                win.geometry(f"+{new_x}+{new_y}")

        def on_release(e: tk.Event) -> None:
            self._is_dragging_hud = False     # UNFREEZE ANIMATION
            if not state["dragging"] and on_click:
                on_click()

        widget.bind("<ButtonPress-1>",   on_press)
        widget.bind("<B1-Motion>",       on_motion)
        widget.bind("<ButtonRelease-1>", on_release)

    def _show_hud(self) -> None:
        """Show the corner HUD (called on close or minimize).

        Guarded by _hud_pending so rapid calls (e.g. spamming the X button)
        cannot stack up multiple _create_hud callbacks on the event queue.
        """
        try:
            if self._hud_pending:
                return
            # If HUD already exists and is visible, just bring it forward
            if self._hud_win is not None:
                try:
                    if self._hud_win.winfo_exists():
                        self._hud_win.deiconify()
                        self._hud_win.lift()
                        return
                except Exception:
                    self._hud_win = None
            self._hud_pending = True
            self._root.after(0, self._create_hud)
        except Exception:
            pass

    def _hide_hud(self) -> None:
        """Hide the corner HUD (called when main window is restored)."""
        try:
            if self._hud_win and self._hud_win.winfo_exists():
                self._hud_win.withdraw()
        except Exception:
            pass

    def _minimize_to_hud(self) -> None:
        """Called from WM_DELETE_WINDOW — hides main, shows corner HUD."""
        try:
            self._root.withdraw()
            self._main_visible = False
            self._show_hud()
        except Exception:
            pass

    def _create_hud(self) -> None:
        """Create (or show) the 200×200 corner HUD window."""
        self._hud_pending = False   # release the guard regardless of outcome
        try:
            if self._hud_win is not None:
                try:
                    if self._hud_win.winfo_exists():
                        self._hud_win.deiconify()
                        self._hud_win.lift()
                        return
                except Exception:
                    self._hud_win = None

            hud = tk.Toplevel(self._root)
            self._hud_win = hud
            hud.overrideredirect(True)
            hud.attributes("-topmost", True)
            hud.configure(bg=BG_HEX)

            sw = hud.winfo_screenwidth()
            sh = hud.winfo_screenheight()
            hud.geometry(f"200x200+{sw - 220}+{sh - 240}")

            # v1.6.1: real overlay styles (topmost band, no focus steal,
            # hidden from Alt-Tab) + immediate topmost assert
            hud.update_idletasks()
            self._apply_overlay_styles()
            self._assert_hud_topmost()

            hc = tk.Canvas(hud, width=200, height=200, bg=BG_HEX,
                           highlightthickness=0)
            hc.pack(fill=tk.BOTH, expand=True)
            self._hud_canvas = hc

            # Draggable HUD with click/drag discriminator (Fix 3a)
            # Apply only to the canvas (which covers 100% of the window).
            # window_to_move=hud so the entire toplevel is repositioned.
            self._make_draggable(hc, on_click=self._restore_from_hud,
                                 window_to_move=hud)

            # Section 4c: Right-click context menu
            self._pure_anim_mode = tk.BooleanVar(value=False)
            menu = tk.Menu(hud, tearoff=0, bg="#1C1C1A", fg="#F2F0EA",
                           activebackground="#2A2A27", activeforeground="#C1F73A",
                           borderwidth=1, relief=tk.FLAT)
            menu.add_command(
                label="Show Wivexx",
                command=self._restore_from_hud,
            )
            menu.add_separator()
            menu.add_command(
                label="Hide Overlay",
                command=lambda: hud.withdraw(),
            )
            menu.add_command(
                label="Show Overlay",
                command=lambda: hud.deiconify(),
            )
            menu.add_separator()
            menu.add_checkbutton(
                label="Pure Animation Mode",
                variable=self._pure_anim_mode,
                command=self._toggle_pure_anim_mode,
            )
            menu.add_separator()
            menu.add_command(
                label="Close Wivexx",
                # module-level teardown: closes both the WebView2 window (web
                # mode) and the tk sidecar — not just this root.
                command=_force_close,
            )

            def _show_menu(event: tk.Event) -> None:
                try:
                    menu.post(event.x_root, event.y_root)
                except Exception:
                    pass

            hud.bind("<Button-3>", _show_menu)
            hc.bind("<Button-3>",  _show_menu)

            # Start HUD animation loop
            self._root.after(self._frame_ms, self._animate_hud)
        except Exception:
            pass

    def _toggle_pure_anim_mode(self) -> None:
        """Pure animation mode — transparent HUD background, no text labels (Fix 3b)."""
        try:
            if self._hud_win is None or not self._hud_win.winfo_exists():
                return
            pure = bool(self._pure_anim_mode and self._pure_anim_mode.get())
            if pure:
                self._hud_win.wm_attributes("-transparentcolor", "#010101")
                self._hud_win.configure(bg="#010101")
                self._hud_canvas.configure(bg="#010101")
                # Hide any tk.Label widgets stored in _hud_labels
                for _lbl in self._hud_labels:
                    try:
                        _lbl.pack_forget()
                        _lbl.place_forget()
                        _lbl.grid_remove()
                    except Exception:
                        pass
            else:
                try:
                    self._hud_win.wm_attributes("-transparentcolor", "")
                except Exception:
                    pass
                self._hud_win.configure(bg=BG_HEX)
                self._hud_canvas.configure(bg=BG_HEX)
                # Restore any tk.Label widgets
                for _lbl in self._hud_labels:
                    try:
                        _lbl.pack(pady=(0, 4))
                    except Exception:
                        pass
        except Exception:
            pass

    @staticmethod
    def _overlay_hwnd(win: tk.Toplevel) -> int:
        """Resolve the real top-level Win32 handle for a Tk Toplevel.
        winfo_id() returns an inner child window — GA_ROOT walks up to the
        actual OS window that SetWindowPos/SetWindowLong must target."""
        import ctypes
        user32 = ctypes.windll.user32
        GA_ROOT = 2
        return user32.GetAncestor(win.winfo_id(), GA_ROOT) or win.winfo_id()

    def _apply_overlay_styles(self) -> None:
        """One-time extended styles for the HUD (v1.6.1): TOPMOST band plus
        NOACTIVATE (never steals focus from a game) and TOOLWINDOW (hidden
        from Alt-Tab).  Games that use *borderless* fullscreen render through
        the compositor, so the styled overlay stays visible above them."""
        try:
            import ctypes
            user32 = ctypes.windll.user32
            hwnd = self._overlay_hwnd(self._hud_win)
            GWL_EXSTYLE      = -20
            WS_EX_TOPMOST    = 0x00000008
            WS_EX_NOACTIVATE = 0x08000000
            WS_EX_TOOLWINDOW = 0x00000080
            cur = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
            user32.SetWindowLongW(hwnd, GWL_EXSTYLE,
                                  cur | WS_EX_TOPMOST | WS_EX_NOACTIVATE
                                  | WS_EX_TOOLWINDOW)
        except Exception:
            pass

    def _assert_hud_topmost(self) -> None:
        """Force the HUD above every other window (v1.6).

        A single '-topmost' set at creation is not enough on Windows: fullscreen
        apps, Explorer restarts and some games knock overlays out of the topmost
        band.  Re-asserting via Tk AND the raw Win32 SetWindowPos call keeps the
        overlay genuinely always-on-top.

        Note: a game in EXCLUSIVE fullscreen bypasses the desktop compositor —
        no window of any kind can render above it.  Use the game's
        'borderless' / 'windowed fullscreen' display mode for the overlay."""
        try:
            self._hud_win.attributes("-topmost", True)
            self._hud_win.lift()
        except Exception:
            pass
        try:
            import ctypes
            user32 = ctypes.windll.user32
            hwnd = self._overlay_hwnd(self._hud_win)
            HWND_TOPMOST   = -1
            SWP_NOSIZE     = 0x0001
            SWP_NOMOVE     = 0x0002
            SWP_NOACTIVATE = 0x0010
            user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,
                                SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE)
        except Exception:
            pass

    def _animate_hud(self) -> None:
        try:
            if self._hud_win is None or not self._hud_win.winfo_exists():
                return
            if not getattr(self, '_is_dragging_hud', False):
                self._draw_hud_frame()
            # Re-assert always-on-top roughly every second (v1.6.1)
            self._hud_topmost_tick += 1
            if self._hud_topmost_tick >= self._fps:
                self._hud_topmost_tick = 0
                self._assert_hud_topmost()
            self._root.after(self._frame_ms, self._animate_hud)
        except Exception:
            pass

    def _draw_hud_frame(self) -> None:
        hc = self._hud_canvas
        if hc is None:
            return
        try:
            # Determine background color (pure anim mode uses #010101)
            bg = "#010101" if (
                self._pure_anim_mode and self._pure_anim_mode.get()
            ) else BG_HEX
            hc.delete("all")
            hc.configure(bg=bg)
            cx, cy = 100, 95
            color_rgb = (
                int(max(0, min(255, self._cur_color[0]))),
                int(max(0, min(255, self._cur_color[1]))),
                int(max(0, min(255, self._cur_color[2]))),
            )
            # ── Animate HUD Rotation, Phase, and Scale ────────────────────────
            if self._state == "speaking":
                self._speak_phase += 0.18
                self._ry += 0.015
                eff_amp = 0.6 + 0.8 * abs(math.sin(self._speak_phase))
                scale = 42.0 * (0.80 + 0.50 * abs(math.sin(self._speak_phase)))
            elif self._state == "listening":
                self._ry += 0.015 + self._amp * 0.025
                eff_amp = self._amp
                scale = 42.0 * (1.0 + eff_amp * 0.45)
            elif self._state in ("processing", "searching"):
                self._ry += 0.03
                eff_amp = self._amp
                scale = 42.0
            else:
                self._ry += 0.005
                eff_amp = self._amp
                scale = 42.0

            # ── Draw Scaled Waves in HUD ──────────────────────────────────────
            # v1.6.1: speaking amplitude only moves the sphere, never the waves
            ring_amp = 0.0 if self._state == "speaking" else eff_amp
            scale_factor = scale / self._sphere_r
            for ring in self._rings:
                if ring.dead:
                    continue
                base_r = ring.r * (1.0 + ring_amp * 0.3) * scale_factor
                r_x = base_r
                r_y = base_r * 0.55
                for w, f in ((2, 0.3), (1, 0.7)):
                    c = _dim_color(color_rgb, ring.alpha * f)
                    hc.create_oval(cx - r_x, cy - r_y, cx + r_x, cy + r_y, outline=c, width=w)

            proj_hud: list[tuple[float, float, float]] = []
            for (x, y, z) in self._sphere_pts:
                sx, sy, sz = _project(x, y, z, cx, cy, scale, self._rx, self._ry)
                proj_hud.append((sx, sy, sz))

            for idx, (i, j) in enumerate(self._sphere_edges):
                if idx % 2 == 0:
                    sx1, sy1, _ = proj_hud[i]
                    sx2, sy2, _ = proj_hud[j]
                    avg_z = (proj_hud[i][2] + proj_hud[j][2]) * 0.5
                    ea = 0.2 + 0.3 * (avg_z + 1.0) / 2.0
                    hc.create_line(sx1, sy1, sx2, sy2,
                                   fill=_dim_color(color_rgb, ea), width=1)

            dot_r = max(2, int(42.0 * 0.04))
            for (sx, sy, sz) in proj_hud:
                da = 0.35 + 0.65 * (sz + 1.0) / 2.0
                dc = _dim_color(color_rgb, min(1.0, da * 0.8))
                hc.create_oval(sx - dot_r, sy - dot_r,
                                sx + dot_r, sy + dot_r,
                                fill=dc, outline="")

            # Memory Indicator (disabled in HUD — shown in main window only)
            # mem_color = "#C1F73A" if getattr(self, '_mem_active', False) else "#55554F"
            # hc.create_text(165, 15, text="[  ]", fill=mem_color, font=("Consolas", 10, "bold"))

            # Label — omitted entirely in pure animation mode (Fix 3b)
            if not (self._pure_anim_mode and self._pure_anim_mode.get()):
                hc.create_text(100, 188, text="Wivexx  ·  click to restore",
                               fill="#55554F", font=("Consolas", 7))
        except Exception:
            pass

    def _restore_from_hud(self) -> None:
        """Hide HUD, restore main window.

        Calls state('normal') before deiconify() to reliably un-minimize on
        Windows 11 where deiconify() alone sometimes fails to raise a taskbar-
        minimised window.  focus_force() ensures the window comes to the front.
        """
        try:
            if self._hud_win and self._hud_win.winfo_exists():
                self._hud_win.withdraw()
            # v1.20 web mode: the main window is the WebView2 page, not this
            # (withdrawn) sidecar root — restore it through the override.
            if self._restore_override is not None:
                try:
                    self._restore_override()
                    self._main_visible = True
                    return
                except Exception:
                    pass
            try:
                self._root.state("normal")  # un-minimize (no-op on borderless)
            except Exception:
                pass                        # overrideredirect windows reject state()
            self._root.deiconify()
            self._root.lift()
            self._root.focus_force()
            self._main_visible = True
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # v1.6 — Call mode button
    # ──────────────────────────────────────────────────────────────────────────

    def _on_call_mode_clicked(self) -> None:
        toggle = _INTEGRATIONS.get("toggle_call_mode")
        if toggle is None:
            messagebox.showinfo("Wivexx", "Call mode is not available yet.")
            return
        # toggle speaks a confirmation (blocking TTS) — never run on GUI thread
        threading.Thread(target=toggle, daemon=True).start()

    # ──────────────────────────────────────────────────────────────────────────
    # v1.11.1 — ◉ Modes dropdown: every toggleable mode in one place
    # ──────────────────────────────────────────────────────────────────────────

    def _mode_entries(self) -> list:
        """[(label, is_on, toggle_callable), …] — built fresh so the menu and
        the button counter always reflect the live state."""
        entries: list = []

        get_cm = _INTEGRATIONS.get("get_call_mode")
        if get_cm is not None:
            on = False
            try:
                on = bool(get_cm())
            except Exception:
                pass
            entries.append(("Call mode — wake word off, push-to-talk only",
                            on, self._on_call_mode_clicked))

        toggle_gm = _INTEGRATIONS.get("toggle_gaming_mode")
        get_gm = _INTEGRATIONS.get("get_gaming_mode")
        if toggle_gm is not None:
            on = False
            try:
                on = bool(get_gm()) if get_gm else False
            except Exception:
                pass

            def _toggle_gaming(_t=toggle_gm) -> None:
                # suspends many procs + speaks → never on the GUI thread
                threading.Thread(target=_t, daemon=True).start()
            entries.append(("Performance mode — freeze background apps",
                            on, _toggle_gaming))

        wm = _INTEGRATIONS.get("workmode")
        if wm is not None:
            on = False
            try:
                on = bool(wm.is_enabled())
            except Exception:
                pass

            def _toggle_work(_wm=wm) -> None:
                try:
                    _wm.set_enabled(not _wm.is_enabled())
                except Exception:
                    pass
            entries.append(("Work mode — auto-extract Downloads zips",
                            on, _toggle_work))

        overlay_on = False
        try:
            overlay_on = (self._goals_overlay is not None
                          and self._goals_overlay.winfo_exists())
        except Exception:
            pass
        entries.append(("Goals overlay — today's focus on screen",
                        overlay_on, self._toggle_goals_overlay))

        perf_on = False
        try:
            perf_on = (self._perf_hud is not None and self._perf_hud.winfo_exists())
        except Exception:
            perf_on = False
        entries.append(("Performance HUD — live CPU/GPU/RAM on screen",
                        perf_on, lambda: self.toggle_perf_hud()))
        return entries

    def _open_modes_menu(self) -> None:
        menu = tk.Menu(self._root, tearoff=0, bg="#1C1C1A", fg="#F2F0EA",
                       activebackground="#2A2A27", activeforeground="#C1F73A",
                       borderwidth=1, relief=tk.FLAT,
                       font=("Consolas", 10))
        for label, on, cb in self._mode_entries():
            menu.add_command(
                label=("✓ " if on else " ") + label,
                foreground="#C1F73A" if on else "#F2F0EA",
                command=cb)
        if self._low_power:
            menu.add_separator()
            menu.add_command(label="     Low-power mode (automatic)",
                             state=tk.DISABLED)
        try:
            n_items = menu.index(tk.END)
            n_items = 0 if n_items is None else n_items + 1
            x = self._modes_btn.winfo_rootx()
            # open upward so the list sits above the button, not over the input
            y = max(0, self._modes_btn.winfo_rooty() - n_items * 28 - 6)
            menu.tk_popup(x, y)
        except Exception:
            pass
        finally:
            try:
                menu.grab_release()
            except Exception:
                pass

    # ──────────────────────────────────────────────────────────────────────────
    # v1.9 —  Goals manager + always-on-top focus overlay
    # ──────────────────────────────────────────────────────────────────────────

    def _open_goals_manager(self) -> None:
        if self._focus_existing(self._goals_win):
            return
        goals = _INTEGRATIONS.get("goals")
        if goals is None:
            messagebox.showinfo("Wivexx", "Goals system not available yet.")
            return

        win = tk.Toplevel(self._root)
        self._goals_win = win
        self._style_editor_window(win, "Wivexx — Goals", "760x680")
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))

        win.grid_columnconfigure(0, weight=5)
        win.grid_columnconfigure(1, weight=4)
        win.grid_rowconfigure(1, weight=1)

        stats_lbl = tk.Label(win, text="", bg=BG_HEX, fg="#C1F73A",
                             font=("Consolas", 10, "bold"), anchor=tk.W)
        stats_lbl.grid(row=0, column=0, columnspan=2, sticky="ew",
                       padx=10, pady=(10, 4))

        # Left: goal list
        left = tk.Frame(win, bg=BG_HEX)
        left.grid(row=1, column=0, sticky="nsew", padx=(10, 4), pady=4)
        left.grid_rowconfigure(1, weight=1)
        left.grid_columnconfigure(0, weight=1)
        tk.Label(left, text="Open goals (sorted: overdue → due today → priority)",
                 **L_KW).grid(row=0, column=0, sticky="w")
        lb = tk.Listbox(left, bg="#1C1C1A", fg="#F2F0EA",
                        selectbackground="#2A2A27", selectforeground="#C1F73A",
                        relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        lb.grid(row=1, column=0, sticky="nsew", pady=4)
        show_done = tk.BooleanVar(value=False)
        ids_by_index: list[int] = []

        # Right: form
        right = tk.Frame(win, bg=BG_HEX)
        right.grid(row=1, column=1, sticky="nsew", padx=(4, 10), pady=4)
        right.grid_columnconfigure(1, weight=1)

        def _row(r, label):
            tk.Label(right, text=label, **L_KW).grid(row=r, column=0,
                                                     sticky="w", pady=2)
        _row(0, "Title")
        title_e = tk.Entry(right, **E_KW)
        title_e.grid(row=0, column=1, sticky="ew", pady=2)
        _row(1, "Notes")
        notes_e = tk.Entry(right, **E_KW)
        notes_e.grid(row=1, column=1, sticky="ew", pady=2)
        _row(2, "Priority")
        prio_cb = ttk.Combobox(right, state="readonly", font=("Consolas", 10),
                               values=["high", "normal", "low"])
        prio_cb.set("normal")
        prio_cb.grid(row=2, column=1, sticky="ew", pady=2)
        _row(3, "Due (YYYY-MM-DD)")
        due_e = tk.Entry(right, **E_KW)
        due_e.grid(row=3, column=1, sticky="ew", pady=2)
        _row(4, "Reminder")
        rem_cb = ttk.Combobox(right, state="readonly", font=("Consolas", 10),
                              values=["none", "hourly", "daily", "weekly"])
        rem_cb.set("none")
        rem_cb.grid(row=4, column=1, sticky="ew", pady=2)
        _row(5, "Remind at (HH:MM)")
        rtime_e = tk.Entry(right, **E_KW)
        rtime_e.insert(0, "09:00")
        rtime_e.grid(row=5, column=1, sticky="ew", pady=2)
        _row(6, "Weekly day")
        rday_cb = ttk.Combobox(right, state="readonly", font=("Consolas", 10),
                               values=list(goals.WEEKDAYS))
        rday_cb.set("Monday")
        rday_cb.grid(row=6, column=1, sticky="ew", pady=2)

        info = tk.Label(right, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W,
                        wraplength=300, justify=tk.LEFT)
        info.grid(row=7, column=0, columnspan=2, sticky="ew", pady=6)
        selected_id = {"id": None}

        def _refresh() -> None:
            lb.delete(0, tk.END)
            ids_by_index.clear()
            import datetime as _d
            today = _d.date.today().isoformat()
            for g in goals.list_goals(include_done=show_done.get()):
                mark = "✓" if g.get("done") else \
                       ("‼" if g.get("due") and g["due"] < today else
                        ("●" if g.get("priority") == "high" else "○"))
                due = f"  (due {g['due']})" if g.get("due") else ""
                rem = f"  {g['reminder']}" if g.get("reminder", "none") != "none" else ""
                lb.insert(tk.END, f" {mark} {g['title']}{due}{rem}")
                ids_by_index.append(g["id"])
            s = goals.stats()
            stats_lbl.config(text=f"{s['open']} open   · "
                                  f"{s['done_week']} completed this week   · "
                                  f"{s['streak']}-day streak")

        def _on_select(_e=None) -> None:
            sel = lb.curselection()
            if not sel:
                return
            gid = ids_by_index[sel[0]]
            g = next((x for x in goals.list_goals(include_done=True)
                      if x["id"] == gid), None)
            if not g:
                return
            selected_id["id"] = gid
            title_e.delete(0, tk.END); title_e.insert(0, g["title"])
            notes_e.delete(0, tk.END); notes_e.insert(0, g.get("notes", ""))
            prio_cb.set(g.get("priority", "normal"))
            due_e.delete(0, tk.END); due_e.insert(0, g.get("due") or "")
            rem_cb.set(g.get("reminder", "none"))
            rtime_e.delete(0, tk.END); rtime_e.insert(0, g.get("remind_time", "09:00"))
            rday_cb.set(goals.WEEKDAYS[int(g.get("remind_day", 0)) % 7])
        lb.bind("<<ListboxSelect>>", _on_select)

        def _form_fields() -> dict:
            due = due_e.get().strip()
            return dict(
                title=title_e.get().strip(),
                notes=notes_e.get().strip(),
                priority=prio_cb.get() or "normal",
                due=due if due else None,
                reminder=rem_cb.get() or "none",
                remind_time=rtime_e.get().strip() or "09:00",
                remind_day=list(goals.WEEKDAYS).index(rday_cb.get() or "Monday"),
            )

        def _clear_form() -> None:
            selected_id["id"] = None
            title_e.delete(0, tk.END); notes_e.delete(0, tk.END)
            due_e.delete(0, tk.END); prio_cb.set("normal"); rem_cb.set("none")
            info.config(text="")

        def _save() -> None:
            f = _form_fields()
            if not f["title"]:
                messagebox.showwarning("Wivexx", "The goal needs a title.",
                                       parent=win)
                return
            if selected_id["id"] is None:
                goals.add_goal(**f)
                info.config(text="Goal added ✓")
            else:
                goals.update_goal(selected_id["id"], **f)
                info.config(text="Goal updated ✓")
            _refresh()
            self._refresh_goals_overlay()

        def _complete() -> None:
            sel = lb.curselection()
            if sel:
                goals.complete_goal(ids_by_index[sel[0]])
                _clear_form()
                _refresh()
                self._refresh_goals_overlay()

        def _delete() -> None:
            sel = lb.curselection()
            if sel and messagebox.askyesno("Wivexx", "Delete this goal?",
                                           parent=win):
                goals.delete_goal(ids_by_index[sel[0]])
                _clear_form()
                _refresh()
                self._refresh_goals_overlay()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=8, column=0, columnspan=2, sticky="ew")
        tk.Button(btns, text="＋ New",      command=_clear_form, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Save",     command=_save,       **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="✓ Complete", command=_complete,   **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete",   command=_delete,     **_BTN_KW).pack(side=tk.LEFT, padx=2)

        # ──  Social media trackers (v1.12) — manage from the to-do list ────
        socials = _INTEGRATIONS.get("socials")
        if socials is not None:
            trk = tk.Frame(win, bg=BG_HEX)
            trk.grid(row=2, column=0, columnspan=2, sticky="ew",
                     padx=10, pady=(6, 0))
            trk.grid_columnconfigure(0, weight=1)
            tk.Label(trk, text="Social media trackers — growth is checked "
            "once per session and on demand",
                     **L_KW).grid(row=0, column=0, columnspan=4, sticky="w")
            trk_lb = tk.Listbox(trk, bg="#1C1C1A", fg="#F2F0EA", height=3,
                                selectbackground="#2A2A27",
                                selectforeground="#C1F73A", relief=tk.FLAT,
                                font=("Consolas", 10), exportselection=False)
            trk_lb.grid(row=1, column=0, columnspan=4, sticky="ew", pady=4)
            trk_handles: list[tuple] = []

            def _trk_refresh(with_stats: bool = False) -> None:
                trk_lb.delete(0, tk.END)
                trk_handles.clear()
                for a in socials.list_trackers():
                    trk_lb.insert(tk.END, f"  {a['platform']} @{a['handle']}")
                    trk_handles.append((a["platform"], a["handle"]))
                if not trk_handles:
                    trk_lb.insert(tk.END, "  (no trackers — add an Instagram "
                    "handle below)")
                elif with_stats:
                    def _bg() -> None:
                        lines = []
                        for plat, h in list(trk_handles):
                            r = socials.report(plat, h)
                            if r is None:
                                lines.append(f"  {plat} @{h} — unreachable "
                                             f"right now")
                                continue
                            line = (f"  {plat} @{h} — "
                                    f"{r['followers']:,} followers")
                            if r.get("follower_delta") is not None:
                                d = r["follower_delta"]
                                line += f"  ({'+' if d >= 0 else ''}{d:,} this week)"
                            if r.get("engagement_rate") is not None:
                                line += f"  · eng {r['engagement_rate']}%"
                            lines.append(line)

                        def _apply() -> None:
                            try:
                                trk_lb.delete(0, tk.END)
                                for ln in lines:
                                    trk_lb.insert(tk.END, ln)
                            except Exception:
                                pass
                        try:
                            win.after(0, _apply)
                        except Exception:
                            pass
                    threading.Thread(target=_bg, daemon=True,
                                     name="wivexx-socials-gui").start()

            trk_new = tk.Entry(trk, **E_KW)
            trk_new.grid(row=2, column=0, sticky="ew", pady=(0, 2))
            trk_new.insert(0, "@handle")

            def _trk_add() -> None:
                h = trk_new.get().strip().lstrip("@")
                if not h or h == "handle":
                    return
                try:
                    socials.add_tracker("instagram", h)
                    trk_new.delete(0, tk.END)
                    _trk_refresh(with_stats=True)
                except ValueError as ve:
                    messagebox.showwarning("Wivexx", str(ve), parent=win)

            def _trk_remove() -> None:
                sel = trk_lb.curselection()
                if sel and sel[0] < len(trk_handles):
                    plat, h = trk_handles[sel[0]]
                    if messagebox.askyesno("Wivexx",
                                           f"Stop tracking @{h}?", parent=win):
                        socials.remove_tracker(plat, h)
                        _trk_refresh()

            tk.Button(trk, text="＋ Track", command=_trk_add,
                      **_BTN_KW).grid(row=2, column=1, padx=(4, 2))
            tk.Button(trk, text="Remove", command=_trk_remove,
                      **_BTN_KW).grid(row=2, column=2, padx=2)
            tk.Button(trk, text="Stats", command=lambda: _trk_refresh(True),
                      **_BTN_KW).grid(row=2, column=3, padx=(2, 0))
            _trk_refresh()

        bottom = tk.Frame(win, bg=BG_HEX)
        bottom.grid(row=3, column=0, columnspan=2, sticky="ew", padx=10, pady=(2, 10))
        tk.Checkbutton(bottom, text="show completed", variable=show_done,
                       command=_refresh, bg=BG_HEX, fg="#F2F0EA",
                       selectcolor="#1C1C1A", activebackground=BG_HEX,
                       activeforeground="#C1F73A",
                       font=("Consolas", 9)).pack(side=tk.LEFT)
        tk.Button(bottom, text="Toggle desktop overlay",
                  command=self._toggle_goals_overlay, **_BTN_KW).pack(side=tk.RIGHT)

        _refresh()

    # ──────────────────────────────────────────────────────────────────────────
    #  Caption Studio (v1.13)
    # ──────────────────────────────────────────────────────────────────────────

    @staticmethod
    def _load_preview_photo(path: str, frames_fn=None, max_px: int = 200):
        """(PhotoImage|None, kind) preview for a media file — image files via
        PIL, videos via an extracted frame (needs opencv-python)."""
        from PIL import Image as _PILImage, ImageTk as _PILImageTk
        ext = os.path.splitext(path)[1].lower()
        img = None
        kind = "file"
        if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp",
        ".heic", ".heif", ".tif", ".tiff"):
            kind = "image"
            try:
                img = _PILImage.open(path).convert("RGB")
            except Exception:
                img = None
        elif ext in (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"):
            kind = "video"
            if frames_fn is not None:
                try:
                    import base64 as _b64
                    import io as _io
                    fr = frames_fn(path, 1)
                    if fr:
                        img = _PILImage.open(
                            _io.BytesIO(_b64.b64decode(fr[0])))
                except Exception:
                    img = None
        if img is None:
            return None, kind
        img.thumbnail((max_px, max_px))
        return _PILImageTk.PhotoImage(img), kind

    def _open_caption_studio(self, topic: str = "") -> None:
        if self._focus_existing(self._caption_win):
            return
        gen = _INTEGRATIONS.get("caption_generate")
        if gen is None:
            messagebox.showinfo("Wivexx",
            "Caption engine not available yet.")
            return
        frames_fn = _INTEGRATIONS.get("video_frames")
        encode_fn = _INTEGRATIONS.get("encode_image")

        win = tk.Toplevel(self._root)
        self._caption_win = win
        self._style_editor_window(win, "Wivexx —  Caption Studio",
        "660x620")
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        H_KW = dict(bg=BG_HEX, fg="#C1F73A", font=("Consolas", 10, "bold"),
                    anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))
        win.grid_columnconfigure(0, weight=1)

        state: dict = {"path": None, "thumb": None, "kind": "",
        "working": False}

        tk.Label(win, text="1 · THE CONTENT", **H_KW).grid(
            row=0, column=0, sticky="ew", padx=12, pady=(12, 2))
        frow = tk.Frame(win, bg=BG_HEX)
        frow.grid(row=1, column=0, sticky="ew", padx=12)
        frow.grid_columnconfigure(2, weight=1)
        thumb_lbl = tk.Label(frow, bg="#1C1C1A", text="", width=2)
        thumb_lbl.grid(row=0, column=1, padx=(8, 8))
        file_lbl = tk.Label(frow, text="no file — I'll work from your "
        "description", **L_KW)
        file_lbl.grid(row=0, column=2, sticky="ew")

        def _pick_file() -> None:
            p = filedialog.askopenfilename(
                parent=win, title="The post — video or photo",
                filetypes=[("Videos & photos",
                "*.mp4 *.mov *.avi *.mkv *.webm *.m4v "
                "*.jpg *.jpeg *.png *.webp *.heic *.bmp *.gif"),
                           ("All files", "*.*")])
            if not p:
                return
            state["path"] = p
            ph, kind = self._load_preview_photo(p, frames_fn)
            state["kind"] = kind
            state["thumb"] = ph
            if ph is not None:
                thumb_lbl.config(image=ph, text="")
            else:
                thumb_lbl.config(image="",
                                 text="video" if kind == "video" else "image")
            note = ""
            if kind == "video" and ph is None:
                note = "  (tip: pip install opencv-python so I can watch it)"
            file_lbl.config(text=os.path.basename(p) + note)

        tk.Button(frow, text="Choose video / photo", command=_pick_file,
                  **_BTN_KW).grid(row=0, column=0)

        tk.Label(win, text="What is the post about?", **L_KW).grid(
            row=2, column=0, sticky="ew", padx=12, pady=(8, 0))
        topic_e = tk.Entry(win, **E_KW)
        topic_e.grid(row=3, column=0, sticky="ew", padx=12, ipady=3)
        if topic:
            topic_e.insert(0, topic)

        tk.Label(win, text="2 · WHO IS IT FOR  (optional — I'll study the "
        "profile's voice & niche)", **H_KW).grid(
            row=4, column=0, sticky="ew", padx=12, pady=(14, 2))
        who = tk.Frame(win, bg=BG_HEX)
        who.grid(row=5, column=0, sticky="ew", padx=12)
        who.grid_columnconfigure(1, weight=2)
        who.grid_columnconfigure(3, weight=3)
        tk.Label(who, text="Name", **L_KW).grid(row=0, column=0, padx=(0, 4))
        name_e = tk.Entry(who, **E_KW)
        name_e.grid(row=0, column=1, sticky="ew", ipady=3)
        tk.Label(who, text="  IG handle  @", **L_KW).grid(row=0, column=2)
        handle_e = tk.Entry(who, **E_KW)
        handle_e.grid(row=0, column=3, sticky="ew", ipady=3)

        tk.Label(win, text="3 · PLATFORMS  (hashtags are researched per "
        "platform)", **H_KW).grid(
            row=6, column=0, sticky="ew", padx=12, pady=(14, 2))
        plats = tk.Frame(win, bg=BG_HEX)
        plats.grid(row=7, column=0, sticky="ew", padx=12)
        ig_var = tk.BooleanVar(value=True)
        tt_var = tk.BooleanVar(value=False)
        CB_KW = dict(bg=BG_HEX, fg="#F2F0EA", selectcolor="#1C1C1A",
                     activebackground=BG_HEX, activeforeground="#C1F73A",
                     font=("Consolas", 10))
        tk.Checkbutton(plats, text="Instagram", variable=ig_var,
                       **CB_KW).pack(side=tk.LEFT, padx=(0, 14))
        tk.Checkbutton(plats, text="TikTok", variable=tt_var,
                       **CB_KW).pack(side=tk.LEFT)

        status = tk.Label(win, text="", bg=BG_HEX, fg="#C1F73A",
                          font=("Consolas", 9, "italic"), anchor=tk.W)
        status.grid(row=8, column=0, sticky="ew", padx=12, pady=(14, 2))

        gen_btn = tk.Button(win, text="Generate captions",
                            **{**_BTN_KW, "font": ("Consolas", 11, "bold"),
                            "pady": 6})
        gen_btn.grid(row=9, column=0, padx=12, pady=(2, 14), sticky="ew")

        def _spin() -> None:
            if not state["working"]:
                return
            tick = int(time.time() * 4) % 4
            status.config(text=state.get("status_base", "Working")
                          + " " + "|/-\\"[tick])
            win.after(250, _spin)

        def _set_status(text: str, working: bool = False) -> None:
            state["working"] = working
            state["status_base"] = text
            status.config(text=text)
            if working:
                _spin()

        def _generate() -> None:
            topic_txt = topic_e.get().strip()
            if not topic_txt and not state["path"]:
                messagebox.showwarning(
                "Wivexx", "Give me the topic or attach the post "
                "itself, sir.", parent=win)
                return
            platforms = [p for p, v in
                         (("instagram", ig_var), ("tiktok", tt_var))
                         if v.get()]
            if not platforms:
                messagebox.showwarning("Wivexx",
                "Pick at least one platform.",
                                       parent=win)
                return
            gen_btn.config(state=tk.DISABLED)
            _set_status("Analysing the content", working=True)

            payload = {
            "topic": topic_txt,
            "platforms": platforms,
            "client_name": name_e.get().strip(),
            "handle": handle_e.get().strip().lstrip("@"),
            "file_name": (os.path.basename(state["path"])
                              if state["path"] else ""),
                              "media_kind": state["kind"],
                              "frames_b64": [],
            }

            def _work() -> None:
                try:
                    p = state["path"]
                    if p:
                        if state["kind"] == "video" and frames_fn is not None:
                            payload["frames_b64"] = frames_fn(p, 3)
                        elif state["kind"] == "image" and encode_fn is not None:
                            b64 = encode_fn(p)
                            if b64:
                                payload["frames_b64"] = [b64]
                    res = gen(payload)
                except Exception as e:
                    res = {"ok": False, "text": "", "error": str(e)}

                def _done() -> None:
                    gen_btn.config(state=tk.NORMAL)
                    if res.get("ok"):
                        _set_status("Done ✓")
                        self._show_caption_results(payload, res["text"],
                                                   state["thumb"],
                                                   state["kind"])
                    else:
                        _set_status(" " + (res.get("error")
                                            or "generation failed"))
                try:
                    win.after(0, _done)
                except Exception:
                    pass

            threading.Thread(target=_work, daemon=True,
                             name="wivexx-caption-gen").start()

        gen_btn.config(command=_generate)

    def _show_caption_results(self, payload: dict, text: str,
                              thumb, kind: str) -> None:
        """Results pop-up: thumbnail + caption options + hashtags + a
        feedback box that regenerates 3 new options."""
        try:
            if (self._caption_res_win is not None
                    and self._caption_res_win.winfo_exists()):
                self._caption_res_win.destroy()
        except Exception:
            pass
        win = tk.Toplevel(self._root)
        self._caption_res_win = win
        self._style_editor_window(win, "Wivexx —  Caption pack",
        "720x680")
        win.grid_columnconfigure(0, weight=1)
        win.grid_rowconfigure(1, weight=1)
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)

        state = {"text": text, "working": False}

        # ── header: thumbnail + meta ───────────────────────────────────────────
        head = tk.Frame(win, bg=BG_HEX)
        head.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 4))
        head.grid_columnconfigure(1, weight=1)
        th = tk.Label(head, bg="#1C1C1A")
        if thumb is not None:
            th.config(image=thumb)
            th.image = thumb                  # keep the PhotoImage alive
        else:
            th.config(text="video" if kind == "video" else "",
                      font=("Consolas", 28), fg="#C1F73A")
        th.grid(row=0, column=0, rowspan=2, padx=(0, 10))
        meta1 = (payload.get("file_name") or payload.get("topic")
                 or "caption pack")
        plats = " + ".join(p.capitalize()
                           for p in payload.get("platforms", []))
        meta2 = plats + (f"   ·   @{payload['handle']}"
                         if payload.get("handle") else "")
        tk.Label(head, text=meta1, bg=BG_HEX, fg="#C1F73A",
                 font=("Consolas", 11, "bold"), anchor=tk.W
                 ).grid(row=0, column=1, sticky="ew")
        tk.Label(head, text=meta2, **L_KW).grid(row=1, column=1, sticky="ew")

        # ── the pack ───────────────────────────────────────────────────────────
        body = tk.Frame(win, bg=BG_HEX)
        body.grid(row=1, column=0, sticky="nsew", padx=12)
        body.grid_rowconfigure(0, weight=1)
        body.grid_columnconfigure(0, weight=1)
        sb = tk.Scrollbar(body)
        sb.grid(row=0, column=1, sticky="ns")
        txt = tk.Text(body, bg="#1C1C1A", fg="#F2F0EA", wrap=tk.WORD,
                      relief=tk.FLAT, font=("Consolas", 10), padx=10, pady=8,
                      yscrollcommand=sb.set)
        txt.grid(row=0, column=0, sticky="nsew")
        sb.config(command=txt.yview)
        txt.tag_configure("hdr", foreground="#C1F73A",
                          font=("Consolas", 10, "bold"))
        txt.tag_configure("tags", foreground="#F2F0EA")

        _HDR_RE = re.compile(r"^\s*(OPTION\s+\d|[A-Z][A-Z ]*HASHTAGS:"
                             r"|WHY THESE TAGS:|POSTING TIP:)")

        def _render(t: str) -> None:
            txt.config(state=tk.NORMAL)
            txt.delete("1.0", tk.END)
            for line in t.splitlines():
                if _HDR_RE.match(line):
                    tag = ("tags" if "HASHTAGS" in line.upper()
                           else "hdr")
                    txt.insert(tk.END, line + "\n", tag)
                else:
                    txt.insert(tk.END, line + "\n")
            txt.config(state=tk.DISABLED)

        def _options(t: str) -> dict:
            found = re.findall(
                r"OPTION\s+(\d)[^\n]*\n(.*?)(?=\n\s*OPTION\s+\d"
                r"|\n\s*[A-Z][A-Z ]*HASHTAGS:|\n\s*WHY THESE TAGS:"
                r"|\n\s*POSTING TIP:|\Z)", t, re.S)
            return {int(n): body.strip() for n, body in found}

        def _tag_lines(t: str) -> str:
            return "\n".join(
                f"{m.group(1).strip()}: {m.group(2).strip()}"
                for m in re.finditer(
                    r"^\s*([A-Z][A-Z ]*?)\s*HASHTAGS:\s*(.+)$", t, re.M))

        def _copy(s: str, note: str) -> None:
            try:
                win.clipboard_clear()
                win.clipboard_append(s)
                fb_status.config(text=f"{note} copied")
            except Exception:
                pass

        def _copy_option(n: int) -> None:
            opts = _options(state["text"])
            if n in opts:
                tags = _tag_lines(state["text"])
                _copy(opts[n] + ("\n\n" + tags if tags else ""),
                      f"option {n} + hashtags")

        crow = tk.Frame(win, bg=BG_HEX)
        crow.grid(row=2, column=0, sticky="ew", padx=12, pady=(6, 0))
        for n in (1, 2, 3):
            tk.Button(crow, text=f"Option {n}",
                      command=lambda n=n: _copy_option(n),
                      **_BTN_KW).pack(side=tk.LEFT, padx=(0, 4))
        tk.Button(crow, text="Hashtags",
                  command=lambda: _copy(_tag_lines(state["text"]), "hashtags"),
                  **_BTN_KW).pack(side=tk.LEFT, padx=4)
        tk.Button(crow, text="Everything",
                  command=lambda: _copy(state["text"], "everything"),
                  **_BTN_KW).pack(side=tk.LEFT, padx=4)

        # ── feedback → 3 new options ──────────────────────────────────────────
        tk.Label(win, text="Not quite right? Tell me what to change — I'll "
        "give you 3 new options:", **L_KW).grid(
            row=3, column=0, sticky="ew", padx=12, pady=(10, 0))
        fbrow = tk.Frame(win, bg=BG_HEX)
        fbrow.grid(row=4, column=0, sticky="ew", padx=12, pady=(2, 4))
        fbrow.grid_columnconfigure(0, weight=1)
        fb_e = tk.Entry(fbrow, bg="#1C1C1A", fg="#F2F0EA",
                        insertbackground="#C1F73A", relief=tk.FLAT,
                        font=("Consolas", 10))
        fb_e.grid(row=0, column=0, sticky="ew", ipady=4)
        revise_btn = tk.Button(fbrow, text="Revise ×3", **_BTN_KW)
        revise_btn.grid(row=0, column=1, padx=(6, 0))
        fb_status = tk.Label(win, text="", bg=BG_HEX, fg="#C1F73A",
                             font=("Consolas", 9, "italic"), anchor=tk.W)
        fb_status.grid(row=5, column=0, sticky="ew", padx=12, pady=(0, 10))

        def _spin_fb() -> None:
            if not state["working"]:
                return
            tick = int(time.time() * 4) % 4
            fb_status.config(text="Revising the pack "
                             + "|/-\\"[tick])
            win.after(250, _spin_fb)

        def _revise() -> None:
            fb = fb_e.get().strip()
            if not fb:
                return
            gen = _INTEGRATIONS.get("caption_generate")
            if gen is None:
                return
            revise_btn.config(state=tk.DISABLED)
            state["working"] = True
            _spin_fb()

            def _work() -> None:
                try:
                    res = gen({**payload, "feedback": fb,
                    "previous": state["text"]})
                except Exception as e:
                    res = {"ok": False, "error": str(e)}

                def _done() -> None:
                    state["working"] = False
                    revise_btn.config(state=tk.NORMAL)
                    if res.get("ok"):
                        state["text"] = res["text"]
                        _render(res["text"])
                        fb_e.delete(0, tk.END)
                        fb_status.config(text="Revised ✓")
                    else:
                        fb_status.config(
                            text=" " + (res.get("error") or "revise failed"))
                try:
                    win.after(0, _done)
                except Exception:
                    pass

            threading.Thread(target=_work, daemon=True,
                             name="wivexx-caption-revise").start()

        revise_btn.config(command=_revise)
        fb_e.bind("<Return>", lambda _e: _revise())
        _render(text)

    # ──────────────────────────────────────────────────────────────────────────
    # v1.14 —  Client Delivery studio (ARW→JPG develop + Google Drive)
    # ──────────────────────────────────────────────────────────────────────────

    def _open_deliver_studio(self) -> None:
        if self._focus_existing(self._deliver_win):
            return
        dlv = _INTEGRATIONS.get("deliver")
        if dlv is None:
            messagebox.showinfo("Wivexx",
            "Delivery engine not available yet.")
            return

        win = tk.Toplevel(self._root)
        self._deliver_win = win
        self._style_editor_window(win, "Wivexx —  Client Delivery",
        "640x700")
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        H_KW = dict(bg=BG_HEX, fg="#C1F73A", font=("Consolas", 10, "bold"),
                    anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))
        CB_KW = dict(bg=BG_HEX, fg="#F2F0EA", selectcolor="#1C1C1A",
                     activebackground=BG_HEX, activeforeground="#C1F73A",
                     font=("Consolas", 10))
        RB_KW = dict(bg=BG_HEX, fg="#F2F0EA", selectcolor="#1C1C1A",
                     activebackground=BG_HEX, activeforeground="#C1F73A",
                     font=("Consolas", 10))
        win.grid_columnconfigure(0, weight=1)

        state: dict = {"src": None, "count": 0, "working": False,
        "cancel": None, "pause": None}

        # 1 · SOURCE ────────────────────────────────────────────────────────────
        tk.Label(win, text="1 · SOURCE FOLDER", **H_KW).grid(
            row=0, column=0, sticky="ew", padx=12, pady=(12, 2))
        srow = tk.Frame(win, bg=BG_HEX)
        srow.grid(row=1, column=0, sticky="ew", padx=12)
        srow.grid_columnconfigure(1, weight=1)
        src_lbl = tk.Label(srow, text="no folder chosen", **L_KW)

        def _pick_folder() -> None:
            p = filedialog.askdirectory(parent=win,
                                        title="The shoot folder (.ARW photos)")
            if not p:
                return
            state["src"] = p
            try:
                state["count"] = dlv.count_raws(p)
            except Exception:
                state["count"] = 0
            base = os.path.basename(p.rstrip("\\/")) or p
            src_lbl.config(text=f"{base}   ·   {state['count']} RAW photo"
                                f"{'s' if state['count'] != 1 else ''} found")
            if not gallery_e.get().strip():
                gallery_e.insert(0, base)
            _refresh_seq()

        tk.Button(srow, text="Choose folder", command=_pick_folder,
                  **_BTN_KW).grid(row=0, column=0)
        src_lbl.grid(row=0, column=1, sticky="ew", padx=(8, 0))
        inc_jpg = tk.BooleanVar(value=False)
        tk.Checkbutton(win, text="Also include photos already in JPG/PNG",
                       variable=inc_jpg, **CB_KW).grid(
            row=2, column=0, sticky="w", padx=12, pady=(2, 0))

        # 2 · CONVERT ───────────────────────────────────────────────────────────
        tk.Label(win, text="2 · CONVERT  (.ARW → full-quality JPG)", **H_KW).grid(
            row=3, column=0, sticky="ew", padx=12, pady=(14, 2))
        mode_var = tk.StringVar(value="develop")
        crow = tk.Frame(win, bg=BG_HEX)
        crow.grid(row=4, column=0, sticky="ew", padx=12)
        tk.Radiobutton(crow, text="◉ True develop (best quality)",
                       variable=mode_var, value="develop", **RB_KW
                       ).pack(side=tk.LEFT, padx=(0, 12))
        tk.Radiobutton(crow, text="Camera JPEG (instant)",
                       variable=mode_var, value="preview", **RB_KW
                       ).pack(side=tk.LEFT)
        qrow = tk.Frame(win, bg=BG_HEX)
        qrow.grid(row=5, column=0, sticky="ew", padx=12, pady=(4, 0))
        tk.Label(qrow, text="JPEG quality", **L_KW).pack(side=tk.LEFT)
        quality_sb = tk.Spinbox(qrow, from_=70, to=100, width=4, **E_KW)
        quality_sb.delete(0, tk.END)
        quality_sb.insert(0, "95")
        quality_sb.pack(side=tk.LEFT, padx=(6, 0))
        tk.Label(qrow, text="   Brightness", **L_KW).pack(side=tk.LEFT)
        bright_sb = tk.Spinbox(qrow, from_=0.5, to=2.0, increment=0.05,
                               width=5, **E_KW)
        bright_sb.delete(0, tk.END)
        bright_sb.insert(0, "1.0")
        bright_sb.pack(side=tk.LEFT, padx=(6, 0))
        autobright_var = tk.BooleanVar(value=True)
        tk.Checkbutton(qrow, text="Auto-brighten", variable=autobright_var,
                       **CB_KW).pack(side=tk.LEFT, padx=(10, 0))
        skip_conv = tk.BooleanVar(value=False)
        tk.Checkbutton(win, text="Skip conversion — this folder is already JPGs",
                       variable=skip_conv, **CB_KW).grid(
            row=6, column=0, sticky="w", padx=12, pady=(4, 0))

        # 3 · DELIVER ───────────────────────────────────────────────────────────
        tk.Label(win, text="3 · DELIVER TO GOOGLE DRIVE", **H_KW).grid(
            row=7, column=0, sticky="ew", padx=12, pady=(14, 2))
        tk.Label(win, text="Gallery / folder name (leave blank to only convert)",
                 **L_KW).grid(row=8, column=0, sticky="ew", padx=12)
        gallery_e = tk.Entry(win, **E_KW)
        gallery_e.grid(row=9, column=0, sticky="ew", padx=12, ipady=3)
        tk.Label(win, text="Photo name sequence (blank = keep original names)",
                 **L_KW).grid(row=10, column=0, sticky="ew", padx=12, pady=(8, 0))
        seq_e = tk.Entry(win, **E_KW)
        seq_e.grid(row=11, column=0, sticky="ew", padx=12, ipady=3)
        seq_prev = tk.Label(win, text="", bg=BG_HEX, fg="#F2F0EA",
                            font=("Consolas", 9), anchor=tk.W)
        seq_prev.grid(row=12, column=0, sticky="ew", padx=12)

        def _refresh_seq(*_a) -> None:
            seq = seq_e.get().strip()
            if not seq:
                seq_prev.config(text="")
                return
            n = max(state.get("count", 0), 1)
            width = max(3, len(str(n)))
            last = f"{n:0{width}d}"
            seq_prev.config(text=f"→ {seq}-{'1'.zfill(width)}.jpg … "
                                 f"{seq}-{last}.jpg")
        seq_e.bind("<KeyRelease>", _refresh_seq)

        public_var = tk.BooleanVar(value=True)
        tk.Checkbutton(win, text="Make the link public (anyone with the link)",
                       variable=public_var, **CB_KW).grid(
            row=13, column=0, sticky="w", padx=12, pady=(6, 0))

        status = tk.Label(win, text="", bg=BG_HEX, fg="#C1F73A",
                          font=("Consolas", 9, "italic"), anchor=tk.W,
                          wraplength=600, justify=tk.LEFT)
        status.grid(row=14, column=0, sticky="ew", padx=12, pady=(12, 2))

        btnf = tk.Frame(win, bg=BG_HEX)
        btnf.grid(row=15, column=0, padx=12, pady=(2, 14), sticky="ew")
        btnf.grid_columnconfigure(0, weight=1)
        go_btn = tk.Button(btnf, text="Convert & Deliver",
                           **{**_BTN_KW, "font": ("Consolas", 11, "bold"),
                           "pady": 6})
        go_btn.grid(row=0, column=0, sticky="ew")
        pause_btn = tk.Button(btnf, text="Pause", state=tk.DISABLED, **_BTN_KW)
        pause_btn.grid(row=0, column=1, padx=(6, 0))
        cancel_btn = tk.Button(btnf, text="✕ Cancel", state=tk.DISABLED,
                               **_BTN_KW)
        cancel_btn.grid(row=0, column=2, padx=(6, 0))

        def _spin() -> None:
            if not state["working"]:
                return
            p = state.get("pause")
            if p is not None and p.is_set():
                status.config(text="Paused — tap Resume to continue, sir.")
                win.after(250, _spin)
                return
            tick = int(time.time() * 4) % 4
            status.config(text=state.get("status_base", "Working")
                          + " " + "|/-\\"[tick])
            win.after(250, _spin)

        def _set_status(text: str, working: bool = False) -> None:
            state["working"] = working
            state["status_base"] = text
            status.config(text=text)
            if working:
                _spin()

        def _fmt_eta(secs: float) -> str:
            s = int(secs)
            if s < 60:
                return f"{s}s"
            m, sec = divmod(s, 60)
            if m < 60:
                return f"{m}m {sec}s" if sec else f"{m}m"
            h, m = divmod(m, 60)
            return f"{h}h {m}m"

        def _idle_buttons() -> None:
            go_btn.config(state=tk.NORMAL)
            pause_btn.config(state=tk.DISABLED, text="Pause")
            cancel_btn.config(state=tk.DISABLED)

        def _toggle_pause() -> None:
            ev = state.get("pause")
            if ev is None:
                return
            if ev.is_set():                       # currently paused → resume
                ev.clear()
                pause_btn.config(text="Pause")
            else:                                 # running → pause
                ev.set()                          # _spin shows the paused text
                pause_btn.config(text="▶ Resume")

        def _cancel() -> None:
            if state.get("cancel") is not None:
                state["cancel"].set()
            if state.get("pause") is not None:
                state["pause"].clear()            # unpause so the loop sees it
            cancel_btn.config(state=tk.DISABLED)
            pause_btn.config(state=tk.DISABLED, text="Pause")
            _set_status("Cancelling after the current photo, sir…", working=True)

        pause_btn.config(command=_toggle_pause)
        cancel_btn.config(command=_cancel)

        def _go() -> None:
            src = state["src"]
            if not src:
                messagebox.showwarning("Wivexx",
                "Choose the shoot folder first, sir.",
                                       parent=win)
                return
            gallery = gallery_e.get().strip()
            do_convert = not skip_conv.get()
            if not do_convert and not gallery:
                messagebox.showwarning(
                "Wivexx", "Nothing to do — either convert, or give a "
                "gallery name to upload, sir.", parent=win)
                return
            try:
                quality = int(quality_sb.get())
            except Exception:
                quality = 95
            try:
                exposure = float(bright_sb.get())
            except Exception:
                exposure = 1.0
            opts = {
            "src": src, "convert": do_convert, "mode": mode_var.get(),
            "quality": quality, "include_jpegs": inc_jpg.get(),
            "auto_bright": bool(autobright_var.get()), "exposure": exposure,
            "gallery": gallery, "sequence": seq_e.get().strip(),
            "public": public_var.get(),
            }
            state["cancel"] = threading.Event()
            state["pause"] = threading.Event()
            go_btn.config(state=tk.DISABLED)
            cancel_btn.config(state=tk.NORMAL)
            pause_btn.config(state=tk.NORMAL, text="Pause")
            _set_status("Starting…", working=True)

            def _prog(stage: str):
                def _cb(done, total, name, eta=None):
                    et = ((" · ~" + _fmt_eta(eta) + " left")
                          if eta and eta > 0 else "")
                    self.set_busy("deliver", f"{stage} {done}/{total}{et}")
                    try:
                        win.after(0, lambda: _set_status(
                            f"{stage} {done}/{total}{et} — {name}",
                            working=True))
                    except Exception:
                        pass
                return _cb

            def _work() -> None:
                result = {"folder": src, "link": "", "conv": None, "up": None,
                "error": ""}
                try:
                    folder = src
                    if opts["convert"]:
                        conv = dlv.convert_folder(
                            src, mode=opts["mode"], quality=opts["quality"],
                            include_jpegs=opts["include_jpegs"],
                            auto_bright=opts["auto_bright"],
                            exposure=opts["exposure"],
                            progress=_prog("Developing"),
                            cancel_evt=state["cancel"])
                        result["conv"] = conv
                        if conv.get("cancelled") and not conv.get("converted"):
                            return
                        if not conv.get("ok"):
                            result["error"] = conv.get("error") \
                                or "Conversion produced nothing."
                            return
                        folder = conv["out_dir"]
                    result["folder"] = folder

                    if opts["gallery"] and not state["cancel"].is_set():
                        st = dlv.drive_auth_status()
                        if st == "need_credentials":
                            result["error"] = dlv.drive_setup_help()
                            return
                        if st == "need_consent":
                            win.after(0, lambda: _set_status(
                            "Opening your browser to sign in to Google "
                            "Drive…", working=True))
                        up = dlv.upload_folder(
                            folder, opts["gallery"],
                            name_sequence=opts["sequence"] or None,
                            make_public=opts["public"],
                            progress=_prog("Uploading"),
                            cancel_evt=state["cancel"],
                            pause_evt=state["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)

                def _done() -> None:
                    self.set_busy("deliver", None)
                    state["working"] = False
                    _idle_buttons()
                    conv = result.get("conv") or {}
                    up = result.get("up") or {}
                    if conv.get("cancelled") or up.get("cancelled"):
                        nu = up.get("uploaded", 0)
                        nc = conv.get("converted", 0)
                        if nu:
                            _set_status(f"Cancelled — {nu} photo"
                                        f"{'s' if nu != 1 else ''} uploaded "
                                        f"before you stopped, sir.")
                            self._show_delivery_results(result)
                        elif nc:
                            _set_status(f"Cancelled, sir — {nc} photo"
                                        f"{'s' if nc != 1 else ''} were "
                                        f"developed locally, none uploaded.")
                            self._show_delivery_results(result)
                        else:
                            _set_status("Cancelled, sir — nothing was sent.")
                        return
                    if result["error"] and not (up or conv):
                        _set_status(" " + result["error"])
                        return
                    _set_status("Done ✓")
                    self._show_delivery_results(result)
                try:
                    win.after(0, _done)
                except Exception:
                    pass

            threading.Thread(target=_work, daemon=True,
                             name="wivexx-deliver").start()

        go_btn.config(command=_go)

    def _show_delivery_results(self, result: dict) -> None:
        """Pop-up: thumbnail + counts + the public share link (copy / open)."""
        try:
            if (self._deliver_res_win is not None
                    and self._deliver_res_win.winfo_exists()):
                self._deliver_res_win.destroy()
        except Exception:
            pass
        win = tk.Toplevel(self._root)
        self._deliver_res_win = win
        self._style_editor_window(win, "Wivexx —  Delivery", "620x420")
        win.grid_columnconfigure(0, weight=1)
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)

        folder = result.get("folder", "")
        # thumbnail of the first delivered photo
        thumb = None
        try:
            jpgs = sorted(os.path.join(folder, f) for f in os.listdir(folder)
                          if os.path.splitext(f)[1].lower()
                          in (".jpg", ".jpeg", ".png"))
            if jpgs:
                thumb, _k = self._load_preview_photo(jpgs[0])
        except Exception:
            thumb = None

        head = tk.Frame(win, bg=BG_HEX)
        head.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 4))
        head.grid_columnconfigure(1, weight=1)
        th = tk.Label(head, bg="#1C1C1A")
        if thumb is not None:
            th.config(image=thumb)
            th.image = thumb
        else:
            th.config(text="files", font=("Consolas", 28), fg="#C1F73A")
        th.grid(row=0, column=0, rowspan=2, padx=(0, 10))

        conv = result.get("conv") or {}
        up = result.get("up") or {}
        bits = []
        if conv:
            bits.append(f"{conv.get('converted', 0)} developed")
            if conv.get("failed"):
                bits.append(f"{conv['failed']} failed")
        if up:
            bits.append(f"{up.get('uploaded', 0)} uploaded")
            if up.get("failed"):
                bits.append(f"{up['failed']} upload-failed")
        tk.Label(head, text="Delivery complete" if result.get("link")
                 else "Conversion complete", bg=BG_HEX, fg="#C1F73A",
                 font=("Consolas", 12, "bold"), anchor=tk.W
                 ).grid(row=0, column=1, sticky="ew")
        tk.Label(head, text="   · ".join(bits) or folder, **L_KW
                 ).grid(row=1, column=1, sticky="ew")

        link = result.get("link", "")
        if link:
            tk.Label(win, text="Public share link:", **L_KW).grid(
                row=1, column=0, sticky="ew", padx=12, pady=(10, 2))
            link_e = tk.Entry(win, bg="#1C1C1A", fg="#F2F0EA", relief=tk.FLAT,
                              font=("Consolas", 10), readonlybackground="#1C1C1A")
            link_e.insert(0, link)
            link_e.config(state="readonly")
            link_e.grid(row=2, column=0, sticky="ew", padx=12, ipady=4)
            brow = tk.Frame(win, bg=BG_HEX)
            brow.grid(row=3, column=0, sticky="ew", padx=12, pady=(8, 0))
            note = tk.Label(win, text="", bg=BG_HEX, fg="#C1F73A",
                            font=("Consolas", 9, "italic"), anchor=tk.W)

            def _copy() -> None:
                try:
                    win.clipboard_clear()
                    win.clipboard_append(link)
                    note.config(text="link copied to clipboard")
                except Exception:
                    pass

            def _open_link() -> None:
                try:
                    import webbrowser
                    webbrowser.open(link)
                except Exception:
                    pass

            tk.Button(brow, text="Copy link", command=_copy,
                      **_BTN_KW).pack(side=tk.LEFT, padx=(0, 6))
            tk.Button(brow, text="Open in browser", command=_open_link,
                      **_BTN_KW).pack(side=tk.LEFT, padx=6)
            note.grid(row=4, column=0, sticky="ew", padx=12, pady=(6, 0))
        elif result.get("error"):
            tk.Label(win, text="Upload note: " + result["error"], bg=BG_HEX,
                     fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W,
                     wraplength=580, justify=tk.LEFT).grid(
                row=1, column=0, sticky="ew", padx=12, pady=(8, 0))

        frow = tk.Frame(win, bg=BG_HEX)
        frow.grid(row=5, column=0, sticky="ew", padx=12, pady=(14, 0))
        tk.Button(frow, text="Open local JPG folder",
                  command=lambda: self._open_file(folder),
                  **_BTN_KW).pack(side=tk.LEFT)

    # ── Goals overlay: small always-on-top "today focus" panel ────────────────

    # ── v1.17: screen-click overlay (numbered badges over clickable controls) ─
    def show_click_overlay(self, items) -> None:
        try:
            if self._root:
                self._root.after(0, lambda: self._show_click_overlay(items))
        except Exception:
            pass

    def hide_click_overlay(self) -> None:
        try:
            if self._root:
                self._root.after(0, self._hide_click_overlay)
        except Exception:
            pass

    def _hide_click_overlay(self) -> None:
        try:
            if (self._click_overlay is not None
                    and self._click_overlay.winfo_exists()):
                self._click_overlay.destroy()
        except Exception:
            pass
        self._click_overlay = None

    def _show_click_overlay(self, items) -> None:
        self._hide_click_overlay()
        if not items:
            return
        import ctypes
        try:
            u = ctypes.windll.user32
            vx, vy = u.GetSystemMetrics(76), u.GetSystemMetrics(77)
            vw, vh = u.GetSystemMetrics(78), u.GetSystemMetrics(79)
        except Exception:
            vx, vy, vw, vh = 0, 0, self._root.winfo_screenwidth(), \
                self._root.winfo_screenheight()
        ov = tk.Toplevel(self._root)
        self._click_overlay = ov
        ov.overrideredirect(True)
        ov.attributes("-topmost", True)
        TRANS = "#010203"                       # color key → transparent + click-through
        ov.configure(bg=TRANS)
        try:
            ov.attributes("-transparentcolor", TRANS)
        except Exception:
            ov.attributes("-alpha", 0.45)        # fallback: translucent, not click-through
        ov.geometry(f"{vw}x{vh}+{vx}+{vy}")
        cv = tk.Canvas(ov, bg=TRANS, highlightthickness=0)
        cv.pack(fill=tk.BOTH, expand=True)

        def _rr(x0, y0, x1, y1, rad, **kw):
            """Draw a rounded rectangle (smooth polygon) on the canvas."""
            rad = min(rad, abs(x1 - x0) / 2, abs(y1 - y0) / 2)
            pts = [x0 + rad, y0, x1 - rad, y0, x1, y0, x1, y0 + rad,
                   x1, y1 - rad, x1, y1, x1 - rad, y1, x0 + rad, y1,
                   x0, y1, x0, y1 - rad, x0, y0 + rad, x0, y0]
            return cv.create_polygon(pts, smooth=True, **kw)

        for it in items:
            try:
                l, t, r, b = it["rect"]
                x0, y0, x1, y1 = l - vx, t - vy, r - vx, b - vy
                # soft rounded highlight around the control
                _rr(x0, y0, x1, y1, 7, outline="#C1F73A", width=1.5, fill="")
                # rounded volt pill badge, ink number (Wivexx wordmark dot vibe)
                lbl = str(it["n"])
                bw = max(22, 9 * len(lbl) + 14)
                _rr(x0, y0, x0 + bw, y0 + 21, 8, fill="#C1F73A", outline="")
                cv.create_text(x0 + bw / 2, y0 + 11, text=lbl, fill="#0d0d0d",
                               font=("Consolas", 10, "bold"))
            except Exception:
                continue
        ov.after(20000, self._hide_click_overlay)   # auto-clear if unused

    # ── v1.16: status overlay (Wivexx state + active timers + token cost) ─────
    def toggle_status_overlay(self, show=None) -> None:
        """Thread-safe public toggle.  show=True ensure-shown, False ensure-
        hidden, None toggle."""
        try:
            if self._root:
                self._root.after(0, lambda: self._toggle_status_overlay(show))
        except Exception:
            pass

    def _toggle_status_overlay(self, show=None) -> None:
        exists = False
        try:
            exists = (self._status_overlay is not None
                      and self._status_overlay.winfo_exists())
        except Exception:
            exists = False
        if exists:
            if show is True:
                return
            try:
                self._status_overlay.destroy()
            except Exception:
                pass
            self._status_overlay = None
            return
        if show is False:
            return

        ov = tk.Toplevel(self._root)
        self._status_overlay = ov
        ov.overrideredirect(True)
        ov.attributes("-topmost", True)
        ov.configure(bg="#C1F73A")
        sw = ov.winfo_screenwidth()
        ov.geometry(f"250x158+{sw - 272}+310")
        inner = tk.Frame(ov, bg=BG_HEX)
        inner.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)

        head = tk.Frame(inner, bg=BG_HEX)
        head.pack(fill=tk.X, padx=6, pady=(5, 2))
        title = tk.Label(head, text="◉ Wivexx", bg=BG_HEX, fg="#C1F73A",
                         font=("Consolas", 9, "bold"))
        title.pack(side=tk.LEFT)
        tk.Button(head, text="✕", command=self._toggle_status_overlay,
                  bg=BG_HEX, fg="#8A8A85", activebackground=BG_HEX,
                  activeforeground="#d24", relief=tk.FLAT, borderwidth=0,
                  font=("Consolas", 9), cursor="hand2").pack(side=tk.RIGHT)
        self._make_draggable(head, window_to_move=ov)
        self._make_draggable(title, window_to_move=ov)

        body = tk.Frame(inner, bg=BG_HEX)
        body.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 6))
        ov._body = body
        self._refresh_status_overlay()

    def _refresh_status_overlay(self) -> None:
        ov = self._status_overlay
        try:
            if ov is None or not ov.winfo_exists():
                return
        except Exception:
            return
        try:
            for w in ov._body.winfo_children():
                w.destroy()
            state = self._state
            dot = {"listening": "#C1F73A", "active": "#C1F73A",
            "speaking": "#C1F73A", "processing": "#F2F0EA",
            "searching": "#F2F0EA"}.get(state, "#F2F0EA")
            srow = tk.Frame(ov._body, bg=BG_HEX)
            srow.pack(fill=tk.X, pady=(3, 5))
            tk.Label(srow, text="●", bg=BG_HEX, fg=dot,
                     font=("Consolas", 11)).pack(side=tk.LEFT)
            tk.Label(srow, text=" " + state.capitalize(), bg=BG_HEX,
                     fg="#F2F0EA", font=("Consolas", 10)).pack(side=tk.LEFT)

            get_timers = _INTEGRATIONS.get("get_timers")
            timers = []
            try:
                timers = get_timers() if get_timers else []
            except Exception:
                timers = []
            now = time.time()
            if timers:
                for tm in timers[:3]:
                    rem = int(max(0, (tm.get("ends", now)) - now))
                    mm, ss = divmod(rem, 60)
                    tk.Label(ov._body,
                             text=f"{str(tm.get('label', 'timer'))[:13]}"
                                  f"  {mm}:{ss:02d}",
                             bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9),
                             anchor=tk.W).pack(fill=tk.X)
            else:
                tk.Label(ov._body, text="no active timers", bg=BG_HEX,
                         fg="#8A8A85", font=("Consolas", 9),
                         anchor=tk.W).pack(fill=tk.X)

            get_usage = _INTEGRATIONS.get("get_usage")
            if get_usage:
                try:
                    u = get_usage()
                    tk.Label(ov._body,
                             text=f"{u['calls']} calls · ${u['cost']:.2f} "
                                  f"today", bg=BG_HEX, fg="#8A8A85",
                             font=("Consolas", 8), anchor=tk.W
                             ).pack(fill=tk.X, pady=(5, 0))
                except Exception:
                    pass
        except Exception:
            pass
        try:
            ov.after(1000, self._refresh_status_overlay)
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # v1.30 —  Performance HUD: a draggable, customizable, always-on-top panel
    #          of live hardware stats (CPU/GPU/VRAM/RAM/net/…). Our own telemetry
    #          (wivexx_sysmon), floats over borderless-fullscreen games.
    # ──────────────────────────────────────────────────────────────────────────
    _PERF_ORDER = ["cpu", "gpu", "vram", "ram", "disk", "net", "fps"]
    _PERF_LABELS = {"cpu": "CPU", "gpu": "GPU", "vram": "VRAM", "ram": "RAM",
    "disk": "DISK", "net": "NET", "fps": "FPS"}

    def _perf_cfg_path(self) -> str:
        return os.path.join(os.path.dirname(os.path.abspath(__file__)),
        "wivexx_hud.json")

    def _load_perf_cfg(self) -> dict:
        cfg = {"metrics": ["cpu", "gpu", "vram", "ram", "net"], "pos": None}
        try:
            import json
            with open(self._perf_cfg_path(), "r", encoding="utf-8") as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                for k in ("metrics", "pos"):
                    if k in saved:
                        cfg[k] = saved[k]
        except Exception:
            pass
        cfg["metrics"] = [m for m in cfg.get("metrics", []) if m in self._PERF_ORDER] \
            or ["cpu", "gpu", "ram"]
        return cfg

    def _save_perf_cfg(self) -> None:
        try:
            import json
            with open(self._perf_cfg_path(), "w", encoding="utf-8") as f:
                json.dump(self._perf_cfg, f)
        except Exception:
            pass

    def toggle_perf_hud(self, show=None) -> None:
        """Thread-safe entry point (from voice / API / Modes menu)."""
        try:
            self._root.after(0, lambda: self._toggle_perf_hud(show))
        except Exception:
            pass

    def _toggle_perf_hud(self, show=None) -> None:
        exists = False
        try:
            exists = (self._perf_hud is not None and self._perf_hud.winfo_exists())
        except Exception:
            exists = False
        if exists:
            if show is True:
                return
            try:                                  # remember where it was left
                if self._perf_cfg is not None:
                    self._perf_cfg["pos"] = [self._perf_hud.winfo_x(),
                                             self._perf_hud.winfo_y()]
                    self._save_perf_cfg()
            except Exception:
                pass
            try:
                self._perf_hud.destroy()
            except Exception:
                pass
            self._perf_hud = None
            return
        if show is False:
            return
        try:
            import wivexx_sysmon
            wivexx_sysmon.start(1.2)               # make sure telemetry is sampling
        except Exception:
            pass
        self._build_perf_hud()

    def _build_perf_hud(self) -> None:
        if self._perf_cfg is None:
            self._perf_cfg = self._load_perf_cfg()
        ov = tk.Toplevel(self._root)
        self._perf_hud = ov
        ov.overrideredirect(True)
        ov.attributes("-topmost", True)
        try:
            ov.attributes("-alpha", 0.96)
        except Exception:
            pass
        ov.configure(bg=VOLT)                      # 1px volt frame
        sw = ov.winfo_screenwidth()
        pos = self._perf_cfg.get("pos")
        if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
            pos = [sw - 250, 90]
        ov.geometry(f"+{int(pos[0])}+{int(pos[1])}")

        inner = tk.Frame(ov, bg=BG_HEX)
        inner.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)

        head = tk.Frame(inner, bg=BG_HEX)
        head.pack(fill=tk.X, padx=8, pady=(5, 2))
        title = tk.Label(head, text="◉ PERFORMANCE", bg=BG_HEX, fg=VOLT,
                         font=(MONO_FONT, 8, "bold"))
        title.pack(side=tk.LEFT)
        tk.Button(head, text="✕", command=self._toggle_perf_hud, bg=BG_HEX,
                  fg=STONE, activebackground=BG_HEX, activeforeground=DANGER,
                  relief=tk.FLAT, borderwidth=0, font=(MONO_FONT, 9),
                  cursor="hand2").pack(side=tk.RIGHT)
        gear = tk.Label(head, text="opts", bg=BG_HEX, fg=STONE,
                        font=(MONO_FONT, 9), cursor="hand2")
        gear.pack(side=tk.RIGHT, padx=(0, 8))
        gear.bind("<Button-1>", lambda e: self._perf_customize_menu())
        opt = tk.Label(head, text="go", bg=BG_HEX, fg=STONE,    # one-tap optimize
                       font=(MONO_FONT, 10), cursor="hand2")
        opt.pack(side=tk.RIGHT, padx=(0, 8))
        opt.bind("<Button-1>", lambda e: self._perf_optimize())
        self._make_draggable(head, window_to_move=ov)
        self._make_draggable(title, window_to_move=ov)

        body = tk.Frame(inner, bg=BG_HEX)
        body.pack(fill=tk.BOTH, expand=True, padx=8, pady=(2, 7))
        ov._body = body
        self._apply_perf_hud_styles()
        self._refresh_perf_hud()

    def _apply_perf_hud_styles(self) -> None:
        """TOPMOST + NOACTIVATE (never steal focus from a game) + TOOLWINDOW
        (hidden from Alt-Tab) — so the HUD floats over borderless-fullscreen."""
        try:
            import ctypes
            user32 = ctypes.windll.user32
            hwnd = self._overlay_hwnd(self._perf_hud)
            GWL_EXSTYLE = -20
            cur = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
            user32.SetWindowLongW(hwnd, GWL_EXSTYLE,
                                  cur | 0x8 | 0x08000000 | 0x80)
        except Exception:
            pass

    @staticmethod
    def _perf_load_color(frac):
        if frac is None:
            return STONE_DIM
        if frac >= 0.90:
            return DANGER
        if frac >= 0.70:
            return AMBER
        return VOLT

    def _perf_metric(self, key, snap) -> dict:
        """→ {bar, frac, main, sub} for one metric from a sysmon snapshot."""
        g = snap.get("gpu") or {}
        cpu = snap.get("cpu") or {}
        ram = snap.get("ram") or {}
        net = snap.get("net") or {}
        disk = snap.get("disk") or {}

        def pct(v):
            return None if v is None else max(0.0, min(1.0, v / 100.0))

        if key == "cpu":
            p, t = cpu.get("pct"), cpu.get("temp_c")
            return dict(bar=True, frac=pct(p),
                        main=("%.0f%%" % p) if p is not None else "—",
                        sub=("%.0f°C" % t) if t is not None else "—")
        if key == "gpu":
            p, t = g.get("pct"), g.get("temp_c")
            return dict(bar=True, frac=pct(p),
                        main=("%.0f%%" % p) if p is not None else "—",
                        sub=("%.0f°C" % t) if t is not None else "—")
        if key == "vram":
            mp, u, tot = g.get("mem_pct"), g.get("mem_used_mb"), g.get("mem_total_mb")
            return dict(bar=True, frac=pct(mp),
                        main=("%.0f%%" % mp) if mp is not None else "—",
                        sub=("%.1f/%.0fG" % (u / 1024, tot / 1024)) if u and tot else "—")
        if key == "ram":
            p, u, tot = ram.get("pct"), ram.get("used_gb"), ram.get("total_gb")
            return dict(bar=True, frac=pct(p),
                        main=("%.0f%%" % p) if p is not None else "—",
                        sub=("%.0f/%.0fG" % (u, tot)) if u and tot else "—")
        if key == "disk":
            p = disk.get("pct")
            return dict(bar=True, frac=pct(p),
                        main=("%.0f%%" % p) if p is not None else "—", sub="C:")
        if key == "net":
            def f(k):                   # v1.56: adaptive KB/s < MB/s < GB/s
                if k is None:
                    return "—"
                if k < 1024:
                    return ("%.0f KB/s" % k) if k >= 10 else ("%.1f KB/s" % k)
                m = k / 1024.0
                if m < 1024:
                    return ("%.0f MB/s" % m) if m >= 10 else ("%.1f MB/s" % m)
                g = m / 1024.0
                return ("%.0f GB/s" % g) if g >= 10 else ("%.2f GB/s" % g)
            return dict(bar=False, frac=None,
                        main="↓" + f(net.get("down_kbps")),
                        sub="↑" + f(net.get("up_kbps")))
        if key == "fps":
            fps = snap.get("fps")
            return dict(bar=False, frac=None,
                        main=("%.0f" % fps) if fps else "—",
                        sub="fps" if fps else "RTSS")
        return dict(bar=False, frac=None, main="—", sub="")

    def _perf_row(self, parent, key, snap) -> None:
        m = self._perf_metric(key, snap)
        row = tk.Frame(parent, bg=BG_HEX)
        row.pack(fill=tk.X, pady=1)
        tk.Label(row, text=self._PERF_LABELS.get(key, key), bg=BG_HEX, fg=BONE,
                 font=(MONO_FONT, 8), width=5, anchor=tk.W).pack(side=tk.LEFT)
        BW, BH = 70, 8
        c = tk.Canvas(row, width=BW, height=BH, bg=GRAPHITE, highlightthickness=0)
        c.pack(side=tk.LEFT, padx=(2, 6))
        if m["bar"] and m["frac"] is not None:
            w = max(2, int(BW * m["frac"]))
            c.create_rectangle(0, 0, w, BH, fill=self._perf_load_color(m["frac"]),
                               width=0)
        tk.Label(row, text=m["main"], bg=BG_HEX, fg=BONE, font=(MONO_FONT, 8),
                 width=5, anchor=tk.E).pack(side=tk.LEFT)
        tk.Label(row, text=m["sub"], bg=BG_HEX, fg=STONE, font=(MONO_FONT, 8),
                 anchor=tk.W).pack(side=tk.LEFT, padx=(5, 0))

    def _refresh_perf_hud(self) -> None:
        ov = self._perf_hud
        try:
            if ov is None or not ov.winfo_exists():
                return
        except Exception:
            return
        try:
            import wivexx_sysmon
            snap = wivexx_sysmon.snapshot()
        except Exception:
            snap = {}
        try:
            for w in ov._body.winfo_children():
                w.destroy()
            for key in (self._perf_cfg or {}).get("metrics", ["cpu", "gpu", "ram"]):
                self._perf_row(ov._body, key, snap)
            if not snap:
                tk.Label(ov._body, text="sampling…", bg=BG_HEX, fg=STONE_DIM,
                         font=(MONO_FONT, 8)).pack(anchor=tk.W)
            flash = getattr(self, "_perf_flash", "")    #  optimize result, ~6 s
            if flash and (time.time() - getattr(self, "_perf_flash_ts", 0)) < 6:
                tk.Label(ov._body, text=" " + flash, bg=BG_HEX, fg=VOLT,
                         font=(MONO_FONT, 8, "bold"), anchor=tk.W).pack(fill=tk.X, pady=(3, 0))
            try:
                ov.attributes("-topmost", True)
                ov.lift()
            except Exception:
                pass
        except Exception:
            pass
        try:                                      # no stacked timers (toggle re-calls)
            if getattr(ov, "_after_id", None):
                ov.after_cancel(ov._after_id)
        except Exception:
            pass
        try:
            ov._after_id = ov.after(1000, self._refresh_perf_hud)
        except Exception:
            pass

    def _perf_customize_menu(self) -> None:
        if self._perf_cfg is None:
            self._perf_cfg = self._load_perf_cfg()
        menu = tk.Menu(self._root, tearoff=0, bg=GRAPHITE, fg=BONE,
                       activebackground=HOVER, activeforeground=VOLT,
                       borderwidth=1, relief=tk.FLAT, font=(MONO_FONT, 9))
        cur = set(self._perf_cfg.get("metrics", []))
        names = {"cpu": "CPU usage + temp", "gpu": "GPU usage + temp",
        "vram": "GPU memory (VRAM)", "ram": "System RAM",
        "disk": "Disk (C:)", "net": "Network ↓↑", "fps": "FPS (soon)"}
        for key in self._PERF_ORDER:
            on = key in cur
            menu.add_command(label=("✓ " if on else " ") + names[key],
                             foreground=VOLT if on else BONE,
                             command=lambda k=key: self._perf_toggle_metric(k))
        try:
            x = self._perf_hud.winfo_rootx() + 10
            y = self._perf_hud.winfo_rooty() + 22
            menu.tk_popup(x, y)
        finally:
            try:
                menu.grab_release()
            except Exception:
                pass

    def _perf_toggle_metric(self, key) -> None:
        if self._perf_cfg is None:
            self._perf_cfg = self._load_perf_cfg()
        cur = [m for m in self._perf_cfg.get("metrics", []) if m in self._PERF_ORDER]
        if key in cur:
            cur.remove(key)
        else:
            cur.append(key)
        cur = [m for m in self._PERF_ORDER if m in cur] or [key]
        self._perf_cfg["metrics"] = cur
        self._save_perf_cfg()
        self._refresh_perf_hud()                  # immediate redraw (timer re-armed)

    def _perf_optimize(self) -> None:
        """one-tap optimize from the HUD — trim RAM working sets off-thread, then
        flash the freed amount in the panel for a few seconds."""
        if getattr(self, "_perf_optimizing", False):
            return
        self._perf_optimizing = True
        self._perf_flash = "optimizing…"
        self._perf_flash_ts = time.time()

        def _work() -> None:
            msg = "optimize failed"
            try:
                import wivexx_optimize as _opt
                ram = _opt.optimize("ram").get("ram") or {}
                mb = ram.get("freed_mb")
                msg = ("freed %d MB RAM" % mb) if mb else "RAM already lean"
            except Exception:
                pass

            def _show() -> None:
                self._perf_optimizing = False
                self._perf_flash = msg
                self._perf_flash_ts = time.time()
            try:
                self._root.after(0, _show)
            except Exception:
                self._perf_optimizing = False
        threading.Thread(target=_work, daemon=True,
                         name="wivexx-perf-optimize").start()

    def _toggle_goals_overlay(self) -> None:
        try:
            if self._goals_overlay is not None and self._goals_overlay.winfo_exists():
                self._goals_overlay.destroy()
                self._goals_overlay = None
                return
        except Exception:
            self._goals_overlay = None
        goals = _INTEGRATIONS.get("goals")
        if goals is None:
            return
        ov = tk.Toplevel(self._root)
        self._goals_overlay = ov
        ov.overrideredirect(True)
        ov.attributes("-topmost", True)
        ov.configure(bg="#C1F73A")
        sw = ov.winfo_screenwidth()
        ov.geometry(f"310x240+{sw - 330}+40")
        inner = tk.Frame(ov, bg=BG_HEX)
        inner.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)

        head_row = tk.Frame(inner, bg=BG_HEX)
        head_row.pack(fill=tk.X, padx=6, pady=(6, 2))
        head = tk.Label(head_row, text="TODAY'S FOCUS", bg=BG_HEX,
                        fg="#C1F73A", font=("Consolas", 9, "bold"))
        head.pack(side=tk.LEFT)
        tk.Button(head_row, text="✕", command=self._toggle_goals_overlay,
                  bg=BG_HEX, fg="#8A8A85", activebackground=BG_HEX,
                  activeforeground="#d24", relief=tk.FLAT, borderwidth=0,
                  font=("Consolas", 9), cursor="hand2").pack(side=tk.RIGHT)
        self._make_draggable(head, window_to_move=ov)
        self._make_draggable(head_row, window_to_move=ov)

        rows = tk.Frame(inner, bg=BG_HEX)
        rows.pack(fill=tk.BOTH, expand=True, padx=4)
        ov._rows = rows                       # rebuilt on every refresh

        stats_lbl = tk.Label(inner, text="", bg=BG_HEX, fg="#8A8A85",
                             font=("Consolas", 8))
        stats_lbl.pack(anchor=tk.W, padx=8)
        ov._stats = stats_lbl

        # quick-add bar (v1.11: the overlay is fully interactive)
        add_row = tk.Frame(inner, bg=BG_HEX)
        add_row.pack(fill=tk.X, padx=6, pady=(2, 6))
        add_e = tk.Entry(add_row, bg="#1C1C1A", fg="#F2F0EA",
                         insertbackground="#C1F73A", relief=tk.FLAT,
                         font=("Consolas", 9))
        add_e.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=2)

        def _quick_add(_e=None) -> None:
            title = add_e.get().strip()
            if not title:
                return
            add_e.delete(0, tk.END)
            try:
                goals.add_goal(title)
            except Exception:
                pass
            self._refresh_goals_overlay()
        add_e.bind("<Return>", _quick_add)
        tk.Button(add_row, text="＋", command=_quick_add,
                  **{**_BTN_KW, "padx": 8, "pady": 1}).pack(side=tk.LEFT, padx=(4, 0))

        self._refresh_goals_overlay()

    def _refresh_goals_overlay(self) -> None:
        try:
            ov = self._goals_overlay
            if ov is None or not ov.winfo_exists():
                return
            goals = _INTEGRATIONS.get("goals")
            if goals is None:
                return
            import datetime as _d
            today = _d.date.today().isoformat()
            for w in ov._rows.winfo_children():
                w.destroy()

            mini = {**_BTN_KW, "padx": 4, "pady": 0, "font": ("Consolas", 9)}
            focus = goals.list_goals()[:5]
            if not focus:
                tk.Label(ov._rows, text="All clear ✓", bg=BG_HEX, fg="#F2F0EA",
                         font=("Consolas", 9)).pack(anchor=tk.W, padx=4, pady=4)
            for g in focus:
                row = tk.Frame(ov._rows, bg=BG_HEX)
                row.pack(fill=tk.X, pady=1)

                def _done(gid=g["id"]) -> None:
                    goals.complete_goal(gid)
                    self._refresh_goals_overlay()

                def _research(title=g["title"]) -> None:
                    fn = _INTEGRATIONS.get("research_goal")
                    if fn:
                        fn(title)

                def _remove(gid=g["id"], title=g["title"]) -> None:
                    if messagebox.askyesno("Wivexx",
                                           f"Delete goal '{title}'?"):
                        goals.delete_goal(gid)
                        self._refresh_goals_overlay()

                tk.Button(row, text="○", command=_done,
                          **{**mini, "fg": "#C1F73A"}).pack(side=tk.LEFT)
                flag = ("‼" if g.get("due") and g["due"] < today else
                        ("*" if g.get("priority") == "high" else " "))
                txt = f"{flag} {g['title'][:26]}"
                if g.get("due") == today:
                    txt += " (today)"
                lbl = tk.Label(row, text=txt, bg=BG_HEX,
                               fg="#E0553A" if flag == "‼" else "#F2F0EA",
                               font=("Consolas", 9), anchor=tk.W)
                lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
                tk.Button(row, text="find", command=_research,
                          **{**mini, "fg": "#C1F73A"}).pack(side=tk.RIGHT)
                tk.Button(row, text="✕", command=_remove,
                          **{**mini, "fg": "#d24"}).pack(side=tk.RIGHT)

            s = goals.stats()
            ov._stats.config(text=f"{s['open']} open · {s['done_week']} done "
                                  f"this week · {s['streak']}")
            ov.attributes("-topmost", True)
            ov.lift()
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # v1.10 —  Memory manager (view / search / edit / delete what Wivexx knows)
    # ──────────────────────────────────────────────────────────────────────────

    def _open_memory_manager(self) -> None:
        if self._focus_existing(self._memory_win):
            return
        ms = _INTEGRATIONS.get("memory")
        if ms is None:
            messagebox.showinfo("Wivexx", "Memory system not available yet.")
            return

        win = tk.Toplevel(self._root)
        self._memory_win = win
        self._style_editor_window(win, "Wivexx — Memory", "780x560")
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))

        style = ttk.Style(win)
        try:
            style.theme_use("default")
        except Exception:
            pass
        nb = ttk.Notebook(win)
        nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
        facts_tab  = tk.Frame(nb, bg=BG_HEX)
        topics_tab = tk.Frame(nb, bg=BG_HEX)
        nb.add(facts_tab,  text=" Facts about you ")
        nb.add(topics_tab, text=" Topic summaries ")

        # ── Facts tab (v1.11: full-width table, editor below — readable) ─────
        facts_tab.grid_columnconfigure(0, weight=1)
        facts_tab.grid_rowconfigure(1, weight=3)

        search_row = tk.Frame(facts_tab, bg=BG_HEX)
        search_row.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
        tk.Label(search_row, text="find", bg=BG_HEX, fg="#F2F0EA").pack(side=tk.LEFT)
        search_e = tk.Entry(search_row, **E_KW)
        search_e.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6, ipady=3)

        style.configure("Memory.Treeview", background="#1C1C1A",
                        fieldbackground="#1C1C1A", foreground="#F2F0EA",
                        rowheight=24, font=("Consolas", 10), borderwidth=0)
        style.configure("Memory.Treeview.Heading", background="#2A2A27",
                        foreground="#F2F0EA", font=("Consolas", 9, "bold"),
                        relief="flat")
        style.map("Memory.Treeview",
                  background=[("selected", "#2A2A27")],
                  foreground=[("selected", "#C1F73A")])

        tree_frame = tk.Frame(facts_tab, bg=BG_HEX)
        tree_frame.grid(row=1, column=0, sticky="nsew", padx=8, pady=4)
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)
        tv = ttk.Treeview(tree_frame, columns=("imp", "key", "value", "src"),
                          show="headings", style="Memory.Treeview",
                          selectmode="browse")
        tv.heading("imp",   text="go")
        tv.heading("key",   text="Category")
        tv.heading("value", text="Memory")
        tv.heading("src",   text="From")
        tv.column("imp",   width=36,  minwidth=30,  stretch=False, anchor=tk.CENTER)
        tv.column("key",   width=150, minwidth=100, stretch=False)
        tv.column("value", width=420, minwidth=200, stretch=True)
        tv.column("src",   width=70,  minwidth=50,  stretch=False, anchor=tk.CENTER)
        tv.grid(row=0, column=0, sticky="nsew")
        tsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tv.yview)
        tsb.grid(row=0, column=1, sticky="ns")
        tv.configure(yscrollcommand=tsb.set)

        editor = tk.Frame(facts_tab, bg=BG_HEX)
        editor.grid(row=2, column=0, sticky="ew", padx=8, pady=(2, 6))
        editor.grid_columnconfigure(1, weight=1)

        tk.Label(editor, text="Category", **L_KW).grid(row=0, column=0, sticky="w", pady=2)
        key_e = tk.Entry(editor, **E_KW)
        key_e.grid(row=0, column=1, sticky="ew", pady=2, padx=(6, 12))
        tk.Label(editor, text="Importance", **L_KW).grid(row=0, column=2, sticky="w")
        imp_sb = tk.Spinbox(editor, from_=1, to=10, width=4, **E_KW)
        imp_sb.grid(row=0, column=3, sticky="w", padx=6)
        tk.Label(editor, text="Memory", **L_KW).grid(row=1, column=0, sticky="nw", pady=2)
        val_t = tk.Text(editor, height=3, wrap=tk.WORD, **E_KW)
        val_t.grid(row=1, column=1, columnspan=3, sticky="ew", pady=2, padx=(6, 0))
        meta_lbl = tk.Label(editor, text="", bg=BG_HEX, fg="#8A8A85",
                            font=("Consolas", 8), anchor=tk.W, justify=tk.LEFT)
        meta_lbl.grid(row=2, column=0, columnspan=4, sticky="ew", pady=2)
        info = tk.Label(editor, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W,
                        wraplength=600, justify=tk.LEFT)
        info.grid(row=3, column=0, columnspan=4, sticky="ew", pady=2)
        right = editor                       # buttons attach below the editor
        selected = {"id": None}

        def _refresh(_e=None) -> None:
            tv.delete(*tv.get_children())
            for f in ms.list_facts(search_e.get()):
                src = {"manual": "manual", "explicit": "explicit"}.get(
                    f.get("source") or "", "· auto")
                tv.insert("", tk.END, iid=str(f["id"]),
                          values=(f.get("importance", 5), f["key"],
                                  f["value"], src))
        search_e.bind("<KeyRelease>", _refresh)

        def _on_select(_e=None) -> None:
            sel = tv.selection()
            if not sel:
                return
            fid = int(sel[0])
            f = next((x for x in ms.list_facts() if x["id"] == fid), None)
            if not f:
                return
            selected["id"] = fid
            key_e.delete(0, tk.END); key_e.insert(0, f["key"])
            val_t.delete("1.0", tk.END); val_t.insert("1.0", f["value"])
            imp_sb.delete(0, tk.END); imp_sb.insert(0, f.get("importance", 5))
            import datetime as _d
            upd = _d.datetime.fromtimestamp(f.get("updated_at") or 0)
            meta_lbl.config(text=f"source: {f.get('source') or 'auto'} "
                                 f"updated: {upd:%Y-%m-%d %H:%M} "
                                 f"recalled {f.get('use_count', 0)}×")
        tv.bind("<<TreeviewSelect>>", _on_select)

        def _clear_form() -> None:
            selected["id"] = None
            key_e.delete(0, tk.END)
            val_t.delete("1.0", tk.END)
            imp_sb.delete(0, tk.END); imp_sb.insert(0, 5)
            meta_lbl.config(text="")
            info.config(text="")

        def _save() -> None:
            key = key_e.get().strip()
            val = val_t.get("1.0", tk.END).strip()
            try:
                imp = int(imp_sb.get())
            except ValueError:
                imp = 5
            if not key or not val:
                messagebox.showwarning("Wivexx",
                "Key and value are both required.",
                                       parent=win)
                return
            if selected["id"] is None:
                ok = ms.add_fact(key, val, importance=imp, source="manual")
                info.config(text="Memory added ✓" if ok else "Rejected (junk category).")
            else:
                ok = ms.update_fact(selected["id"], key=key, value=val,
                                    importance=imp)
                info.config(text="Memory corrected ✓" if ok
                            else "Update failed (key already exists?).")
            _refresh()

        def _delete() -> None:
            if selected["id"] is None:
                return
            if messagebox.askyesno("Wivexx", "Delete this memory?",
                                   parent=win):
                ms.delete_fact(selected["id"])
                _clear_form()
                _refresh()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=5, column=0, columnspan=2, sticky="ew")
        tk.Button(btns, text="＋ New",    command=_clear_form, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Save",   command=_save,       **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete", command=_delete,     **_BTN_KW).pack(side=tk.LEFT, padx=2)

        tk.Label(right, text="explicit ('remember that…')    manual   · learned\n"
        "High importance (8-10) = always in Wivexx's context.\n"
        "Voice: 'remember that…', 'forget about…',\n"
        "'what do you remember about me'.",
                 bg=BG_HEX, fg="#8A8A85", font=("Consolas", 8),
                 justify=tk.LEFT).grid(row=6, column=0, columnspan=2,
                                       sticky="w", pady=(10, 0))

        # ── Topics tab ──────────────────────────────────────────────────────
        topics_tab.grid_rowconfigure(0, weight=1)
        topics_tab.grid_columnconfigure(0, weight=1)
        tlb = tk.Listbox(topics_tab, bg="#1C1C1A", fg="#F2F0EA",
                         selectbackground="#2A2A27", selectforeground="#C1F73A",
                         relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        tlb.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
        topic_ids: list[int] = []

        def _refresh_topics() -> None:
            tlb.delete(0, tk.END)
            topic_ids.clear()
            for t in ms.list_topics():
                tlb.insert(tk.END, f" {t['topic']}: {t['summary'][:70]}")
                topic_ids.append(t["id"])

        def _del_topic() -> None:
            sel = tlb.curselection()
            if sel and messagebox.askyesno("Wivexx",
            "Delete this topic summary?",
                                           parent=win):
                ms.delete_topic(topic_ids[sel[0]])
                _refresh_topics()

        tk.Button(topics_tab, text="Delete selected", command=_del_topic,
                  **_BTN_KW).grid(row=1, column=0, sticky="w", padx=8, pady=(0, 8))

        _clear_form()
        _refresh()
        _refresh_topics()

    # ──────────────────────────────────────────────────────────────────────────
    # v1.8 — import cancel + web client tooltip
    # ──────────────────────────────────────────────────────────────────────────

    def _on_cancel_import(self) -> None:
        media = _INTEGRATIONS.get("media")
        if media is None:
            return
        if messagebox.askyesno("Wivexx",
        "Stop the media import in progress?",
                               parent=self._root):
            try:
                media.cancel_import()
            except Exception:
                pass

    def _show_web_clients_tip(self, _e=None) -> None:
        get_clients = _INTEGRATIONS.get("web_clients")
        if get_clients is None:
            return
        try:
            clients = get_clients() or []
        except Exception:
            clients = []
        if not clients:
            return
        self._hide_web_clients_tip()
        try:
            tip = tk.Toplevel(self._root)
            tip.overrideredirect(True)
            tip.attributes("-topmost", True)
            tip.configure(bg="#C1F73A")
            inner = tk.Frame(tip, bg="#1C1C1A")
            inner.pack(padx=1, pady=1)
            tk.Label(inner, text="Connected to the Wivexx website:",
                     bg="#1C1C1A", fg="#F2F0EA",
                     font=("Consolas", 9, "bold")).pack(anchor=tk.W, padx=8, pady=(6, 2))
            for c in clients:
                line = f"  {c.get('name') or 'Unknown device'}"
                sub = f"    IP {c.get('ip', '?')}"
                if c.get("mac"):
                    sub += f"   MAC {c['mac']}"
                tk.Label(inner, text=line, bg="#1C1C1A", fg="#F2F0EA",
                         font=("Consolas", 9)).pack(anchor=tk.W, padx=8)
                tk.Label(inner, text=sub, bg="#1C1C1A", fg="#8A8A85",
                         font=("Consolas", 8)).pack(anchor=tk.W, padx=8, pady=(0, 4))
            x = self._web_lbl.winfo_rootx() - 120
            y = self._web_lbl.winfo_rooty() - (40 + 40 * len(clients))
            tip.geometry(f"+{max(0, x)}+{max(0, y)}")
            self._web_tip = tip
        except Exception:
            pass

    def _hide_web_clients_tip(self) -> None:
        try:
            if self._web_tip is not None and self._web_tip.winfo_exists():
                self._web_tip.destroy()
        except Exception:
            pass
        self._web_tip = None

    # ──────────────────────────────────────────────────────────────────────────
    # v1.6.2 — typed input + image attachment with preview
    # ──────────────────────────────────────────────────────────────────────────

    _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif")

    def _set_attachment(self, path: str, label: Optional[str] = None) -> None:
        """Attach one more file and refresh the preview bar (v1.9: the
        attachment is a LIST — multiple files go out with one message)."""
        if not isinstance(self._attach_path, list):
            self._attach_path = [self._attach_path] if self._attach_path else []
        if path not in self._attach_path:
            self._attach_path.append(path)
        paths = self._attach_path
        # Thumbnail of the first image among the attachments
        first_img = next((p for p in paths
                          if p.lower().endswith(self._IMAGE_EXTS)), None)
        if first_img:
            try:
                from PIL import Image as _PILImage, ImageTk as _PILImageTk
                img = _PILImage.open(first_img)
                img.thumbnail((110, 64))
                self._attach_thumb = _PILImageTk.PhotoImage(img)
                self._attach_img_lbl.config(image=self._attach_thumb, text="")
            except Exception:
                self._attach_thumb = None
                self._attach_img_lbl.config(image="", text="image")
        else:
            self._attach_thumb = None
            self._attach_img_lbl.config(image="", text="file")
        names = [os.path.basename(p) for p in paths]
        shown = ", ".join(names[:3]) + ("…" if len(names) > 3 else "")
        self._attach_name_lbl.config(
            text=(label or f"{len(paths)} file{'s' if len(paths) != 1 else ''}: {shown}")
                 + " — sent with your next message")
        self._attach_frame.grid()

    def _on_attach_image(self) -> None:
        paths = filedialog.askopenfilenames(
            parent=self._root, title="Attach files for Wivexx",
            filetypes=[("Analysable (images, PDF, text)",
            "*.png *.jpg *.jpeg *.webp *.bmp *.gif *.heic *.tif "
            "*.tiff *.pdf *.txt *.md *.csv *.log *.json"),
                       ("Images", "*.png *.jpg *.jpeg *.webp *.bmp *.gif"),
                       ("All files", "*.*")])
        for p in paths:
            self._set_attachment(p)

    def _on_paste(self, _event=None):
        """Ctrl+V in the input bar (v1.6.2): if the clipboard holds an image
        (screenshot, copied picture, or a copied image file), attach it with a
        preview.  Plain text falls through to the normal Entry paste."""
        try:
            from PIL import ImageGrab
            data = ImageGrab.grabclipboard()
        except Exception:
            return None                 # let the default text paste happen
        if data is None:
            return None                 # clipboard is text → default paste
        # Copied file(s) from Explorer → use the first image file
        if isinstance(data, list):
            for p in data:
                if str(p).lower().endswith(self._IMAGE_EXTS):
                    self._set_attachment(str(p))
                    return "break"
            return None
        # Raw bitmap (screenshot / copied picture) → save to a temp PNG
        try:
            import tempfile
            path = os.path.join(tempfile.gettempdir(),
                                f"wivexx_paste_{int(time.time())}.png")
            data.save(path, "PNG")
            self._set_attachment(path, label="pasted image")
            return "break"              # consumed — no text inserted
        except Exception:
            return None

    def _clear_attachment(self) -> None:
        self._attach_path = None
        self._attach_thumb = None
        try:
            self._attach_img_lbl.config(image="", text="")
            self._attach_frame.grid_remove()
        except Exception:
            pass

    def _on_send_text(self) -> None:
        submit = _INTEGRATIONS.get("submit_text")
        if submit is None:
            messagebox.showinfo("Wivexx",
            "Text input not ready yet — try again in a moment.")
            return
        text = self._input_entry.get().strip()
        path = self._attach_path
        if not text and not path:
            return
        self._input_entry.delete(0, tk.END)
        self._clear_attachment()
        try:
            submit(text, path)        # spawns a worker thread in wivexx_api
        except Exception:
            pass

    # ──────────────────────────────────────────────────────────────────────────
    # v1.6 — wivexx_apps.json helpers (used by the Commands editor)
    # ──────────────────────────────────────────────────────────────────────────

    @staticmethod
    def _read_apps_json() -> dict:
        path = _INTEGRATIONS.get("apps_json_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", {})
        data.setdefault("category_aliases", {})
        data.setdefault("phonetic_aliases", {})
        return data

    @staticmethod
    def _write_apps_json(data: dict) -> bool:
        """Write the registry and hot-reload it so Wivexx recognises the new
        commands immediately — no restart, no 'I don't know that command'."""
        path = _INTEGRATIONS.get("apps_json_path", "")
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
        except Exception as e:
            messagebox.showerror("Wivexx", f"Could not save:\n{e}")
            return False
        reload_fn = _INTEGRATIONS.get("reload_apps")
        if reload_fn:
            try:
                reload_fn()
            except Exception:
                pass
        return True

    @staticmethod
    def _style_editor_window(win: tk.Toplevel, title: str, size: str) -> None:
        win.title(title)
        win.configure(bg=BG_HEX)
        win.geometry(size)
        win.attributes("-topmost", True)

    @staticmethod
    def _focus_existing(win: Optional[tk.Toplevel]) -> bool:
        """True if *win* still exists (and was brought to the front) —
        keeps the editors single-instance (v1.6.1)."""
        try:
            if win is not None and win.winfo_exists():
                win.deiconify()
                win.lift()
                win.focus_force()
                return True
        except Exception:
            pass
        return False

    # ──────────────────────────────────────────────────────────────────────────
    # v1.6 —  Commands editor (custom open/close commands + environments)
    # ──────────────────────────────────────────────────────────────────────────

    def _open_commands_editor(self) -> None:
        if self._focus_existing(self._commands_win):
            return
        if not _INTEGRATIONS.get("apps_json_path"):
            messagebox.showinfo("Wivexx",
            "Command editor not ready yet — try again in a moment.")
            return
        win = tk.Toplevel(self._root)
        self._commands_win = win
        self._style_editor_window(win, "Wivexx — Custom Commands", "720x520")

        style = ttk.Style(win)
        try:
            style.theme_use("default")
        except Exception:
            pass
        style.configure("TNotebook", background=BG_HEX, borderwidth=0)
        style.configure("TNotebook.Tab", background="#1C1C1A", foreground="#F2F0EA",
                        font=("Consolas", 10), padding=(12, 4))
        style.map("TNotebook.Tab", background=[("selected", "#2A2A27")],
                  foreground=[("selected", "#C1F73A")])

        nb = ttk.Notebook(win)
        nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
        apps_tab    = tk.Frame(nb, bg=BG_HEX)
        envs_tab    = tk.Frame(nb, bg=BG_HEX)
        audio_tab   = tk.Frame(nb, bg=BG_HEX)
        storage_tab = tk.Frame(nb, bg=BG_HEX)
        nb.add(apps_tab,    text=" Open/Close Commands ")
        nb.add(envs_tab,    text=" Environments ")
        nb.add(audio_tab,   text=" Audio Outputs ")
        nb.add(storage_tab, text=" Storage Import ")
        self._build_apps_tab(apps_tab)
        self._build_envs_tab(envs_tab)
        self._build_audio_tab(audio_tab)
        self._build_storage_tab(storage_tab)

    # ── Apps tab ───────────────────────────────────────────────────────────────

    def _build_apps_tab(self, tab: tk.Frame) -> None:
        L_KW   = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW   = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                      relief=tk.FLAT, font=("Consolas", 10))

        tab.grid_columnconfigure(1, weight=1)
        tab.grid_rowconfigure(0, weight=1)

        # Left: existing custom apps
        left = tk.Frame(tab, bg=BG_HEX)
        left.grid(row=0, column=0, sticky="ns", padx=(8, 4), pady=8)
        tk.Label(left, text="Your custom commands", **L_KW).pack(anchor=tk.W)
        lb = tk.Listbox(left, width=26, bg="#1C1C1A", fg="#F2F0EA",
                        selectbackground="#2A2A27", selectforeground="#C1F73A",
                        relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        lb.pack(fill=tk.Y, expand=True, pady=4)

        # Right: form
        right = tk.Frame(tab, bg=BG_HEX)
        right.grid(row=0, column=1, sticky="nsew", padx=(4, 8), pady=8)
        right.grid_columnconfigure(1, weight=1)

        tk.Label(right, text="Spoken name  (say: 'open <this>')", **L_KW)\
            .grid(row=0, column=0, columnspan=2, sticky="w")
        name_e = tk.Entry(right, **E_KW)
        name_e.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))

        tk.Label(right, text="App path  (.exe, file, folder or https:// URL)", **L_KW)\
            .grid(row=2, column=0, columnspan=2, sticky="w")
        path_e = tk.Entry(right, **E_KW)
        path_e.grid(row=3, column=0, sticky="ew", pady=(0, 8))

        def _browse() -> None:
            p = filedialog.askopenfilename(
                parent=right, title="Select application",
                filetypes=[("Programs", "*.exe *.bat *.lnk"), ("All files", "*.*")])
            if p:
                path_e.delete(0, tk.END)
                path_e.insert(0, os.path.normpath(p))

        tk.Button(right, text="Browse…", command=_browse, **_BTN_KW)\
            .grid(row=3, column=1, sticky="e", padx=(6, 0), pady=(0, 8))

        tk.Label(right, text="Extra spoken aliases (comma separated, optional)", **L_KW)\
            .grid(row=4, column=0, columnspan=2, sticky="w")
        alias_e = tk.Entry(right, **E_KW)
        alias_e.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(0, 8))

        info = tk.Label(right, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W, wraplength=380,
                        justify=tk.LEFT)
        info.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(4, 8))

        def _refresh_list() -> None:
            lb.delete(0, tk.END)
            for key in sorted(self._read_apps_json()["apps"].keys()):
                lb.insert(tk.END, key)

        def _on_select(_e=None) -> None:
            sel = lb.curselection()
            if not sel:
                return
            key = lb.get(sel[0])
            rec = self._read_apps_json()["apps"].get(key, {})
            name_e.delete(0, tk.END); name_e.insert(0, key)
            path_e.delete(0, tk.END)
            exes = rec.get("exes", [])
            path_e.insert(0, exes[0] if exes else "")
            alias_e.delete(0, tk.END)
            extra = [v for v in rec.get("variants", []) if v != key.lower()]
            alias_e.insert(0, ", ".join(extra))

        lb.bind("<<ListboxSelect>>", _on_select)

        def _clear_form() -> None:
            name_e.delete(0, tk.END)
            path_e.delete(0, tk.END)
            alias_e.delete(0, tk.END)
            info.config(text="")

        def _save() -> None:
            name = name_e.get().strip().lower()
            path = path_e.get().strip()
            if not name or not path:
                messagebox.showwarning("Wivexx",
                "Spoken name and app path are both required.",
                                       parent=tab)
                return
            if not path.lower().startswith("http"):
                expanded = os.path.expandvars(os.path.expanduser(path))
                if not os.path.exists(expanded):
                    if not messagebox.askyesno(
                    "Wivexx", f"That path doesn't exist:\n{path}\n\nSave anyway?",
                            parent=tab):
                        return
            aliases = [a.strip().lower() for a in alias_e.get().split(",") if a.strip()]
            variants = [name] + [a for a in aliases if a != name]
            data = self._read_apps_json()
            data["apps"][name] = {
            "display_name": name.title(),
            "exes": [path],
            "variants": variants,
            "categories": [],
            }
            if self._write_apps_json(data):
                _refresh_list()
                info.config(text=f"Saved ✓  Wivexx now answers: "
                                 f"\"open {name}\"  and  \"close {name}\"")

        def _delete() -> None:
            sel = lb.curselection()
            if not sel:
                return
            key = lb.get(sel[0])
            if not messagebox.askyesno("Wivexx",
                                       f"Delete the '{key}' command?", parent=tab):
                return
            data = self._read_apps_json()
            data["apps"].pop(key, None)
            if self._write_apps_json(data):
                _refresh_list()
                _clear_form()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=7, column=0, columnspan=2, sticky="ew")
        tk.Button(btns, text="＋ New",  command=_clear_form, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Save", command=_save,       **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete", command=_delete,   **_BTN_KW).pack(side=tk.LEFT, padx=2)

        _refresh_list()

    # ── Environments tab ───────────────────────────────────────────────────────

    def _build_envs_tab(self, tab: tk.Frame) -> None:
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))

        tab.grid_columnconfigure(1, weight=1)
        tab.grid_rowconfigure(0, weight=1)

        left = tk.Frame(tab, bg=BG_HEX)
        left.grid(row=0, column=0, sticky="ns", padx=(8, 4), pady=8)
        tk.Label(left, text="Your environments", **L_KW).pack(anchor=tk.W)
        lb = tk.Listbox(left, width=22, bg="#1C1C1A", fg="#F2F0EA",
                        selectbackground="#2A2A27", selectforeground="#C1F73A",
                        relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        lb.pack(fill=tk.Y, expand=True, pady=4)

        right = tk.Frame(tab, bg=BG_HEX)
        right.grid(row=0, column=1, sticky="nsew", padx=(4, 8), pady=8)
        right.grid_columnconfigure(0, weight=1)
        right.grid_rowconfigure(4, weight=1)
        right.grid_rowconfigure(7, weight=1)

        tk.Label(right, text="Environment name  (say: 'activate <this> environment')",
                 **L_KW).grid(row=0, column=0, sticky="w")
        name_e = tk.Entry(right, **E_KW)
        name_e.grid(row=1, column=0, sticky="ew", pady=(0, 6))

        tk.Label(right, text="Extra spoken aliases (comma separated, optional)", **L_KW)\
            .grid(row=2, column=0, sticky="w")
        alias_e = tk.Entry(right, **E_KW)
        alias_e.grid(row=3, column=0, sticky="ew", pady=(0, 6))

        tk.Label(right, text="Items to OPEN — one per line "
        "(app name, full path, folder or https:// URL)", **L_KW)\
            .grid(row=4, column=0, sticky="nw")
        open_t = tk.Text(right, height=6, **E_KW)
        open_t.grid(row=5, column=0, sticky="nsew", pady=(0, 6))

        tk.Label(right, text="Apps to CLOSE — one per line "
        "(leave empty to close the same apps)", **L_KW)\
            .grid(row=6, column=0, sticky="nw")
        close_t = tk.Text(right, height=4, **E_KW)
        close_t.grid(row=7, column=0, sticky="nsew", pady=(0, 6))

        info = tk.Label(right, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W,
                        wraplength=420, justify=tk.LEFT)
        info.grid(row=8, column=0, sticky="ew", pady=(2, 6))

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

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

        def _refresh_list() -> None:
            lb.delete(0, tk.END)
            for key in sorted(self._read_apps_json()["environments"].keys()):
                lb.insert(tk.END, key)

        def _on_select(_e=None) -> None:
            sel = lb.curselection()
            if not sel:
                return
            key = lb.get(sel[0])
            rec = self._read_apps_json()["environments"].get(key, {})
            name_e.delete(0, tk.END); name_e.insert(0, key)
            extra = [v for v in rec.get("variants", []) if v != key.lower()]
            alias_e.delete(0, tk.END); alias_e.insert(0, ", ".join(extra))
            open_t.delete("1.0", tk.END)
            open_t.insert("1.0", "\n".join(_item_to_line(i) for i in rec.get("open", [])))
            close_t.delete("1.0", tk.END)
            close_t.insert("1.0", "\n".join(_item_to_line(i) for i in rec.get("close", [])))

        lb.bind("<<ListboxSelect>>", _on_select)

        def _clear_form() -> None:
            name_e.delete(0, tk.END)
            alias_e.delete(0, tk.END)
            open_t.delete("1.0", tk.END)
            close_t.delete("1.0", tk.END)
            info.config(text="")

        def _save() -> None:
            name = name_e.get().strip().lower()
            if not name:
                messagebox.showwarning("Wivexx", "Environment name is required.",
                                       parent=tab)
                return
            open_items  = [_line_to_item(l) for l in open_t.get("1.0", tk.END).splitlines()
                           if l.strip()]
            close_items = [_line_to_item(l) for l in close_t.get("1.0", tk.END).splitlines()
                           if l.strip()]
            if not open_items:
                messagebox.showwarning("Wivexx",
                "Add at least one item to open.", parent=tab)
                return
            aliases = [a.strip().lower() for a in alias_e.get().split(",") if a.strip()]
            data = self._read_apps_json()
            data["environments"][name] = {
            "display_name": name.title(),
            "variants": [name] + [a for a in aliases if a != name],
            "open": open_items,
            "close": close_items,
            }
            if self._write_apps_json(data):
                _refresh_list()
                info.config(text=f"Saved ✓  Say:  \"activate {name} environment\"  / "
                                 f"\"close {name} environment\"")

        def _delete() -> None:
            sel = lb.curselection()
            if not sel:
                return
            key = lb.get(sel[0])
            if not messagebox.askyesno("Wivexx",
                                       f"Delete the '{key}' environment?", parent=tab):
                return
            data = self._read_apps_json()
            data["environments"].pop(key, None)
            if self._write_apps_json(data):
                _refresh_list()
                _clear_form()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=9, column=0, sticky="ew")
        tk.Button(btns, text="＋ New",  command=_clear_form, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Save", command=_save,       **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete", command=_delete,   **_BTN_KW).pack(side=tk.LEFT, padx=2)

        _refresh_list()

    # ── Audio Outputs tab (v1.6.2) ─────────────────────────────────────────────
    # Map a spoken phrase ("change to output 1") to a playback device chosen
    # from a dropdown of the devices currently present on this PC.

    def _build_audio_tab(self, tab: tk.Frame) -> None:
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))

        tab.grid_columnconfigure(1, weight=1)
        tab.grid_rowconfigure(0, weight=1)

        # Left: existing phrase → device mappings
        left = tk.Frame(tab, bg=BG_HEX)
        left.grid(row=0, column=0, sticky="ns", padx=(8, 4), pady=8)
        tk.Label(left, text="Your output commands", **L_KW).pack(anchor=tk.W)
        lb = tk.Listbox(left, width=30, bg="#1C1C1A", fg="#F2F0EA",
                        selectbackground="#2A2A27", selectforeground="#C1F73A",
                        relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        lb.pack(fill=tk.Y, expand=True, pady=4)

        # Right: form
        right = tk.Frame(tab, bg=BG_HEX)
        right.grid(row=0, column=1, sticky="nsew", padx=(4, 8), pady=8)
        right.grid_columnconfigure(0, weight=1)

        tk.Label(right, text="Spoken command  (e.g. 'change to output 1')", **L_KW)\
            .grid(row=0, column=0, columnspan=2, sticky="w")
        phrase_e = tk.Entry(right, **E_KW)
        phrase_e.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 10))

        tk.Label(right, text="Output device this command switches to", **L_KW)\
            .grid(row=2, column=0, columnspan=2, sticky="w")
        device_cb = ttk.Combobox(right, state="readonly",
                                 font=("Consolas", 10), values=["loading devices…"])
        device_cb.grid(row=3, column=0, sticky="ew", pady=(0, 10))
        devices: list[dict] = []      # populated asynchronously

        def _populate_devices() -> None:
            try:
                import wivexx_audio_devices as _ad
                devs = sorted(_ad.list_output_devices(), key=lambda d: d["name"].lower())
            except Exception:
                devs = []

            def _apply() -> None:
                devices.clear()
                devices.extend(devs)
                names = [d["name"] for d in devices] or ["(no devices found)"]
                try:
                    device_cb.config(values=names)
                except Exception:
                    pass
            try:
                tab.after(0, _apply)
            except Exception:
                pass

        def _refresh_devices() -> None:
            device_cb.set("")
            device_cb.config(values=["loading devices…"])
            threading.Thread(target=_populate_devices, daemon=True).start()

        tk.Button(right, text="↻ Refresh devices", command=_refresh_devices, **_BTN_KW)\
            .grid(row=3, column=1, sticky="e", padx=(6, 0), pady=(0, 10))

        info = tk.Label(right, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W,
                        wraplength=400, justify=tk.LEFT)
        info.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(4, 8))

        def _refresh_list() -> None:
            lb.delete(0, tk.END)
            cmds = self._read_apps_json().get("audio_commands", {})
            for phrase in sorted(cmds.keys()):
                lb.insert(tk.END, f"{phrase}  →  {cmds[phrase].get('device_name', '?')}")

        def _on_select(_e=None) -> None:
            sel = lb.curselection()
            if not sel:
                return
            entry_text = lb.get(sel[0])
            phrase = entry_text.split("  → ")[0]
            rec = self._read_apps_json().get("audio_commands", {}).get(phrase, {})
            phrase_e.delete(0, tk.END)
            phrase_e.insert(0, phrase)
            device_cb.set(rec.get("device_name", ""))

        lb.bind("<<ListboxSelect>>", _on_select)

        def _clear_form() -> None:
            phrase_e.delete(0, tk.END)
            device_cb.set("")
            info.config(text="")

        def _save() -> None:
            phrase = phrase_e.get().strip().lower()
            dev_name = device_cb.get().strip()
            if not phrase or not dev_name or dev_name.startswith(("loading", "(no")):
                messagebox.showwarning(
                "Wivexx",
                "Type a spoken phrase and pick an output device.", parent=tab)
                return
            dev_id = ""
            for d in devices:
                if d["name"] == dev_name:
                    dev_id = d["id"]
                    break
            data = self._read_apps_json()
            data.setdefault("audio_commands", {})[phrase] = {
            "device_name": dev_name,
            "device_id":   dev_id,
            }
            if self._write_apps_json(data):
                _refresh_list()
                info.config(text=f"Saved ✓  Say:  \"{phrase}\"  →  {dev_name}")

        def _delete() -> None:
            sel = lb.curselection()
            if not sel:
                return
            phrase = lb.get(sel[0]).split("  → ")[0]
            if not messagebox.askyesno("Wivexx",
                                       f"Delete the '{phrase}' command?", parent=tab):
                return
            data = self._read_apps_json()
            data.get("audio_commands", {}).pop(phrase, None)
            if self._write_apps_json(data):
                _refresh_list()
                _clear_form()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=5, column=0, columnspan=2, sticky="ew")
        tk.Button(btns, text="＋ New",  command=_clear_form, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Save", command=_save,       **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete", command=_delete,   **_BTN_KW).pack(side=tk.LEFT, padx=2)

        tk.Label(right, text="Built-in: you can always say "
        "\"switch output to <device name>\" without setting "
        "anything up here.", wraplength=400, justify=tk.LEFT,
                 **L_KW).grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))

        _refresh_list()
        _refresh_devices()

    # ── Storage Import tab (v1.7) ──────────────────────────────────────────────
    # Manage which external drives auto-import their photos/videos, and the
    # optional tag (subfolder) used for each device.

    def _build_storage_tab(self, tab: tk.Frame) -> None:
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))
        media = _INTEGRATIONS.get("media")
        if media is None:
            tk.Label(tab, text="Storage auto-import is not available.",
                     **L_KW).pack(padx=12, pady=12, anchor=tk.W)
            return

        tab.grid_columnconfigure(1, weight=1)
        tab.grid_rowconfigure(0, weight=1)

        left = tk.Frame(tab, bg=BG_HEX)
        left.grid(row=0, column=0, sticky="ns", padx=(8, 4), pady=8)
        tk.Label(left, text="Known storage devices", **L_KW).pack(anchor=tk.W)
        lb = tk.Listbox(left, width=34, bg="#1C1C1A", fg="#F2F0EA",
                        selectbackground="#2A2A27", selectforeground="#C1F73A",
                        relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        lb.pack(fill=tk.Y, expand=True, pady=4)
        serial_by_index: list[str] = []

        right = tk.Frame(tab, bg=BG_HEX)
        right.grid(row=0, column=1, sticky="nsew", padx=(4, 8), pady=8)
        right.grid_columnconfigure(0, weight=1)

        tk.Label(right, text="When this device is connected:", **L_KW)\
            .grid(row=0, column=0, sticky="w")
        policy_cb = ttk.Combobox(right, state="readonly", font=("Consolas", 10),
                                 values=["allow — auto-import media",
                                 "deny — ignore this device"])
        policy_cb.grid(row=1, column=0, sticky="ew", pady=(0, 10))

        tk.Label(right, text="Tag (optional — creates a subfolder inside "
        "photos/ and video/, e.g. 'osmo')", **L_KW)\
            .grid(row=2, column=0, sticky="w")
        tag_e = tk.Entry(right, **E_KW)
        tag_e.grid(row=3, column=0, sticky="ew", pady=(0, 10))

        info = tk.Label(right, text="", bg=BG_HEX, fg="#C1F73A",
                        font=("Consolas", 9, "italic"), anchor=tk.W,
                        wraplength=380, justify=tk.LEFT)
        info.grid(row=4, column=0, sticky="ew", pady=(2, 8))

        def _refresh_list() -> None:
            lb.delete(0, tk.END)
            serial_by_index.clear()
            for serial, rec in sorted(media.get_devices().items(),
                                      key=lambda kv: kv[1].get("label", "")):
                tag = rec.get("tag", "")
                lb.insert(tk.END,
                          f"{rec.get('label') or serial:<22.22} "
                          f"{rec.get('policy', '?'):<6}"
                          + (f" [{tag}]" if tag else ""))
                serial_by_index.append(serial)

        def _selected_serial():
            sel = lb.curselection()
            return serial_by_index[sel[0]] if sel else None

        def _on_select(_e=None) -> None:
            serial = _selected_serial()
            if not serial:
                return
            rec = media.get_devices().get(serial, {})
            policy_cb.set("allow — auto-import media"
                          if rec.get("policy") == "allow"
                          else "deny — ignore this device")
            tag_e.delete(0, tk.END)
            tag_e.insert(0, rec.get("tag", ""))
            info.config(text=f"Serial {serial}"
                             + (f" · last import {rec['last_import']}"
                                if rec.get("last_import") else ""))

        lb.bind("<<ListboxSelect>>", _on_select)

        def _save() -> None:
            serial = _selected_serial()
            if not serial:
                return
            policy = "allow" if policy_cb.get().startswith("allow") else "deny"
            media.set_device(serial, policy, tag_e.get())
            _refresh_list()
            info.config(text="Saved ✓")

        def _forget() -> None:
            serial = _selected_serial()
            if not serial:
                return
            if messagebox.askyesno("Wivexx",
            "Forget this device? Wivexx will ask again "
            "next time it is connected.", parent=tab):
                media.forget_device(serial)
                _refresh_list()

        btns = tk.Frame(right, bg=BG_HEX)
        btns.grid(row=5, column=0, sticky="ew")
        tk.Button(btns, text="Save",   command=_save,   **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Forget", command=_forget, **_BTN_KW).pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="↻ Refresh", command=_refresh_list, **_BTN_KW).pack(side=tk.LEFT, padx=2)

        tk.Label(right, text="Connect a new SD card / USB drive and Wivexx "
        "will ask whether to import from it. Allowed "
        "devices import automatically on every "
        "connection.", wraplength=380, justify=tk.LEFT,
                 **L_KW).grid(row=6, column=0, sticky="w", pady=(10, 0))

        _refresh_list()

    # ── New-storage prompt (v1.7) — called from the media-import watcher ──────

    def _show_media_prompt(self, dev: dict, on_result) -> None:
        """Build the Always/Never/Later dialog. Runs on the Tk thread."""
        done = {"sent": False}

        def _finish(result) -> None:
            if done["sent"]:
                return
            done["sent"] = True
            try:
                win.destroy()
            except Exception:
                pass
            try:
                on_result(result)
            except Exception:
                pass

        win = tk.Toplevel(self._root)
        win.title("Wivexx — New storage detected")
        win.configure(bg="#C1F73A")
        win.attributes("-topmost", True)
        w, h = 430, 270
        sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
        win.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 3}")
        win.protocol("WM_DELETE_WINDOW", lambda: _finish(None))

        inner = tk.Frame(win, bg=BG_HEX)
        inner.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)

        tk.Label(inner, text="New storage detected",
                 bg=BG_HEX, fg="#C1F73A",
                 font=("Consolas", 12, "bold")).pack(pady=(14, 4))
        tk.Label(inner, text=f"{dev.get('label', '?')}  ({dev.get('letter', '?')})",
                 bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 11)).pack()
        tk.Label(inner, text=f"Auto-import all photos & videos from this device\n"
                             f"to {dev.get('dest', '')} every time it is connected?",
                 bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9),
                 justify=tk.CENTER).pack(pady=(6, 8))

        tag_row = tk.Frame(inner, bg=BG_HEX)
        tag_row.pack(pady=(0, 10))
        tk.Label(tag_row, text="Tag (optional):", bg=BG_HEX, fg="#F2F0EA",
                 font=("Consolas", 9)).pack(side=tk.LEFT, padx=(0, 6))
        tag_e = tk.Entry(tag_row, width=18, bg="#1C1C1A", fg="#F2F0EA",
                         insertbackground="#C1F73A", relief=tk.FLAT,
                         font=("Consolas", 10))
        tag_e.pack(side=tk.LEFT)

        btns = tk.Frame(inner, bg=BG_HEX)
        btns.pack(pady=(2, 0))
        tk.Button(btns, text="✓ Always import",
                  command=lambda: _finish({"policy": "allow",
                  "tag": tag_e.get()}),
                  **_BTN_KW).pack(side=tk.LEFT, padx=4)
        tk.Button(btns, text="✕ Never",
                  command=lambda: _finish({"policy": "deny", "tag": ""}),
                  **_BTN_KW).pack(side=tk.LEFT, padx=4)
        tk.Button(btns, text="Later",
                  command=lambda: _finish(None),
                  **_BTN_KW).pack(side=tk.LEFT, padx=4)
        win.lift()
        win.focus_force()

    # ──────────────────────────────────────────────────────────────────────────
    # v1.6 —  Shortcuts editor (rebindable multi-key hotkeys + custom commands)
    # ──────────────────────────────────────────────────────────────────────────

    def _open_shortcuts_editor(self) -> None:
        if self._focus_existing(self._shortcuts_win):
            return
        hk = _INTEGRATIONS.get("hotkeys")
        if hk is None or not getattr(hk, "available", lambda: False)():
            messagebox.showinfo(
            "Wivexx",
            "Global shortcuts are not available on this system yet.\n"
            "Restart Wivexx and try again.")
            return

        win = tk.Toplevel(self._root)
        self._shortcuts_win = win
        self._style_editor_window(win, "Wivexx — Shortcuts", "640x560")
        L_KW = dict(bg=BG_HEX, fg="#F2F0EA", font=("Consolas", 9), anchor=tk.W)
        E_KW = dict(bg="#1C1C1A", fg="#F2F0EA", insertbackground="#C1F73A",
                    relief=tk.FLAT, font=("Consolas", 10))

        tk.Label(win, text="Built-in actions — click Rebind, then press the new combo "
        "(keyboard keys and mouse side/middle buttons both work)",
                 wraplength=600, justify=tk.LEFT,
                 **L_KW).pack(anchor=tk.W, padx=10, pady=(10, 4))

        rows = tk.Frame(win, bg=BG_HEX)
        rows.pack(fill=tk.X, padx=10)
        binding_lbls: dict[str, tk.Label] = {}

        def _refresh_bindings() -> None:
            b = hk.get_bindings()
            for action, lbl in binding_lbls.items():
                lbl.config(text=b.get(action, "—"))

        def _rebind(action: str, btn: tk.Button) -> None:
            btn.config(text="press keys…", state=tk.DISABLED)

            def _capture() -> None:
                combo = hk.capture_combo(timeout=8.0)

                def _apply() -> None:
                    if combo:
                        hk.set_binding(action, combo)
                    btn.config(text="Rebind", state=tk.NORMAL)
                    _refresh_bindings()
                try:
                    win.after(0, _apply)
                except Exception:
                    pass

            threading.Thread(target=_capture, daemon=True).start()

        for i, (action, label) in enumerate(hk.ACTION_LABELS.items()):
            tk.Label(rows, text=label, width=38, **L_KW).grid(row=i, column=0, sticky="w", pady=2)
            lbl = tk.Label(rows, text="—", width=18, bg="#1C1C1A", fg="#C1F73A",
                           font=("Consolas", 10), anchor=tk.CENTER)
            lbl.grid(row=i, column=1, sticky="w", padx=6, pady=2)
            binding_lbls[action] = lbl
            btn = tk.Button(rows, text="Rebind", **_BTN_KW)
            btn.config(command=lambda a=action, b=btn: _rebind(a, b))
            btn.grid(row=i, column=2, sticky="w", pady=2)

        tk.Frame(win, bg="#1C1C1A", height=1).pack(fill=tk.X, padx=10, pady=8)
        tk.Label(win, text="Custom shortcuts — run any voice command from a key combo",
                 **L_KW).pack(anchor=tk.W, padx=10, pady=(0, 4))

        cust_lb = tk.Listbox(win, height=6, bg="#1C1C1A", fg="#F2F0EA",
                             selectbackground="#2A2A27", selectforeground="#C1F73A",
                             relief=tk.FLAT, font=("Consolas", 10), exportselection=False)
        cust_lb.pack(fill=tk.X, padx=10)

        def _refresh_customs() -> None:
            cust_lb.delete(0, tk.END)
            for c in hk.get_customs():
                cust_lb.insert(tk.END,
                               f"{c.get('keys', '?'):<20} →  {c.get('command', '')}")

        form = tk.Frame(win, bg=BG_HEX)
        form.pack(fill=tk.X, padx=10, pady=6)
        form.grid_columnconfigure(1, weight=1)

        tk.Label(form, text="Command to run", **L_KW).grid(row=0, column=0, sticky="w")
        cmd_e = tk.Entry(form, **E_KW)
        cmd_e.grid(row=0, column=1, sticky="ew", padx=6)
        cmd_e.insert(0, "open spotify")

        keys_var = tk.StringVar(value="(no keys captured)")
        tk.Label(form, textvariable=keys_var, bg=BG_HEX, fg="#C1F73A",
                 font=("Consolas", 10)).grid(row=1, column=1, sticky="w", padx=6, pady=4)

        def _capture_custom(btn: tk.Button) -> None:
            btn.config(text="press keys…", state=tk.DISABLED)

            def _cap() -> None:
                combo = hk.capture_combo(timeout=8.0)

                def _apply() -> None:
                    if combo:
                        keys_var.set(combo)
                    btn.config(text="Capture keys", state=tk.NORMAL)
                try:
                    win.after(0, _apply)
                except Exception:
                    pass

            threading.Thread(target=_cap, daemon=True).start()

        cap_btn = tk.Button(form, text="Capture keys", **_BTN_KW)
        cap_btn.config(command=lambda: _capture_custom(cap_btn))
        cap_btn.grid(row=1, column=0, sticky="w", pady=4)

        def _add_custom() -> None:
            combo = keys_var.get()
            command = cmd_e.get().strip()
            if not command or "(" in combo:
                messagebox.showwarning("Wivexx",
                "Capture a key combo and enter a command first.",
                                       parent=win)
                return
            hk.add_custom(command, combo, command)
            keys_var.set("(no keys captured)")
            _refresh_customs()

        def _del_custom() -> None:
            sel = cust_lb.curselection()
            if sel:
                hk.remove_custom(sel[0])
                _refresh_customs()

        btns = tk.Frame(win, bg=BG_HEX)
        btns.pack(fill=tk.X, padx=10, pady=(0, 10))
        tk.Button(btns, text="＋ Add shortcut", command=_add_custom, **_BTN_KW)\
            .pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="Delete selected", command=_del_custom, **_BTN_KW)\
            .pack(side=tk.LEFT, padx=2)
        tk.Button(btns, text="↺ Reset defaults",
                  command=lambda: (hk.reset_defaults(), _refresh_bindings()), **_BTN_KW)\
            .pack(side=tk.RIGHT, padx=2)

        _refresh_bindings()
        _refresh_customs()

    # ──────────────────────────────────────────────────────────────────────────
    # Public interface — thread-safe (may be called from any thread)
    # ──────────────────────────────────────────────────────────────────────────

    def show_main(self) -> None:
        """Restore main window (thread-safe)."""
        try:
            if self._root:
                self._root.after(0, self._restore_from_hud)
        except Exception:
            pass

    def hide_main(self) -> None:
        """Hide to corner HUD (thread-safe)."""
        try:
            if self._root:
                self._root.after(0, self._minimize_to_hud)
        except Exception:
            pass

    def set_state(self, state: str) -> None:
        try:
            self._state_q.put_nowait(state)
        except queue.Full:
            pass

    def push_level(self, rms: float) -> None:
        if not self._level_q.full():
            self._level_q.put_nowait(float(rms))

    def log_turn(self, role: str, text: str, atts: Optional[list] = None) -> None:
        try:
            self._log_q.put_nowait((role, text, atts))
        except queue.Full:
            pass

    def set_status(self, text: str) -> None:
        # Replace old content so we always show the latest status
        try:
            while not self._status_q.empty():
                try:
                    self._status_q.get_nowait()
                except queue.Empty:
                    break
            self._status_q.put_nowait(text)
        except Exception:
            pass

    def set_busy(self, key: str, text=None) -> None:
        """v1.13: show (text) or clear (None) a named 'working…' job in the
        activity indicators row.  Thread-safe (no Tk calls here)."""
        try:
            with self._busy_lock:
                if text:
                    self._busy_jobs[key] = str(text)
                else:
                    self._busy_jobs.pop(key, None)
        except Exception:
            pass

    def open_caption_studio(self, topic: str = "") -> None:
        """v1.13: open the Caption Studio window.  Thread-safe — may be called
        from the voice-turn thread."""
        try:
            if self._root:
                self._root.after(0, lambda: self._open_caption_studio(topic))
        except Exception:
            pass

    def open_deliver_studio(self) -> None:
        """v1.14: open the Client Delivery window.  Thread-safe — may be called
        from the voice-turn thread."""
        try:
            if self._root:
                self._root.after(0, self._open_deliver_studio)
        except Exception:
            pass

    def set_memory_status(self, is_active: bool) -> None:
        """Update the Local AI Memory indicator.  Thread-safe (no Tk calls)."""
        self._mem_active = bool(is_active)

    def set_power_mode(self, low_power: bool) -> None:
        """Update the low-power indicator (v1.6).  Thread-safe (no Tk calls)."""
        self._low_power = bool(low_power)

    def _force_close(self) -> None:
        """Destroy tkinter root — called by tray 'Close Wivexx'."""
        try:
            if self._root:
                self._root.after(0, self._root.destroy)
        except Exception:
            pass


# ══════════════════════════════════════════════════════════════════════════════
#  Module-level singleton + public API
# ══════════════════════════════════════════════════════════════════════════════

_gui_instance: Optional[WivexxGUI] = None
_web = None        # v1.20: WebGui instance when GUI_ENGINE == "web" (else None)


def _start_tk_sidecar(config: Optional[dict] = None) -> None:
    """v1.20: a *withdrawn* tkinter root on a background thread that hosts the
    heavy native tools (caption / deliver studios, command / shortcut editors,
    the click-overlay, the media-device prompt) while pywebview owns the main
    thread.  The main window itself is the WebView2 page, not this root.

    Dual event loops (WebView2 main thread + this tk thread) were verified to
    coexist cleanly on Win11.  All tk calls still happen on this thread via
    root.after(...), exactly as before.
    """
    global _gui_instance
    inst = WivexxGUI(config, start_thread=False)
    _gui_instance = inst

    def _run() -> None:
        try:
            root = tk.Tk()
            root.withdraw()                      # never shown — only hosts Toplevels
            try:
                _resolve_fonts(root)
            except Exception:
                pass
            inst._root = root
            root.mainloop()
        except Exception as _e:
            _log_gui_error(_e)

    threading.Thread(target=_run, name="wivexx-tk-sidecar", daemon=True).start()
    for _ in range(60):                          # let the root come up (~3s max)
        if inst._root is not None:
            break
        time.sleep(0.05)


def open_native_tool(name: str) -> None:
    """Open an editor/studio. In web mode these are now in-page panels, so route
    to the WebView2 control room; the old tk editors remain as the fallback when
    no web GUI is running."""
    name = (name or "").lower()
    # web mode: captions/deliver/commands/shortcuts/goals/memory are HTML panels
    if _web is not None:
        panel = {"caption": "captions", "captions": "captions",
        "deliver": "deliver", "commands": "commands",
        "shortcuts": "shortcuts", "goals": "goals",
        "memory": "memory"}.get(name)
        if panel:
            try:
                _web.open_panel(panel)
                return
            except Exception:
                pass
    inst = _gui_instance
    if inst is None or inst._root is None:
        return
    fn = {
    "captions":  lambda: inst._open_caption_studio(""),
    "caption":   lambda: inst._open_caption_studio(""),
    "deliver":   inst._open_deliver_studio,
    "commands":  inst._open_commands_editor,
    "shortcuts": inst._open_shortcuts_editor,
    "goals":     inst._open_goals_manager,
    "memory":    inst._open_memory_manager,
    }.get(name)
    if fn is None:
        return
    try:
        inst._root.after(0, fn)
    except Exception:
        pass


def show_minimized_overlay() -> bool:
    """v1.20 (web engine): show the corner HUD as the minimized form of the
    WebView2 window, hosted by the tk sidecar.  Returns True if it was shown
    (so the caller can hide the main window), False if no sidecar is available
    (caller should fall back to a plain taskbar minimize)."""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return False
    try:
        inst._root.after(0, inst._show_hud)
        return True
    except Exception:
        return False


def hide_minimized_overlay() -> None:
    """Withdraw the corner HUD (web engine)."""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return
    try:
        inst._root.after(0, inst._hide_hud)
    except Exception:
        pass


def feed_overlay_state(state: str) -> None:
    """Keep the corner HUD's animation in sync with the live assistant state.
    In web mode the sidecar's main animation loop isn't running, so the state /
    colour the HUD reads are set here directly (thread-safe: plain attr writes)."""
    inst = _gui_instance
    if inst is None:
        return
    try:
        inst._state = state
        inst._cur_color = list(_STATE_COLORS.get(state, _STATE_COLORS["standby"]))
    except Exception:
        pass


def feed_overlay_level(rms: float) -> None:
    """Feed the live mic level into the corner HUD amplitude (web mode)."""
    inst = _gui_instance
    if inst is None:
        return
    try:
        inst._amp = min(1.0, max(0.0, float(rms) * 5.0))
    except Exception:
        pass


# ── minimalist ball overlay (web mode) — a GENUINELY transparent tk orb ───────
# A windowed WebView2 surface can't be made transparent/shaped on Win11 (DComp
# ignores SetWindowRgn / layered color-key / DWM glass / blur-behind — all verified
# to leave a box). A tk window paints via GDI, so its -transparentcolor works → we
# render the bare orb here (wivexx_ball_overlay.BallOverlay) on the sidecar root.
_ball_overlay = None
# Last-known orb dressing, tracked so the floating orb can be RECONCILED whenever it
# appears. The orb is created lazily and its state is pushed by separate events
# (work mode from refresh_modes, activity from the detect loop, gadget from the
# clicker/recorder) — so if a mode was already on before the orb materialised, or it
# was reshown later, its tie/outfit could be stale. Re-applying this on every show
# fixes "the work-mode animation doesn't show even though it's active".
_ball_state = {"work": None, "low": None, "music": None, "activity": "", "gadget": ""}


def _apply_ball_state() -> None:
    """Push the full tracked dressing onto the live orb (call on show / create)."""
    ov = _ball_overlay
    if ov is None:
        return
    try:
        ov.set_outfit(work=_ball_state["work"], low=_ball_state["low"],
                      music=_ball_state["music"])
        ov.set_activity(_ball_state["activity"])
        if hasattr(ov, "set_gadget"):
            ov.set_gadget(_ball_state["gadget"])
    except Exception:
        pass


def _ball_cb_restore() -> None:
    if _web is not None:
        try:
            _web.restore_from_overlay()
        except Exception:
            pass


def _ball_cb_full() -> None:
    # "Full overlay" → back to the WebView2 card overlay
    if _web is not None:
        try:
            _web.set_overlay_minimal(False)
        except Exception:
            pass


def _ball_cb_hide() -> None:
    hide_ball_overlay()
    if _web is not None:
        try:
            _web.hide_overlay()
        except Exception:
            pass


def _get_ball_overlay():
    global _ball_overlay
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return None
    if _ball_overlay is None:
        try:
            import wivexx_ball_overlay
            _ball_overlay = wivexx_ball_overlay.BallOverlay(
                inst._root, size=190,
                on_restore=_ball_cb_restore, on_full=_ball_cb_full,
                on_hide=_ball_cb_hide, on_close=_force_close)
            _apply_ball_state()          # reflect any mode/activity set before it existed
        except Exception as _e:
            _log_gui_error(_e)
            return None
    return _ball_overlay


def show_ball_overlay(state: str = "idle") -> bool:
    """Show the transparent tk orb (created lazily on the sidecar root thread)."""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return False

    def _do() -> None:
        ov = _get_ball_overlay()
        if ov is not None:
            ov.show(state)
            _apply_ball_state()          # re-assert the current dressing on every show

    try:
        inst._root.after(0, _do)
        return True
    except Exception:
        return False


def hide_ball_overlay() -> None:
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return

    def _do() -> None:
        if _ball_overlay is not None:
            _ball_overlay.hide()

    try:
        inst._root.after(0, _do)
    except Exception:
        pass


# ── top-left activity badge overlay (wivexx_status_overlay) ───────────────────
# A small click-through pill that appears top-left while the auto-clicker is firing
# or an action recording is being recorded/played. Driven by the SAME unified signal
# as the mascot pose (set_gadget → set_status_badge), so no separate detection.
_status_overlay = None


def _get_status_overlay():
    global _status_overlay
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return None
    if _status_overlay is None:
        try:
            import wivexx_status_overlay
            _status_overlay = wivexx_status_overlay.StatusOverlay(inst._root)
        except Exception as _e:
            _log_gui_error(_e)
            return None
    return _status_overlay


def set_status_badge(kind: str) -> None:
    """Show/hide the top-left activity overlay (thread-safe — posts to the tk root).
    kind: "clicker" | "recording" | "playback" | "" (hide). Lazily created the first
    time something is actually active, so an idle session never builds the window."""
    kind = kind or ""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return

    def _do() -> None:
        if not kind and _status_overlay is None:
            return                      # nothing active and nothing to hide
        ov = _get_status_overlay()
        if ov is not None:
            ov.set_kind(kind)

    try:
        inst._root.after(0, _do)
    except Exception:
        pass


# ── transparent in-game performance OSD (wivexx_perf_osd) ─────────────────────
_perf_osd = None


def _get_perf_osd():
    global _perf_osd
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return None
    if _perf_osd is None:
        try:
            import wivexx_perf_osd
            metrics = ["cpu", "gpu", "vram", "ram", "net", "speed"]
            try:                                  # share the HUD's metric choice
                if inst._perf_cfg is None:
                    inst._perf_cfg = inst._load_perf_cfg()
                metrics = inst._perf_cfg.get("metrics", metrics) or metrics
            except Exception:
                pass
            _perf_osd = wivexx_perf_osd.PerfOSD(inst._root, metrics=metrics)
        except Exception as _e:
            _log_gui_error(_e)
            return None
    return _perf_osd


def toggle_perf_osd(show=None) -> None:
    """Show/hide the transparent in-game OSD. Thread-safe (posts to the tk root)."""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return

    def _do() -> None:
        osd = _get_perf_osd()
        if osd is None:
            return
        if show is True:
            osd.show()
        elif show is False:
            osd.hide()
        else:
            osd.toggle()
    try:
        inst._root.after(0, _do)
    except Exception:
        pass


def perf_osd_shown() -> bool:
    try:
        return bool(_perf_osd is not None and _perf_osd.is_shown())
    except Exception:
        return False


def ask_open_media() -> str:
    """Show a native file picker (on the tk thread) for an audio/video file to
    transcribe, and return the chosen path ("" if cancelled). Safe to call from
    any thread — it posts to the tk root and waits for the result."""
    inst = _gui_instance
    if inst is None or getattr(inst, "_root", None) is None:
        return ""
    import queue
    q: "queue.Queue[str]" = queue.Queue()
    root = inst._root

    def _do() -> None:
        chosen = ""
        try:
            import tkinter.filedialog as fd
            import wivexx_transcribe as _wt
            chosen = fd.askopenfilename(
                parent=root,
                title="Wivexx - pick an audio or video file to transcribe",
                filetypes=_wt.media_filetypes()) or ""
        except Exception:
            chosen = ""
        q.put(chosen)

    try:
        root.after(0, _do)
        return q.get(timeout=180)
    except Exception:
        return ""


def feed_ball_state(state: str) -> None:
    """Mirror the assistant state onto the orb (plain attr write, any thread)."""
    if _ball_overlay is not None:
        try:
            _ball_overlay.set_state(_STATE_MAP_BALL.get(
                str(state).strip().lower(), str(state)))
        except Exception:
            pass


def feed_ball_level(rms: float) -> None:
    if _ball_overlay is not None:
        try:
            _ball_overlay.set_level(min(1.0, max(0.0, float(rms))))
        except Exception:
            pass


# assistant-state vocabulary → the 4 visual states the orb understands
_STATE_MAP_BALL = {
"idle": "idle", "standby": "idle", "active": "idle",
"mic_muted": "idle", "audio_muted": "idle",
"listening": "listening",
"processing": "processing", "searching": "processing", "thinking": "processing",
"speaking": "speaking",
}


def start_gui(config: Optional[dict] = None) -> Optional[WivexxGUI]:
    global _gui_instance
    if not (config or {}).get("GUI_ENABLED", True):
        return None
    try:
        _gui_instance = WivexxGUI(config)
        return _gui_instance
    except Exception as _e:
        try:
            import logging
            logging.getLogger("jarvis").warning("wivexx_gui: failed to start: %s", _e)
        except Exception:
            pass
        return None


def log_turn(role: str, text: str, atts: Optional[list] = None) -> None:
    try:
        if _web is not None:
            _web.log_turn(role, text, atts)
        elif _gui_instance:
            _gui_instance.log_turn(role, text, atts)
    except Exception:
        pass


def set_busy(key: str, text=None) -> None:
    try:
        if _web is not None:
            _web.set_busy(key, text)
        elif _gui_instance:
            _gui_instance.set_busy(key, text)
    except Exception:
        pass


def open_caption_studio(topic: str = "") -> None:
    try:
        if _web is not None:
            _web.open_panel("captions")
        elif _gui_instance:
            _gui_instance.open_caption_studio(topic)
    except Exception:
        pass


def transcribe_started(job_id, title: str, sub: str = "") -> None:
    """Show a live 'working…' entry in the activity/notifications tab."""
    try:
        if _web is not None:
            _web.transcribe_started(job_id, title, sub)
    except Exception:
        pass


def transcribe_progress(job_id, percent=None, sub: str = "") -> None:
    try:
        if _web is not None:
            _web.transcribe_progress(job_id, percent, sub)
    except Exception:
        pass


def transcribe_finished(job_id, title: str, sub: str = "",
                        file: str = "", status: str = "done") -> None:
    """Move the entry to history with a clickable 'open transcript' action."""
    try:
        if _web is not None:
            _web.transcribe_finished(job_id, title, sub=sub, file=file, status=status)
    except Exception:
        pass


def open_deliver_studio() -> None:
    try:
        if _web is not None:
            _web.open_panel("deliver")
        elif _gui_instance:
            _gui_instance.open_deliver_studio()
    except Exception:
        pass


def fire_timer_alert(label: str, seconds: int) -> bool:
    """Show the timer-completion alert. Returns True if the web overlay handled
    it (so wivexx_api skips the detached tk popup fallback)."""
    try:
        if _web is not None:
            _web.fire_timer_alert(label, int(seconds))
            return True
    except Exception:
        pass
    return False


def fire_goal_alert(title: str) -> bool:
    """Pop the visible goal-reminder alarm (same beeping popup as timers)."""
    try:
        if _web is not None:
            _web.fire_goal_alert(str(title))
            return True
    except Exception:
        pass
    return False


def set_ball_outfit(work=None, low=None, music=None) -> None:
    """Forward outfit flags to the minimalist ball overlay (tie/cap/headphones)."""
    if work is not None:
        _ball_state["work"] = bool(work)
    if low is not None:
        _ball_state["low"] = bool(low)
    if music is not None:
        _ball_state["music"] = bool(music)
    try:
        if _ball_overlay is not None:
            _ball_overlay.set_outfit(work=work, low=low, music=music)
    except Exception:
        pass


def set_activity(name: str) -> None:
    """Context-aware mascot outfit: "music"|"gaming"|"video"|"coding"|"" — drives
    BOTH faces (control-room dot + minimalist ball)."""
    name = name or ""
    _ball_state["activity"] = name
    try:
        if _web is not None:
            _web.set_activity(name)
    except Exception:
        pass
    try:
        if _ball_overlay is not None:
            _ball_overlay.set_activity(name)
    except Exception:
        pass


def set_music_playing(on: bool) -> None:
    """Back-compat: route the old music flag through the activity system."""
    set_activity("music" if on else "")


def set_gadget(name: str) -> None:
    """App-state mascot pose (separate from activity outfits):
        "recording" (camcorder + eye squint) · "clicker" (frantic mouse) ·
        "playback" (key-ripple keyboard) · "" (clear). Drives the control-room dot,
    the minimalist ball overlay AND the top-left activity badge."""
    name = name or ""
    _ball_state["gadget"] = name
    try:
        if _web is not None:
            _web.set_gadget(name)
    except Exception:
        pass
    try:
        if _ball_overlay is not None and hasattr(_ball_overlay, "set_gadget"):
            _ball_overlay.set_gadget(name)
    except Exception:
        pass
    set_status_badge(name)              # top-left "live" badge follows the same signal


def set_taskbar_hidden(hidden: bool) -> bool:
    """Tray-only mode: hide/show the control room's taskbar button."""
    try:
        if _web is not None:
            return bool(_web.set_taskbar_hidden(hidden))
    except Exception:
        pass
    return bool(hidden)


def is_taskbar_hidden() -> bool:
    try:
        if _web is not None:
            return bool(_web.is_taskbar_hidden())
    except Exception:
        pass
    return False


def set_gui_state(state: str) -> None:
    try:
        if _web is not None:
            _web.set_state(state)
        elif _gui_instance:
            _gui_instance.set_state(state)
    except Exception:
        pass


def push_level(rms: float) -> None:
    try:
        if _web is not None:
            _web.push_level(rms)
        elif _gui_instance:
            _gui_instance.push_level(rms)
    except Exception:
        pass


def set_status(text: str) -> None:
    try:
        if _web is not None:
            _web.set_status(text)
        elif _gui_instance:
            _gui_instance.set_status(text)
    except Exception:
        pass


def toast(text: str) -> None:
    """Show a transient toast in the control room (no-op if the web UI isn't up)."""
    try:
        if _web is not None and hasattr(_web, "toast"):
            _web.toast(str(text))
    except Exception:
        pass


def mascot_react(name: str, hold: float = 0.7) -> None:
    """Play a transient mascot expression on whichever face is showing — the
    minimalist tk ball and/or the control-room WebGL dot (e.g. 'happy' on a
    success, 'suspicious' on an error, 'alert' when you copy something)."""
    if _ball_overlay is not None:
        try:
            _ball_overlay.react(name, hold)
        except Exception:
            pass
    try:
        if _web is not None:
            _web.react(name, hold)
    except Exception:
        pass


def show_main() -> None:
    try:
        if _web is not None:
            _web.show_main()
        elif _gui_instance:
            _gui_instance.show_main()
    except Exception:
        pass


def hide_main() -> None:
    try:
        if _web is not None:
            _web.hide_main()
        elif _gui_instance:
            _gui_instance.hide_main()
    except Exception:
        pass


def set_memory_status(is_active: bool) -> None:
    """Update the Local AI Memory indicator.  Safe to call from any thread.

    If called before run_gui_mainloop() has instantiated the GUI (pre-flight),
    the value is cached in _INITIAL_MEM_STATUS so WivexxGUI.__init__ reads it
    and boots with the correct indicator colour immediately.
    """
    global _INITIAL_MEM_STATUS
    _INITIAL_MEM_STATUS = is_active            # always cache the latest
    try:
        if _web is not None:
            _web.set_memory(is_active)
        elif _gui_instance:
            _gui_instance.set_memory_status(is_active)
    except Exception:
        pass


def set_integrations(integrations: dict) -> None:
    """Receive callbacks from wivexx_api.py (v1.6): apps-registry path + hot
    reload, call-mode toggle, and the hotkey manager module.  Safe to call
    before or after the GUI window exists."""
    try:
        _INTEGRATIONS.update(integrations or {})
    except Exception:
        pass
    try:
        if _web is not None:
            _web.refresh_integrations()
    except Exception:
        pass


def set_power_mode(low_power: bool) -> None:
    """Update the low-power indicator (v1.6). Safe to call from any thread."""
    try:
        if _web is not None:
            _web.set_power(low_power)
        elif _gui_instance:
            _gui_instance.set_power_mode(low_power)
    except Exception:
        pass
    set_ball_outfit(low=bool(low_power))      # sleepy cap on the ball too


def toggle_goals_overlay() -> None:
    """Show/hide the goals overlay (v1.11 hotkey). Thread-safe.
    v1.20 web: a real always-on-top 'today's focus' overlay window."""
    try:
        if _web is not None:
            _web.toggle_goals_overlay()
        elif _gui_instance and _gui_instance._root:
            _gui_instance._root.after(0, _gui_instance._toggle_goals_overlay)
    except Exception:
        pass


def toggle_timer_overlay() -> None:
    """Show/hide the live timer countdown overlay (v1.0 hotkey). Thread-safe;
    web control room only (the countdown widget is a web overlay)."""
    try:
        if _web is not None:
            _web.toggle_timer_overlay()
    except Exception:
        pass


def toggle_status_overlay(show=None) -> None:
    """Show/hide the v1.16 status overlay. Thread-safe.
    v1.20 web: a real always-on-top status widget window."""
    try:
        if _web is not None:
            _web.toggle_status_overlay(show)
        elif _gui_instance:
            _gui_instance.toggle_status_overlay(show)
    except Exception:
        pass


def toggle_perf_hud(show=None) -> None:
    """Show/hide the v1.30 Performance HUD (live hardware stats). Thread-safe.
    Prefers the WebView2 overlay (control-room styled) when the web GUI is up;
    falls back to the tk HUD otherwise."""
    if _web is not None and hasattr(_web, "toggle_perf_overlay"):
        try:
            _web.toggle_perf_overlay(show)
            return
        except Exception:
            pass
    inst = _gui_instance
    if inst is not None and hasattr(inst, "toggle_perf_hud"):
        try:
            inst.toggle_perf_hud(show)
        except Exception:
            pass


def perf_hud_visible() -> bool:
    if _web is not None and hasattr(_web, "perf_overlay_visible"):
        try:
            return bool(_web.perf_overlay_visible())
        except Exception:
            pass
    inst = _gui_instance
    try:
        return bool(inst is not None and inst._perf_hud is not None
                    and inst._perf_hud.winfo_exists())
    except Exception:
        return False


def show_click_overlay(items) -> None:
    """Draw the v1.17 numbered click overlay. Thread-safe."""
    try:
        if _gui_instance:
            _gui_instance.show_click_overlay(items)
    except Exception:
        pass


def hide_click_overlay() -> None:
    try:
        if _gui_instance:
            _gui_instance.hide_click_overlay()
    except Exception:
        pass


def prompt_media_device(dev: dict, on_result) -> None:
    """Show the new-storage Always/Never/Later dialog (v1.7).
    Thread-safe: marshals onto the Tk thread. If the GUI isn't available the
    callback gets None so the watcher simply asks again next time."""
    try:
        if _gui_instance and _gui_instance._root:
            _gui_instance._root.after(
                0, lambda: _gui_instance._show_media_prompt(dev, on_result))
            return
    except Exception:
        pass
    try:
        on_result(None)
    except Exception:
        pass


def _force_close() -> None:
    # tray 'Close Wivexx' → tear down both the web window and the tk sidecar.
    try:
        if _web is not None:
            _web.destroy()
    except Exception:
        pass
    try:
        if _gui_instance:
            _gui_instance._force_close()
    except Exception:
        pass


# ── Error logging helper ───────────────────────────────────────────────────────

def _log_gui_error(exc: Exception) -> None:
    """Write a GUI startup exception to wivexx_gui_error.log and show a msgbox."""
    import traceback
    log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
    "wivexx_gui_error.log")
    try:
        with open(log_path, "w", encoding="utf-8") as _f:
            traceback.print_exc(file=_f)
            _f.write(f"\n{exc}\n")
    except Exception:
        pass
    try:
        import tkinter.messagebox as _mb
        import tkinter as _tk
        _r = _tk.Tk()
        _r.withdraw()
        _mb.showerror("Wivexx GUI Error",
                      f"GUI failed to start:\n{exc}\n\nSee wivexx_gui_error.log")
        _r.destroy()
    except Exception:
        pass


# ── Main-thread GUI entry point ────────────────────────────────────────────────

def run_gui_mainloop(config: Optional[dict] = None) -> None:
    """Create the GUI and run mainloop on the CALLING (main) thread.

    This is the correct entry point on Windows.  tkinter must be created and
    driven from the process main thread; spawning it in a daemon thread causes
    silent failure or a window that never renders.

    Blocks until the window is destroyed (user quits via tray / right-click menu).

    v1.20: when GUI_ENGINE == "web" (default), the visible window is the Edge
    WebView2 page (ui/) driven by wivexx_webgui.WebGui — pywebview.start() blocks
    this (main) thread. The heavy native tools run on a withdrawn tk sidecar
    thread. Falls back to the legacy tkinter window if WebView2 is unavailable.
    """
    global _gui_instance, _web
    cfg = config or {}
    if not cfg.get("GUI_ENABLED", True):
        return

    engine = str(cfg.get("GUI_ENGINE", "web")).lower()
    if engine == "web":
        webmod = None
        try:
            import wivexx_webgui as webmod
        except Exception as _e:
            _log_gui_error(_e)
        if webmod is not None and webmod.available():
            try:
                _start_tk_sidecar(cfg)            # hosts studios/editors/overlays
                web = webmod.WebGui(cfg)
                web._last_mem = _INITIAL_MEM_STATUS
                _web = web
                # Restoring from the corner HUD must show the WebView2 window,
                # not the (hidden) tk sidecar root.
                if _gui_instance is not None:
                    _gui_instance._restore_override = web.show_main
                web.run()                          # creates window + webview.start()
            except Exception as _e:
                _log_gui_error(_e)
            return                                 # committed to web — never also tk
        # WebView2 unavailable → fall through to the legacy tk window

    try:
        # Build instance WITHOUT spawning a background thread
        inst = WivexxGUI(cfg, start_thread=False)
        _gui_instance = inst
        # Build window and block on mainloop -- all on this (main) thread
        inst._build_main_window()
        inst._root.mainloop()
    except Exception as _e:
        _log_gui_error(_e)
