(function () {
  // app-ws.js (the /app/api → /app/ws fetch proxy) must load in EVERY document
  // that includes widget.js — in shell mode the workspace runs inside the
  // iframe, where the early return below would skip it, leaving the user's
  // /app/api calls as raw tunnel POSTs. app-ws.js guards itself
  // (window.__appWs), so duplicate loads are no-ops.
  var awsScript = document.createElement('script');
  awsScript.src = '/bloby/app-ws.js';
  document.head.appendChild(awsScript);

  // Shell mode: the immortal shell loads this script at top level, but legacy
  // workspace index.html + the interstitial pages still reference it too, and
  // those now render inside the shell's iframe — inside any iframe this must
  // no-op so exactly one bubble exists. Cross-origin window.top access throws,
  // which also means we're not the top window.
  try { if (window.top !== window) return; } catch (e) { return; }
  if (document.getElementById('bloby-widget')) return;

  // Perf marks land in the shell's beacon (see shell.ts) — no-op when the collector is absent.
  function pmark(name) { try { if (window.__blobyPerf) window.__blobyPerf.mark(name); } catch (e) {} }
  pmark('widget:start');

  var PANEL_WIDTH = '480px';

  // ── Styles ──
  var style = document.createElement('style');
  style.textContent = [
    '#bloby-widget-bubble{-webkit-tap-highlight-color:transparent}',
    '#bloby-widget-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.4);opacity:0;transition:opacity .2s ease;pointer-events:none}',
    '#bloby-widget-backdrop.open{opacity:1;pointer-events:auto}',
    '#bloby-widget-panel{position:fixed;top:0;right:0;bottom:0;z-index:99999;width:' + PANEL_WIDTH + ';max-width:100vw;transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1);box-shadow:-4px 0 24px rgba(0,0,0,0.3);border-left:1px solid #3a3a3a;overflow:hidden}',
    '#bloby-widget-panel.open{transform:translateX(0)}',
    '#bloby-widget-panel iframe{width:100%;height:100%;border:none;background:#212121}',
    '@media(max-width:480px){#bloby-widget-panel{width:100vw}}',
    '@keyframes bloby-bubble-record{0%,100%{transform:scale(1.2) translateY(-12px)}50%{transform:scale(1.25) translateY(-14px)}}',
  ].join('\n');
  document.head.appendChild(style);

  // ── Backdrop ──
  var backdrop = document.createElement('div');
  backdrop.id = 'bloby-widget-backdrop';
  document.body.appendChild(backdrop);

  // ── Panel ──
  var panel = document.createElement('div');
  panel.id = 'bloby-widget-panel';
  document.body.appendChild(panel);

  // The chat app is ~1 MB of JS — loading it at parse time competed head-to-head with the
  // workspace module waterfall on the same (often residential) uplink while the panel was
  // closed. The iframe element exists from the start (layout, contentWindow targets) but gets
  // its src lazily: on first open, when the workspace signals ready (+idle), or a hard 8s
  // backstop — the chat is the user's lifeline and must always self-load eventually.
  // Messages destined for the chat queue until its document is actually loaded.
  var iframe = document.createElement('iframe');
  panel.appendChild(iframe);
  var chatRequested = false;
  var chatReady = false;
  var chatQueue = [];

  function ensureChatLoaded() {
    if (chatRequested) return;
    chatRequested = true;
    pmark('widget:chat-requested');
    iframe.addEventListener('load', function () {
      chatReady = true;
      pmark('widget:chat-loaded');
      for (var i = 0; i < chatQueue.length; i++) {
        try { iframe.contentWindow.postMessage(chatQueue[i], location.origin); } catch (e) {}
      }
      chatQueue = [];
    });
    iframe.src = '/bloby/';
  }

  function postToChat(msg) {
    if (chatReady && iframe.contentWindow) {
      iframe.contentWindow.postMessage(msg, location.origin);
    } else {
      chatQueue.push(msg);
      ensureChatLoaded();
    }
  }

  function scheduleChatLoad() {
    if (chatRequested) return;
    if (typeof requestIdleCallback === 'function') requestIdleCallback(ensureChatLoaded, { timeout: 4000 });
    else setTimeout(ensureChatLoaded, 1200);
  }
  window.addEventListener('bloby:app-ready', scheduleChatLoad);
  if (window.__blobyAppReady) scheduleChatLoad();
  setTimeout(ensureChatLoaded, 8000); // lifeline backstop: load even if the workspace never does

  // ══════════════════════════════════════════════════════════════════
  // ── Blob Sprite Sheet Config (splash only) ───────────────────────
  // Loaded from /morphy/teleporting.json. Clips map to animStates:
  //   idle (hold)      → animState 'idle'
  //   enter (forward)  → animState 'melting'
  //   active (pingpong)→ animState 'traveling'
  //   exit (forward)   → animState 'reforming'
  // ══════════════════════════════════════════════════════════════════

  var BLOB_CONFIG_URL = '/morphy/teleporting.json';
  var BLOB_ASSETS_DIR = '/morphy/';

  // Defaults mirror the teleporting.json shipped at the URL above so
  // animation works even if the fetch is cached at the wrong revision.
  var COLS = 16;
  var FRAME_W = 218;
  var FRAME_H = 180;
  var DISPLAY_H = 58;
  var DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);

  var IDLE_START = 19, IDLE_END = 19;
  var MELT_START = 20, MELT_END = 45;
  var TRAVEL_START = 46, TRAVEL_END = 68;
  var REFORM_START = 69, REFORM_END = 121;

  var FPS = 24;
  var FRAME_MS = 1000 / FPS;
  var IDLE_FPS = 24;
  var IDLE_FRAME_MS = 1000 / IDLE_FPS;
  var REFORM_FPS = 24;
  var REFORM_FRAME_MS = 1000 / REFORM_FPS;

  var TRAVEL_PX_PER_MS = 0.65;
  var TRAVEL_MIN = 385;
  var TRAVEL_MAX = 1150;
  var MIN_SPLASH_MS = 2000;
  var BUBBLE_SIZE = 60;
  var BUBBLE_MARGIN = 24;

  // ── Badge element ──
  var badgeEl = document.createElement('div');
  badgeEl.id = 'bloby-widget-badge';
  badgeEl.style.cssText = 'display:none;position:fixed;bottom:' + (BUBBLE_MARGIN + BUBBLE_SIZE - 29) + 'px;right:' + (BUBBLE_MARGIN + 10) + 'px;z-index:99999;min-width:20px;height:20px;border-radius:10px;background:#F04D68;color:#fff;font:bold 11px -apple-system,BlinkMacSystemFont,sans-serif;text-align:center;line-height:20px;padding:0 5px;box-sizing:border-box;pointer-events:none;';
  document.body.appendChild(badgeEl);

  // ══════════════════════════════════════════════════════════════════
  // ── Headphones Sprite Sheet Config (loaded from JSON) ────────────
  // Clips map to hpStates:
  //   idle (hold)      → hpState 'idle'
  //   enter (forward)  → hpState 'activating'
  //   active (pingpong)→ hpState 'recording'
  //   exit (forward)   → hpState 'deactivating'
  // ══════════════════════════════════════════════════════════════════

  var HP_CONFIG_URL = '/morphy/headphones.json';

  // Defaults mirror the shipped JSON so animation works even if fetch is cached.
  var HP_COLS = 16;
  var HP_FRAME_W = 145;
  var HP_FRAME_H = 150;
  var HP_BUBBLE_H = 40;
  var HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H);

  var HP_IDLE_FRAME = 15;
  var HP_ACTIVATE_START = 16, HP_ACTIVATE_END = 25;
  var HP_RECORD_START = 26, HP_RECORD_END = 94;
  var HP_EXIT_START = 96, HP_EXIT_END = 105;

  var HP_BASE_FPS = 24;       // active / record default
  var HP_ACTIVATE_FPS = 48;   // enter clip fps
  var HP_EXIT_FPS = 24;       // exit clip fps

  // Long-press threshold
  var HP_HOLD_MS = 500;

  // ── Bubble CSS (shared) ──
  var BUBBLE_CSS = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;-webkit-tap-highlight-color:transparent;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;';

  // ── Easing ──
  function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }

  // ── Splash guard ──
  var SPLASH_KEY = 'bloby_splash_played';
  var splashSeen = false;
  try { splashSeen = sessionStorage.getItem(SPLASH_KEY) === '1'; } catch(e) {}

  // ── Canvas setup ──
  var canvas = document.createElement('canvas');
  canvas.id = 'bloby-widget-bubble';
  canvas.setAttribute('role', 'button');
  canvas.setAttribute('aria-label', 'Open Bloby chat');
  canvas.dataset.blobyWidget = '1';
  canvas.style.cssText = 'position:fixed;inset:0;width:100vw;height:100dvh;z-index:9999;pointer-events:none;';
  document.body.appendChild(canvas);

  var bubble = canvas;
  var ctx = canvas.getContext('2d');
  var dpr = window.devicePixelRatio || 1;
  var W = 0, H = 0;

  // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'disabled' ──
  var canvasPhase = 'splash';
  var canvasCreatedAt = Date.now();
  var appReady = false;
  var hideAfterTransition = false;
  var skipSplash = false;

  function resizeCanvas() {
    if (canvasPhase === 'bubble') {
      W = BUBBLE_SIZE; H = BUBBLE_SIZE;
    } else {
      W = window.innerWidth; H = window.innerHeight;
    }
    canvas.width = W * dpr;
    canvas.height = H * dpr;
    canvas.style.width = W + 'px';
    canvas.style.height = H + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resizeCanvas();
  window.addEventListener('resize', function () {
    resizeCanvas();
    if (canvasPhase === 'splash' && animState === 'idle') {
      center.x = Math.round(W / 2);
      center.y = Math.round(H / 2);
    }
  });

  // ── Blob animation state (splash only) ──
  var spriteSheet = null;
  var animState = 'loading';
  var currentFrame = 0;
  var idleDirection = 1;
  var travelFrameDir = 1;
  var lastFrameTime = 0;
  var travelDuration = 0;
  var travelStartTime = 0;
  var travelAngle = 0;
  var travelDrawAngle = 0;
  var travelFlip = 1;
  var center  = { x: 0, y: 0 };
  var travelA = { x: 0, y: 0 };
  var target  = { x: 0, y: 0 };

  // ── Headphones animation state (bubble) ──
  var hpSpriteSheet = null;
  // idle | activating | activating_then_deactivate | recording | deactivating
  var hpState = 'idle';
  var hpFrame = HP_IDLE_FRAME;
  var hpPingPong = 1;
  var hpLastFrameTime = 0;

  // ── Unread badge ──
  var unreadCount = 0;

  // ── Mic recording state ──
  var hpMediaRecorder = null;
  var hpAudioChunks = [];
  var hpStream = null;
  var hpPointerDown = false;
  var hpHoldTimer = null;
  var hpWasHold = false;
  var hpMicPermitted = false;
  var hpWhisperEnabled = false;
  var hpWebSpeechCtor = window.SpeechRecognition || window.webkitSpeechRecognition || null;
  var hpSpeechInstance = null;
  var hpSpeechTranscript = '';

  // ── Load blob sprite sheet (config + image) ──
  function applyBlobConfig(cfg) {
    COLS = cfg.grid.cols;
    FRAME_W = cfg.frame.w;
    FRAME_H = cfg.frame.h;
    DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
    IDLE_START = cfg.clips.idle.from;   IDLE_END = cfg.clips.idle.to;
    MELT_START = cfg.clips.enter.from;  MELT_END = cfg.clips.enter.to;
    TRAVEL_START = cfg.clips.active.from; TRAVEL_END = cfg.clips.active.to;
    REFORM_START = cfg.clips.exit.from; REFORM_END = cfg.clips.exit.to;
    FPS = cfg.fps; FRAME_MS = 1000 / FPS;
    IDLE_FPS = cfg.fps; IDLE_FRAME_MS = 1000 / IDLE_FPS;
    REFORM_FPS = cfg.fps; REFORM_FRAME_MS = 1000 / REFORM_FPS;
  }

  function loadSprite(onDone, onFail) {
    fetch(BLOB_CONFIG_URL)
      .then(function (r) { if (!r.ok) throw new Error('blob config ' + r.status); return r.json(); })
      .then(function (cfg) {
        applyBlobConfig(cfg);
        pmark('widget:sprite-config');
        var img = new Image();
        img.onload = function () {
          pmark('widget:sprite-sheet');
          spriteSheet = img;
          center.x = Math.round(W / 2);
          center.y = Math.round(H / 2);
          currentFrame = IDLE_START;
          animState = 'idle';
          var splash = document.getElementById('splash');
          if (splash) splash.style.display = 'none';
          onDone();
        };
        img.onerror = function () { if (onFail) onFail(); };
        img.src = BLOB_ASSETS_DIR + cfg.spritesheet;
      })
      .catch(function () { if (onFail) onFail(); });
  }

  // ── Load headphones sprite sheet (config + image) ──
  function applyHpConfig(cfg) {
    HP_COLS = cfg.grid.cols;
    HP_FRAME_W = cfg.frame.w;
    HP_FRAME_H = cfg.frame.h;
    HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H);
    HP_IDLE_FRAME = cfg.clips.idle.from;
    HP_ACTIVATE_START = cfg.clips.enter.from; HP_ACTIVATE_END = cfg.clips.enter.to;
    HP_RECORD_START = cfg.clips.active.from;  HP_RECORD_END = cfg.clips.active.to;
    HP_EXIT_START = cfg.clips.exit.from;      HP_EXIT_END = cfg.clips.exit.to;
    HP_BASE_FPS = (cfg.clips.active.fps != null ? cfg.clips.active.fps : cfg.fps);
    HP_ACTIVATE_FPS = (cfg.clips.enter.fps  != null ? cfg.clips.enter.fps  : cfg.fps);
    HP_EXIT_FPS = (cfg.clips.exit.fps   != null ? cfg.clips.exit.fps   : cfg.fps);
  }

  function loadHpSprite(cb) {
    if (hpSpriteSheet) { if (cb) cb(); return; }
    fetch(HP_CONFIG_URL)
      .then(function (r) { if (!r.ok) throw new Error('hp config ' + r.status); return r.json(); })
      .then(function (cfg) {
        applyHpConfig(cfg);
        var img = new Image();
        img.onload = function () { hpSpriteSheet = img; hpFrame = HP_IDLE_FRAME; if (cb) cb(); };
        img.onerror = function () { console.error('[widget] headphones sprite failed'); };
        img.src = BLOB_ASSETS_DIR + cfg.spritesheet;
      })
      .catch(function (err) { console.error('[widget] headphones config failed', err); });
  }

  // The bubble at rest is a single frame, but the full headphones sheet is ~1 MB — so the
  // sheet now loads on demand (first long-press → beginHpAnimation already fetches it) and
  // bubble mode starts with this few-KB single-frame image instead.
  var hpIdleImg = null;
  function loadHpIdle() {
    if (hpIdleImg || hpSpriteSheet) return;
    var img = new Image();
    img.onload = function () { hpIdleImg = img; };
    img.onerror = function () { console.error('[widget] headphones idle frame failed'); };
    img.src = BLOB_ASSETS_DIR + 'headphones-idle.webp';
  }

  // ── Trigger blob move (splash) ──
  function moveTo(tx, ty) {
    if (animState !== 'idle') return;
    target.x = tx; target.y = ty;
    travelA.x = center.x; travelA.y = center.y;
    var dx = target.x - center.x, dy = target.y - center.y;
    var dist = Math.sqrt(dx * dx + dy * dy);
    travelAngle = Math.atan2(dy, dx);
    travelDrawAngle = travelAngle;
    travelFlip = 1;
    if (travelDrawAngle > Math.PI / 2) { travelDrawAngle -= Math.PI; travelFlip = -1; }
    else if (travelDrawAngle < -Math.PI / 2) { travelDrawAngle += Math.PI; travelFlip = -1; }
    travelDuration = Math.min(TRAVEL_MAX, Math.max(TRAVEL_MIN, dist / TRAVEL_PX_PER_MS));
    currentFrame = MELT_START;
    lastFrameTime = performance.now();
    animState = 'melting';
  }

  // ══════════════════════════════════════════════════════════════════
  // ── Update ───────────────────────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  function hpFps() {
    if (hpState === 'activating' || hpState === 'activating_then_deactivate') return HP_ACTIVATE_FPS;
    if (hpState === 'deactivating') return HP_EXIT_FPS;
    return HP_BASE_FPS;
  }

  function update(now) {
    // ── Headphones animation (bubble mode) ──
    if (canvasPhase === 'bubble' && hpState !== 'idle') {
      var hpInterval = 1000 / hpFps();
      var hpDelta = now - hpLastFrameTime;
      if (hpDelta >= hpInterval) {
        hpLastFrameTime = now - (hpDelta % hpInterval);

        if (hpState === 'activating' || hpState === 'activating_then_deactivate') {
          hpFrame++;
          if (hpFrame > HP_ACTIVATE_END) {
            if (hpState === 'activating_then_deactivate') {
              hpState = 'deactivating';
              hpFrame = HP_EXIT_START;
            } else {
              hpState = 'recording';
              hpFrame = HP_RECORD_START;
              hpPingPong = 1;
            }
          }
        } else if (hpState === 'recording') {
          hpFrame += hpPingPong;
          if (hpFrame >= HP_RECORD_END) { hpFrame = HP_RECORD_END; hpPingPong = -1; }
          else if (hpFrame <= HP_RECORD_START) { hpFrame = HP_RECORD_START; hpPingPong = 1; }
        } else if (hpState === 'deactivating') {
          hpFrame++;
          if (hpFrame > HP_EXIT_END) {
            hpState = 'idle';
            hpFrame = HP_IDLE_FRAME;
          }
        }
      }
      return; // skip blob update
    }

    // ── Blob animation (splash/transition only) ──
    if (animState === 'traveling') {
      var elapsed = now - travelStartTime;
      var progress = Math.min(1, elapsed / travelDuration);
      var e = easeInOutCubic(progress);
      center.x = travelA.x + (target.x - travelA.x) * e;
      center.y = travelA.y + (target.y - travelA.y) * e;
      if (progress >= 1) {
        center.x = target.x; center.y = target.y;
        animState = 'reforming';
        currentFrame = REFORM_START;
        lastFrameTime = now;
        return;
      }
    }

    var frameInterval = animState === 'idle' ? IDLE_FRAME_MS
                      : animState === 'reforming' ? REFORM_FRAME_MS
                      : FRAME_MS;
    var frameDelta = now - lastFrameTime;
    if (frameDelta < frameInterval) return;
    lastFrameTime = now - (frameDelta % frameInterval);

    if (animState === 'idle') {
      currentFrame += idleDirection;
      if (currentFrame >= IDLE_END) { currentFrame = IDLE_END; idleDirection = -1; }
      else if (currentFrame <= IDLE_START) { currentFrame = IDLE_START; idleDirection = 1; }
    } else if (animState === 'melting') {
      currentFrame++;
      if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; travelFrameDir = 1; }
    } else if (animState === 'traveling') {
      currentFrame += travelFrameDir;
      if (currentFrame >= TRAVEL_END) { currentFrame = TRAVEL_END; travelFrameDir = -1; }
      else if (currentFrame <= TRAVEL_START) { currentFrame = TRAVEL_START; travelFrameDir = 1; }
    } else if (animState === 'reforming') {
      currentFrame++;
      if (currentFrame > REFORM_END) {
        animState = 'idle';
        currentFrame = IDLE_START;
        idleDirection = 1;
        if (canvasPhase === 'transitioning') {
          enterBubbleMode();
        }
      }
    }
  }

  // ══════════════════════════════════════════════════════════════════
  // ── Draw ─────────────────────────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  function drawBlobFrame(frame, x, y, rotate, flipX) {
    if (!spriteSheet) return;
    var col = frame % COLS, row = Math.floor(frame / COLS);
    ctx.save();
    ctx.translate(x, y);
    if (rotate) ctx.rotate(rotate);
    if (flipX === -1) ctx.scale(-1, 1);
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(spriteSheet, col * FRAME_W, row * FRAME_H, FRAME_W, FRAME_H, -DISPLAY_W / 2, -DISPLAY_H / 2, DISPLAY_W, DISPLAY_H);
    ctx.restore();
  }

  function drawHpFrame(frame) {
    var dx = (W - HP_BUBBLE_W) / 2, dy = (H - HP_BUBBLE_H) / 2;
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    if (!hpSpriteSheet) {
      // Sheet not loaded yet (deferred until the first long-press) — the only frame ever
      // requested in that window is idle, which the standalone image covers.
      if (hpIdleImg) ctx.drawImage(hpIdleImg, dx, dy, HP_BUBBLE_W, HP_BUBBLE_H);
      return;
    }
    var col = frame % HP_COLS, row = Math.floor(frame / HP_COLS);
    ctx.drawImage(hpSpriteSheet, col * HP_FRAME_W, row * HP_FRAME_H, HP_FRAME_W, HP_FRAME_H, dx, dy, HP_BUBBLE_W, HP_BUBBLE_H);
  }

  function updateBadge() {
    if (unreadCount <= 0 || canvasPhase !== 'bubble' || isOpen) {
      badgeEl.style.display = 'none';
      return;
    }
    badgeEl.textContent = unreadCount > 9 ? '9+' : String(unreadCount);
    badgeEl.style.display = 'block';
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);

    // Bubble mode → headphones sprite
    if (canvasPhase === 'bubble') {
      drawHpFrame(hpFrame);
      return;
    }

    // Splash / transition → blob sprite
    if (!spriteSheet || animState === 'loading') return;
    if (animState === 'melting') {
      var p = (currentFrame - MELT_START) / (MELT_END - MELT_START);
      drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * p * p, travelFlip);
    } else if (animState === 'traveling') {
      drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle, travelFlip);
    } else if (animState === 'reforming') {
      var p2 = (currentFrame - REFORM_START) / (REFORM_END - REFORM_START);
      drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - (1 - (1 - p2) * (1 - p2))));
    } else {
      drawBlobFrame(currentFrame, center.x, center.y);
    }
  }

  // ── Animation loop ──
  function loop(now) {
    if (animState !== 'loading' || canvasPhase === 'bubble') {
      update(now);
      draw();
    }
    requestAnimationFrame(loop);
  }

  // ══════════════════════════════════════════════════════════════════
  // ── Bubble Mode ──────────────────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  function enterBubbleMode() {
    canvasPhase = 'bubble';
    canvas.style.cssText = BUBBLE_CSS;
    W = BUBBLE_SIZE; H = BUBBLE_SIZE;
    canvas.width = BUBBLE_SIZE * dpr;
    canvas.height = BUBBLE_SIZE * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    hpState = 'idle';
    hpFrame = HP_IDLE_FRAME;
    try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
    pmark('widget:bubble');
    loadHpIdle(); // full sheet defers to the first long-press (beginHpAnimation loads it)
    if (hideAfterTransition) canvas.style.display = 'none';
  }

  // ══════════════════════════════════════════════════════════════════
  // ── Mic Recording ────────────────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  function checkMicPermission() {
    if (hpMicPermitted) return Promise.resolve('granted');
    if (navigator.permissions && navigator.permissions.query) {
      return navigator.permissions.query({ name: 'microphone' }).then(function(s) {
        if (s.state === 'granted') { hpMicPermitted = true; return 'granted'; }
        return s.state;
      }).catch(function() { return 'unknown'; });
    }
    return Promise.resolve('unknown');
  }

  function startMicCapture() {
    if (hpWhisperEnabled) {
      navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
        if (!hpPointerDown) {
          stream.getTracks().forEach(function(t) { t.stop(); });
          if (hpState === 'activating') hpState = 'activating_then_deactivate';
          return;
        }
        hpStream = stream;
        var mimeType = (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
          ? 'audio/webm;codecs=opus' : 'audio/webm';
        var recorder = new MediaRecorder(stream, { mimeType: mimeType });
        hpAudioChunks = [];
        recorder.ondataavailable = function(ev) { if (ev.data.size > 0) hpAudioChunks.push(ev.data); };
        hpMediaRecorder = recorder;
        recorder.start();
      }).catch(function(err) {
        console.error('[widget] mic start failed:', err);
        if (hpState === 'activating') hpState = 'activating_then_deactivate';
      });
    } else if (hpWebSpeechCtor) {
      var recognition = new hpWebSpeechCtor();
      recognition.continuous = false;
      recognition.interimResults = true;
      recognition.lang = navigator.language || 'en-US';
      hpSpeechTranscript = '';
      hpSpeechInstance = recognition;
      recognition.onresult = function(event) { hpSpeechTranscript = event.results[event.results.length - 1][0].transcript; };
      recognition.onend = function() { hpSpeechInstance = null; };
      recognition.onerror = function(ev) { console.error('[widget] speech error:', ev.error); hpSpeechInstance = null; };
      try { recognition.start(); } catch(e) {}
    }
  }

  function startHpRecording() {
    if (!hpWhisperEnabled && !hpWebSpeechCtor) return;

    checkMicPermission().then(function(permState) {
      if (permState === 'denied') return;
      if (permState === 'prompt' || permState === 'unknown') {
        navigator.mediaDevices.getUserMedia({ audio: true }).then(function(testStream) {
          testStream.getTracks().forEach(function(t) { t.stop(); });
          hpMicPermitted = true;
          if (!hpPointerDown) return;
          beginHpAnimation();
        }).catch(function() {});
        return;
      }
      beginHpAnimation();
    });
  }

  function beginHpAnimation() {
    if (!hpPointerDown) return;
    if (!hpSpriteSheet) {
      loadHpSprite(function() { if (hpPointerDown) beginHpAnimation(); });
      return;
    }
    try { navigator.vibrate(50); } catch(e) {}
    badgeEl.style.display = 'none';
    // Grow + shift up, then start pulsating after the transition
    canvas.style.transition = 'transform 0.2s ease';
    canvas.style.transform = 'scale(1.2) translateY(-12px)';
    setTimeout(function() {
      if (hpState === 'activating' || hpState === 'recording') {
        canvas.style.transition = 'none';
        canvas.style.animation = 'bloby-bubble-record 2s ease-in-out infinite';
      }
    }, 220);
    hpState = 'activating';
    hpFrame = HP_ACTIVATE_START;
    hpPingPong = 1;
    hpLastFrameTime = performance.now();
    startMicCapture();
  }

  function stopHpRecording(cancelled) {
    if (cancelled) {
      if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
      if (hpMediaRecorder) { try { hpMediaRecorder.stop(); } catch(e) {} hpMediaRecorder = null; }
      hpAudioChunks = [];
      if (hpSpeechInstance) { try { hpSpeechInstance.abort(); } catch(e) {} hpSpeechInstance = null; }
      hpSpeechTranscript = '';
    } else {
      if (hpWhisperEnabled && hpMediaRecorder && hpMediaRecorder.state !== 'inactive') {
        var recorder = hpMediaRecorder;
        var stream = hpStream;
        recorder.onstop = function() {
          if (stream) stream.getTracks().forEach(function(t) { t.stop(); });
          var blob = new Blob(hpAudioChunks, { type: 'audio/webm' });
          hpAudioChunks = []; hpMediaRecorder = null; hpStream = null;
          if (blob.size < 1000) return;
          var reader = new FileReader();
          reader.onloadend = function() {
            var base64 = reader.result.split(',')[1];
            if (base64) postToChat({ type: 'bloby:voice-record', audio: base64 });
          };
          reader.readAsDataURL(blob);
        };
        recorder.stop();
      } else if (hpSpeechInstance) {
        var instance = hpSpeechInstance;
        var sent = false;
        var sendTranscript = function() {
          if (sent) return; sent = true;
          var text = hpSpeechTranscript.trim();
          hpSpeechTranscript = '';
          if (text) postToChat({ type: 'bloby:voice-record', transcript: text });
        };
        instance.onend = function() { hpSpeechInstance = null; sendTranscript(); };
        try { instance.stop(); } catch(e) { hpSpeechInstance = null; sendTranscript(); }
        setTimeout(function() { if (hpSpeechInstance === instance) hpSpeechInstance = null; sendTranscript(); }, 2000);
      } else {
        if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
      }
    }

    // Shrink back smoothly
    canvas.style.animation = '';
    canvas.style.transition = 'transform 0.2s ease';
    canvas.style.transform = '';

    if (hpState === 'activating') {
      hpState = 'activating_then_deactivate';
    } else if (hpState === 'recording') {
      hpState = 'deactivating';
      hpFrame = HP_EXIT_START;
      hpLastFrameTime = performance.now();
    }
  }

  // ══════════════════════════════════════════════════════════════════
  // ── App-ready Coordination ───────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  function maybeTransition() {
    if (!appReady || animState !== 'idle' || canvasPhase !== 'splash') return;
    var elapsed = Date.now() - canvasCreatedAt;
    if (elapsed < MIN_SPLASH_MS) { setTimeout(maybeTransition, MIN_SPLASH_MS - elapsed); return; }
    canvasPhase = 'transitioning';
    var tx = W - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
    var ty = H - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
    moveTo(tx, ty);
  }

  function skipToBubble() {
    canvasPhase = 'bubble';
    canvas.style.cssText = BUBBLE_CSS;
    W = BUBBLE_SIZE; H = BUBBLE_SIZE;
    canvas.width = BUBBLE_SIZE * dpr;
    canvas.height = BUBBLE_SIZE * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    hpState = 'idle';
    hpFrame = HP_IDLE_FRAME;
    animState = 'idle';
    var splash = document.getElementById('splash');
    if (splash) splash.style.display = 'none';
    pmark('widget:bubble');
    loadHpIdle(); // full sheet defers to the first long-press (beginHpAnimation loads it)
  }

  window.addEventListener('bloby:app-ready', function () { appReady = true; maybeTransition(); });
  if (window.__blobyAppReady) appReady = true;

  var onboardActive = false;

  // ── Boot sequence ─────────────────────────────────────────────────
  // Warm refresh (splash already played this session — known synchronously from
  // sessionStorage): jump straight to bubble mode with the tiny idle frame. No settings RTT,
  // no 500 KB splash sheet — the old code downloaded the full sheet behind TWO serial fetches
  // (settings → sprite config → sheet) just to skip past it.
  var loopStarted = false;
  function startLoop() {
    if (loopStarted) return;
    loopStarted = true;
    requestAnimationFrame(loop);
  }

  if (splashSeen) {
    skipSplash = true;
    skipToBubble();
    startLoop();
  }

  var settingsDone = false;
  var spriteOutcome = 'pending'; // cold path: 'pending' | 'ok' | 'fail'

  function maybeStart() { // cold path only — runs once both settings and the sprite settled
    if (splashSeen || !settingsDone || spriteOutcome === 'pending') return;
    if (spriteOutcome === 'fail') { canvas.style.display = 'none'; canvasPhase = 'disabled'; return; }
    if (skipSplash) { skipToBubble(); if (onboardActive) canvas.style.display = 'none'; }
    startLoop();
    maybeTransition();
  }

  // Cold load: the sprite config + sheet start downloading IMMEDIATELY, in parallel with
  // /api/settings — settings only decides flags (onboarding hides the canvas, whisper, skip).
  // The draw loop starts the moment the sprite is ready (loadSprite hides the splash div as a
  // side effect, so waiting for settings here would leave a blank gap), but the splash→bubble
  // transition and any skip decision still wait for the settings flags in maybeStart().
  if (!splashSeen) {
    loadSprite(
      function () { spriteOutcome = 'ok'; startLoop(); maybeStart(); },
      function () { spriteOutcome = 'fail'; maybeStart(); }
    );
  }

  fetch('/api/settings')
    .then(function (r) { return r.json(); })
    .then(function (s) {
      pmark('widget:settings');
      if (s.onboard_complete !== 'true') { onboardActive = true; skipSplash = true; }
      if (s.whisper_enabled === 'true') hpWhisperEnabled = true;
      if (splashSeen) { if (onboardActive) canvas.style.display = 'none'; return; }
      settingsDone = true;
      maybeStart();
    })
    .catch(function () { pmark('widget:settings-failed'); settingsDone = true; maybeStart(); });

  // ══════════════════════════════════════════════════════════════════
  // ── Widget Interaction ───────────────────────────────────────────
  // ══════════════════════════════════════════════════════════════════

  var isOpen = false;
  var toggleCooldown = false;

  function toggle() {
    if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
      skipToBubble();
      try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
    }
    if (canvasPhase !== 'bubble') return;
    // If headphones animation is running, reset to idle
    if (hpState !== 'idle') {
      stopHpRecording(true);
      hpState = 'idle';
      hpFrame = HP_IDLE_FRAME;
    }
    isOpen = !isOpen;
    if (isOpen) ensureChatLoaded(); // first open may beat the deferred load — start it now
    panel.classList.toggle('open', isOpen);
    backdrop.classList.toggle('open', isOpen);
    canvas.style.display = isOpen ? 'none' : 'block';
    if (isOpen) unreadCount = 0;
    updateBadge();
  }

  // ── Bubble pointer handlers ──
  // Tap detected via 'click' (reliable on all browsers/mobile).
  // Hold detected via pointerdown timer. pointerup stops recording.

  bubble.addEventListener('pointerdown', function(e) {
    if (canvasPhase !== 'bubble') return;
    e.preventDefault(); // Prevent iOS long-press text selection
    hpPointerDown = true;
    hpWasHold = false;

    hpHoldTimer = setTimeout(function() {
      hpWasHold = true;
      if (hpState === 'idle' || hpState === 'deactivating') {
        startHpRecording();
      }
    }, HP_HOLD_MS);
  });

  bubble.addEventListener('pointerup', function() {
    hpPointerDown = false;
    if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
    if (hpState === 'activating' || hpState === 'recording') {
      stopHpRecording(false);
    }
    // Tap detection: preventDefault on pointerdown suppresses click on mobile,
    // so we handle taps here instead. Set cooldown to prevent the synthesized
    // click from hitting the backdrop (bubble is hidden by then).
    if (!hpWasHold) {
      toggleCooldown = true;
      setTimeout(function() { toggleCooldown = false; }, 400);
      toggle();
    }
    hpWasHold = false;
  });

  bubble.addEventListener('pointercancel', function() {
    hpPointerDown = false;
    if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
    if (hpState === 'activating' || hpState === 'recording') stopHpRecording(true);
  });

  // click still fires on desktop (pointerdown preventDefault does not suppress it there).
  // On mobile, pointerup already handled the tap, so this is a no-op guard.
  bubble.addEventListener('click', function(e) {
    // Prevent double-toggle: pointerup already handled taps.
    e.stopPropagation();
  });

  // Click on blob during splash → skip and open
  document.addEventListener('click', function(e) {
    if (canvasPhase !== 'splash' && canvasPhase !== 'transitioning') return;
    if (animState === 'loading') return;
    var dx = e.clientX - center.x, dy = e.clientY - center.y;
    if (Math.sqrt(dx * dx + dy * dy) < DISPLAY_H / 2 + 15) toggle();
  });

  bubble.addEventListener('contextmenu', function(e) { e.preventDefault(); });
  backdrop.addEventListener('click', function() { if (!toggleCooldown) toggle(); });
  document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen) toggle(); });

  // ── PWA Install ──
  var deferredInstallPrompt = null;
  window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault(); deferredInstallPrompt = e; });

  // Handle messages from iframe
  window.addEventListener('message', function (e) {
    // Chat iframe + workspace iframe are same-origin; hardens against any third-party frame.
    if (e.origin !== location.origin) return;
    if (!e.data || !e.data.type) return;
    if (e.data.type === 'bloby:close' && isOpen) toggle();
    // Workspace iframe forwards Escape keypresses so ESC still closes the chat
    // when focus is inside the user's app.
    if (e.data.type === 'bloby:esc' && isOpen) toggle();
    // The chat detected the server runs a newer version than this page's bundles — the
    // immortal shell survived a self-update running pre-update code. One full reload picks
    // up the new widget + chat + workspace together (the chat throttles this to once/5min).
    if (e.data.type === 'bloby:version-changed') { location.reload(); return; }
    // Shell mode: the workspace app's same-document 'bloby:app-ready' window
    // event fires inside the iframe, so the guard forwards it as a postMessage.
    if (e.data.type === 'bloby:app-ready') { appReady = true; maybeTransition(); }
    if (e.data.type === 'bloby:new-message' && !isOpen) { unreadCount++; updateBadge(); }
    if (e.data.type === 'bloby:install-app') {
      if (deferredInstallPrompt) {
        deferredInstallPrompt.prompt();
        deferredInstallPrompt.userChoice.then(function (r) { if (r.outcome === 'accepted') deferredInstallPrompt = null; });
      } else { postToChat({ type: 'bloby:show-ios-install' }); }
    }
    if (e.data.type === 'bloby:onboard-complete') { onboardActive = false; hideAfterTransition = false; canvas.style.display = 'block'; }
  });

  try { if (sessionStorage.getItem('bloby_widget_open') === '1') { sessionStorage.removeItem('bloby_widget_open'); if (canvasPhase === 'bubble') toggle(); } } catch (e) {}
})();
