// Power Dialer — Impact CRM floating panel.
//
// Talks to the Cloudflare Worker over WebSocket. Sends session.start /
// session.dial / session.skip etc; receives status/transcript/disposition
// events back. On voicemail-or-no-answer it auto-clicks Impact's "No Answer"
// link and advances to the next lead. On a live human, it stops and lets the
// agent talk via the parked-cell bridge.
//
// Storage layout (chrome.storage.local):
//   pd_active_profile : "me" | "partner"
//   pd_profiles       : { me: {name, agentCell, vmDropUrl}, partner: {...} }
//   pd_worker_base    : "https://app.goalpowerdialer.com"
//   pd_panel_pos      : { right, top }    (drag position)
//   pd_collapsed      : bool
//   pd_counters       : { dials, noAnswers, completed }
//   pd_counters_date  : "Thu Apr 30 2026"  (rolls daily)

(() => {
  if (window.__pdLoaded) return;
  window.__pdLoaded = true;

  // ─────────────────────────────────────────────────────────────────
  // State
  // ─────────────────────────────────────────────────────────────────
  // v1.31.61 — Worker-config Layer 2 baked-in DEFAULTS. Mirrors
  // worker/src/runtime_config.js RUNTIME_CONFIG_DEFAULTS. If the worker
  // is unreachable (fresh install, offline, transient outage), these are
  // what the extension uses — guaranteeing day-one behavior is byte-
  // identical to pre-refactor.
  //
  // NOTE: in this layer, NOTHING reads from state.runtimeConfig yet.
  // Layer 3 (v1.31.62) swaps the first hardcoded value (APPT_RE) to read
  // from this cache. Future patches add more swap points.
  const RUNTIME_CONFIG_DEFAULTS = Object.freeze({
    version: 1,
    updatedAt: null,
    apptRegex: "\\bSchedule(?:d)?\\s+(?:Virtual|In[\\s-]?Home)\\s+Appointment\\b",
    // v1.32.2 — Double-Tap GLOBAL feature flag (hot-swappable via
    // /api/admin/runtime-config). When false, the toggle is hidden from
    // ALL users regardless of their isTester role. AIL vendor-approval prep.
    doubleTapEnabled: true,
  });

  const state = {
    workerBase: "",
    // v1.31.61 — Runtime config cache. Hydrated from chrome.storage.local
    // on boot, then refreshed from the worker /api/runtime-config endpoint.
    runtimeConfig: { ...RUNTIME_CONFIG_DEFAULTS },
    activeProfile: "me",
    profiles: {},
    // Daily counters: persist across page navigations via pd_counters.
    // Reset at midnight (rolled over by date check on load).
    counters: {
      // v1.29.70 — Counter semantics:
      //   dials      = total outbound calls placed (every press of Dial)
      //   noAnswers  = dials where a real PERSON did not pick up (ring-out,
      //                machine AMD, voicemail-drop, busy, fast-bounce)
      //   answered   = dials where a real PERSON picked up (human convo)
      //   completed  = total dials that ended (kept for back-compat; the
      //                "Complete" tile was renamed to "Answered" and now
      //                renders `answered` instead)
      // Invariant: noAnswers + answered === completed (eventually === dials)
      dials:      0,
      contacts:   0,  // live humans we bridged to (used elsewhere; matches `answered`)
      voicemails: 0,  // calls where AMD said "machine" or trigger words fired
      noAnswers:  0,
      badNumbers: 0,
      answered:   0,
      completed:  0,
    },
    pos: null,
    collapsed: false,

    ws: null,
    wsStatus: "disconnected", // disconnected | connecting | connected
    wsReconnectTimer: null,

    audioWs: null,            // separate WS for the prospect audio fork
    audioStatus: "off",       // off | listening | suspended

    session: "idle",          // idle | starting | dialing-agent | parked | ending
    prospect: null,           // { number, leadId, status, amd?, transcript[], interim?, disposition? }
    lastError: null,
    autoAdvanceTimer: null,
    autoDialMode: false,      // when true, auto-click Mobile on each fresh /Lead/InboxDetail
    autoDialTimer: null,
    lastAutoDialAt: 0,        // ms — guard to prevent loops on the same lead

    // v1.31.81 — Lead-lock during active call (Thomas + Ramin drift bug).
    // Set on prospect.connected, released on prospect.hangup or session
    // end. While set, advanceToNextLead/maybeAutoDial/cross-nav advance
    // flags REFUSE to fire — protects the current lead context from
    // drifting under an agent who's on a live call.
    //   { leadId: string, lockedAt: ms, callControlId: string }
    activeLeadLock: null,
    activeLeadLockDriftTimer: null,
    // Pending-advance reason if autopilot tried to advance during call —
    // we execute it post-hangup so autopilot doesn't lose its place.
    pendingAdvanceAfterHangup: false,

    // v1.31.97 — End-Human-Call flow. Set TRUE when the agent presses the
    // End Human Call button during a live conversation. While true, the
    // URL-flip detection (WhatHappend → InboxDetail) fires the
    // back-to-Impact popup with 3 choices (Continue / Stay paused / End
    // session). Auto-dispositioned outcomes (no-answer, VM,
    // attendant-hangup) NEVER set this flag, so they bypass the popup.
    // Reset to false after popup is shown / resolved.
    // See [[gpd-call-cycle]] + [[end-human-call-button]] for the full
    // outcome routing table.
    endHumanCallTriggered: false,
    // v1.32.18 — Timestamp of when endHumanCallTriggered was last set true.
    // Used by the URL watcher to ignore stale triggers (>5 min old).
    // Dustin's report 2026-06-18: agents see popup fire on URL changes
    // long after they armed it, causing autopilot to resume after they've
    // moved on.
    endHumanCallTriggeredAt: 0,
    // v1.32.18 — Timestamp of when the agent explicitly paused autopilot
    // (clicked Pause button OR clicked End Session). Used to block the
    // End-Human-Call popup's Continue button from auto-unpausing within
    // 30 minutes — respecting the agent's explicit intent.
    userPausedAt: 0,
    // Active popup ref so we can close/replace if a new End-Human-Call
    // fires before the previous popup is dismissed.
    endCallPopupEl: null,

    // v1.29.71 — One-shot flag that gets set when captureVisibleTab is
    // blocked by Chrome's host-permission check (typically because the
    // user has the extension's site access set to "On click" instead of
    // "On all sites"). Suppresses repeated retries that spam the console
    // and the Web Store error dashboard. Cleared on session start so a
    // fresh sign-in after granting permissions can try again.
    screenshotPermissionBlocked: false,

    // UI skin: "phone" (Galaxy S26) or "goal" (soccer goal frame).
    // Picked from the extension popup; persisted in chrome.storage.local.
    skin: "phone",
    // ms timestamp the current prospect call started — drives the in-call
    // timer (mm:ss). Cleared when state.prospect goes back to null.
    callStartedAt: 0,
    callTimerInterval: null,

    // Maximize toggle — when true, the panel grows wider + the transcript
    // gets more room. Persists across sessions via pd_maximized in storage.
    maximized: false,

    // Double-tap dial — call each non-human result twice with a short pause
    // before advancing. Higher pickup ratio statistically.
    doubleTap: false,

    // v1.34 — Leave Voicemail. When ON, the system auto-plays the agent's
    // per-lead-type Voicemail Studio recording on voicemail detection and
    // dispositions "Voicemail Left". OFF (default) = voicemails hang up as
    // No Answer with no message. AUTO-OFF at every Start Session so it can't
    // be silently left on; protected against silent-reset via cmpKeys +
    // _pdWriteToggleVerified like the other toggles.
    leaveVoicemail: false,

    // v1.31.90 — TCPA calling hours per timezone. When enabled, every
    // lead is checked: derive prospect timezone from area code/state,
    // compute the prospect's local time, and skip the dial if outside
    // the configured window. Five zones × M-F + S/S = 4 windows each.
    // Stored as { PST:{wdStart,wdEnd,weStart,weEnd}, MST:{...}, ... }.
    // Defaults: 9am-9pm weekdays, 10am-7pm weekends (conservative).
    // v1.32.8 — Default flipped to TRUE. Calling-hours protection is now
    // STANDARD ON — every agent gets TCPA-safe dialing automatically.
    // Toggle is opt-OUT only. Admin can globally kill via runtime config
    // (callingHoursEnabled flag) if the feature ever misbehaves.
    callingHoursEnabled: true,
    callingHours: null, // hydrated by loadConfig

    // v1.31.90 — Lead-type skip set (per-session, not persistent).
    // When agent toggles ON a lead type, autopilot SKIPS any lead with
    // that type during this session. End-session clears the set.
    skipLeadTypes: {},

    // v1.33.3 — Ring buffer of the 3 most-recent dials this session.
    // Pushed on every prospect.dialing, wiped in endSession(). Each
    // entry: { leadId, leadName, number, dialedAt, result }. Renders
    // as an inline strip above the manual-dial input so the agent can
    // see context at a glance + redial recent numbers without diving
    // into History. Result starts null and gets updated on
    // prospect.hangup (human/no_answer/voicemail/ended).
    lastDials: [],

    // v1.33.3 — Wake Lock state. Default ON. Acquired when a session
    // starts (state.session moves to "parked" or later) and released
    // when the session ends. Prevents the desktop from sleeping /
    // screen from blanking during a dialing shift, which would drop
    // the WebRTC audio + WS heartbeat. Held via the modern
    // navigator.wakeLock.request("screen") API; the sentinel is kept
    // in state._wakeLockSentinel. Auto-releases when the tab hides
    // (browser-imposed) and re-acquires on visibilitychange to visible.
    wakeLockEnabled: true,

    // v1.31.68 — When ON, the worker treats auto-attendant IVR phrases
    // (detected from prospect transcription) the same as voicemail:
    // auto-hangup + No Answer disposition. Default OFF preserves the
    // legacy "navigate IVRs manually with 🛡️ button" behavior.
    // v1.32.2 — This is the SESSION-LONG IVR mode. For IVR-heavy lists,
    // agents flip this on once and every IVR-detected call gets dropped
    // as No Answer for the rest of the session. The per-call 🛡️ button
    // (state.overrideActive) is the OPPOSITE: a one-call escape hatch
    // for when a real human IVR menu needs to be navigated.
    autoAttendantHangup: false,
    // v1.32.2 — REMOVED state.stickyAutoAttendOverride. Was a confused
    // duplicate of autoAttendantHangup with inverted semantics. The
    // per-call 🛡️ Auto-Attd button (state.overrideActive) is meant to
    // be a per-call choice and ALWAYS resets at the start of each new
    // prospect — that's intentional. The session-long "I'm working an
    // IVR list" mode is autoAttendantHangup, above.
    // v1.31.69 — Away Mode. When ON, human-ended calls are auto-
    // dispositioned as No Answer in Impact, autopilot keeps running
    // (no Continue modal), and a screenshot of the lead is captured
    // to both Downloads and chrome.storage.local for later review.
    // Designed for the "agent listens on phone while running errands"
    // workflow — they set the real disposition when they're back home.
    awayMode: false,

    // v1.31.2 — Manual disposition mode. When OFF (default), the dialer
    // auto-marks every no-answer hangup and advances to the next lead.
    // When ON, the dialer pauses after every hangup; the agent
    // dispositions in Impact manually, and when Impact navigates back to
    // the main lead list page, the dialer detects it and resumes by
    // advancing + dialing the next lead.
    manualDisposition: false,
    // Internal flag — when true, we're waiting for the agent to finish
    // their manual disposition in Impact and return to the lead list.
    waitingForManualDisposition: false,

    // v1.31.40 — Custom ring timeout. When ringTimeoutEnabled is true,
    // the agent's ringTimeoutSecs value overrides Telnyx's default 24s
    // dial timeout. Covers both the "ringing but no pickup" and "stuck
    // in queue / never connects" scenarios — Telnyx hangs up the leg
    // after this many seconds either way, disposition stays no-answer,
    // autopilot advances to the next lead. Requested by Ramin via Dustin.
    ringTimeoutEnabled: false,
    ringTimeoutSecs: 24,

    // Test-mode activation passcode. Default "4015"; admin can rotate via
    // the /admin Settings card. Refreshed from /api/me on every Start
    // Session. Cached in chrome.storage.local for offline / mid-session
    // toggles. If somehow blank we fall back to the default at the
    // comparison site so we never lock the agent out of test mode.
    testerPasscode: "4015",
    // v1.32.2 — Tester role for Double-Tap gating. Set from /api/me
    // (u.isTester). Combined with runtimeConfig.doubleTapEnabled to
    // decide whether to render the Double-Tap toggle. Cached locally
    // for stable visibility decisions across boots before /api/me lands.
    userIsTester: false,

    // Session timing — set when the user clicks Start Session, used for
    // the slide-in stats summary on End Session.
    sessionStartedAt: 0,
    // Counter snapshot at session start — so the summary shows session
    // deltas, not all-time totals.
    sessionStartCounters: null,
    // Per-call log for the live History slide-out + end-of-session report.
    // Each entry: { leadId, leadName, number, result, startedAt, endedAt,
    //              durationMs, amd, status }
    callLog: [],
    // Leads we couldn't dial — surfaced in the end-of-session report.
    // Each entry: { leadId, leadName, reason, time }
    skippedLeads: [],
    // Monthly usage — cumulative dial count for the current YYYY-MM. Rolls
    // over on the 1st automatically (date check on load). Persisted as
    // pd_monthly + pd_monthly_key in chrome.storage.local.
    monthlyDials: 0,
    monthlyKey: "",
    // Soft monthly dial target — shown as the denominator on the progress
    // bar in the end-of-session summary. Configurable later; sane default.
    monthlyTarget: 5000,
    // Disposition click counts captured by watching Impact's UI. Reset each
    // session so the summary shows just-this-session picks.
    dispositions: {},
  };

  const $ = (id) => document.getElementById(id);

  // ── v1.32.0 — Self-diagnostics ring buffers ──────────────────────
  // Captures the last N events from each of three signal streams that
  // the previous offscreen-only diag couldn't see. When the agent (or
  // an auto-trigger) submits diagnostics, these buffers ride along so
  // we can root-cause content-script bugs (drift, double-tap, lead-lock
  // races) without asking the agent for DevTools data.
  //
  // Privacy: log() messages, URL nav, WS messages are ALL extension
  // self-telemetry. They contain leadIds, state transitions, and worker
  // protocol — but NOT prospect phone numbers or transcripts. We trust
  // the existing log() call sites not to log PII (verified pattern
  // across content.js).
  // v1.33.4.11 — Cross-nav-PERSISTENT diagnostic buffers.
  // PROBLEM these fix: content.js reloads on EVERY Impact page navigation
  // (InboxDetail -> WhatHappend -> InboxDetail, once per lead). The old
  // buffers were plain in-memory `const [] ` arrays with no persistence, so
  // every nav WIPED them — a diagnostic only ever held events since the last
  // navigation (often a single entry). Post-nav failures — which in this app
  // is essentially every failure — were therefore undiagnosable. Kevin Holtz
  // 2026-07-15: sent a diag DURING the stuck-autopilot issue, but its
  // logBuffer had exactly ONE line because he'd just navigated; the actual
  // failure sequence was already erased.
  //
  // FIX: persist the ring buffers to sessionStorage (per-tab, survives
  // navigation, auto-clears on tab close) and restore on boot, so they become
  // a true rolling ~10-minute window ACROSS page loads. Caps raised so the
  // window is meaningful; age-based prune so a burst of pings can't push out
  // older-but-relevant events; and a synchronous flush on pagehide so the
  // last entries survive the content.js teardown that the nav triggers.
  const _PD_LOG_CAP    = 250;                 // was 50
  const _PD_NAV_CAP    = 60;                  // was 20
  const _PD_WS_CAP     = 40;                  // was 5
  const _PD_MAX_AGE_MS = 10 * 60 * 1000;      // 10-minute horizon
  const _PD_SS_KEYS    = { log: "pd_diag_log", nav: "pd_diag_nav", ws: "pd_diag_ws" };
  function _pdRestore(key) {
    try {
      const raw = sessionStorage.getItem(key);
      if (!raw) return [];
      const arr = JSON.parse(raw);
      if (!Array.isArray(arr)) return [];
      const cutoff = Date.now() - _PD_MAX_AGE_MS;
      return arr.filter((e) => e && typeof e.t === "number" && e.t >= cutoff);
    } catch { return []; }
  }
  // NB: `let`, not `const` — reassigned once here from the restored copy.
  let _pdLogBuffer = _pdRestore(_PD_SS_KEYS.log);   // {t, level, msg}
  let _pdNavBuffer = _pdRestore(_PD_SS_KEYS.nav);   // {t, pathname, search}
  let _pdWsBuffer  = _pdRestore(_PD_SS_KEYS.ws);    // {t, dir, type, summary}
  let _pdPersistScheduled = false;
  function _pdPersistNow() {
    _pdPersistScheduled = false;
    try {
      sessionStorage.setItem(_PD_SS_KEYS.log, JSON.stringify(_pdLogBuffer));
      sessionStorage.setItem(_PD_SS_KEYS.nav, JSON.stringify(_pdNavBuffer));
      sessionStorage.setItem(_PD_SS_KEYS.ws,  JSON.stringify(_pdWsBuffer));
    } catch { /* quota / disabled storage — diagnostics are best-effort */ }
  }
  function _pdPersistSoon() {
    if (_pdPersistScheduled) return;
    _pdPersistScheduled = true;
    try { setTimeout(_pdPersistNow, 1000); } catch { _pdPersistNow(); }
  }
  // Flush synchronously right before the page tears down (navigation or
  // reload) so the newest entries aren't lost with the old content.js.
  try {
    window.addEventListener("pagehide", _pdPersistNow, { capture: true });
    window.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") _pdPersistNow();
    }, { capture: true });
  } catch {}
  function _pdRing(buf, cap, entry) {
    buf.push(entry);
    // Age-based prune first (keeps the window time-bounded), then count cap.
    const cutoff = Date.now() - _PD_MAX_AGE_MS;
    while (buf.length && buf[0] && buf[0].t < cutoff) buf.shift();
    while (buf.length > cap) buf.shift();
    _pdPersistSoon();
  }
  // v1.32.0 — Replaced bare console.log with buffered version. The
  // first arg is treated as a level if it's "info"|"warn"|"error",
  // otherwise the level defaults to "info" and arg becomes the msg.
  // This matches the existing call sites where log("warn", "...") is
  // common throughout content.js.
  function log(...args) {
    let level = "info", msg;
    if (typeof args[0] === "string" && /^(info|warn|error|debug)$/.test(args[0])) {
      level = args[0];
      msg = args.slice(1).map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
    } else {
      msg = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
    }
    console.log("[pd]", ...args);
    try { _pdRing(_pdLogBuffer, _PD_LOG_CAP, { t: Date.now(), level, msg: msg.slice(0, 500) }); } catch {}
  }
  function _pdNoteNav() {
    try { _pdRing(_pdNavBuffer, _PD_NAV_CAP, {
      t: Date.now(),
      pathname: location.pathname,
      search:   location.search.slice(0, 200),
    }); } catch {}
  }
  function _pdNoteWs(dir, msg) {
    try {
      const summary = (msg && typeof msg === "object")
        ? JSON.stringify({ type: msg.type, leadId: msg.leadId || msg?.prospect?.leadId, status: msg?.prospect?.status, result: msg?.result }).slice(0, 200)
        : String(msg).slice(0, 200);
      _pdRing(_pdWsBuffer, _PD_WS_CAP, { t: Date.now(), dir, type: msg?.type || "?", summary });
    } catch {}
  }

  // Auto-trigger throttle (one auto-submit per session per failure type).
  const _pdAutoSubmitted = new Set();
  let _pdRecentDialFails = 0;
  let _pdRecentDialFailWindowStart = 0;
  let _pdRecentWsReconnects = 0;
  let _pdRecentWsReconnectWindowStart = 0;
  function _pdRecordWsReconnect() {
    const now = Date.now();
    if (now - _pdRecentWsReconnectWindowStart > 60_000) {
      _pdRecentWsReconnectWindowStart = now;
      _pdRecentWsReconnects = 0;
    }
    _pdRecentWsReconnects++;
    if (_pdRecentWsReconnects >= 4 && !_pdAutoSubmitted.has("ws-reconnect-storm")) {
      _pdAutoSubmitted.add("ws-reconnect-storm");
      _pdAutoSubmitWithReason("auto:ws-reconnect-storm");
    }
  }
  function _pdRecordDialFail() {
    const now = Date.now();
    if (now - _pdRecentDialFailWindowStart > 5 * 60_000) {
      _pdRecentDialFailWindowStart = now;
      _pdRecentDialFails = 0;
    }
    _pdRecentDialFails++;
    if (_pdRecentDialFails >= 3 && !_pdAutoSubmitted.has("dial-fail-streak")) {
      _pdAutoSubmitted.add("dial-fail-streak");
      _pdAutoSubmitWithReason("auto:dial-fail-streak");
    }
  }
  // v1.33.3.2 — Verify-after-write for toggle storage. Karin (Edge MV3
  // 2026-06-24 diags) showed every toggle key missing from
  // storageSnapshot — chrome.storage.local.set silently rejected.
  // Edge's MV3 storage is known to drop writes when the extension's
  // storage quota is near full or when the page is mid-unload. This
  // helper writes the value, reads it back 250ms later, and if the
  // read doesn't match, retries once and surfaces a toast. Always
  // returns a Promise; safe to fire-and-forget.
  async function _pdWriteToggleVerified(key, value) {
    try {
      await chrome.storage.local.set({ [key]: value });
    } catch (e) {
      console.warn("[pd][toggle-write] set threw for " + key + ":", e?.message || e);
      try { _pdAutoSubmitWithReason("auto:toggle-write-threw"); } catch {}
      return false;
    }
    // Verify after a short delay — Edge sometimes commits async.
    return new Promise((resolve) => {
      setTimeout(async () => {
        try {
          const r = await chrome.storage.local.get(key);
          const got = r ? r[key] : undefined;
          if (got === value) { resolve(true); return; }
          console.warn("[pd][toggle-write] mismatch for " + key + ": wrote=" + JSON.stringify(value) + " readback=" + JSON.stringify(got));
          // Retry once.
          try { await chrome.storage.local.set({ [key]: value }); } catch {}
          const r2 = await chrome.storage.local.get(key);
          if (r2 && r2[key] === value) { resolve(true); return; }
          // Still mismatched — auto-submit diag and toast.
          try { _pdAutoSubmitWithReason("auto:toggle-write-rejected"); } catch {}
          try { showToast("Setting may not have saved. If it reverts, try toggling again.", "warn"); } catch {}
          resolve(false);
        } catch (_e) { resolve(false); }
      }, 250);
    });
  }

  function _pdAutoSubmitWithReason(reason) {
    try {
      // v1.33.3.2 — Throttle by reason. Previously every call sent a
      // fresh diagnostic POST; Casey saw 100+ submissions in one night
      // because premature-hangup-defer fired every hangup. We were ALSO
      // never populating _pdAutoSubmitted, so `autoSubmittedReasons` in
      // the payload was always [] — making the throttle invisible from
      // the admin diagnostics list, AND making it look like throttle
      // wasn't working when it wasn't even WIRED UP.
      if (_pdAutoSubmitted.has(reason)) {
        // Already submitted this reason this session — skip silently.
        return;
      }
      _pdAutoSubmitted.add(reason);
      if (typeof __pdSendDiagnostics === "function") {
        __pdSendDiagnostics({ reason })
          .then(r => console.log("[pd][diag] auto-submit (" + reason + ") result:", r))
          .catch(e => console.warn("[pd][diag] auto-submit (" + reason + ") failed:", e?.message || e));
      }
    } catch {}
  }

  // True once the extension's content-script context has been invalidated
  // (typically right after the user reloads the extension while this page
  // is still open). Once invalidated, any chrome.* API call throws — so we
  // bail out of every async path and tell the user to refresh.
  let pdInvalidated = false;
  function isExtensionAlive() {
    try { return !!(chrome && chrome.runtime && chrome.runtime.id); }
    catch { return false; }
  }

  // v1.32.9 — Silently swallow "Extension context invalidated" errors that
  // escape any unguarded chrome.runtime call site. Reported by Rabecca
  // Lathrop 2026-06-16: a cosmetic uncaught-in-promise error fires after
  // the extension auto-updates while her Impact page is still open.
  // Functionally harmless (markInvalidated already paints "refresh to
  // continue"), but the raw stack trace alarms agents and clutters the
  // Web Store error dashboard. Catch + mark + drop.
  window.addEventListener("unhandledrejection", (e) => {
    const msg = String(e?.reason?.message || e?.reason || "");
    if (/Extension context invalidated|extension context was invalidated/i.test(msg)) {
      try { markInvalidated(); } catch {}
      e.preventDefault();
    }
  });
  window.addEventListener("error", (e) => {
    const msg = String(e?.message || e?.error?.message || "");
    if (/Extension context invalidated|extension context was invalidated/i.test(msg)) {
      try { markInvalidated(); } catch {}
      e.preventDefault();
    }
  });
  function markInvalidated() {
    if (pdInvalidated) return;
    pdInvalidated = true;
    try { if (state.ws) state.ws.close(); } catch {}
    state.ws = null;
    state.wsStatus = "disconnected";
    state.lastError = "Extension reloaded — refresh this page (F5) to continue";
    try { paint(); } catch {}
  }

  // ─────────────────────────────────────────────────────────────────
  // Config load/save
  // ─────────────────────────────────────────────────────────────────
  async function loadConfig() {
    if (!isExtensionAlive()) { markInvalidated(); return; }
    let data;
    try {
      data = await chrome.storage.local.get([
        "pd_active_profile", "pd_profiles", "pd_worker_base",
        "pd_panel_pos", "pd_collapsed",
        // v1.31.73 — per-skin storage maps (keyed by skin name).
        "pd_panel_pos_by_skin", "pd_panel_scale_by_skin",
        // v1.31.75 — Remember the user's last LARGE skin (phone or goal)
        // so the ball/jersey "maximize" button can return there.
        "pd_last_large_skin",
        "pd_counters", "pd_counters_date",
        "pd_autodial", "pd_skin", "pd_maximized", "pd_double_tap", "pd_my_numbers", "pd_skip_future_callbacks",
        "pd_leave_voicemail",  // v1.34 — Leave Voicemail toggle (auto-off at Start Session)
        "pd_wake_lock",  // v1.33.3 — keep desktop awake during sessions (default true)
        "pd_manual_disposition",  // v1.31.2 — manual disposition mode
        "pd_ring_timeout_enabled", "pd_ring_timeout_secs",  // v1.31.40 — custom ring timeout
        "pd_panel_scale",
        "pd_monthly", "pd_monthly_key", "pd_monthly_target",
        "pd_size_mode", "pd_dial_mode",
        "pd_active_park_source",  // v1.28.0: explicit 3-way mode (cell/mobile_web/desktop)
        "pd_mic_granted",         // v1.28.0: cached mic permission flag
        "pd_test_mode",
        "pd_more_open",  // v1.34 redesign — "More options" expander open state
        "pd_bottom_to_top", "pd_call_from_comments",
        "pd_comments_dialed",
        "pd_relay_leads",
        "pd_tester_passcode",
        // v1.32.2 — Cached tester role for Double-Tap gating.
        "pd_user_is_tester",
        "pd_last_dialed_at",      // v1.29.13: recency gate for autopilot restore
        // v1.31.76 — Update banner state. pd_update_available is the
        // {current, latest, checkedAt, zipUrl} object written by the
        // background service worker. pd_update_dismissed_for is the
        // version string the agent tapped × on — banner suppressed
        // until a NEWER version supersedes it.
        "pd_update_available", "pd_update_dismissed_for",
        // v1.31.78 — True minimize: panel hidden, floating restore dot
        // shown. Distinct from state.collapsed + state.sizeMode.
        "pd_minimized",
        // v1.31.80 — Jersey watermark overlay on the soccer-ball mini.
        "pd_jersey_bg",
        // v1.31.80 fix-up — Restore dot drag + resize persistence.
        "pd_restore_pos", "pd_restore_scale",
        // v1.33.4.8 — keys that ARE written elsewhere but were missing
        // from this fetch list, so they silently reset on every Impact
        // navigation (content.js reboots constantly). Restoring them fixes:
        //   pd_auto_attendant_hangup  -> "Auto-hangup keeps unchecking itself"
        //   pd_calling_hours[_enabled] -> custom TCPA hours reset each nav
        //   pd_screenshot_blocked     -> screenshot re-attempted each nav
        //   pd_skip_lead_types        -> "keeps losing Skip Lead Types selections"
        "pd_auto_attendant_hangup",
        "pd_calling_hours_enabled", "pd_calling_hours",
        "pd_screenshot_blocked",
        "pd_skip_lead_types",
      ]);
    } catch (err) {
      markInvalidated();
      return;
    }
    // Default to production worker URL if storage is empty (first install).
    // v1.33 — Staging build auto-detects its env via the bundled marker
    // file `pd_build_env.json` (only present in builds/staging/), so
    // testers loading the unpacked staging build don't have to manually
    // override the Worker Base URL every time they reload the ext.
    let defaultWorkerBase = "https://app.goalpowerdialer.com";
    try {
      const markerUrl = chrome.runtime.getURL("pd_build_env.json");
      const markerRes = await fetch(markerUrl);
      if (markerRes.ok) {
        const marker = await markerRes.json();
        if (marker && typeof marker.defaultWorkerBase === "string") {
          defaultWorkerBase = marker.defaultWorkerBase;
        }
      }
    } catch (_e) { /* marker not present → prod default stands */ }
    state.workerBase = (data.pd_worker_base || defaultWorkerBase).replace(/\/+$/, "");
    state.activeProfile = data.pd_active_profile || "me";
    state.profiles = data.pd_profiles || {};
    state.pos = data.pd_panel_pos || null;
    // v1.31.76 — Update detection state. Banner only renders when
    // updateAvailable is set AND updateDismissedFor !== updateAvailable.latest.
    state.updateAvailable = data.pd_update_available || null;
    state.updateDismissedFor = data.pd_update_dismissed_for || null;
    // v1.31.78 — Minimized state. Distinct from collapsed/sizeMode.
    // When true, panel is fully hidden and a floating restore dot
    // appears at fixed position (top:80px right:24px by default).
    state.minimized = !!data.pd_minimized;
    // v1.31.80 — Jersey watermark overlay flag. When true, the
    // compact UI shows the jersey image as the watermark instead of
    // the soccer ball SVG. Persists across page navigations.
    state.jerseyBg = !!data.pd_jersey_bg;
    // v1.31.80 fix-up — Restore dot persisted geometry. pos is
    // {right, top}, scale is a multiplier on the 60px base size
    // (clamped 0.5–2.0).
    state.restorePos = (data.pd_restore_pos && typeof data.pd_restore_pos.right === "number") ? data.pd_restore_pos : null;
    {
      const s = parseFloat(data.pd_restore_scale);
      state.restoreScale = (s >= 0.5 && s <= 2.0) ? s : 1;
    }
    // v1.31.73 — Per-skin position + scale. Migrate legacy single-value
    // keys (pd_panel_pos, pd_panel_scale) into the per-skin map under the
    // current skin's key on first load. Subsequent saves write to the
    // per-skin map. state.pos / state.scale stay as the LIVE values for
    // the currently-displayed skin; we re-point them on every skin change.
    state.posBySkin = (typeof data.pd_panel_pos_by_skin === "object" && data.pd_panel_pos_by_skin) || {};
    state.scaleBySkin = (typeof data.pd_panel_scale_by_skin === "object" && data.pd_panel_scale_by_skin) || {};
    state.collapsed = !!data.pd_collapsed;
    // v1.29.15 — Reverted v1.29.13's recency-gated reset. That check ran
    // inside loadConfig, which is invoked by chrome.storage.onChanged
    // every time pd_autodial is written — including the moment the user
    // clicks Start Autopilot. The reset would fire and instantly disarm
    // the autopilot the user just turned on (because no dial had happened
    // yet, so pd_last_dialed_at was stale). v1.29.12's explicit clear at
    // Start Session click already handles the main stale-autopilot case;
    // here we just trust the persisted value.
    state.autoDialMode = !!data.pd_autodial;
    // Skin = phone | goal. Legacy "medium" → "phone", "soccer-medium" → "goal".
    // Mini compact dashboard is now bound to the O size mode of either theme,
    // not a separate skin option.
    {
      const raw = data.pd_skin;
      if (raw === "medium") state.skin = "phone";
      else if (raw === "soccer-medium") state.skin = "goal";
      // v1.31.75 — Accept ball + jersey as valid skins.
      // v1.31.80 — Accept classic as valid skin too (6th skin).
      else state.skin = (["phone", "goal", "ball", "jersey", "classic"].includes(raw)) ? raw : "phone";
      // Track last LARGE skin (phone/goal/classic qualify) so ball/jersey
      // maximize knows where to go back to.
      state.lastLargeSkin = (["goal", "classic"].includes(data.pd_last_large_skin))
        ? data.pd_last_large_skin
        : "phone";
    }
    state.maximized = !!data.pd_maximized;
    // v1.32.8 — Persist screenshotPermissionBlocked across page navigations.
    // Before: flag was in-memory only, so every Impact navigation reset it
    // and captureVisibleTab was re-attempted, spamming the console with the
    // same "Site access: On all sites" warning every dial. Now it survives
    // until the agent actually fixes the permission (cleared at session start
    // so a fresh sign-in after granting permissions re-tries fresh).
    state.screenshotPermissionBlocked = !!data.pd_screenshot_blocked;
    state.doubleTap = !!data.pd_double_tap;
    state.leaveVoicemail = !!data.pd_leave_voicemail;  // v1.34 (auto-off at Start Session)
    state.myNumbers = Array.isArray(data.pd_my_numbers) ? data.pd_my_numbers : [];  // v1.34 — manual dial-from chooser
    state.skipFutureCallbacks = !!data.pd_skip_future_callbacks;  // v1.34 — persistent per-agent skip of future "Call Back appointment on" leads
    // v1.33.3 — Wake Lock toggle. Default TRUE if storage is undefined
    // (first run / never set). Existing false value is respected. The
    // toggle controls whether we hold a screen wake lock during active
    // sessions to keep the desktop from sleeping mid-shift.
    state.wakeLockEnabled = true; // v1.33.4.5 — always on during sessions; agent toggle removed (pd_wake_lock no longer gates it)
    // v1.31.68 — auto-attendant hangup toggle persistence
    state.autoAttendantHangup = !!data.pd_auto_attendant_hangup;
    // v1.33.4.8 — Restore Skip Lead Types across Impact navigations.
    // skipLeadTypes is a per-session map of leadType->true; it used to
    // live only in memory, so every page reboot wiped the selections.
    // Now persisted on change + cleared on endSession, so it rides the
    // whole session but resets when the session ends.
    state.skipLeadTypes = (data.pd_skip_lead_types && typeof data.pd_skip_lead_types === "object")
      ? data.pd_skip_lead_types
      : {};
    // v1.32.2 — REMOVED state.stickyAutoAttendOverride hydration. The
    // pd_sticky_auto_attend key is left orphaned in chrome.storage.local
    // for existing installs; it's harmless (no reader). Per-call 🛡️
    // override ALWAYS resets per prospect now.
    // v1.31.90 — TCPA calling hours
    // v1.32.8 — Hydration logic flipped. Default is ON. The toggle is
    // opt-OUT only: only treat as disabled if the stored value is
    // EXPLICITLY false. undefined/missing means "never seen, default ON".
    state.callingHoursEnabled = data.pd_calling_hours_enabled !== false;
    const ch = data.pd_calling_hours;
    state.callingHours = (ch && typeof ch === "object")
      ? { ...defaultCallingHours(), ...ch }
      : defaultCallingHours();
    // v1.31.69 — Away Mode persistence
    state.awayMode = false; // v1.33.4.5 — Away Mode disabled; toggle removed (was pd_away_mode)
    // v1.31.2 — Manual disposition mode (persisted)
    state.manualDisposition = !!data.pd_manual_disposition;
    // v1.31.40 — Custom ring timeout (persisted)
    state.ringTimeoutEnabled = !!data.pd_ring_timeout_enabled;
    const _rtSecs = parseInt(data.pd_ring_timeout_secs, 10);
    state.ringTimeoutSecs = (Number.isFinite(_rtSecs) && _rtSecs >= 5 && _rtSecs <= 180) ? _rtSecs : 24;
    // Three-state size: "full" | "mini" | "ball". Cycles via the – button.
    state.sizeMode = (["full","mini","ball"].includes(data.pd_size_mode))
      ? data.pd_size_mode
      : (state.collapsed ? "ball" : "full");
    // Dial mode: phone (cell rings) or desktop (browser audio).
    // v1.28.0: state.activeParkSource is the new 3-way truth source
    // (cell / mobile_web / desktop); state.dialMode stays for back-compat
    // with the many existing code paths that branch on phone vs desktop.
    // v1.29.0: only mobile_web / desktop allowed. Legacy "cell" auto-migrates.
    // v1.33.7.7 — Fresh-install default is Mobile App, NOT Desktop. Desktop
    // parks via the extension's offscreen WebRTC client + mic; when the
    // offscreen is inactive (common on MV3, esp. on SPA CRMs like Geopointe
    // that never navigate) a Desktop-defaulted fresh install dead-ends at an
    // unsatisfiable mic gate — the agent "grants mic" but Start Session never
    // proceeds (Sharla + Logan, LNL 2026-08-15). Mobile mode fails gracefully
    // (shows the "get the mobile app" callout) and matches the mobile-first
    // fleet. Rules: explicit "mobile_web"→mobile_web; explicit "desktop"→desktop
    // (never yank a chosen mode); never-chosen but mic already granted (evidence
    // of prior Desktop use)→keep desktop so an established desktop agent isn't
    // moved; never-chosen + no mic→default Mobile App. Legacy "cell" → mobile.
    if (data.pd_active_park_source === "mobile_web") {
      state.activeParkSource = "mobile_web";
    } else if (data.pd_active_park_source === "desktop") {
      state.activeParkSource = "desktop";
    } else {
      state.activeParkSource = data.pd_mic_granted ? "desktop" : "mobile_web";
    }
    state.dialMode = "desktop"; // always WebRTC now
    state.micGranted = !!data.pd_mic_granted;
    // Test mode: when on, prospect dials are simulated server-side (no
    // real Telnyx call). Cell parking stays real. Useful for testing
    // off-hours without bothering customers.
    state.testMode = !!data.pd_test_mode;
    state._moreOpen = !!data.pd_more_open;  // v1.34 redesign — "More options" expander open state
    // Cached test-mode passcode from the last /api/me sync. The actual
    // value is admin-rotatable from /admin; cache here so the toggle
    // works on cold-load before the next /api/me round-trip.
    if (typeof data.pd_tester_passcode === "string" && data.pd_tester_passcode.trim()) {
      state.testerPasscode = data.pd_tester_passcode.trim();
    }
    // v1.32.2 — Cached isTester role for Double-Tap gating. Refreshed
    // from /api/me on next sign-in; cached locally so the gate decision
    // is stable on fresh boots before the round-trip.
    state.userIsTester = !!data.pd_user_is_tester;
    // Bottom-to-Top: advanceToNextLead clicks Impact's UP arrow instead
    // of the down arrow. Lets two agents split a list (one top→bottom,
    // one bottom→top) without calling the same prospect twice.
    state.bottomToTop = !!data.pd_bottom_to_top;
    // Call only from comments: on bad-number leads, scrape comment text
    // for phone numbers and dial each one in sequence instead of clicking
    // Mobile/Home. Useful for working through a "bad number" lead pile.
    state.callFromComments = !!data.pd_call_from_comments;
    // Per-lead bookkeeping for the comments-dial flow: which phone
    // numbers we've already attempted. Map: { leadId: [num1, num2, ...] }
    state.commentsDialed = (typeof data.pd_comments_dialed === "object" && data.pd_comments_dialed) || {};
    // Per-lead relay-mode flags. Map: { leadId: enabledAtMs }. Persists
    // across Impact navigations so a manually-flagged relay lead
    // remains in relay mode through the dial cycle.
    state.relayLeads = (typeof data.pd_relay_leads === "object" && data.pd_relay_leads) || {};
    state.relayMode = false;       // active state for current lead — set in paint()
    state.relayDetected = false;   // true if comments mention deaf/relay/TTY/VRS
    // Persisted panel scale (0.35–2.5 → mini ranges from ~1″ to ~6″).
    // Defaults to 1 (no scale).
    //
    // v1.31.73 — Migrate legacy single value into per-skin map (under
    // current skin), then look up the live state.scale from the per-skin
    // map. Same for state.pos. Migration is one-time + idempotent.
    {
      const legacyScale = parseFloat(data.pd_panel_scale);
      if (!state.scaleBySkin[state.skin] && legacyScale >= 0.35 && legacyScale <= 2.5) {
        state.scaleBySkin[state.skin] = legacyScale;
      }
      if (!state.posBySkin[state.skin] && state.pos) {
        state.posBySkin[state.skin] = state.pos;
      }
      const s = state.scaleBySkin[state.skin];
      state.scale = (s >= 0.35 && s <= 2.5) ? s : 1;
      state.pos = state.posBySkin[state.skin] || state.pos || null;
    }

    // Monthly usage — rolls over to 0 on the 1st of each new month.
    {
      const now = new Date();
      const mk  = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}`;
      if (data.pd_monthly_key === mk) {
        state.monthlyDials = data.pd_monthly | 0;
      } else {
        state.monthlyDials = 0;
      }
      state.monthlyKey = mk;
      state.monthlyTarget = (data.pd_monthly_target | 0) || 5000;
    }

    const today = new Date().toDateString();
    const zeroCounters = {
      dials: 0, contacts: 0, voicemails: 0, noAnswers: 0, badNumbers: 0, answered: 0, completed: 0,
    };
    if (data.pd_counters_date === today && data.pd_counters) {
      // Merge — preserve existing counts, add any new fields with 0.
      state.counters = { ...zeroCounters, ...data.pd_counters };
    } else {
      state.counters = { ...zeroCounters };
      try {
        chrome.storage.local.set({ pd_counters: state.counters, pd_counters_date: today });
      } catch { markInvalidated(); }
    }

    // v1.30.4 — Self-heal a broken-invariant state. Pre-v1.30.4, local
    // dials could fall behind local noAnswers+answered because dials was
    // only set by the worker's `counters` broadcast (which resets to 0 on
    // every Durable Object hibernation). Result: users like Dustin saw
    // "3 DIALS / 5 NO ANS / 1 ANSWERED" — mathematically impossible.
    // On boot, repair the invariant by raising dials to at least
    // noAnswers + answered, since those events presuppose a dial.
    const ans  = state.counters.answered  | 0;
    const noa  = state.counters.noAnswers | 0;
    const dial = state.counters.dials     | 0;
    if (dial < ans + noa) {
      state.counters.dials = ans + noa;
      try {
        chrome.storage.local.set({ pd_counters: state.counters });
      } catch { markInvalidated(); }
    }
  }

  // v1.29.13 — Merge worker counters with local, never reducing any field.
  // Reason: worker counters live in the Durable Object's RAM and reset to
  // zero whenever the DO hibernates (which it does between Impact page
  // navs). If we let the worker overwrite local counters on every reconnect,
  // a quick page hop wipes the running totals to 0/0/0. Local pd_counters
  // is the source of truth for the daily roll-up; worker counters are
  // advisory only. Merge by taking the max for each field, then commit.
  function mergeCounters(local, incoming) {
    if (!incoming || typeof incoming !== "object") return local;
    const out = { ...local };
    for (const k of Object.keys(incoming)) {
      const lv = Number(out[k] || 0);
      const iv = Number(incoming[k] || 0);
      out[k] = Math.max(lv, iv);
    }
    return out;
  }

  function persistCounters() {
    if (!isExtensionAlive()) { markInvalidated(); return; }
    try {
      chrome.storage.local.set({
        pd_counters: state.counters,
        pd_counters_date: new Date().toDateString(),
      });
    } catch { markInvalidated(); }
    // v1.29.54 — Keep the Stats Jersey live. persistCounters is called
    // from every counter increment (dial, contact, voicemail, etc.) so
    // hooking here means we don't have to thread the refresh through
    // every individual handler. No-op when the jersey isn't open.
    try { refreshJerseyIfOpen(); } catch (_e) {}
  }

  function activeProfile() {
    return state.profiles[state.activeProfile] || { name: "", agentCell: "", vmDropUrl: "" };
  }

  // ─────────────────────────────────────────────────────────────────
  // WebSocket
  // ─────────────────────────────────────────────────────────────────
  // v1.31.89 — Multi-device support. Each browser install gets a stable
  // device ID stored in chrome.storage.local. When the worker has
  // MULTI_DEVICE_ROUTING="true" (currently staging + lab), the deviceId
  // routes to a per-device Durable Object so two browsers on the same
  // account get fully independent sessions. When the flag is "false"
  // (currently prod), the worker ignores the deviceId and routes by
  // agentId as before — sending it is harmless.
  async function loadDeviceId() {
    try {
      const data = await chrome.storage.local.get(["pd_device_id", "pd_device_nickname"]);
      if (data && typeof data.pd_device_id === "string" && data.pd_device_id.length > 0) {
        state.deviceId = data.pd_device_id;
      } else {
        // Generate a new one. Prefer crypto.randomUUID() if available
        // (Chrome 92+), else fall back to a timestamp + random suffix.
        let id;
        try {
          id = (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : null;
        } catch { id = null; }
        if (!id) id = "d-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
        state.deviceId = id;
        try { await chrome.storage.local.set({ pd_device_id: id }); } catch {}
      }
      state.deviceNickname = (typeof data?.pd_device_nickname === "string") ? data.pd_device_nickname : "";
      // Default nickname: something readable derived from UA platform.
      if (!state.deviceNickname) {
        let plat = "This Browser";
        try {
          const lc = (navigator.userAgent || "").toLowerCase();
          if (lc.includes("mac")) plat = "Mac · Chrome";
          else if (lc.includes("windows")) plat = "Windows · Chrome";
          else if (lc.includes("linux")) plat = "Linux · Chrome";
        } catch {}
        state.deviceNickname = plat;
        try { await chrome.storage.local.set({ pd_device_nickname: plat }); } catch {}
      }
      return state.deviceId;
    } catch (_e) {
      return null;
    }
  }
  // Fetch the list of all devices on this account from the worker so
  // we can populate the picker dropdown. Only fires when env supports
  // multi-device (staging/lab); prod skips entirely.
  async function fetchDevicesList() {
    if (!isMultiDeviceEnv()) return [];
    const tok = (activeProfile().authToken || "").trim();
    if (!tok || !state.workerBase) return [];
    try {
      const base = state.workerBase.replace(/\/+$/, "");
      const r = await fetch(`${base}/api/devices/list?token=${encodeURIComponent(tok)}`, { cache: "no-store" });
      if (!r.ok) return [];
      const j = await r.json();
      const devs = Array.isArray(j?.devices) ? j.devices : [];
      state.devicesList = devs;
      return devs;
    } catch (_e) {
      return [];
    }
  }
  // Populate the picker <select> with all devices the worker knows about
  // for this account. Highlights this device + the saved primary choice.
  async function refreshDevicesDropdown() {
    const picker = document.getElementById("pd-device-picker");
    if (!picker) return;
    const devs = await fetchDevicesList();
    // Ensure THIS device is represented even if the worker hasn't
    // registered it yet (first ever open of Setup before the register
    // POST lands).
    const has = devs.some((d) => d.id === state.deviceId || d.id === `dev_${state.deviceId}`);
    const merged = has ? devs : [...devs, { id: state.deviceId, name: state.deviceNickname || "(this device)", lastSeenAt: Date.now() }];
    picker.innerHTML = "";
    const blank = document.createElement("option");
    blank.value = "";
    blank.textContent = "— Any device (worker chooses) —";
    picker.appendChild(blank);
    for (const d of merged) {
      const opt = document.createElement("option");
      opt.value = d.id;
      const isThis = (d.id === state.deviceId || d.id === `dev_${state.deviceId}`);
      opt.textContent = `${d.name || "(unnamed)"}${isThis ? " · THIS DEVICE" : ""}`;
      picker.appendChild(opt);
    }
    try {
      const data = await chrome.storage.local.get(["pd_primary_device"]);
      const saved = data?.pd_primary_device || "";
      if (saved) picker.value = saved;
      state.primaryDeviceId = saved;
    } catch {}
  }
  // Register this device (or update its nickname) on the worker. Idempotent.
  async function registerThisDevice() {
    if (!isMultiDeviceEnv()) return;
    if (!state.deviceId) return;
    const tok = (activeProfile().authToken || "").trim();
    if (!tok || !state.workerBase) return;
    try {
      const base = state.workerBase.replace(/\/+$/, "");
      await fetch(`${base}/api/devices/register`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          token:    tok,
          deviceId: state.deviceId,
          name:     state.deviceNickname || "",
          // v1.33 Phase 3 — Phone identifier on device record so dashboard
          // device manager + Mobile App chooser can scope by phone.
          phone:    activeProfile().phone || "",
          type:     "extension",
        }),
      });
    } catch (_e) {}
  }
  // v1.31.89 — Env gate. Multi-device features only activate when the
  // extension is talking to a worker that has MULTI_DEVICE_ROUTING="true".
  // Today that's staging + lab + lab2. Prod is "false" → these features
  // stay dormant in prod even though the code runs. Detection by
  // workerBase host (staging-prefixed subdomain or "lab" in URL).
  function isMultiDeviceEnv() {
    const base = String(state.workerBase || "").toLowerCase();
    // v1.32.21 — ROLLED BACK Option B (the "true on all *.goalpowerdialer
    // hosts" version). With worker MULTI_DEVICE_ROUTING flipped to false
    // on staging tonight to recover from the routing mismatch, having
    // this stay true on prod-shaped hosts caused offscreen to mint a
    // PER-DEVICE Telnyx credential (gencred_X) and register it — but
    // session.start in the worker bridges to the LEGACY credential
    // (telnyxSipUsername_desktop) when the flag is false. Result:
    // ext WS connected (101) but session.start hung at "Starting"
    // because Telnyx had no registration on the legacy SIP user.
    // (Diagnosed live with Dustin via DevTools Network panel
    // 2026-06-19 ~01:00 — agent +12164023686 ext stuck at "Worker
    // connected Starting" after Start Session.)
    //
    // Back to the v1.31.89-era gate: staging + lab only. Real Option B
    // (or Option C — worker auto-fallback) gets revisited in the
    // multi-user update with proper end-to-end testing.
    // v1.33.6.20 — Progressive multi-device alignment. LNL added so the Liberty
    // extension sends the deviceId on the control WS (its worker already runs
    // MULTI_DEVICE_ROUTING="true"), giving each device its own DO instead of
    // colliding on one phone-keyed DO. PROD (app.) is intentionally NOT here yet
    // — it stays deviceId=none until the two-device test on LNL proves the
    // aligned path (no supersede loop, no "stuck at Starting"). Add "\/\/app\."
    // here for the prod cutover once verified.
    return /staging|\/\/lab\d*\.|\/\/lnl\.|power-dialer-(staging|lab|liberty)/.test(base);
  }
  // v1.32.2 — Persist the multi-device-env decision + workerBase to
  // chrome.storage.local so offscreen.js (which doesn't have access to
  // state.workerBase or isMultiDeviceEnv()) can decide whether to call
  // the per-device JWT endpoint (/api/devices/jwt) or the legacy
  // single-credential endpoint (/api/desktop/jwt). This is the closing
  // half of the multi-device desktop completion — content.js already
  // registers the device via /api/devices/register when the env supports
  // it; offscreen.js now needs to also use the per-device SIP credentials
  // so two desktop installs on one account don't collide on SIP REGISTER.
  async function persistMultiDeviceFlagForOffscreen() {
    try {
      await chrome.storage.local.set({
        pd_multi_device_env: !!isMultiDeviceEnv(),
        pd_worker_base:      String(state.workerBase || ""),
      });
    } catch (_e) {}
  }
  // ─────────────────────────────────────────────────────────────────
  // v1.31.90 — TCPA calling hours
  //
  // State → IANA timezone mapping. Every US state maps to ONE of five
  // zones agents work in: PST, MST, CST, EST, AST. Arizona is MST
  // year-round (no DST) so uses America/Phoenix. PR/USVI uses
  // America/Puerto_Rico (AST).
  // ─────────────────────────────────────────────────────────────────
  const STATE_TZ = {
    // Pacific
    CA: { key: "PST", iana: "America/Los_Angeles" },
    OR: { key: "PST", iana: "America/Los_Angeles" },
    WA: { key: "PST", iana: "America/Los_Angeles" },
    NV: { key: "PST", iana: "America/Los_Angeles" },
    // Mountain
    AZ: { key: "MST", iana: "America/Phoenix" },       // no DST
    CO: { key: "MST", iana: "America/Denver" },
    UT: { key: "MST", iana: "America/Denver" },
    WY: { key: "MST", iana: "America/Denver" },
    MT: { key: "MST", iana: "America/Denver" },
    NM: { key: "MST", iana: "America/Denver" },
    ID: { key: "MST", iana: "America/Denver" },        // most of ID
    // Central
    TX: { key: "CST", iana: "America/Chicago" },
    IL: { key: "CST", iana: "America/Chicago" },
    MO: { key: "CST", iana: "America/Chicago" },
    MN: { key: "CST", iana: "America/Chicago" },
    IA: { key: "CST", iana: "America/Chicago" },
    AR: { key: "CST", iana: "America/Chicago" },
    AL: { key: "CST", iana: "America/Chicago" },
    MS: { key: "CST", iana: "America/Chicago" },
    LA: { key: "CST", iana: "America/Chicago" },
    OK: { key: "CST", iana: "America/Chicago" },
    KS: { key: "CST", iana: "America/Chicago" },
    NE: { key: "CST", iana: "America/Chicago" },
    SD: { key: "CST", iana: "America/Chicago" },
    ND: { key: "CST", iana: "America/Chicago" },
    TN: { key: "CST", iana: "America/Chicago" },
    WI: { key: "CST", iana: "America/Chicago" },
    // Eastern
    NY: { key: "EST", iana: "America/New_York" },
    PA: { key: "EST", iana: "America/New_York" },
    OH: { key: "EST", iana: "America/New_York" },
    FL: { key: "EST", iana: "America/New_York" },
    GA: { key: "EST", iana: "America/New_York" },
    NC: { key: "EST", iana: "America/New_York" },
    SC: { key: "EST", iana: "America/New_York" },
    VA: { key: "EST", iana: "America/New_York" },
    WV: { key: "EST", iana: "America/New_York" },
    MI: { key: "EST", iana: "America/New_York" },
    IN: { key: "EST", iana: "America/New_York" },
    KY: { key: "EST", iana: "America/New_York" },
    ME: { key: "EST", iana: "America/New_York" },
    NH: { key: "EST", iana: "America/New_York" },
    VT: { key: "EST", iana: "America/New_York" },
    MA: { key: "EST", iana: "America/New_York" },
    RI: { key: "EST", iana: "America/New_York" },
    CT: { key: "EST", iana: "America/New_York" },
    NJ: { key: "EST", iana: "America/New_York" },
    DE: { key: "EST", iana: "America/New_York" },
    MD: { key: "EST", iana: "America/New_York" },
    DC: { key: "EST", iana: "America/New_York" },
    // Atlantic
    PR: { key: "AST", iana: "America/Puerto_Rico" },
    // Outliers — map to closest for safety (agents rarely sell here)
    AK: { key: "PST", iana: "America/Anchorage" },     // labeled PST for window match
    HI: { key: "PST", iana: "Pacific/Honolulu" },
    // ── v1.32.0 Canadian provinces. Reusing the same window-key vocabulary
    //    as US (PST/MST/CST/EST/AST) since the calling-hours UI is
    //    structured around 5 keys. AKST/NST/NLT aren't user-selectable
    //    keys yet — Newfoundland (NL) is mapped to AST to keep TZ window
    //    logic working; the IANA name still gives the right local time
    //    for window comparison.
    ON: { key: "EST", iana: "America/Toronto"      },  // Ontario (primary)
    QC: { key: "EST", iana: "America/Toronto"      },  // Québec
    NS: { key: "AST", iana: "America/Halifax"      },  // Nova Scotia
    NB: { key: "AST", iana: "America/Moncton"      },  // New Brunswick
    PE: { key: "AST", iana: "America/Halifax"      },  // Prince Edward Island
    NL: { key: "AST", iana: "America/St_Johns"     },  // Newfoundland (actually NT, mapping to AST window)
    MB: { key: "CST", iana: "America/Winnipeg"     },  // Manitoba
    SK: { key: "CST", iana: "America/Regina"       },  // Saskatchewan (no DST)
    AB: { key: "MST", iana: "America/Edmonton"     },  // Alberta
    BC: { key: "PST", iana: "America/Vancouver"    },  // British Columbia
    YT: { key: "PST", iana: "America/Whitehorse"   },  // Yukon
    NT: { key: "MST", iana: "America/Yellowknife"  },  // Northwest Territories
    NU: { key: "EST", iana: "America/Iqaluit"      },  // Nunavut (mostly EST)
  };
  // Area code → state. Built lazily from the worker's STATE_AREA_CODES.
  // We hardcode a slim local copy here so content.js doesn't have to
  // round-trip the worker on every dial. Covers all 50 states + DC + PR.
  const AC_STATE = (() => {
    const m = {};
    const byState = {
      AL:["205","256","334","251","938"],AK:["907"],AZ:["602","480","520","623","928"],
      AR:["501","479","870","327"],
      CA:["213","415","619","408","323","310","510","714","818","916","209","510","530","559","562","626","650","661","707","747","760","805","831","858","909","925","949","951","424","442","657","669","747","820","840"],
      CO:["303","720","719","970","983"],
      CT:["203","860","475","959"],DE:["302"],
      FL:["305","813","407","904","954","561","239","786","352","850","321","386","407","689","727","754","772","786","941"],
      GA:["404","770","678","912","470","229","706","762"],HI:["808"],ID:["208","986"],
      IL:["312","773","847","630","708","224","815","217","618","309","464","331"],
      IN:["317","812","260","574","765","219","463","930"],
      IA:["515","319","563","712","641"],KS:["913","316","785","620"],
      KY:["502","859","270","606","364"],
      LA:["504","225","337","318","985"],ME:["207"],
      MD:["410","301","443","240","667"],
      MA:["617","508","781","978","857","413","351","339","774"],
      MI:["313","248","616","734","586","810","517","231","269","989","679","947"],
      MN:["612","651","763","952","320","218","507"],
      MS:["601","228","662","769"],MO:["314","816","417","636","573","660","975"],
      MT:["406"],NE:["402","531","308"],NV:["702","725","775"],NH:["603"],
      NJ:["201","732","973","908","609","856","551","862","848","640"],
      NM:["505","575"],
      NY:["212","718","516","585","917","347","646","315","631","914","585","607","716","518","845","332","363","680","838","929"],
      NC:["704","919","336","828","252","910","980","743","984"],ND:["701"],
      OH:["216","614","513","330","440","419","234","740","937","567","220","380"],
      OK:["405","918","539","580"],OR:["503","541","971","458"],
      PA:["215","412","610","717","267","484","570","814","878","223","272"],
      RI:["401"],SC:["803","843","864","854","839"],SD:["605"],
      TN:["615","901","865","423","931","629","731"],
      TX:["713","214","512","210","281","832","972","469","817","915","254","325","361","409","430","432","512","682","737","806","830","903","936","940","945","956","979"],
      UT:["801","385","435"],VT:["802"],
      VA:["703","804","757","434","571","540","276","948"],
      WA:["206","425","253","509","360","564"],WV:["304","681"],
      WI:["414","608","262","920","715","534","353"],WY:["307"],
      DC:["202"],PR:["787","939"],VI:["340"],
      // v1.32.0 — Canadian provinces. Ontario gets the heaviest coverage
      // since launch is ON-first. Other provinces listed primary codes
      // only; can be expanded once we sign agents in those provinces.
      ON:["416","647","437","905","289","365","613","343","753","519","226","548","705","249","683","807"],
      QC:["514","438","450","579","418","581","367","819","873","263"],
      BC:["604","778","672","250","236"],
      AB:["403","780","587","825"],
      MB:["204","431"],
      SK:["306","639"],
      NS:["902","782"],
      NB:["506"],
      PE:["902"],
      NL:["709"],
      YT:["867"],
      NT:["867"],
      NU:["867"],
    };
    for (const st of Object.keys(byState)) {
      for (const ac of byState[st]) {
        if (!m[ac]) m[ac] = st;
      }
    }
    return m;
  })();
  // v1.31.92 — Federal TCPA baseline: 8am-9pm in the called party's
  // local time (47 CFR 64.1200(c)(1)). This is the LATEST a call may
  // be placed under federal law — states can be stricter but not looser.
  const TCPA_FEDERAL = { wdStart: "08:00", wdEnd: "21:00", weStart: "08:00", weEnd: "21:00" };

  // State-specific TCPA mandate OVERRIDES. Only states where the law
  // is STRICTER than the federal baseline. Missing states default to
  // TCPA_FEDERAL (8am-9pm all days).
  //
  // ⚠️ LEGAL DISCLAIMER: this table reflects Anthropic training data
  // as of late 2024. State telemarketing laws change frequently.
  // BEFORE relying on these for compliance defense, review the current
  // statute and any recent state attorney-general advisories with
  // qualified TCPA counsel. These values are a safety net, not a
  // substitute for legal review.
  const STATE_TCPA_HOURS_OVERRIDE = {
    AL: { wdStart: "08:00", wdEnd: "20:00", weStart: "13:00", weEnd: "20:00" }, // No Sun calls before 1pm
    FL: { wdStart: "08:00", wdEnd: "20:00", weStart: "09:00", weEnd: "20:00" }, // FL: 8a-8p Mon-Sat, 9a-8p Sun
    LA: { wdStart: "08:00", wdEnd: "21:00", weStart: "09:00", weEnd: "19:00" }, // Stricter weekend
    MA: { wdStart: "08:00", wdEnd: "20:00", weStart: "09:00", weEnd: "20:00" }, // Massachusetts 8a-8p
    TX: { wdStart: "09:00", wdEnd: "21:00", weStart: "12:00", weEnd: "21:00" }, // TX: 9a M-Sat, noon Sun
    WY: { wdStart: "09:00", wdEnd: "21:00", weStart: "09:00", weEnd: "21:00" }, // 9a start
    // ── Canadian provinces — CRTC Unsolicited Telecommunications Rules
    //    9:00-21:30 M-F + 10:00-18:00 S/S (STRICTER than US federal).
    ON: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    QC: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    BC: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    AB: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    MB: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    SK: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    NS: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    NB: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    PE: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    NL: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    YT: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    NT: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
    NU: { wdStart: "09:00", wdEnd: "21:30", weStart: "10:00", weEnd: "18:00" },
  };

  function getStateTcpaHours(stateCode) {
    const code = String(stateCode || "").toUpperCase();
    return STATE_TCPA_HOURS_OVERRIDE[code] || TCPA_FEDERAL;
  }
  // Pick "more restrictive" between two HH:MM strings.
  // - For starts: LATER wins (push first-call window further right).
  // - For ends:   EARLIER wins (pull last-call window further left).
  function tightenWindow(agentWin, mandateWin) {
    return {
      wdStart: (agentWin.wdStart > mandateWin.wdStart) ? agentWin.wdStart : mandateWin.wdStart,
      wdEnd:   (agentWin.wdEnd   < mandateWin.wdEnd)   ? agentWin.wdEnd   : mandateWin.wdEnd,
      weStart: (agentWin.weStart > mandateWin.weStart) ? agentWin.weStart : mandateWin.weStart,
      weEnd:   (agentWin.weEnd   < mandateWin.weEnd)   ? agentWin.weEnd   : mandateWin.weEnd,
    };
  }
  function defaultCallingHours() {
    // v1.31.92 — Defaults are now the MOST RESTRICTIVE mandate among
    // states in each timezone. Auto-populated from STATE_TCPA_HOURS_OVERRIDE.
    // Agent can adjust to be TIGHTER (later start, earlier end) but the
    // runtime check at isWithinCallingHours() also enforces the mandate
    // per-prospect, so loosening the UI value can never let a call go
    // outside the prospect's actual state mandate.
    //
    // Algorithm: for each TZ, look up every state mapped to that TZ in
    // STATE_TZ. Combine their mandates by taking the most restrictive
    // window (max start, min end) for both weekday and weekend.
    const stateToTz = {};
    for (const [st, info] of Object.entries(STATE_TZ)) {
      const k = info.key;
      stateToTz[k] = stateToTz[k] || [];
      stateToTz[k].push(st);
    }
    const result = {};
    for (const tzKey of ["PST", "MST", "CST", "EST", "AST"]) {
      let acc = { ...TCPA_FEDERAL };
      for (const st of (stateToTz[tzKey] || [])) {
        acc = tightenWindow(acc, getStateTcpaHours(st));
      }
      result[tzKey] = acc;
    }
    return result;
  }
  // v1.31.90 — Scrape the prospect's CURRENT physical address state
  // from Impact's lead detail DOM. Per Dustin: phone numbers move with
  // people; their address is the TCPA-relevant locator. Returns 2-letter
  // state code or null. Lightweight — only inspects targeted patterns,
  // not the full leaf walk.
  function getProspectStateFromImpact() {
    const tryGet = (el) => (el && (el.textContent || "")).replace(/\s+/g, " ").trim();
    // Strategy 1: dedicated "State" / "StateCode" field — Impact sometimes
    // labels these explicitly. Walk leaves once, but only check short
    // text nodes for "State: XX" pattern.
    try {
      const leaves = document.querySelectorAll(
        "td, th, div, span, dd, label, p, li"
      );
      for (let i = 0; i < leaves.length; i++) {
        const t = tryGet(leaves[i]);
        if (!t || t.length > 80) continue;
        // "State: TX" or "StateCode: TX" or "State Code: TX"
        const m1 = t.match(/^State(?:\s?Code)?\s*[:\-]\s*([A-Z]{2})\s*$/i);
        if (m1) return m1[1].toUpperCase();
      }
    } catch {}
    // v1.32.21 — Strategy 1b: sibling-label pattern. Walk elements
    // whose entire text content is just "State" or "State:" — Impact's
    // two-column layouts often render the label and value in separate
    // <td> / <div> nodes so Strategy 1's inline regex never matches.
    // We look at the next-sibling AND the parent's next-sibling for the
    // value. Accept either a 2-letter state code OR a full state name.
    try {
      const labelEls = document.querySelectorAll("td, th, div, span, dd, dt, label");
      for (let i = 0; i < labelEls.length; i++) {
        const t = tryGet(labelEls[i]);
        if (!t) continue;
        if (!/^State\s*:?$/i.test(t)) continue;
        const candidates = [];
        if (labelEls[i].nextElementSibling) candidates.push(labelEls[i].nextElementSibling);
        if (labelEls[i].parentElement && labelEls[i].parentElement.nextElementSibling) {
          candidates.push(labelEls[i].parentElement.nextElementSibling);
        }
        for (const c of candidates) {
          const v = tryGet(c);
          if (!v) continue;
          // 2-letter code
          const m1b1 = v.match(/^([A-Z]{2})$/);
          if (m1b1 && STATE_TZ[m1b1[1].toUpperCase()]) return m1b1[1].toUpperCase();
          // Full state name (compound names supported)
          const m1b2 = v.match(/^([A-Z][a-z]+(?:\s[A-Z][a-z]+)?)$/);
          if (m1b2) {
            const code = STATE_NAME_TO_CODE[m1b2[1].toLowerCase()];
            if (code && STATE_TZ[code]) return code;
          }
        }
      }
    } catch {}
    // Strategy 2: extract from a full address line. Looks for the
    // standard US "City, ST 12345" pattern in any text node up to 200
    // characters. Picks the FIRST match (lead's home address is usually
    // shown above any agent/office address in Impact).
    try {
      const allText = document.body && document.body.textContent || "";
      // Restrict to first ~6000 characters to keep this fast on big DOMs.
      const slice = allText.slice(0, 6000).replace(/\s+/g, " ");
      const re = /,\s*([A-Z]{2})\s+\d{5}(?:-\d{4})?\b/g;
      let m;
      while ((m = re.exec(slice)) !== null) {
        const st = m[1].toUpperCase();
        if (STATE_TZ[st]) return st;
      }
    } catch {}
    // v1.32.10 — Strategy 3: match "City, FullStateName" (no ZIP, full
    // state name). Impact mobile view shows leads as "Cincinnati, Ohio,"
    // without the ZIP — strategies 1 and 2 don't match and the TZ-
    // unknown popup fires even on clearly-US-resident leads. Dustin
    // 2026-06-17 demo prep. Uses STATE_NAME_TO_CODE lookup table.
    try {
      const allText = document.body && document.body.textContent || "";
      const slice = allText.slice(0, 6000).replace(/\s+/g, " ");
      // Match "Word(s), StateName" — accepts compound state names too
      // ("New York", "North Carolina") via [A-Z][a-z]+(?:\s[A-Z][a-z]+)?.
      const re3 = /,\s*([A-Z][a-z]+(?:\s[A-Z][a-z]+)?)\b/g;
      let m;
      while ((m = re3.exec(slice)) !== null) {
        const nm = m[1].trim().toLowerCase();
        const code = STATE_NAME_TO_CODE[nm];
        if (code && STATE_TZ[code]) return code;
      }
    } catch {}
    // v1.32.21 — Strategy 4: "City, ST" without ZIP. Last-resort match
    // for addresses displayed without a ZIP. To minimize false positives
    // we require: a city-shaped 1-3-word capitalized phrase before the
    // comma, the 2-letter state code NOT followed by another letter or
    // digit (so "OH4" or "OKlahoma" don't match), and the state code
    // must be in STATE_TZ. Runs after strategies 1-3 so labeled and
    // ZIP-bearing variants always win.
    try {
      const allText = document.body && document.body.textContent || "";
      const slice = allText.slice(0, 6000).replace(/\s+/g, " ");
      const re4 = /\b([A-Z][a-z]+(?:\s[A-Z][a-z]+){0,2}),\s*([A-Z]{2})(?![A-Za-z0-9])/g;
      let m;
      while ((m = re4.exec(slice)) !== null) {
        const st = m[2].toUpperCase();
        if (STATE_TZ[st]) return st;
      }
    } catch {}
    return null;
  }

  // v1.32.10 — Lookup: state full name → 2-letter code. Lowercase keys for
  // case-insensitive lookup. Used by Strategy 3 of getProspectStateFromImpact.
  const STATE_NAME_TO_CODE = {
    "alabama":"AL","alaska":"AK","arizona":"AZ","arkansas":"AR","california":"CA",
    "colorado":"CO","connecticut":"CT","delaware":"DE","florida":"FL","georgia":"GA",
    "hawaii":"HI","idaho":"ID","illinois":"IL","indiana":"IN","iowa":"IA",
    "kansas":"KS","kentucky":"KY","louisiana":"LA","maine":"ME","maryland":"MD",
    "massachusetts":"MA","michigan":"MI","minnesota":"MN","mississippi":"MS","missouri":"MO",
    "montana":"MT","nebraska":"NE","nevada":"NV","new hampshire":"NH","new jersey":"NJ",
    "new mexico":"NM","new york":"NY","north carolina":"NC","north dakota":"ND","ohio":"OH",
    "oklahoma":"OK","oregon":"OR","pennsylvania":"PA","rhode island":"RI","south carolina":"SC",
    "south dakota":"SD","tennessee":"TN","texas":"TX","utah":"UT","vermont":"VT",
    "virginia":"VA","washington":"WA","west virginia":"WV","wisconsin":"WI","wyoming":"WY",
    "district of columbia":"DC",
  };
  function tzKeyForProspect(opts) {
    // v1.31.90 → v1.31.93 — ADDRESS-ONLY timezone derivation.
    //
    // PRIOR (v1.31.90): we fell back to phone area code when the address
    // couldn't be scraped. PROBLEM (per Dustin): people MOVE — phone
    // numbers don't reflect their current state. A NY 212 number on a
    // prospect who's now in CA would get the wrong TZ window and
    // potentially violate California TCPA hours.
    //
    // NEW (v1.31.93): address is the SOLE source of truth.
    //   1. EXPLICIT state passed in (caller already resolved it)
    //   2. Lead's PHYSICAL ADDRESS state scraped from Impact
    //   3. Returns null — no phone fallback. Caller (isWithinCallingHours)
    //      will surface the unknown state and let the agent decide via
    //      modal popup whether to proceed or skip.
    const explicit = String(opts?.state || "").toUpperCase();
    if (explicit && STATE_TZ[explicit]) return STATE_TZ[explicit];
    try {
      const addrState = getProspectStateFromImpact();
      if (addrState && STATE_TZ[addrState]) return STATE_TZ[addrState];
    } catch {}
    return null;
  }
  function prospectLocalTime(tzInfo) {
    // Returns { hhmm, day } where hhmm is "HH:MM" and day is 0-6 (0=Sun).
    try {
      const now = new Date();
      const opts = { timeZone: tzInfo.iana, hour: "2-digit", minute: "2-digit", hour12: false, weekday: "short" };
      const parts = new Intl.DateTimeFormat("en-US", opts).formatToParts(now);
      let hh = "00", mm = "00", wd = "Mon";
      for (const p of parts) {
        if (p.type === "hour") hh = p.value;
        if (p.type === "minute") mm = p.value;
        if (p.type === "weekday") wd = p.value;
      }
      const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
      return { hhmm: `${hh}:${mm}`, day: dayMap[wd] ?? 1 };
    } catch {
      return { hhmm: "00:00", day: 1 };
    }
  }
  // v1.31.90 — Diagnostic for verifying which state + TZ a lead resolves
  // to. Run __pdProspectTzDebug() in DevTools console while viewing a
  // lead. Returns the address-state vs phone-area-code vs final pick.
  window.__pdProspectTzDebug = function () {
    // v1.31.93 — Address-only resolution. Phone area code is no longer
    // used as a fallback. If address can't be derived, pickedTz=null
    // and at dial time the agent gets the TZ-unknown modal.
    let addrState = null;
    let addrError = null;
    try { addrState = getProspectStateFromImpact(); } catch (e) { addrError = e.message; }
    const homeA = document.querySelector('a[href^="tel:"]');
    const num = homeA ? (homeA.getAttribute("href") || "").replace(/^tel:/, "") : "";
    const tz  = tzKeyForProspect({});  // explicitly NO number — address only
    const wouldShowModal = (state.callingHoursEnabled && !tz);
    return {
      addrState,
      addrError,
      pickedTz: tz,
      source: tz ? "address" : "unknown",
      localTime: tz ? prospectLocalTime(tz) : null,
      // Diagnostic-only: what the phone area code WOULD have resolved to
      // historically (NOT used for the actual TCPA check anymore — kept
      // for reference so the agent can sanity-check)
      _phoneOnlyForReference: {
        areaCode: num.replace(/[^\d]/g, "").slice(-10).slice(0, 3),
        stateGuessFromPhone: AC_STATE[num.replace(/[^\d]/g, "").slice(-10).slice(0, 3)] || null,
      },
      wouldShowTzUnknownModalAtDialTime: wouldShowModal,
      currentSetting: { callingHoursEnabled: state.callingHoursEnabled },
    };
  };
  function isWithinCallingHours(prospectInfo) {
    // prospectInfo: { number, state }. Returns { ok, reason, tzKey,
    // localTime, window }. ok=true when within configured hours.
    //
    // v1.32.8 — GLOBAL KILL SWITCH. If runtime config says the feature
    // is disabled worker-wide (admin set callingHoursEnabled=false via
    // /api/admin/runtime-config), bypass the gate for ALL users
    // regardless of their per-agent toggle. Quick fail-safe lever for
    // emergencies — e.g., bad TZ data, false-positive blocks, etc.
    const globalEnabled = state.runtimeConfig?.callingHoursEnabled !== false;
    if (!globalEnabled) return { ok: true, reason: "feature-disabled-globally" };
    if (!state.callingHoursEnabled) return { ok: true, reason: "feature-disabled" };

    // v1.31.93 — Per-lead bypass: agent confirmed Proceed in the
    // tz-unknown modal for this lead. One-shot — cleared after use.
    const currentLeadId = (() => { try { return getLeadId(); } catch { return null; } })();
    if (state.bypassCallingHoursOnce && currentLeadId && String(state.bypassCallingHoursOnce) === String(currentLeadId)) {
      state.bypassCallingHoursOnce = null;
      return { ok: true, reason: "agent-bypass-confirmed", localTime: null };
    }

    const tz = tzKeyForProspect(prospectInfo);
    if (!tz) {
      // v1.31.93 — TZ UNKNOWN. Caller (maybeAutoDial) is expected to
      // detect ok===null + needsPrompt and show the Proceed/Skip modal.
      return { ok: null, reason: "tz-unknown", needsPrompt: true, localTime: null };
    }
    const agentHrs = (state.callingHours || defaultCallingHours())[tz.key];
    if (!agentHrs) return { ok: true, reason: "no-config", localTime: null };

    // v1.31.92 — Determine the prospect's state and enforce that state's
    // TCPA mandate.
    // v1.32.21 — RUNTIME NOW USES STATE MANDATE ONLY. Previously the
    // check tightened the agent's per-timezone window against the state
    // mandate, which meant the agent's CST row (auto-populated with the
    // MOST RESTRICTIVE state in CST = TX's no-Sunday-before-noon rule)
    // would block calls to CST agents calling permissive non-TX states
    // on Sundays. Concrete report 2026-06-18: agent calling 8:10 PM CST
    // to a state with 9–9 Mon-Sun, blocked because CST row inherited
    // TX restriction. Fix: ignore agent per-timezone settings, use the
    // prospect's state mandate directly.
    let prospectState = "";
    try {
      if (prospectInfo && prospectInfo.state) prospectState = String(prospectInfo.state).toUpperCase();
      if (!prospectState) prospectState = getProspectStateFromImpact() || "";
    } catch {}
    const mandate = getStateTcpaHours(prospectState);
    const effective = mandate; // v1.32.21 — no agent override

    const lt = prospectLocalTime(tz);
    const isWeekend = (lt.day === 0 || lt.day === 6);
    const startStr = isWeekend ? effective.weStart : effective.wdStart;
    const endStr   = isWeekend ? effective.weEnd   : effective.wdEnd;
    const mandStart = isWeekend ? mandate.weStart : mandate.wdStart;
    const mandEnd   = isWeekend ? mandate.weEnd   : mandate.wdEnd;
    const agentStart = isWeekend ? agentHrs.weStart : agentHrs.wdStart;
    const agentEnd   = isWeekend ? agentHrs.weEnd   : agentHrs.wdEnd;
    const ok = (lt.hhmm >= startStr) && (lt.hhmm < endStr);
    return {
      ok,
      reason: ok ? "within" : "outside",
      tzKey: tz.key,
      state: prospectState,
      localTime: lt.hhmm,
      day: lt.day,
      window: `${startStr}–${endStr}`,
      mandateWindow: `${mandStart}–${mandEnd}`,
      agentWindow: `${agentStart}–${agentEnd}`,
      isMandateCeiling: (startStr === mandStart || endStr === mandEnd),
    };
  }
  function tokenQueryString() {
    const tok = (activeProfile().authToken || "").trim();
    const parts = [];
    if (tok) parts.push("token=" + encodeURIComponent(tok));
    // v1.31.89 — Only attach deviceId when env supports it. Keeps prod
    // URLs identical to the v1.31.88 shape.
    if (isMultiDeviceEnv()) {
      const did = state.deviceId || "";
      if (did) parts.push("deviceId=" + encodeURIComponent(did));
    }
    // v1.33 Phase 2 — Append the agent's chosen phone identifier so
    // the worker routes the ext WS to phone_<digits> DO. Mobile that
    // signed in with the same phone lands in the same DO. Falls back
    // to server-side user.agentCell when phone is missing (legacy).
    const phone = (activeProfile().phone || "").trim();
    if (phone) parts.push("phone=" + encodeURIComponent(phone));
    return parts.length ? "?" + parts.join("&") : "";
  }

  // Stable per-user identifier used as the Durable Object name. Phone-mode
  // users get their cell number (preserves existing behavior). Desktop-mode
  // users — including overseas VAs without a US cell — fall back to a
  // deterministic ID derived from their auth token, so the worker still
  // v1.29.93 — Master "is the extension actively intercepting Impact?"
  // predicate. When this returns FALSE, every document-level listener
  // bails out immediately and every boot-time programmatic-click path
  // refuses to fire. The extension's panel UI stays visible, but it
  // does NOT touch Impact's DOM, click anything, or block any clicks.
  //
  // Active = session is parked AND (autopilot is running OR a call
  // is in progress). Anything else = dormant.
  //
  // Why: agents reported "the extension is blocking my clicks" even
  // when not in a session. Root cause was the boot-time auto-advance
  // consumers + global click listeners running regardless of state.
  // The fix is to gate ALL Impact-side activity on session+autopilot,
  // so there's nothing happening when the agent isn't actively dialing.
  //
  // Returns true when:
  //   • state.session === "parked" AND state.autoDialMode === true   (autopilot running)
  //   • state.session === "parked" AND state.prospect != null         (mid-call — keep listeners so disposition + screenshot fire)
  //   • state.session in {"starting","dialing-agent","ending"}        (transitional, still active)
  // Returns false when:
  //   • state.session === "idle"
  //   • state.session === "parked" AND autopilot paused AND no call
  function isExtensionLive() {
    if (state.session === "starting" || state.session === "dialing-agent" || state.session === "ending") return true;
    if (state.session !== "parked") return false;
    if (state.prospect) return true;          // mid-call
    if (state.autoDialMode) return true;      // autopilot running
    return false;                              // parked but autopilot paused — dormant
  }

  // routes them to a consistent DO across reconnects without needing a
  // cell on file.
  function agentId() {
    const p = activeProfile();
    if (p.agentCell && p.agentCell.trim()) return p.agentCell.trim();
    const tok = (p.authToken || "").trim();
    if (!tok) return null;
    // Take a short slice of the token (after the tok_ prefix if present)
    // — opaque, stable, and short enough for a clean URL path.
    const stripped = tok.replace(/^tok_/, "");
    return "desk_" + stripped.slice(0, 16);
  }

  function wsUrl() {
    if (!state.workerBase) return null;
    const id = agentId();
    if (!id) return null;
    const proto = state.workerBase.startsWith("https") ? "wss" : "ws";
    const host = state.workerBase.replace(/^https?:\/\//, "");
    // v1.33.5.1 — Advertise last-fire-wins support. Tells the worker this
    // client understands control-WS close code 4001 ("superseded") and will
    // go PASSIVE instead of auto-reconnecting, so the worker can safely
    // close this session's older sockets when a newer tab connects. Older
    // builds omit this flag and keep the legacy coexist behavior, so they
    // can never be pushed into a kill/reconnect storm.
    const qs = tokenQueryString();
    // v1.33.5.2 — cid = stable per-tab id (see pdClientId). Lets the worker
    // skip superseding this tab's OWN prior socket on reconnect/navigation,
    // so a single tab never 4001s itself into the "another tab" banner.
    return `${proto}://${host}/session/${encodeURIComponent(id)}${qs}${qs ? "&" : "?"}sup=1&cid=${encodeURIComponent(pdClientId())}`;
  }

  function connectWs() {
    const url = wsUrl();
    if (!url) {
      state.wsStatus = "disconnected";
      paint();
      return;
    }
    // v1.33.6.8 — Idempotency guard (fixes the reconnect storm). NEVER open a
    // second control socket while one is already CONNECTING or OPEN. Without
    // this, any caller that fires while a socket is live (reconnect timer, WS/
    // offscreen watchdogs, a storage/status message, Start-Session preflight)
    // spawns a DUPLICATE socket → the worker replaces the prior one ("1 same-tab
    // closed") → the replaced socket's close event schedules a reconnect → a new
    // socket opens on top of the live one again → self-sustaining ~1.5s storm
    // (tail 2026-08-10: endless "control-WS accept: 1 same-tab closed"). This
    // makes connectWs safe to call redundantly.
    try {
      if (state.ws && (state.ws.readyState === 0 /* CONNECTING */ || state.ws.readyState === 1 /* OPEN */)) {
        return;
      }
    } catch (_e) {}
    if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) return;

    // v1.33.5.1 — Any explicit connect attempt (banner take-over, Start
    // Session preflight, Refresh, sign-in change) means this tab is
    // deliberately reclaiming the session, so drop the passive flag.
    state.wsSuperseded = false;
    state.wsStatus = "connecting";
    paint();
    try {
      const ws = new WebSocket(url);
      state.ws = ws;
      // v1.33.2 — Expose WS to __pdFanoutDiagRequest for paired diag.
      try { window.__pdWS = ws; } catch (_e) {}

      ws.addEventListener("open", () => {
        state.wsStatus = "connected";
        state.lastError = null;
        // v1.33.5.1 — This tab is now the session owner (we just fired
        // last). Start the cross-tab heartbeat so passive siblings know a
        // real owner is alive and stay out of the way.
        try { pdBecomeWsOwner(); } catch (_e) {}
        // v1.33.7.7 — STORM CIRCUIT-BREAKER. Only reset the reconnect-backoff
        // counter once the socket has stayed open for a STABILITY window (12s).
        // Previously we reset to 0 on EVERY open, which defeated the backoff
        // whenever the DO kills the socket ~1.5s AFTER a clean open — the
        // Durable Object storage-stall reset loop (v1.33.4.25) or a competing
        // supersede. Each reconnect re-opened cleanly, reset n=0, so the delay
        // stayed pinned at ~1.5s → self-sustaining storm that starves autopilot
        // (every close cancels the dial timer). LNL tail 2026-08-15 showed even
        // ~1.5s hello cadence with ZERO backoff escalation. Now a socket that
        // dies before 12s leaves the counter climbing, so rapid open→close→open
        // escalates 1.5→3→6→12→20s and gives the DO room to stabilize; a socket
        // that survives 12s resets to fast recovery for the next isolated blip.
        try { clearTimeout(state.wsStableTimer); } catch (_e) {}
        state.wsStableTimer = setTimeout(() => {
          try {
            if (state.ws && state.ws.readyState === 1 /* OPEN */) {
              state.wsReconnectAttempts = 0;
            }
          } catch (_e) {}
        }, 12000);
        // Push current toggle + autopilot state so any connected mobile
        // viewer hydrates immediately. The worker caches and replays for
        // any newly-opened viewer too.
        try { broadcastToggleState(); } catch {}
        // v1.33.6.10 — Control-socket keepalive. Sends a tiny {type:"ping"}
        // every 8s so the DialerSession Durable Object stays warm and the
        // socket never goes idle. Without it, a CRM that never navigates
        // (Liberty/Geopointe SPA) lets the DO cold-hibernate between messages;
        // each cold-wake re-hydrate can stall/reset the isolate, closing the
        // WS (1006) and triggering an endless reconnect storm at idle (diag
        // 2026-08-10: wsBuffer showed inbound hello/toggles only, zero
        // outbound — no heartbeat). Impact masks this because its constant
        // page-navs keep the DO warm. Cleared on close so it never leaks or
        // pings a dead socket. The worker treats "ping" as a no-op.
        try { clearInterval(state.wsKeepAliveTimer); } catch (_e) {}
        state.wsKeepAliveTimer = setInterval(() => {
          try {
            if (state.ws && state.ws.readyState === 1 /* OPEN */) {
              state.ws.send(JSON.stringify({ type: "ping", t: Date.now() }));
            }
          } catch (_e) {}
        }, 8000);
        paint();
      });

      ws.addEventListener("message", (e) => {
        let msg;
        try { msg = JSON.parse(e.data); } catch { return; }
        try { _pdNoteWs("in", msg); } catch {}  // v1.32.0 diag
        handleServerMessage(msg);
      });

      ws.addEventListener("close", (ev) => {
        // v1.33.7.11 — STALE-CLOSE GUARD. ROOT CAUSE of the ~2s "same-tab closed"
        // reconnect storm (platform-wide: PROD build 921d81da DO b11a5a26 churned
        // 1,129× over 35 min continuous; LNL Ray'mona 561aaa7e 58-run @5s). When
        // THIS tab opens a fresh same-cid socket, the worker retires the PREVIOUS
        // one with a clean 1000 ("1 same-tab closed"). That old socket's close
        // event then fires HERE — but by now state.ws already points to the NEW,
        // live socket. The original handler ran `state.ws = null` + scheduleReconnect()
        // unconditionally, so it nulled the LIVE reference and kicked off yet another
        // connect → worker retires that one too → self-sustaining storm the connectWs
        // idempotency guard can't stop (it only ever sees a null state.ws). Fix: if
        // state.ws is already a DIFFERENT socket, this is a stale close from an
        // already-superseded socket — ignore it completely (don't null the live ref,
        // don't clear the live socket's timers, don't reconnect).
        if (state.ws && state.ws !== ws) {
          try { _pdNoteWs("in", { type: "_stale-close-ignored", code: ev.code }); } catch {}
          return;
        }
        state.wsStatus = "disconnected";
        state.ws = null;
        try { clearInterval(state.wsKeepAliveTimer); state.wsKeepAliveTimer = null; } catch (_e) {}  // v1.33.6.10 stop keepalive
        try { clearTimeout(state.wsStableTimer); state.wsStableTimer = null; } catch (_e) {}  // v1.33.7.7 storm circuit-breaker — cancel stability reset if socket died early
        try { _pdRecordWsReconnect(); } catch {}  // v1.32.0 diag — auto-submit on reconnect storm
        // v1.32.8 — Reconnect-storm root-cause fix (A). Cancel any in-flight
        // autopilot dial timer so it cannot survive the 4s reconnect window
        // and fire AFTER the new hello has reconciled state. Same for any
        // other pending action timers we know about. Without this, the
        // sequence is: maybeAutoDial sets 1200ms timer → ws.close → 4s
        // wait → new ws + hello → state momentarily clears prospect → the
        // OLD timer's gates re-evaluate true → duplicate session.dial fires.
        // This was Kevin's "rings twice" + drift symptom 2026-06-15.
        try { clearTimeout(state.autoDialTimer); state.autoDialTimer = null; } catch {}
        try { clearTimeout(state.dialChainTimer); state.dialChainTimer = null; } catch {}
        // 1006 = abnormal closure (no upgrade response). 1008 = policy /
        // auth violation. Surface actionable hints instead of the generic
        // "WebSocket error" message.
        // v1.33.5.1 — 4001 = SUPERSEDED. A newer connection (another tab,
        // window, or device) claimed this session — last fire wins. This tab
        // goes PASSIVE and deliberately does NOT auto-reconnect; that's what
        // stops two tabs from killing each other in a loop (the v1.32.4
        // storm). The agent reclaims it here with one click. Agents should
        // never have to close tabs by hand to fix a stuck dialer.
        if (ev.code === 4001) {
          // v1.33.5.2 — Only honor 4001 as a real hand-off if a DIFFERENT
          // tab's ownership heartbeat is actually alive. The worker's cid check
          // already refuses to supersede a tab's own socket, so this is a
          // belt-and-suspenders against any residual race (a rollout-era socket
          // with no cid, sessionStorage cleared, etc). No live peer = this was
          // same-tab noise (a reconnect/navigation race) → reconnect instead of
          // falsely showing the "running in another tab" banner and going dark.
          // Casey England 2026-07-22: single tab, no other window, stuck on the
          // banner with the dialer dead.
          pdOwnerIsAlive().then((peerAlive) => {
            if (peerAlive) {
              state.wsSuperseded = true;
              // Hand ownership over — stop claiming it so the new owner's
              // heartbeat is the only fresh one.
              try { pdReleaseWsOwnership(); } catch (_e) {}
              state.lastError = "The dialer is running in another tab or window.";
              paint();
              try { showSupersededBanner(); } catch (_e) {}
            } else {
              // No real competitor holding the session — reclaim it.
              state.wsSuperseded = false;
              scheduleReconnect();
            }
          }).catch(() => { scheduleReconnect(); });
          return; // ownership decision handled async above
        }
        if (ev.code === 1008 || ev.reason && /unauth|token/i.test(ev.reason)) {
          state.lastError = "Sign-in expired. Click ⚙ Setup → Sign out → sign in again.";
        } else if (ev.code === 1006) {
          state.lastError = "Can't reach worker. Retrying… If this persists, check internet or the Worker URL in Setup.";
        } else if (ev.code !== 1000) {
          state.lastError = `Worker disconnected (code ${ev.code}). Reconnecting…`;
        }
        paint();
        scheduleReconnect();
      });

      ws.addEventListener("error", () => {
        // Browser fires "error" before "close" — keep a generic banner
        // until the close event resolves what actually went wrong.
        if (!state.lastError) state.lastError = "Connecting…";
      });
    } catch (err) {
      state.lastError = err.message;
      state.wsStatus = "disconnected";
      paint();
      scheduleReconnect();
    }
  }

  // v1.33.4.25 — Reconnect-storm dampener. Prod logs 2026-07-06 showed a
  // single desktop DO reset ~14× in 20s: the DialerSession Durable Object
  // hit a storage-operation timeout → Cloudflare reset the isolate → the
  // control WS closed 1006 → the extension reconnected on a FIXED 4s timer →
  // re-entered the DO → same storage stall → reset again. The fixed cadence
  // amplified a transient storage hiccup into a sustained loop (and the
  // downstream orphaned-call bridge/playback 422 wave). Exponential backoff
  // with jitter spaces retries out (1.5s → 3s → 6s → 12s → cap 20s) so a
  // resetting/overloaded DO gets room to stabilize, while the counter reset
  // on a clean "open" keeps single-blip recovery fast. Cap stays modest so
  // agents aren't stranded offline for long after a real outage clears.
  function scheduleReconnect() {
    clearTimeout(state.wsReconnectTimer);
    const n = state.wsReconnectAttempts || 0;
    state.wsReconnectAttempts = n + 1;
    const base = Math.min(20000, 1500 * Math.pow(2, n));
    const delay = Math.round(base * (0.8 + Math.random() * 0.4)); // ±20% jitter
    state.wsReconnectTimer = setTimeout(connectWs, delay);
  }

  // v1.33.5.1 — Cross-tab session-ownership heartbeat.
  //
  // This is what MERGES the 5s WS watchdog with the last-fire-wins
  // supersede so the two cooperate instead of fighting. The tab that owns
  // the session refreshes `pd_ws_owner` in chrome.storage.local (shared
  // across all Impact tabs) every few seconds. A superseded (passive) tab
  // stays passive ONLY while that heartbeat is fresh — so two live tabs
  // never ping-pong. The moment the owning tab is closed, crashes, or the
  // machine sleeps, the heartbeat goes stale and the passive tab reclaims
  // on its next watchdog tick.
  //
  // Net effect: the 5s watchdog always reconnects the RIGHT tab, and the
  // agent never has to click anything — or close tabs — to get the dialer
  // back. Last fire wins while it's alive; ownership falls through to a
  // surviving tab automatically when it isn't.
  const PD_OWNER_KEY      = "pd_ws_owner";
  const PD_OWNER_BEAT_MS  = 5000;
  const PD_OWNER_STALE_MS = 15000;   // 3 missed beats → owner is gone

  function pdClientId() {
    // v1.33.5.2 — STABLE per-tab id. Must survive same-tab Impact page
    // navigation (InboxDetail↔WhatHappend) so the worker + owner-heartbeat can
    // tell "this one tab reconnecting/navigating" apart from "a real second
    // tab." An in-memory id regenerates on every page load, which made a
    // single tab's own reconnect look like a competing tab → false 4001
    // "running in another tab" banner (Casey England 2026-07-22). sessionStorage
    // is per-tab and persists across same-origin navigation, but is NOT shared
    // with other tabs — exactly the identity we need. Falls back to in-memory
    // if sessionStorage is unavailable.
    if (state.wsClientId) return state.wsClientId;
    try {
      let cid = window.sessionStorage.getItem("pd_ws_cid");
      if (!cid) {
        cid = "t_" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
        window.sessionStorage.setItem("pd_ws_cid", cid);
      }
      state.wsClientId = cid;
    } catch (_e) {
      state.wsClientId = "t_" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
    }
    return state.wsClientId;
  }

  function pdWriteOwnerBeat() {
    try {
      chrome.storage.local.set({ [PD_OWNER_KEY]: { id: pdClientId(), ts: Date.now() } });
    } catch (_e) {}
  }

  function pdBecomeWsOwner() {
    state.wsSuperseded = false;
    pdWriteOwnerBeat();
    if (state.wsOwnerBeatTimer) return;   // already beating
    state.wsOwnerBeatTimer = setInterval(() => {
      // Keep claiming ownership while this tab is alive and hasn't been
      // superseded — INCLUDING while it's mid-reconnect after a network
      // blip, so a passive sibling can't steal the session out from under
      // a recovering owner. The interval dies with the tab, which is
      // exactly what makes the staleness check meaningful.
      if (state.wsSuperseded) return;
      pdWriteOwnerBeat();
    }, PD_OWNER_BEAT_MS);
  }

  function pdReleaseWsOwnership() {
    try { clearInterval(state.wsOwnerBeatTimer); } catch (_e) {}
    state.wsOwnerBeatTimer = null;
  }

  // True only when a DIFFERENT tab is actively holding the session.
  function pdOwnerIsAlive() {
    return new Promise((resolve) => {
      try {
        chrome.storage.local.get([PD_OWNER_KEY], (data) => {
          void chrome.runtime.lastError;
          const o = data && data[PD_OWNER_KEY];
          if (!o || typeof o.ts !== "number") return resolve(false);
          if (o.id === pdClientId()) return resolve(false);   // we're the owner
          resolve(Date.now() - o.ts < PD_OWNER_STALE_MS);
        });
      } catch (_e) { resolve(false); }
    });
  }

  // v1.33.5.1 — "Running in another tab" banner. Shown when the worker
  // closed this tab's control socket with 4001 because a newer connection
  // claimed the session (last-fire-wins). This tab stays passive until the
  // agent explicitly reclaims it — that's what keeps several open Impact
  // tabs from fighting over one session without making the agent hunt down
  // and close them.
  function showSupersededBanner() {
    if (document.getElementById("pd-superseded-banner")) return;
    const bar = document.createElement("div");
    bar.id = "pd-superseded-banner";
    bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:2147483646;background:linear-gradient(180deg,#1B4332,#16263d);color:#fff;border-bottom:2px solid #F2C94C;padding:10px 16px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:13px;box-shadow:0 6px 20px rgba(0,0,0,0.45);";
    bar.innerHTML = `
      <span>⚑ The dialer is now running in another tab or window.</span>
      <button id="pd-superseded-takeover" style="background:#F2C94C;border:1px solid #C49B12;color:#1B4332;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Use the dialer here instead</button>
      <button id="pd-superseded-dismiss" style="background:transparent;border:1px solid rgba(255,255,255,0.25);color:#a8c1e6;padding:6px 12px;border-radius:6px;font-size:12px;cursor:pointer;font-family:inherit;">Dismiss</button>
    `;
    document.body.appendChild(bar);
    document.getElementById("pd-superseded-dismiss")?.addEventListener("click", () => bar.remove());
    document.getElementById("pd-superseded-takeover")?.addEventListener("click", () => {
      bar.remove();
      state.wsSuperseded = false;
      state.wsReconnectAttempts = 0;
      state.lastError = null;
      try { connectWs(); } catch (_e) {}
    });
  }

  function send(type, extra) {
    if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
      toast("Not connected to Worker — check settings");
      return;
    }
    state.ws.send(JSON.stringify({ type, ...(extra || {}) }));
  }

  // ─────────────────────────────────────────────────────────────────
  // Audio: receive Telnyx-forked prospect audio over WS, decode µ-law,
  // play via Web Audio API. Auto-stops when the DO calls streaming_stop
  // (which happens at bridge time — agent then hears via the parked cell).
  // ─────────────────────────────────────────────────────────────────
  let audioCtx = null;
  let audioNextStart = 0;

  // µ-law (PCMU) decode table — Telnyx streams 8 kHz / 8-bit µ-law audio.
  const MU_LAW_TABLE = (() => {
    const table = new Float32Array(256);
    for (let i = 0; i < 256; i++) {
      const u = ~i & 0xFF;
      const sign = (u & 0x80) ? -1 : 1;
      const exponent = (u & 0x70) >> 4;
      const mantissa = u & 0x0F;
      let sample = ((mantissa << 3) + 0x84) << exponent;
      sample -= 0x84;
      table[i] = sign * sample / 32768;
    }
    return table;
  })();

  function ensureAudioCtx() {
    // v1.33.5.1 — Only create/resume the AudioContext once the page has had a
    // user gesture (sticky activation). Chrome BLOCKS AudioContext start
    // before any gesture and emits "AudioContext was not allowed to start",
    // which agents forwarded as scary "error" emails (Casey England + Chanelle
    // Perez 2026-07-20, from startRingback during autopilot on a fresh Impact
    // page). The tone here is cosmetic (ringback / hangup blip) — real Desktop
    // call audio is the offscreen WebRTC SDK, not this context — so skipping
    // until activation costs nothing and silences the warning. Activation is
    // sticky per document, so the first click on the page (agents always click)
    // makes it work normally for the rest of that page's life.
    const activated = (() => {
      try { return navigator.userActivation ? navigator.userActivation.hasBeenActive : true; }
      catch { return true; }
    })();
    if (!audioCtx) {
      if (!activated) return null;   // don't provoke the blocked-start warning
      try {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      } catch { return null; }
    }
    if (audioCtx.state === "suspended") {
      if (!activated) return null;   // resume() pre-activation warns too
      audioCtx.resume().catch(() => {});
    }
    return audioCtx;
  }

  // v1.31.42 — Prime AudioContext on FIRST user gesture (any click / key /
  // touch on the page). Chrome's autoplay policy blocks `new AudioContext()`
  // unless it's created or resumed in direct response to a user gesture.
  // Previously we created it lazily inside ensureAudioCtx() when ringback /
  // hangup tone / streamed µ-law chunks first arrived — but if those arrived
  // BEFORE the agent clicked anything on the page, Chrome silently blocked
  // the context creation and every subsequent audio call was a no-op for the
  // rest of the page session. Observed on Becca's machine 2026-06-01 20:18
  // ("AudioContext was not allowed to start", content.js:509) and likely
  // contributed to her "going too fast / skipping leads" report — without
  // ringback or call audio, she couldn't tell calls were live and clicked
  // Skip repeatedly. Pre-creating on any gesture (Impact has clickable UI
  // long before the panel ever dials) guarantees the context is alive.
  (function primeAudioOnFirstGesture() {
    const prime = () => {
      try {
        if (!audioCtx) {
          audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        }
        if (audioCtx && audioCtx.state === "suspended") {
          audioCtx.resume().catch(() => {});
        }
      } catch (_e) { /* one-shot, swallow */ }
    };
    const opts = { once: true, capture: true, passive: true };
    ["click", "keydown", "touchstart", "pointerdown"].forEach((ev) => {
      try { window.addEventListener(ev, prime, opts); } catch {}
    });
  })();

  function playMuLawChunk(b64Payload) {
    const ctx = ensureAudioCtx();
    if (!ctx) return;
    let bytes;
    try {
      const bin = atob(b64Payload);
      bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    } catch { return; }
    if (!bytes.length) return;

    const samples = new Float32Array(bytes.length);
    for (let i = 0; i < bytes.length; i++) samples[i] = MU_LAW_TABLE[bytes[i]];

    const buffer = ctx.createBuffer(1, samples.length, 8000);
    buffer.copyToChannel(samples, 0);
    const source = ctx.createBufferSource();
    source.buffer = buffer;
    source.connect(ctx.destination);

    const now = ctx.currentTime;
    // Re-anchor if our scheduling has drifted behind (e.g. tab was hidden)
    // or if it's the very first chunk this session.
    if (audioNextStart < now + 0.02) audioNextStart = now + 0.05;
    source.start(audioNextStart);
    audioNextStart += buffer.duration;
  }

  function audioWsUrl() {
    if (!state.workerBase) return null;
    const id = agentId();
    if (!id) return null;
    const proto = state.workerBase.startsWith("https") ? "wss" : "ws";
    const host = state.workerBase.replace(/^https?:\/\//, "");
    return `${proto}://${host}/audio/${encodeURIComponent(id)}${tokenQueryString()}`;
  }

  // v1.29.0: Audio WS bridge REMOVED. With Cell mode gone and media
  // streaming disabled server-side, this WS never receives frames.
  // The actual call audio reaches the agent via the WebRTC SIP register
  // on the /mobile or /desktop surface (the Telnyx WebRTC SDK plays it
  // directly through the page's <audio> element). These stubs stay as
  // no-ops so existing callers (paint, ws-message handlers) don't crash.
  function connectAudioWs() { /* no-op in v1.29.0 */ }
  function disconnectAudioWs() {
    state.audioWs = null;
    audioNextStart = 0;
    state.audioStatus = "off";
  }

  // ─────────────────────────────────────────────────────────────────
  // Synthetic ringback tone (US standard: 440 Hz + 480 Hz, 2 s on / 4 s off)
  // Played in the browser while a prospect call is dialing/ringing so the
  // agent has audible feedback the line is alive. Stopped the moment the
  // prospect actually answers (real audio takes over).
  // ─────────────────────────────────────────────────────────────────
  let ringback = null;

  function startRingback() {
    // v1.29.28 — Suppress the synthetic ringback on the desktop when the
    // user is parked on the phone (Mobile App mode). The agent is wearing
    // the phone and any audible feedback should come from there — playing
    // ringback through the computer just creates noise and confused the
    // diagnosis of where audio paths actually were. The phone's
    // TelnyxRTC + Telnyx hold music handles the dialing-feedback signal.
    if ((state.activeParkSource || "desktop") === "mobile_web") return;
    const ctx = ensureAudioCtx();
    if (!ctx || ringback) return;
    const osc1 = ctx.createOscillator();
    const osc2 = ctx.createOscillator();
    const gain = ctx.createGain();
    osc1.frequency.value = 440;
    osc2.frequency.value = 480;
    osc1.type = "sine";
    osc2.type = "sine";
    gain.gain.value = 0; // start silent; cycle ramps it
    osc1.connect(gain);
    osc2.connect(gain);
    gain.connect(ctx.destination);
    osc1.start();
    osc2.start();

    ringback = { osc1, osc2, gain, interval: null };

    const TARGET = 0.18; // moderate volume so it isn't piercing
    const ON_MS  = 2000;
    const OFF_MS = 4000;
    const CYCLE  = ON_MS + OFF_MS;

    const ramp = (to, t) => gain.gain.linearRampToValueAtTime(to, ctx.currentTime + t);

    // Schedule one full cycle starting now: ON for 2s, OFF for 4s.
    const oneCycle = () => {
      ramp(TARGET, 0.05);
      setTimeout(() => { if (ringback) ramp(0, 0.05); }, ON_MS);
    };
    oneCycle();
    ringback.interval = setInterval(oneCycle, CYCLE);
    // v1.33.6.11 — Ringback safety-stop. No real OR simulated ring lasts this
    // long (longest test-mode ring is ~12s; real ring-timeout ~24s). If we're
    // still ringing after 35s, a connect/hangup event was lost — e.g. a stuck
    // test-mode "live" call whose connect timer didn't fire, or a dropped WS
    // message — so kill the tone rather than let it loop forever. Cleared in
    // stopRingback on the normal connect/hangup path.
    try { clearTimeout(state._ringbackSafety); } catch (_e) {}
    state._ringbackSafety = setTimeout(() => {
      state._ringbackSafety = null;
      try { stopRingback(); } catch (_e) {}
      try { log("warn", "Ringback safety-stop: call stuck ringing >35s — tone killed. Skip/End if the panel is still stuck."); } catch (_e) {}
    }, 35000);
  }

  function stopRingback() {
    try { clearTimeout(state._ringbackSafety); state._ringbackSafety = null; } catch (_e) {}
    if (!ringback) return;
    const { osc1, osc2, gain, interval } = ringback;
    if (interval) clearInterval(interval);
    try { gain.gain.cancelScheduledValues(audioCtx?.currentTime || 0); } catch {}
    try { gain.gain.value = 0; } catch {}
    try { osc1.stop(); osc1.disconnect(); } catch {}
    try { osc2.stop(); osc2.disconnect(); } catch {}
    try { gain.disconnect(); } catch {}
    ringback = null;
  }

  // v1.29.31 — Short descending two-tone "call ended" notification.
  // 700 Hz for 140ms, then 500 Hz for 160ms. Only plays in Desktop
  // WebRTC mode (in Mobile App mode the phone plays its own tone).
  function playHangupTone() {
    if ((state.activeParkSource || "desktop") === "mobile_web") return;
    try {
      const ctx = ensureAudioCtx();
      if (!ctx) return;
      const now = ctx.currentTime;
      const gain = ctx.createGain();
      gain.gain.value = 0;
      gain.connect(ctx.destination);

      const osc = ctx.createOscillator();
      osc.type = "sine";
      osc.connect(gain);

      // Tone 1: 700 Hz, 0–140 ms (quick ramp in/out to avoid clicks).
      osc.frequency.setValueAtTime(700, now);
      gain.gain.linearRampToValueAtTime(0.22, now + 0.01);
      gain.gain.setValueAtTime(0.22, now + 0.13);
      gain.gain.linearRampToValueAtTime(0, now + 0.14);
      // Tone 2: 500 Hz, 160–300 ms.
      osc.frequency.setValueAtTime(500, now + 0.16);
      gain.gain.linearRampToValueAtTime(0.22, now + 0.17);
      gain.gain.setValueAtTime(0.22, now + 0.29);
      gain.gain.linearRampToValueAtTime(0, now + 0.30);

      osc.start(now);
      osc.stop(now + 0.32);
      osc.onended = () => {
        try { osc.disconnect(); } catch {}
        try { gain.disconnect(); } catch {}
      };
    } catch (err) {
      console.warn("[pd hangup-tone] failed:", err && err.message);
    }
  }

  // ─────────────────────────────────────────────────────────────────
  // Server-message handling
  // ─────────────────────────────────────────────────────────────────
  function handleServerMessage(msg) {
    switch (msg.type) {
      case "hello":
        // v1.29.13 — Merge instead of overwrite. The worker's in-memory
        // counters reset to zero when the Durable Object hibernates (every
        // few seconds of inactivity), and hello fires on every WS reconnect
        // — including after an Impact page nav. Without the merge, a quick
        // page hop would wipe daily totals to 0/0/0.
        if (msg.counters) {
          state.counters = mergeCounters(state.counters, msg.counters);
          persistCounters();
        }
        // v1.31.28 → v1.31.88 — Toggle reconciliation on hello.
        //
        // PRIOR BEHAVIOR (v1.31.28): worker's _lastToggles was treated as
        // authoritative. Hello overwrote local state.X (and chrome.storage)
        // with worker's cached values. PROBLEM: when an Impact page-nav
        // reboot fired and the worker's hibernated _lastToggles had stale
        // defaults (false), the loaded chrome.storage true values got
        // wiped → "all toggles reset after each call" (Dustin's report).
        // Only doubleTap survived because of some other interaction.
        //
        // NEW BEHAVIOR (v1.31.88): chrome.storage is THIS device's source
        // of truth. On hello, we LOG mismatches but DON'T overwrite local
        // state. Then we immediately re-broadcast our authoritative state
        // so the worker's _lastToggles gets corrected for any other
        // connected device (mobile companion). Cross-device sync still
        // works via explicit session.toggle.set messages handled below.
        if (msg.toggles && typeof msg.toggles === "object") {
          const tog = msg.toggles;
          const desyncs = [];
          const cmpKeys = ["doubleTap","leaveVoicemail","bottomToTop","callFromComments","relayMode","autoAttendantHangup","awayMode","testMode"];
          for (const k of cmpKeys) {
            if (typeof tog[k] === "boolean" && !!tog[k] !== !!state[k]) {
              desyncs.push(`${k}(local=${!!state[k]} worker=${tog[k]})`);
            }
          }
          if (desyncs.length) {
            try { log("info", `[v1.31.88] hello toggle desync — local wins: ${desyncs.join(", ")}`); } catch {}
          }
          // Re-broadcast our local state so the worker's _lastToggles is
          // refreshed. Other clients (mobile) will receive this via the
          // worker's broadcast fan-out at session.js:536.
          try { broadcastToggleState(); } catch {}
        }
        if (typeof msg.autoDialMode === "boolean") {
          state.autoDialMode = msg.autoDialMode;
          try { chrome.storage.local.set({ pd_autodial: state.autoDialMode }); } catch (_e) {}
        }
        // Restore session + prospect state so the panel survives Impact's
        // page navigations (each navigation reloads content.js fresh).
        //
        // v1.33.4.1 — Hello-handler sessionState DOWNGRADE guard.
        // Symptom (Casey + Jonathan + several agents 2026-06-25):
        // autopilot running smoothly for 3-4 dials then session
        // suddenly drops back to "Start Session" UI mid-stream.
        // Root mechanism is the same class as v1.33.2.4 phantom
        // session.ended: worker DO hibernates on idle (every few
        // seconds), then on wake the hello payload carries
        // sessionState="idle" because the in-RAM session hasn't
        // been re-hydrated from storage yet. The line below USED to
        // overwrite local state unconditionally, killing live
        // sessions mid-dial. v1.33.3 Wake Lock + Audio Test (new
        // this week) raised WS-reconnect frequency, which made
        // this regression bite many more agents.
        //
        // New rule: if the worker is trying to DOWNGRADE us from a
        // live state (parked / active / dialing-agent / starting)
        // back to idle, and the user did NOT recently click End
        // Session (60s window, same pattern as v1.33.2.4 session
        // .ended guard at line 1964), ignore the downgrade.
        // Upgrades (idle → parked) and equal states always apply.
        // The auto-submit gives admin diag visibility into how
        // often this races so we can track if a worker-side fix
        // (persist session.state to KV before hibernation) is
        // needed in a later release.
        if (msg.sessionState) {
          const _localLive = state.session && state.session !== "idle";
          const _wantsIdle = msg.sessionState === "idle";
          const _userJustEnded = state.userEndedSessionAt && (Date.now() - state.userEndedSessionAt < 60_000);
          if (_localLive && _wantsIdle && !_userJustEnded) {
            try {
              log("warn", `[v1.33.4.1 hello-downgrade-guard] worker hello says sessionState=idle but local is '${state.session}' — preserving (hibernation race)`);
            } catch {}
            try { _pdAutoSubmitWithReason("auto:hello-downgrade-ignored"); } catch {}
            // Skip overwrite — keep state.session as it was.
          } else {
            state.session = msg.sessionState;
          }
        }
        if (msg.prospect) {
          state.prospect = {
            number: msg.prospect.number,
            fromNumber: msg.prospect.fromNumber || null,   // v1.33.7.10 — survive reconnect/hello during an active call
            leadId: msg.prospect.leadId,
            status: msg.prospect.status,
            transcript: [],
            interim: "",
            amd: null,
            disposition: msg.prospect.disposition || null,
          };
          // Restore: we don't know the true start time, so count from now.
          state.callStartedAt = Date.now();
        } else {
          state.prospect = null;
          state.callStartedAt = 0;
          // Page just loaded with NO active prospect — if autoDial is on and we're
          // on a lead detail page, fire one off after a beat.
          //
          // v1.32.8 — Reconnect-storm root-cause fix (B/gate). DO NOT fire
          // maybeAutoDial if a prospect.hangup happened in the last 3 seconds.
          // Reason: WS reconnect storms during the hangup window cause hello
          // to arrive with prospect:null because the worker's prospectLeg
          // already cleared, but the disposition write / advance for the
          // PRIOR call is still in flight. Firing autopilot here races and
          // can produce a duplicate dial against the just-ended lead, OR
          // can land the disposition on the wrong (next) lead. Wait until
          // the hangup quiesces before considering this hello "page load".
          const sinceHangup = Date.now() - (state.lastHangupAt || 0);
          if (sinceHangup > 3000) {
            maybeAutoDial();
          } else {
            log("info", `hello: skipping maybeAutoDial — only ${sinceHangup}ms since last hangup (v1.32.8 gate)`);
          }
        }
        break;

      case "session.starting":
      case "agent.dialing":
        state.session = "dialing-agent";
        // v1.33.3 — Acquire wake lock when the session is spinning up.
        // Idempotent — re-acquiring while held is a no-op.
        try { _pdAcquireWakeLock(); } catch {}
        break;

      case "agent.parked":
        state.session = "parked";
        toast("Cell parked — ready to dial");
        // v1.29.98 — Removed the deferred-consumer re-trigger that v1.29.93
        // added as a workaround for the now-removed gate. Consumers fire
        // at boot directly; no re-trigger needed here.
        // Auto-refresh equivalent to clicking the ↻ button: reload
        // config from chrome.storage so the panel reflects the latest
        // server state, ensure the audio WS is up if Desktop mode, and
        // force a second paint after the DOM settles so the "Parked"
        // banner unambiguously replaces the "Dialing agent…" view.
        try { loadConfig().then(() => paint()).catch(() => {}); } catch {}
        try { connectAudioWs(); } catch {}
        try { refreshDesktopStatusInDrawer && refreshDesktopStatusInDrawer(); } catch {}
        // Defensive: a few legacy paints in case Impact's CSS layout
        // engine debounces the first one.
        setTimeout(() => { try { paint(); } catch {} }, 50);
        setTimeout(() => { try { paint(); } catch {} }, 300);
        // Refresh the mode-row banner so the active-pane park pip turns green
        try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
        break;

      case "agent.hangup":
        state.session = "idle";
        state.prospect = null;
        state.callStartedAt = 0;
        break;

      // v1.33.2 — Paired diagnostics. The OTHER surface (mobile PWA)
      // tapped its Send Diagnostics button. DO broadcast diag.request
      // to all attached WS. Fire our own snapshot tagged with the same
      // correlationId so admin can view both side-by-side.
      case "diag.request":
        try { window.__pdHandleDiagRequest && window.__pdHandleDiagRequest(msg); } catch (_e) {}
        break;

      // v1.31.80 — Becca 2026-06-09: Mobile "End Call" tap when no active
      // call now broadcasts this event. Click Impact's down-arrow to
      // advance to next lead (same path the desktop normally takes).
      case "agent.advance-lead":
        log("info", "agent.advance-lead from " + (msg.source || "?") + " — clicking down-arrow");
        toast("Advancing to next lead…");
        setTimeout(() => {
          try { advanceToNextLead(); } catch (err) {
            log("warn", "advance-lead failed: " + (err?.message || err));
          }
        }, 300);
        break;

      case "session.ended": {
        // v1.33.2.4 — Defend against worker hibernation phantom-ends.
        // Symptom (Karla Ireta 2026-06-22 + others): agent presses
        // Pause Autopilot, call ends naturally, then "ringing again
        // without Start Autopilot" — looks like a phantom session
        // restart. Root mechanism: worker DO hibernates after a few
        // seconds of inactivity; on wake the DO has no in-RAM session
        // state, so it sends session.ended out of habit. Previously
        // we zeroed state.session + state.prospect + state.callStartedAt
        // here unconditionally, which made content.js drop the agent
        // into the "Start Session" view. Then any inbound event from
        // the woken DO recreated a fresh session — phantom restart.
        //
        // New behavior: trust msg.reason if the worker provides it.
        // If reason is missing OR is "hibernate"/"do-restart"-ish,
        // treat as advisory: log + soft-clear prospect (since the
        // CALL definitely doesn't survive hibernation) but PRESERVE
        // state.session unless the agent recently clicked End Session
        // (state.userEndedSessionAt within last 60s). This keeps
        // autopilot running and skip-lead types intact across worker
        // micro-restarts. The proper fix lives worker-side in v1.33.5
        // (persist session.state to KV before hibernation).
        const _reason = msg && (msg.reason || msg.cause) || null;
        const _looksHibernation = !_reason || /hiberna|do.*restart|woke|reload/i.test(String(_reason));
        const _userJustEnded = state.userEndedSessionAt && (Date.now() - state.userEndedSessionAt < 60_000);
        if (_looksHibernation && !_userJustEnded && state.session !== "idle") {
          try {
            log("warn", `[v1.33.2.4] session.ended received from worker (reason=${_reason || "<none>"}) but no recent user-initiated End — treating as hibernation advisory. Preserving session + autopilot; clearing prospect only.`);
          } catch {}
          try { _pdAutoSubmitWithReason("auto:phantom-session-ended"); } catch {}
          state.prospect = null;
          state.callStartedAt = 0;
          try { releaseActiveLeadLock("session.ended-hibernation-soft"); } catch (_e) {}
          break;
        }
        state.session = "idle";
        state.prospect = null;
        state.callStartedAt = 0;
        // v1.33.3 — Release Wake Lock on confirmed (non-phantom) session end.
        try { _pdReleaseWakeLock(); } catch {}
        // v1.31.81 — Defensive: release lead-lock on session end too,
        // in case prospect.hangup was missed (e.g., agent ended session
        // mid-call without graceful hangup).
        try { releaseActiveLeadLock("session.ended"); } catch (_e) {}
        // v1.32.8 — Sweep all stale Double-Tap per-lead state. The
        // per-lead cleanup in advanceToNextLead handles the common case,
        // but session-end is the belt to that suspender: catches any
        // pd_dt_state_{leadId} keys left behind by abandoned sessions,
        // worker errors, or pre-v1.32.8 builds. Without this sweep,
        // chrome.storage.local would slowly accumulate stale phase
        // records forever, and a lead re-entering the queue tomorrow
        // would resume from yesterday's phase instead of fresh.
        try {
          chrome.storage.local.get(null).then((items) => {
            const dtKeys = Object.keys(items || {}).filter((k) => k.startsWith("pd_dt_state_"));
            if (dtKeys.length > 0) {
              chrome.storage.local.remove(dtKeys);
              log("info", `session.ended: swept ${dtKeys.length} stale Double-Tap state record(s) (v1.32.8)`);
            }
          }).catch(() => {});
        } catch (_e) {}
        break;
      }

      case "session.error": {
        state.lastError = msg.text;
        // Telnyx-account-disabled / 403 D17 detection — surface a much more
        // actionable message and a help link instead of the raw API error.
        const isAccountBlock = /403|Account is disabled|D17|account.*blocked|account.*disabl/i.test(msg.text || "");
        if (isAccountBlock) {
          toast(
            "Telnyx account is blocked (403 D17). Open https://portal.telnyx.com → support chat to re-enable outbound. Code unchanged once Telnyx unblocks.",
            { error: true, ms: 14000 }
          );
        } else {
          toast("Session error: " + msg.text, { error: true });
        }
        // If the session never made it past the agent-leg dial, auto-reset to
        // idle so the agent isn't stuck staring at a non-functional Cancel
        // button. (After parked, errors during a call don't reset the session
        // — the agent leg may still be valid for the next prospect.)
        if (state.session === "starting" || state.session === "dialing-agent") {
          state.session = "idle";
          state.prospect = null;
          state.callStartedAt = 0;
        }
        break;
      }

      case "prospect.dialing": {
        // v1.33.2.4 — Capture old call state BEFORE we overwrite it, so
        // we can detect drift-during-active-call. Symptom (Brandon
        // Keesler, Rabecca Lathrop, Aaron Schwantes, [925], and 7+
        // others reported 2026-06-22): worker broadcasts a new
        // prospect.dialing for lead Y while the agent is still on an
        // active call to lead X. Without this guard, the v1.31.49
        // Impact-sync block below yanks the agent's UI to lead Y
        // mid-call, so they're talking to X but staring at Y.
        // Likely upstream cause: mobile companion auto-progressed
        // and broadcast its own dial through the same DO, or the
        // worker's autopilot mistimed the next dial. Until we fix
        // the worker-side cause in v1.33.5, refuse to MOVE the
        // agent's UI when a call is clearly in flight.
        const _oldProspect = state.prospect;
        const _oldLock     = state.activeLeadLock;
        const _oldLockAge  = _oldLock ? Date.now() - _oldLock.lockedAt : Infinity;
        const _activeCallToOtherLead =
          _oldLock &&
          _oldLockAge < 60_000 &&
          String(_oldLock.leadId) !== String(msg.leadId || "") &&
          (!_oldProspect ||
            ["dialing", "ringing", "answered", "connected", "active"].includes(_oldProspect.status));
        state.prospect = {
          number: msg.number,
          fromNumber: msg.fromNumber || (msg.prospect && msg.prospect.fromNumber) || null,   // v1.33.7.10 — outbound caller ID for the "calling from" line
          leadId: msg.leadId,
          status: "dialing",
          transcript: [],
          interim: "",
          amd: null,
          disposition: null,
        };
        // v1.31.88 — ACQUIRE lead-lock here at dial-time, not at
        // prospect.connected. Previously the lock fired only when the
        // prospect picked up, leaving the RINGING window unguarded.
        // During that window, maybeAutoDial + advanceToNextLead could
        // race and drift to the next lead while the current call was
        // still ringing — Ricky's "skipped no-number lead then drift"
        // and Ramin's "marked no-answer + advanced while still ringing"
        // both come from this gap. Idempotent on double-tap retries
        // (same leadId → lock is overwritten with same leadId + fresh
        // lockedAt, preserving correctness).
        try { acquireActiveLeadLock(msg); } catch (_e) {}
        // Reset the Auto-Attendant override flag — each new prospect
        // starts fresh. If the agent hits another IVR they click the
        // button again. This is the INTENTIONAL per-call behavior of
        // the 🛡️ button. For session-long "I'm working an IVR list,
        // drop every IVR as no-answer" behavior, agents use the
        // separate state.autoAttendantHangup toggle.
        // v1.32.2 — Removed the stickyAutoAttendOverride bypass that
        // formerly kept 🛡️ engaged across calls; the toggle was a
        // confused duplicate of autoAttendantHangup with inverted
        // semantics. Per-call 🛡️ now ALWAYS resets per prospect.
        state.overrideActive = false;
        // v1.29.29 — Same reset for the Desktop mic mute. Each new
        // call starts unmuted; the agent presses the button again
        // if they need to mute for this prospect.
        state.deskMicMuted = false;
        state.callStartedAt = Date.now();
        // v1.33.3 — Push to 3-last-dial ring buffer. Captures every dial
        // attempt (including ring-outs) so the agent can scan their
        // recent dials + redial without diving into History.
        try {
          state.lastDials = state.lastDials || [];
          state.lastDials.unshift({
            leadId:   msg.leadId || null,
            leadName: (function tryGetName() {
              try { return getLeadName() || ""; } catch { return ""; }
            })(),
            number:   msg.number || "",
            dialedAt: Date.now(),
            result:   null,  // filled on prospect.hangup
          });
          if (state.lastDials.length > 3) state.lastDials.length = 3;
        } catch {}
        // Bump monthly dial count for the Monthly Usage progress bar.
        state.monthlyDials = (state.monthlyDials | 0) + 1;
        try {
          chrome.storage.local.set({
            pd_monthly:     state.monthlyDials,
            pd_monthly_key: state.monthlyKey,
          });
        } catch {}
        // v1.30.4 — Bump the local dials counter HERE, mirroring how
        // we increment answered/noAnswers locally on prospect.hangup.
        // Previously, dials was only set by the worker's `counters`
        // broadcast — but the worker's counters live in the Durable
        // Object's RAM, which Cloudflare hibernates between Impact
        // page navs. After a hibernate, the worker's dials starts
        // back at 0 and never re-broadcasts a number high enough to
        // beat the local value (mergeCounters takes MAX). Result:
        // local dials froze at whatever it was at the last hibernate,
        // while local noAnswers/answered kept climbing — yielding the
        // impossible 3 dials / 5 NoAns / 1 Answered pattern Dustin saw.
        // Bumping locally on every prospect.dialing keeps the
        // invariant dials >= noAnswers + answered intact.
        state.counters.dials = (state.counters.dials | 0) + 1;
        persistCounters();
        clearTimeout(state.autoAdvanceTimer);
        startRingback();

        // v1.31.49 — Active Impact URL sync. The worker broadcasts
        // prospect.dialing to ALL connected devices (extension + mobile
        // PWA), so when mobile fires a dial, this fires here too. Close
        // the multi-device drift bug where mobile autopilot advances but
        // desktop Impact stays stuck on a stale lead (Becca's 147-lead
        // gap example). Logic:
        //   1. Read Impact's currentLeadId from the URL
        //   2. If mismatch with msg.leadId, soft-nav to the right detail
        //      page via history.pushState + popstate (Impact's SPA router
        //      picks this up without a full reload, preserving any notes
        //      the agent was typing)
        //   3. If pushState throws (rare), fall back to hard location.href
        //   4. Show a 5-sec toast so the agent KNOWS we synced
        try {
          if (msg.leadId && CRM.isOnLeadPage()) {
            const currentLeadId = getLeadId();
            if (String(currentLeadId || "") !== String(msg.leadId)) {
              // v1.33.2.4 — Drift-during-active-call guard. If we just
              // detected an in-flight call to a DIFFERENT lead, refuse
              // to navigate Impact. The dial still proceeds (worker is
              // sovereign on outbound dial), but the agent's UI stays
              // with the lead they're currently talking to instead of
              // jumping to the new prospect's record mid-call. They'll
              // sync naturally after the active call disposes. We also
              // auto-submit a diagnostic so we can root-cause the
              // upstream "why is the worker dialing again so soon?"
              // bug in v1.33.5.
              if (_activeCallToOtherLead) {
                try {
                  log("warn",
                    `[v1.33.2.4 drift-protect] prospect.dialing for lead ${msg.leadId} arrived during active call to lead ${_oldLock.leadId} ` +
                    `(lock age ${Math.round(_oldLockAge / 1000)}s, prospect.status=${_oldProspect?.status || "?"}) — refusing to sync Impact UI mid-call`);
                } catch {}
                try { _pdAutoSubmitWithReason("auto:drift-during-active-call"); } catch {}
                try {
                  toast(`⚠️ Dial fired for lead #${msg.leadId} but you're still on #${_oldLock.leadId} — UI stayed put`, { ms: 6000 });
                } catch {}
                // Skip the pushState navigation. Fall through so the
                // rest of prospect.dialing handler runs (ringback,
                // counter bump, etc — all are independent of UI nav).
              } else {
                try { log("info", `prospect.dialing: syncing Impact ${currentLeadId} -> ${msg.leadId}`); } catch {}
                const newUrl = CRM.leadUrl(msg.leadId);
                try {
                  history.pushState({}, "", newUrl);
                  window.dispatchEvent(new PopStateEvent("popstate"));
                } catch (e) {
                  // pushState failed — fall back to hard nav
                  try { location.href = newUrl; } catch {}
                }
                try {
                  toast(`Synced to lead #${msg.leadId}${msg.number ? " (" + msg.number + ")" : ""}`, { ms: 4000 });
                } catch {}
              }
            }
          }
        } catch (err) {
          try { log("warn", "[v1.31.49] Impact lead-sync threw:", err.message); } catch {}
        }
        break;
      }

      case "prospect.ringing":
        if (state.prospect) state.prospect.status = "ringing";
        startRingback(); // no-op if already running
        break;

      case "prospect.answered":
        if (state.prospect) state.prospect.status = "answered";
        stopRingback();
        break;

      case "prospect.amd":
        if (state.prospect) {
          state.prospect.amd = msg.result;
          // Track AMD-confirmed voicemail. Trigger-word voicemail detection
          // adds to this in prospect.disposition handler below.
          if (msg.result === "machine") {
            state.counters.voicemails++;
            state.prospect.detectedVoicemail = true;
            persistCounters();
          }
          // v1.31.97 — Mark prospect as "was live" the moment AMD detects
          // a human. This sticks even if state changes later (e.g. agent
          // hangs up, AMD result flips). Drives the in-call button label:
          // "End Human Call" when _wasLive, "Hangup" otherwise.
          if (typeof msg.result === "string" && msg.result.startsWith("human")) {
            state.prospect._wasLive = true;  // human_residence / human_business
          }
        }
        break;

      case "prospect.override-active":
        // Worker confirms our auto-attendant-override message was received
        // and the per-call flag is set. Update the panel UI to show the
        // 🛡️ button as active (locked-in IVR mode for this call).
        state.overrideActive = true;
        paint();  // v1.31.15-stable: was render() (undefined → ReferenceError → lead-drift bug)
        break;

      case "prospect.human-confirmed":
        // v1.29.74 — Worker has locked the call as a confirmed human
        // conversation. Show a positive indicator so the agent knows
        // VM auto-hangup is now disabled and they can talk without
        // worrying about the system pulling the call out from under
        // them. Fires from: 15s-bridged auto-confirm, AMD says human,
        // or late-AMD-machine override.
        if (state.prospect) state.prospect.humanConfirmed = true;
        toast(`✓ Human confirmed — VM auto-hangup disabled`, { ms: 4000 });
        paint();  // v1.31.15-stable: was render() (undefined → ReferenceError)
        break;

      // session.paused / session.resumed / park-source.switched events
      // REMOVED in v1.28.0 — mid-session switching is gone, each mode
      // has its own dedicated code path. Mode is chosen once in the
      // mode-row before starting a session.

      case "prospect.disposition":
        // Server sets disposition explicitly (e.g. "no-answer" via trigger
        // match, "human-connected" via bridge). Track voicemail counter if
        // a transcription trigger flagged it as voicemail.
        if (state.prospect) {
          state.prospect.disposition = msg.disposition;
          // v1.33.5.1 — Catch the MOBILE-remote VM drop too. The mobile app's
          // VM Drop sends session.dropvm, which the worker reports with
          // reason "mobile-vmdrop" (and the desktop button uses "manual").
          // Either way, flag it so the human-override in prospect.hangup is
          // bypassed and the drop auto-dispositions as No Answer + advances.
          const r = String(msg.reason || "");
          if (r === "manual" || r === "mobile-vmdrop") {
            state.prospect._manualVmDrop = true;
          }
          if (msg.disposition === "no-answer" && !state.prospect.detectedVoicemail
              && (msg.reason || "").startsWith("trigger")) {
            state.counters.voicemails++;
            state.prospect.detectedVoicemail = true;
            persistCounters();
          }
        }
        break;

      case "prospect.transcript":
        if (!state.prospect) break;
        if (msg.isFinal) {
          state.prospect.transcript.push(msg.text);
          state.prospect.interim = "";
          if (state.prospect.transcript.length > 8) state.prospect.transcript.shift();
        } else {
          state.prospect.interim = msg.text;
        }
        break;

      case "prospect.bridging":
        if (state.prospect) state.prospect.status = "bridging";
        break;

      case "prospect.connected":
        if (state.prospect) state.prospect.status = "connected";
        // v1.29.22 — Removed `state.counters.contacts++` from here.
        // prospect.connected fires for ANY bridged call, including
        // voicemails that briefly bridge before AMD flags them. That
        // double-counted every VM as both contact + voicemail. Now
        // contacts only increments in the hangup handler when the
        // final result is "human" (see below).
        toast("Call connected — checking for human");
        // v1.31.81+v1.31.88 — REFRESH lead-lock. Primary acquire now
        // happens at prospect.dialing (earlier — covers the ringing
        // window). Calling here again is idempotent (overwrites with
        // same leadId, resets lockedAt timestamp), giving us a fresh
        // 5-min TTL from the connected moment forward. Safe even if
        // prospect.dialing was somehow missed (worker restart between
        // dial + connect).
        try { acquireActiveLeadLock(msg); } catch (_e) {}
        // Lead details + screenshot are pushed at click time (in
        // dialPhone() / manualDial()), not here. By the time
        // prospect.connected fires, the agent may have switched focus
        // away from the Impact tab and chrome.tabs.captureVisibleTab
        // would fail. Pushing at click time guarantees focus.
        // v1.29.60 — Hide any stale transfer-warning banner from a
        // previous call.
        try { hidePdTransferWarning(); } catch (_e) {}
        break;

      case "transfer.warning":
        // v1.29.60 — Worker fired the 5-min warning. Render banner +
        // countdown. Auto-transfer happens server-side at countdown=0.
        try { showPdTransferWarning(msg); } catch (_e) {}
        break;
      case "transfer.initiated":
        try { hidePdTransferWarning(); } catch (_e) {}
        toast(`📱 Moving call to your cell (${msg.to})…`, { ms: 5000 });
        break;

      case "prospect.hangup": {
        // v1.31.81 — RELEASE lead-lock. Call ended → safe for advance
        // operations to fire again. Also drains any pending-advance
        // request that autopilot deferred during the call.
        try { releaseActiveLeadLock("prospect.hangup"); } catch (_e) {}
        // v1.32.8 — Reconnect-storm root-cause fix (B/setter). Stamp the
        // moment of the hangup so the hello handler can debounce
        // maybeAutoDial calls that race against the worker's prospectLeg
        // clear-window. See hello case below for the gate that reads this.
        state.lastHangupAt = Date.now();
        stopRingback();
        // v1.32.0 — Track dial-fail streak for auto-diagnostics. We treat
        // any of: severity=err, severity=warn, OR "no-answer with connection
        // never established" as a fail signal. 3 fails in 5 min auto-submits
        // a diagnostic snapshot.
        try {
          const failSev = msg.hangupSeverity === "err" || msg.hangupSeverity === "warn";
          const neverConnected = !state.prospect?.amd && !state.prospect?._wasLive;
          const dispNoAns = (msg.disposition || "no-answer") === "no-answer";
          if (failSev || (dispNoAns && neverConnected && state.prospect?.status !== "ringing")) {
            _pdRecordDialFail();
          }
        } catch {}
        // v1.29.31 — Audible "call ended" feedback (Desktop mode only;
        // gated inside playHangupTone for Mobile App mode).
        playHangupTone();
        // v1.29.75 — Show the agent a clear "who hung up + why" toast.
        // Removes the ambiguity of "did the prospect end the call, or
        // did the dialer glitch?" Worker classifies the hangup into
        // source/message/severity in _classifyHangup(). Severity drives
        // toast styling — "ok" = green (prospect ended a normal call),
        // "info" = blue, "warn" = yellow, "err" = red.
        if (msg.hangupMessage) {
          const sev = msg.hangupSeverity || "info";
          const ms  = sev === "err" || sev === "warn" ? 8000 : 5000;
          toast(msg.hangupMessage, { ms, kind: sev });
        }
        let disp = msg.disposition || (state.prospect && state.prospect.disposition) || "no-answer";
        // v1.33.4.8 — Worker mislabel guard. Telnyx AMD returns "not_sure" on
        // many residential cell phones, and the worker then dispositions a
        // real, bridged, two-way conversation as "no-answer" (Kevin Holtz,
        // 2026-06-29 prod tail: connected calls logged disposition=no-answer).
        // That sent live human calls down the no-answer AUTO-ADVANCE path, so
        // the Continue/Stay/End popup never armed and autopilot moved on (or,
        // on older builds, froze). If THIS leg actually reached a connected
        // human conversation (bridged, not a machine/VM), treat it as
        // human-connected regardless of the worker's label so the live-human
        // pause + Continue modal fires.
        if (disp === "no-answer" && state.prospect
            && state.prospect.status === "connected"
            && typeof state.prospect.amd === "string"
            && state.prospect.amd.startsWith("human")
            // v1.33.5.1 — NEVER override a MANUAL VM drop. If the agent hit
            // the VM Drop button, they've explicitly declared voicemail —
            // it must hang up, stamp No Answer, and advance like any other
            // no-answer, regardless of what AMD guessed. Without this, a
            // VM AMD-misread as human forced the agent to click No Answer
            // by hand every time (agent 920-255-7913).
            && !state.prospect._manualVmDrop) {
          // Only a CONFIRMED-human AMD verdict (human_residence /
          // human_business) flips this — machine / silence / not_sure /
          // no-AMD stay no-answer so voicemails never pop the modal.
          log("info", "prospect.hangup: AMD confirmed a human (amd=" + state.prospect.amd + ") but worker said no-answer — overriding to human-connected so the dialer pauses for the agent disposition instead of auto-stamping No Answer");
          disp = "human-connected";
        }
        // v1.34 — A LEFT voicemail (worker played the Leave-Voicemail
        // recording) flows EXACTLY like a no-answer — advance + keep dialing,
        // no human pause/popup — but Impact is dispositioned "Voicemail Left"
        // instead of No Answer. We normalize disp to "no-answer" so every
        // downstream branch (counters, double-tap, legacy advance) is
        // unchanged, and stash the label the auto-disposition click reads.
        // (Placed AFTER the AMD-human override so it can't be misread; a left
        // voicemail is always AMD=machine anyway.)
        if (disp === "voicemail-left") {
          _pdPendingDispositionLabel = "voicemail left";
          try { chrome.storage.local.set({ pd_pending_disposition_label: "voicemail left" }); } catch {}
          disp = "no-answer";
        }
        // Append to the per-session call log BEFORE we clear state.prospect.
        // The result string drives the History chips + summary breakdown.
        if (state.prospect) {
          const pr = state.prospect;
          let result;
          if (pr.detectedVoicemail || pr.amd === "machine") result = "voicemail";
          else if (disp === "no-answer")                    result = "no_answer";
          else if (pr.status === "connected")               result = "human";
          else                                              result = "ended";
          state.callLog.push({
            leadId:    pr.leadId || null,
            leadName:  pr.leadName || "",
            number:    pr.number || "",
            result,
            startedAt: state.callStartedAt || Date.now(),
            endedAt:   Date.now(),
            durationMs: state.callStartedAt ? (Date.now() - state.callStartedAt) : 0,
            amd:       pr.amd || null,
            status:    pr.status || null,
          });
          // v1.29.22 — Increment contacts ONLY when the final call result
          // is "human" (real conversation). Replaces the broken old
          // prospect.connected increment which double-counted voicemails.
          if (result === "human") {
            state.counters.contacts++;
            persistCounters();
          }
          // v1.33.3 — Update most-recent lastDials entry with the
          // call's final result so the inline strip can show ✓/✗/📞
          // icons. Most-recent dial is at index 0; if it matches this
          // lead/number, attach the result.
          try {
            if (state.lastDials && state.lastDials.length > 0) {
              const top = state.lastDials[0];
              const sameLead = pr.leadId && top.leadId && String(pr.leadId) === String(top.leadId);
              const sameNum  = pr.number && top.number && pr.number === top.number;
              if (sameLead || sameNum) top.result = result;
            }
          } catch {}
        }
        // v1.29.70 — Local mirror of the worker's Answered / NoAnswer split.
        // Worker is the source of truth (counters broadcast wins on merge),
        // but we increment locally too so the panel updates instantly without
        // a WS round-trip. Classifier matches worker (session.js hangup):
        //   answered  = real human picked up
        //   noAnswers = anything else (voicemail, machine, ring-out, ended)
        if (state.prospect) {
          const pr = state.prospect;
          const isMachine = pr.detectedVoicemail || pr.amd === "machine"
            || disp === "no-answer" || disp === "voicemail-left" || disp === "voicemail-drop";
          const reachedConnected = pr.status === "connected" || pr.status === "transferred-to-cell";
          if (reachedConnected && !isMachine) {
            state.counters.answered++;
          } else {
            state.counters.noAnswers++;
          }
          state.counters.completed++;
          persistCounters();
        }
        // Liberty (list model): the driver logs THIS dial's own disposition via
        // Chatter Feed (Called - No Contact / Bad Phone) and advances with the
        // red X, or pauses for the agent on a human pickup. Bypasses the Impact
        // manual-disposition / double-tap / no-answer / Continue-modal blocks.
        if (CRM.kind === "liberty") {
          const _pr = state.prospect;
          // v1.33.7.8 — VM-Drop / no-answer disposition is AUTHORITATIVE. When the
          // worker hangs up with a no-answer/voicemail disposition it means WE
          // dropped the call — AMD "machine", a trigger phrase, ring-timeout, OR
          // the manual VM Drop button (which the worker always dispositions as
          // "no-answer"). Previously _isHuman keyed only off status+AMD, so a
          // MANUAL VM Drop on a call that had bridged but that AMD never flagged
          // as machine computed _isHuman=true → the driver PAUSED on the human
          // path instead of logging "Called - No Contact" (No Answer) and
          // advancing. Honor the disposition first so every VM-Drop hangs up →
          // dispositions No-Contact → red-✕ advances to the next lead. Parity
          // with the Impact/prod path (disp==="no-answer" → handleHangupAdvance).
          const _nonHumanDisp = disp === "no-answer" || disp === "voicemail-left"
            || disp === "voicemail-drop" || disp === "bad-phone";
          const _isHuman = !_nonHumanDisp && (
            disp === "human-connected"
            || (_pr && _pr.status === "connected" && !(_pr.detectedVoicemail || _pr.amd === "machine"))
          );
          try { if (state.libertyAP) state.libertyAP.onHangup(disp, _isHuman ? "human" : "", _pr); } catch (_e) {}
          state.prospect = null;
          state.callStartedAt = 0;
          break;
        }
        // v1.31.2 — Manual disposition mode: pause on EVERY hangup
        // and wait for the agent to disposition in Impact + return to
        // the lead list. The URL watcher (see boot section) will detect
        // navigation back to the main lead list and trigger advance.
        if (state.manualDisposition) {
          if (state.autoDialMode) {
            log("info", "Manual disposition mode — pausing autopilot, waiting for agent to disposition + return to lead list");
            state.autoDialMode = false;
            try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
            try { send("autodial.set", { running: false }); } catch (_e) {}
            try { state.ws && state.ws.send(JSON.stringify({ type: "autopilot", running: false })); } catch (_e) {}
          }
          state.waitingForManualDisposition = true;
          toast("✋ Call ended — disposition this lead in Impact. Autopilot resumes when you return to the lead list.", { ms: 9000, kind: "info" });
          state.prospect = null;
          state.callStartedAt = 0;
          break;
        }
        // v1.32.2 — Double-Tap unified path. When toggle is ON, ALL
        // hangup dispositions (no-answer, human, voicemail) flow
        // through handleHangupAdvance → doubleTapFlow. That single
        // function then decides:
        //   - no-answer       → dequeue next chain entry, redial
        //   - human/VM on Home → trim remaining Home, jump to Mobile
        //   - human/VM on Mobile → finish lead, advance
        //   - Away Mode on    → also fire awayModeCapture before the chain decision
        //
        // This is the locked-spec "separate code path that runs INSTEAD
        // OF normal process" rebuild. The Continue modal + per-disposition
        // pause behavior below is preserved for NON-double-tap users.
        if (state.doubleTap) {
          const number = msg.number || (state.prospect && state.prospect.number);
          const leadId = msg.leadId || (state.prospect && state.prospect.leadId);
          handleHangupAdvance(disp, number, leadId);
          state.prospect = null;
          state.callStartedAt = 0;
          break;
        }
        if (disp === "no-answer") {
          // ── Legacy single-tap no-answer path ──
          // (Double-tap users intercepted above.) Standard chain queue
          // handles the disposition + advance via handleHangupAdvance.
          const number = msg.number || (state.prospect && state.prospect.number);
          const leadId = msg.leadId || (state.prospect && state.prospect.leadId);
          handleHangupAdvance(disp, number, leadId);
        } else {
          // v1.29.76 — Live human conversation ended. Auto-pause autopilot
          // so the dialer doesn't fire the next call while the agent is
          // typing notes in Impact. Set a flag that the next /Lead/InboxDetail
          // boot consumes — it shows a "Continue?" modal with three options:
          //   • Resume autopilot (advance + auto-dial back on)
          //   • Manual continue (advance only, keep autopilot off)
          //   • Stay here (no advance — let agent re-read or re-dial manually)
          //
          // Previously the dialer auto-advanced to the next lead the moment
          // the agent picked a disposition. That sometimes fired before the
          // agent had finished typing notes, blowing past leads they meant
          // to dwell on. The popup makes the transition explicit.
          // v1.31.69 — Away Mode branch. When awayMode is on, do NOT
          // pause autopilot and do NOT show the Continue modal — instead
          // capture a screenshot of the lead + save metadata, then let
          // the standard No Answer flow advance.
          const leadId = msg.leadId || (state.prospect && state.prospect.leadId);
          const leadName = (state.prospect && state.prospect.leadName) || getLeadName() || "";
          const prospectPhone = (state.prospect && state.prospect.number) || "";
          if (state.awayMode) {
            log("info", "Away Mode: human call ended — capturing + auto-dispositioning No Answer");
            // Capture screenshot + save to Downloads + storage log entry.
            // Fire-and-forget; even if capture fails, we still want to
            // disposition + advance so autopilot keeps running.
            try {
              awayModeCapture({ leadId, leadName, phone: prospectPhone }).catch((e) => {
                log("warn", "Away Mode capture failed: " + (e && e.message));
              });
            } catch (e) {
              log("warn", "Away Mode capture threw: " + (e && e.message));
            }
            // Clear chain queue (human means we don't continue down the
            // list of remaining numbers for this lead).
            try { chrome.storage.local.remove("pd_lead_chain_queue"); } catch {}
            // Disposition path — same as no-answer: click No Answer and
            // let the standard advance flag fire. Autopilot stays on.
            toast(`🚶 Away Mode: ${leadName || "lead"} captured to Downloads — auto-dispositioning No Answer`, { ms: 4000, kind: "warn" });
            // Mark in the lead chain that this lead should NOT redial,
            // but do NOT set pd_last_call_was_human (that would block
            // autopilot per Guard #2 in maybeAutoDial).
            // dispositionOrAdvance will click No Answer + advance.
            dispositionOrAdvance();
            state.prospect = null;
            state.callStartedAt = 0;
            break;
          }
          if (state.autoDialMode) {
            log("info", "Live call ended — pausing autopilot for notes");
            state.autoDialMode = false;
            try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
            // Broadcast to mobile companion + worker so they reflect the pause.
            try { send("autodial.set", { running: false }); } catch (_e) {}
            try { state.ws && state.ws.send(JSON.stringify({ type: "autopilot", running: false })); } catch (_e) {}
          }
          toast("📝 Live call ended — autopilot paused. Add notes in Impact, then click Continue when ready.", { ms: 8000, kind: "ok" });
          try {
            chrome.storage.local.set({
              // Kept the same key name so existing in-flight flags are
              // still consumed cleanly post-deploy. Behavior changed: the
              // consumer now shows a modal instead of auto-advancing.
              pd_pending_human_advance: { leadId: leadId || null, at: Date.now() },
              // v1.31.44 — Flag for maybeAutoDial's Guard #2: this leadId
              // had a real human conversation. Autopilot must NOT re-dial
              // the same lead — agent has to explicitly advance to a new
              // one. Cleared when leadId actually changes (in maybeAutoDial
              // and in the Continue modal Resume handler).
              [pdGuardKey("pd_last_call_was_human")]: { leadId: leadId || null, at: Date.now() },
            });
          } catch {}
          // v1.31.66 — Human conversation on any chain attempt means the
          // remaining numbers in the chain should NOT be dialed. Clear
          // the queue so the Continue modal flow runs cleanly.
          try { chrome.storage.local.remove("pd_lead_chain_queue"); } catch {}
        }
        state.prospect = null;
        state.callStartedAt = 0;
        break;
      }

      case "counters":
        // v1.29.13 — Merge, never overwrite. See mergeCounters() for why.
        state.counters = mergeCounters(state.counters, msg.counters);
        persistCounters();
        // v1.29.54 — Refresh the Stats Jersey if it's currently open
        // so its numbers don't go stale as calls roll in.
        refreshJerseyIfOpen();
        break;

      case "counters.reset":
        // v1.29.54 — Hard reset: zero ALL local counters too. The
        // regular `counters` broadcast can't reset us because
        // mergeCounters() takes the max, so we'd ignore a zeroed
        // payload as if it were a stale read. This event is the only
        // path that bypasses the merge and wipes state.
        state.counters = {
          dials: 0, contacts: 0, voicemails: 0,
          noAnswers: 0, badNumbers: 0, answered: 0, completed: 0,
        };
        state.sessionStartCounters = { ...state.counters };
        persistCounters();
        try { paint && paint(); } catch (_e) {}
        refreshJerseyIfOpen();
        toast("🔄 Counters reset to 0", { ms: 2500 });
        break;

      case "balance":
        // Prepaid balance update from the worker after a charge.
        state.balanceCents = msg.balanceCents;
        if (state.balanceCents != null && state.balanceCents <= 50) {
          toast(`Low balance: $${(state.balanceCents/100).toFixed(2)} — top up at the dashboard`);
        }
        break;

      case "log":
        log(msg.level || "info", msg.text);
        // Surface info+ logs as toasts so the agent can see what the worker
        // is doing during bridge / AMD / voicemail handling. Helps diagnose
        // bridge failures live instead of needing Cloudflare logs.
        if (msg.level === "info" || msg.level === "warn") {
          toast(msg.text);
        }
        break;

      case "remote.autopilot.toggle":
        // Mobile companion tapped the Autopilot button. Flip our local
        // autopilot state to mirror what the desktop's own button would
        // do. Re-paint so the button label updates.
        state.autoDialMode = !state.autoDialMode;
        try { chrome.storage.local.set({ pd_autodial: state.autoDialMode }); } catch {}
        if (state.autoDialMode) clearSessionDialedMemory("remote toggle");  // v1.33.5.2 fresh pass
        toast(state.autoDialMode
          ? "▶ Autopilot ON (toggled from mobile)"
          : "⏸ Autopilot paused (toggled from mobile)",
          { ms: 4000 });
        broadcastToggleState();
        paint();
        // v1.33.7.6 — Actually START the dial loop when the mobile turns
        // autopilot ON. Previously remote.autopilot.toggle only flipped the
        // flag; on the Liberty SPA (no page-navs to re-fire maybeAutoDial)
        // autopilot then sat idle — "hit autopilot and nothing happened"
        // (Sharla Wilson 2026-08-15). The desktop's own button kicks the loop;
        // the remote path must too.
        if (state.autoDialMode) { try { maybeAutoDial(); } catch (_e) {} }
        if (state.autoDialMode) maybeAutoDial();
        break;

      case "remote.session.start":
        // Mobile asked us to begin a session. Same gating as the desktop
        // Start Session button — only fires if signed in and not already
        // running. Toast either way so the agent knows we acted.
        if (state.session !== "idle") {
          toast("Mobile asked to Start, but a session is already " + state.session, { ms: 4000 });
        } else {
          toast("▶ Starting session — mobile remote", { ms: 3000 });
          try { startSession(); } catch (err) {
            toast("Couldn't start: " + (err?.message || "unknown"), { error: true });
          }
        }
        break;

      case "remote.toggle.set":
        // Mobile changed a per-lead toggle. Validate against our known
        // toggle keys, then apply locally + persist + broadcast back so
        // any other connected viewer sees the new state.
        if (typeof msg.key === "string") {
          const TOG_MAP = {
            doubleTap:        "pd_double_tap",
            bottomToTop:      "pd_bottom_to_top",
            callFromComments: "pd_call_from_comments",
            relayMode:        "pd_relay_mode",
            testMode:         "pd_test_mode",
            // v1.31.88 — Missing toggles that were silently dropped when
            // mobile companion toggled them. Restored so all toggles
            // sync across devices identically.
            autoAttendantHangup:      "pd_auto_attendant_hangup",
            // v1.33.4.5 — awayMode removed from remote toggle map (feature disabled).
            manualDisposition:        "pd_manual_disposition",
            // v1.32.2 — REMOVED stickyAutoAttendOverride map entry.
          };
          // v1.33.4.2 — self-echo / no-op guard (Ricky 2026-06-26: Double
          // Tap toggling "by itself"). Only act on an ACTUAL change. If the
          // value already matches our state we skip the re-persist +
          // broadcastToggleState() that fed an echo loop.
          if (TOG_MAP[msg.key] && state[msg.key] !== !!msg.value) {
            state[msg.key] = !!msg.value;
            const storageKey = TOG_MAP[msg.key];
            try { chrome.storage.local.set({ [storageKey]: state[msg.key] }); } catch {}
            toast(`${msg.key} → ${msg.value ? "ON" : "OFF"} (mobile)`, { ms: 2200 });
            broadcastToggleState();
            paint();
          }
        }
        break;
    }
    paint();
  }

  // Broadcast the current toggle states + autopilot state to viewers via
  // v1.29.60 — Transfer-warning banner. Worker fires `transfer.warning`
  // at 5 min into a connected call with a 5-min countdown to
  // auto-transfer to the agent's cell. We paint a corner banner with
  // a live ticking timer + a "Transfer now" button (manual fire) +
  // a "Dismiss" (hide the banner; auto-transfer still fires unless
  // the agent has no cell on file).
  let _pdTransferWarnTimer = 0;
  let _pdTransferDeadline = 0;
  function showPdTransferWarning(msg) {
    hidePdTransferWarning();
    _pdTransferDeadline = msg.countdown_at || (Date.now() + (msg.countdown_ms || 300000));
    const wrap = document.createElement("div");
    wrap.id = "pd-transfer-warn";
    wrap.style.cssText = "position:fixed;right:16px;bottom:90px;width:340px;background:linear-gradient(180deg,#3d2c0a,#26180a);border:1px solid #c98e3a;border-radius:10px;padding:12px;color:#ffd28a;font-size:13px;box-shadow:0 8px 20px rgba(0,0,0,0.5);z-index:2147483646;font-family:-apple-system,sans-serif;";
    const willAuto = !!msg.will_auto_transfer;
    const cellLine = msg.agent_cell
      ? `Auto-transfer to <strong>${pdEscapeHtml(msg.agent_cell)}</strong> in <span id="pd-tw-cd">5:00</span>`
      : `⚠ No cell phone on file — set one in the extension popup before the next long call.`;
    wrap.innerHTML = `
      <div style="font-weight:600;margin-bottom:4px;">📱 Please transfer to phone for more reliable call quality</div>
      <div style="font-size:12px;color:#ffc77b;margin-bottom:10px;">${cellLine}</div>
      <div style="display:flex;gap:6px;">
        <button id="pd-tw-now" style="flex:1;background:rgba(45,171,95,0.35);border:1px solid rgba(45,171,95,0.7);color:#aaf3c3;border-radius:6px;padding:6px 10px;cursor:${willAuto ? "pointer" : "not-allowed"};${willAuto ? "" : "opacity:0.55;"}" ${willAuto ? "" : "disabled"}>📱 Transfer now</button>
        <button id="pd-tw-dismiss" style="background:transparent;border:1px solid rgba(255,255,255,0.2);color:#ffd28a;border-radius:6px;padding:6px 10px;cursor:pointer;">Dismiss</button>
      </div>
    `;
    document.body.appendChild(wrap);
    document.getElementById("pd-tw-now")?.addEventListener("click", () => {
      if (state.ws && state.ws.readyState === 1) {
        try { state.ws.send(JSON.stringify({ type: "session.transfer-to-cell" })); } catch (_e) {}
      }
    });
    document.getElementById("pd-tw-dismiss")?.addEventListener("click", hidePdTransferWarning);
    _pdTransferWarnTimer = setInterval(() => {
      const el = document.getElementById("pd-tw-cd");
      if (!el) return;
      const remainMs = Math.max(0, _pdTransferDeadline - Date.now());
      const mm = Math.floor(remainMs / 60000);
      const ss = Math.floor((remainMs % 60000) / 1000);
      el.textContent = `${mm}:${ss.toString().padStart(2, "0")}`;
      if (remainMs <= 0) { clearInterval(_pdTransferWarnTimer); _pdTransferWarnTimer = 0; }
    }, 250);
  }
  function hidePdTransferWarning() {
    if (_pdTransferWarnTimer) { clearInterval(_pdTransferWarnTimer); _pdTransferWarnTimer = 0; }
    document.getElementById("pd-transfer-warn")?.remove();
  }
  function pdEscapeHtml(s) {
    return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
  }

  // v1.29.76 — "Continue?" modal shown after a live human conversation
  // ends and the agent has returned to Impact's lead-detail page (i.e.
  // they've finished typing notes / picking a disposition). Three CTAs:
  //
  //   • "▶ Resume autopilot" — advance to next lead AND turn autopilot
  //                            back on. Primary CTA (gradient green).
  //   • "▷ Continue manually" — advance to next lead, leave autopilot
  //                              paused. Outline button.
  //   • "Stay here"           — close modal without advancing. Subtle.
  //
  // Replaces the pre-v1.29.76 silent auto-advance which sometimes fired
  // before the agent had finished typing notes — blowing past leads
  // they wanted to dwell on. Now the transition is explicit.
  function showContinueModal({ leadId } = {}) {
    // Idempotent — never stack two modals on top of each other.
    document.getElementById("pd-continue-modal")?.remove();

    const wrap = document.createElement("div");
    wrap.id = "pd-continue-modal";
    wrap.className = "pd-continue-modal";
    wrap.innerHTML = `
      <div class="pd-continue-backdrop"></div>
      <div class="pd-continue-card">
        <div class="pd-continue-head">
          <span class="pd-continue-icon">✅</span>
          <span class="pd-continue-title">Ready to continue?</span>
          <button class="pd-continue-close" id="pd-continue-close" type="button" aria-label="Close">×</button>
        </div>
        <div class="pd-continue-body">
          Live call wrapped up. Pick how you want to move on — autopilot
          stays off until you choose.
        </div>
        <div class="pd-continue-actions">
          <button class="pd-continue-btn pd-continue-btn-primary" id="pd-continue-autopilot" type="button">
            ▶ Resume autopilot
          </button>
          <button class="pd-continue-btn pd-continue-btn-secondary" id="pd-continue-manual" type="button">
            ▷ Continue manually
          </button>
          <button class="pd-continue-btn pd-continue-btn-tertiary" id="pd-continue-stay" type="button">
            Stay on this lead
          </button>
        </div>
      </div>
    `;
    document.body.appendChild(wrap);
    requestAnimationFrame(() => wrap.classList.add("pd-open"));

    const closeModal = () => {
      wrap.classList.remove("pd-open");
      setTimeout(() => wrap.remove(), 200);
    };

    document.getElementById("pd-continue-close")?.addEventListener("click", closeModal);
    document.querySelector("#pd-continue-modal .pd-continue-backdrop")
      ?.addEventListener("click", closeModal);

    document.getElementById("pd-continue-autopilot")?.addEventListener("click", () => {
      log("info", "Continue modal — Resume Autopilot clicked");
      closeModal();
      // Flip autopilot back on FIRST so when the page navigates to the
      // next lead, the autopilot logic picks up immediately.
      state.autoDialMode = true;
      try { chrome.storage.local.set({ pd_autodial: true }); } catch {}
      try { send("autodial.set", { running: true }); } catch (_e) {}
      try { state.ws && state.ws.send(JSON.stringify({ type: "autopilot", running: true })); } catch (_e) {}
      try { broadcastToggleState(); } catch (_e) {}
      toast("▶ Autopilot resumed — advancing to next lead", { kind: "ok", ms: 3000 });
      setTimeout(() => advanceToNextLead(), 400);
    });

    document.getElementById("pd-continue-manual")?.addEventListener("click", () => {
      log("info", "Continue modal — Manual continue clicked");
      closeModal();
      toast("▷ Advancing — autopilot stays off", { kind: "info", ms: 2500 });
      setTimeout(() => advanceToNextLead(), 400);
    });

    document.getElementById("pd-continue-stay")?.addEventListener("click", () => {
      log("info", "Continue modal — Stay clicked");
      closeModal();
      toast("Staying on this lead. Use Autopilot / arrow when ready.", { kind: "info", ms: 3500 });
    });
  }

  // v1.34 — End-Session confirmation dialog (fired by the Alt+S hotkey).
  // Reuses the pd-continue-* modal styling for a consistent look. Three
  // choices: Continue (dismiss, keep going), Pause autopilot, or End session.
  function showEndSessionModal() {
    document.getElementById("pd-endsession-modal")?.remove();
    document.getElementById("pd-continue-modal")?.remove();
    const wrap = document.createElement("div");
    wrap.id = "pd-endsession-modal";
    wrap.className = "pd-continue-modal";
    wrap.innerHTML = `
      <div class="pd-continue-backdrop"></div>
      <div class="pd-continue-card">
        <div class="pd-continue-head">
          <span class="pd-continue-icon">⏹</span>
          <span class="pd-continue-title">End your session?</span>
          <button class="pd-continue-close" id="pd-es-close" type="button" aria-label="Close">×</button>
        </div>
        <div class="pd-continue-body">
          Your park line stays connected unless you end the session. Pick how to proceed.
        </div>
        <div class="pd-continue-actions">
          <button class="pd-continue-btn pd-continue-btn-primary" id="pd-es-continue" type="button">▷ Continue</button>
          <button class="pd-continue-btn pd-continue-btn-secondary" id="pd-es-pause" type="button">⏸ Pause autopilot</button>
          <button class="pd-continue-btn pd-continue-btn-tertiary" id="pd-es-end" type="button" style="color:#ff8a92;">⏹ End session</button>
        </div>
      </div>`;
    document.body.appendChild(wrap);
    requestAnimationFrame(() => wrap.classList.add("pd-open"));
    const close = () => { wrap.classList.remove("pd-open"); setTimeout(() => wrap.remove(), 200); };
    document.getElementById("pd-es-close")?.addEventListener("click", close);
    document.getElementById("pd-es-continue")?.addEventListener("click", close);
    wrap.querySelector(".pd-continue-backdrop")?.addEventListener("click", close);
    document.getElementById("pd-es-pause")?.addEventListener("click", () => {
      close();
      if (state.autoDialMode) {
        const aBtn = document.getElementById("pd-autopilot") || document.getElementById("pd-mini-pause");
        if (aBtn) { aBtn.click(); }
        else {
          state.autoDialMode = false;
          try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
          try { broadcastToggleState(); } catch (_e) {}
          try { paint(); } catch (_e) {}
          toast("⏸ Autopilot paused", { kind: "info", ms: 2500 });
        }
      } else {
        toast("Autopilot is already off — session still parked", { kind: "info", ms: 2500 });
      }
    });
    document.getElementById("pd-es-end")?.addEventListener("click", () => {
      close();
      try { endSession(); } catch (_e) {}
    });
  }

  // v1.34 — Keyboard shortcuts: Alt+C = End call, Alt+P = Pause/Resume
  // autopilot, Alt+S = End-session dialog. Alt-gated + skipped while the
  // agent is typing in a field, so they never fire mid-note. Capture phase
  // so Impact's own handlers can't swallow them.
  function pdHandleHotkey(e) {
    if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
    const t = e.target;
    const tag = (t && t.tagName || "").toLowerCase();
    if (tag === "input" || tag === "textarea" || tag === "select" || (t && t.isContentEditable)) return;
    const key = (e.key || "").toLowerCase();
    if (key === "c") {                        // End call
      if (!state.prospect) return;
      e.preventDefault();
      try { skipProspect(); } catch (_e) {}
      toast("📞 Call ended (Alt+C)", { kind: "info", ms: 2000 });
    } else if (key === "p") {                 // Pause / Resume autopilot
      if (state.session !== "parked") return;
      e.preventDefault();
      const aBtn = document.getElementById("pd-autopilot") || document.getElementById("pd-mini-pause");
      if (aBtn) aBtn.click();
    } else if (key === "s") {                 // End-session dialog
      if (!state.session || state.session === "idle") return;
      e.preventDefault();
      showEndSessionModal();
    }
  }
  try { document.addEventListener("keydown", pdHandleHotkey, true); } catch (_e) {}

  // v1.33.4.8 — Compact "waiting on your disposition" pill. Shown on
  // Impact's /Lead/WhatHappend page while a human call is paused for the
  // agent to disposition, so the pause reads as intentional, not frozen.
  // Cleared automatically on the next navigation (content.js reboots) when
  // Impact returns to /Lead/InboxDetail and the Continue modal takes over.
  function showDispoWaitingPill() {
    if (document.getElementById("pd-dispo-waiting-pill")) return;
    if (!document.getElementById("pd-dispo-pill-kf")) {
      const st = document.createElement("style");
      st.id = "pd-dispo-pill-kf";
      st.textContent = "@keyframes pdPillPulse{0%{box-shadow:0 0 0 0 rgba(77,163,255,0.65)}70%{box-shadow:0 0 0 7px rgba(77,163,255,0)}100%{box-shadow:0 0 0 0 rgba(77,163,255,0)}}";
      (document.head || document.documentElement).appendChild(st);
    }
    const pill = document.createElement("div");
    pill.id = "pd-dispo-waiting-pill";
    pill.style.cssText = "position:fixed;bottom:16px;right:16px;z-index:2147483646;display:flex;align-items:center;gap:8px;padding:7px 13px;border-radius:999px;background:rgba(20,22,28,0.92);color:#e8eef7;font:600 12.5px/1.2 -apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,0.35);border:1px solid rgba(120,170,255,0.35);";
    const dot = document.createElement("span");
    dot.style.cssText = "width:8px;height:8px;border-radius:50%;background:#4da3ff;animation:pdPillPulse 1.6s infinite;flex:0 0 auto;";
    const txt = document.createElement("span");
    txt.textContent = "Disposition this lead to continue";
    pill.appendChild(dot); pill.appendChild(txt);
    document.body.appendChild(pill);
    log("info", "dispo-waiting pill shown (human call paused on WhatHappend)");
  }
  window.__pdShowDispoWaitingPill = showDispoWaitingPill;

  // the worker (which forwards to /mobile-session viewers). The mobile
  // panel mirrors these in its checkboxes + autopilot button.
  function broadcastToggleState() {
    if (!state.ws || state.ws.readyState !== 1) return;
    try {
      state.ws.send(JSON.stringify({
        type: "toggles",
        autoDialMode: !!state.autoDialMode,
        toggles: {
          doubleTap:        !!state.doubleTap,
          leaveVoicemail:   !!state.leaveVoicemail,
          bottomToTop:      !!state.bottomToTop,
          callFromComments: !!state.callFromComments,
          relayMode:        !!state.relayMode,
          testMode:         !!state.testMode,
          // v1.31.68 — auto-attendant hangup toggle
          autoAttendantHangup: !!state.autoAttendantHangup,
          // v1.31.69 — Away Mode toggle
          awayMode: !!state.awayMode,
        },
      }));
    } catch {}
  }

  // ─────────────────────────────────────────────────────────────────
  // Impact CRM hooks
  // ─────────────────────────────────────────────────────────────────
  function getLeadId__impl() {
    const m = location.search.match(/[?&](?:id|leadId|leadID)=(\d+)/i);
    return m ? m[1] : null;
  }

  // Best-effort grab of the displayed lead's name on Impact's lead detail
  // page. Used for the call log + skipped-leads list in the History view.
  // Format: "LASTNAME, FIRSTNAME" — Impact's standard convention.
  function getLeadName__impl() {
    // v1.33.9.2 — Planet: read the structured hidden inputs (verified DOM). Far
    // more reliable than scraping; falls through to the generic walk if missing.
    if (CRM_KIND === "planet") {
      const ln = (document.getElementById("LastName")?.value || "").trim();
      const fn = (document.getElementById("FirstName")?.value || "").trim();
      if (ln || fn) return (ln + (ln && fn ? ", " : "") + fn).toUpperCase();
    }
    // Impact renders the name in a styled DIV at the top of the lead-
    // detail page (not an h1-h3) — pattern: "KEATING, JERRY" with an
    // edit icon next to it. We walk all leaf-text elements (children=0)
    // looking for the LASTNAME, FIRSTNAME pattern, plus include the
    // explicit selectors as fallback.
    //
    // CRITICAL: must exclude the agent's name in Impact's top-right
    // header bar ("HOLTZ, DUSTIN" with a logout arrow). On POS leads
    // the customer's name lives in the policy data BELOW the header
    // and the agent name in the header was being picked up instead.
    // We detect "page chrome" ancestors and skip those candidates.
    const isInPageChrome = (el) => {
      let cur = el;
      let hops = 0;
      while (cur && cur !== document.body && hops < 20) {
        // Element-level checks
        const tag = cur.tagName ? cur.tagName.toLowerCase() : "";
        if (tag === "header" || tag === "nav") return true;
        const cls = (cur.className || "").toString().toLowerCase();
        const id  = (cur.id || "").toLowerCase();
        if (/\b(?:navbar|nav-bar|topbar|top-bar|header|app-header|page-header|user-menu|account-menu|profile-menu|sign-?out|logout)\b/.test(cls)) return true;
        if (/\b(?:navbar|topbar|header|user-menu|account-menu|profile-menu|sign-?out|logout)\b/.test(id)) return true;
        // Sibling-level check: if a nearby sibling looks like a sign-out/
        // account-menu link, this candidate is in the page header.
        const sibText = ((cur.parentElement?.textContent) || "").toLowerCase();
        if (sibText.length < 200 &&
            /\b(?:sign\s*out|log\s*out|signout|logout|my\s*profile|my\s*account)\b/.test(sibText)) {
          return true;
        }
        cur = cur.parentElement;
        hops++;
      }
      return false;
    };

    const candidates = [];
    const walk = (el, depth) => {
      if (!el || depth > 10) return;
      if (el.children && el.children.length === 0) {
        candidates.push(el);
        return;
      }
      for (const child of (el.children || [])) walk(child, depth + 1);
    };
    walk(document.body, 0);
    // Also include explicit name-bearing selectors
    for (const sel of ["h1", "h2", "h3", ".lead-name", ".lead-title", ".ldname"]) {
      for (const el of document.querySelectorAll(sel)) candidates.push(el);
    }

    // Try strict match first (all-caps "LASTNAME, FIRSTNAME"), then a
    // permissive fallback that accepts Title Case ("Smith, John") and
    // first-name-first ("John Smith").
    const STRICT_RE  = /^[A-Z][A-Z'\-\.]+,\s*[A-Z][A-Z'\-\. ]+$/;
    const COMMA_RE   = /^[A-Z][A-Za-z'\-\.]+,\s*[A-Z][A-Za-z'\-\. ]+$/;
    const SIMPLE_RE  = /^[A-Z][A-Za-z'\-\.]+(?:\s+[A-Z][A-Za-z'\-\.]+){1,3}$/;
    for (const el of candidates) {
      if (isInPageChrome(el)) continue;
      const txt = (el.textContent || "").trim();
      if (!txt || txt.length > 80) continue;
      if (STRICT_RE.test(txt)) return txt;
    }
    for (const el of candidates) {
      if (isInPageChrome(el)) continue;
      const txt = (el.textContent || "").trim();
      if (!txt || txt.length > 80) continue;
      if (COMMA_RE.test(txt)) return txt;
    }
    for (const el of candidates) {
      if (isInPageChrome(el)) continue;
      const txt = (el.textContent || "").trim();
      if (!txt || txt.length > 80) continue;
      // Reject obvious non-name labels (form headings, sections, etc)
      if (/lead|inbox|impact|portal|details|history|search|filter|menu|home|page|login|account/i.test(txt)) continue;
      if (SIMPLE_RE.test(txt)) return txt;
    }

    // Final fallback for POS / structured leads: look at the scraped
    // Label:Value fields for an Insured / Policyholder / Customer name
    // and a separate FirstName + LastName pair.
    try {
      const f = scrapeAllLabelValuePairs();
      const single = f["Insured"] || f["Policyholder"] || f["PolicyHolder"] ||
                     f["Customer Name"] || f["CustomerName"] ||
                     f["Member Name"]   || f["MemberName"];
      if (single && /^[A-Za-z][A-Za-z .,'\-]+$/.test(single) && single.length < 80) {
        return single.toUpperCase();
      }
      const fn = f["FirstName"] || f["First Name"] || f["First"];
      const ln = f["LastName"]  || f["Last Name"]  || f["Last"]  || f["Surname"];
      if (fn && ln) {
        return (ln + ", " + fn).toUpperCase();
      }
    } catch {}

    return "";
  }

  // Scrapes the "lead group" / campaign / source label that Impact shows
  // at the top-middle of every lead card (e.g. "UFCW Local 75",
  // "CSK Lead"). Tries multiple heuristics since Impact's markup may
  // change. Returns "" if nothing plausible is found.
  function getLeadGroup() {
    // First: look for elements with class names suggesting a campaign/
    // group/source label. Most CRMs use one of these conventions.
    const classMatches = document.querySelectorAll(
      ".lead-group, .lead-source, .lead-campaign, .lead-type, " +
      ".campaign-name, .source-name, .group-name, " +
      "[class*='campaign'], [class*='source'], [class*='lead-type'], [class*='LeadType']"
    );
    for (const el of classMatches) {
      const txt = (el.textContent || "").trim();
      if (!txt || txt.length > 80) continue;
      // Reject if it looks like a section heading or the lead name
      if (/^lead\b|^source$|^group$|^campaign$|^type$/i.test(txt)) continue;
      return txt;
    }
    // Fallback: look for prominent header-like elements that aren't the
    // lead name and aren't generic labels. Common pattern: a small
    // colored label/badge at the top of the lead card.
    const candidates = [
      ...document.querySelectorAll("h1, h2, h3, h4, .badge, .label, .pill, .tag, .chip, .lead-header, .card-header"),
    ];
    const leadName = getLeadName();
    for (const el of candidates) {
      const txt = (el.textContent || "").trim();
      if (!txt || txt.length > 60) continue;
      if (leadName && txt === leadName) continue;
      // Heuristic: looks like a group name if it contains "Local",
      // "Lead", "Union", or is short capitalized text with no commas.
      if (/\b(?:Local\s*\d+|Lead|Union|Chapter|Group|Council|Branch)\b/i.test(txt)) return txt;
    }
    return "";
  }

  // Extracts the AD&D / death-benefit dollar amount from the lead page.
  // Looks for "$3,500 Member" / "$1,000 AD&D Benefit" / etc — common
  // patterns insurance lead pages use. Returns the matched substring
  // (e.g. "$3,500 Member & Retiree AD&D Benefit") or "" if not found.
  function getLeadBenefit() {
    const text = (document.body?.textContent || "").replace(/\s+/g, " ");
    // Strong match: dollar amount + benefit-like word within 40 chars
    const strongMatches = [
      /(\$[\d,]+(?:\.\d{2})?\s+[A-Za-z &]{1,40}?(?:AD ?& ?D|Death|Member|Retiree|Coverage|Benefit)[A-Za-z ]{0,30})/,
      /((?:AD ?& ?D|Death|Coverage|Benefit)\s*[—:\-]?\s*\$[\d,]+(?:\.\d{2})?)/,
    ];
    for (const re of strongMatches) {
      const m = text.match(re);
      if (m) return m[1].trim().slice(0, 80);
    }
    // Weak fallback: any $X,XXX pattern near "AD&D" or "Benefit" within 80 chars
    const idx = text.search(/AD ?& ?D|\bBenefit\b/i);
    if (idx >= 0) {
      const window = text.slice(Math.max(0, idx - 50), idx + 50);
      const m = window.match(/\$[\d,]+(?:\.\d{2})?/);
      if (m) return m[0];
    }
    return "";
  }

  // Detects the lead's source type (Vendor Lead, POS, Referral, etc.).
  //
  // v1.32.5 — Targeted lookup: Impact CRM renders the source in a table
  // row labeled "Type" (left cell) followed by the source value (right
  // cell). Per Dustin's screenshot 2026-06-15, the Type row sits below
  // the document image; the value cell is where the categorization lives.
  //
  // Strategy: find any element whose text content is exactly "Type"
  // (case-insensitive, trimmed), then read the adjacent cell or sibling.
  // Falls back to full-page text scan if structured lookup fails.
  function getLeadType__impl() {
    // v1.33.9.2 — Planet: type mapping not yet verified live; "" = no type-based
    // skipping (safe default). TODO: map from Planet group/list data once verified.
    if (CRM_KIND === "planet") return "";
    const KNOWN_TYPES = [
      "Vendor Lead",
      "POS",
      "Referral",
      "Response Card",
      "List Lead",
      "Lapsed POS",
    ];

    // Helper: case-insensitive match of a value string against the known list.
    const matchType = (val) => {
      if (!val) return "";
      const v = String(val).replace(/\s+/g, " ").trim();
      // Prefer exact match first (case-insensitive)
      const exact = KNOWN_TYPES.find((t) => t.toLowerCase() === v.toLowerCase());
      if (exact) return exact;
      // Fall back to substring (Impact may decorate "POS" → "POS Lead", etc.)
      const sub = KNOWN_TYPES.find((t) =>
        new RegExp("\\b" + t.replace(/\s+/g, "\\s+") + "\\b", "i").test(v));
      return sub || "";
    };

    // Strategy 1: find a TD/cell labeled "Type" and read the adjacent
    // TD in the same TR. Most common structure on Impact's lead page.
    try {
      const candidates = document.querySelectorAll("td, th, dt, .label, [class*='label'], [class*='Label']");
      for (const el of candidates) {
        const label = (el.textContent || "").replace(/\s+/g, " ").trim();
        if (label.toLowerCase() !== "type") continue;
        // Adjacent TD in same row (works for table rows)
        const next = el.nextElementSibling;
        if (next) {
          const found = matchType(next.textContent || "");
          if (found) return found;
        }
        // Or DD that follows a DT
        if (el.tagName === "DT") {
          const dd = el.nextElementSibling;
          if (dd && dd.tagName === "DD") {
            const found = matchType(dd.textContent || "");
            if (found) return found;
          }
        }
      }
    } catch {}

    // Strategy 2: regex against full page text — last-resort fallback
    // for layouts the structured pass doesn't recognize. Word-bounded so
    // it doesn't match "POSITIVE" or "REFERRALS" partial words.
    try {
      const text = (document.body?.textContent || "").replace(/\s+/g, " ");
      for (const t of KNOWN_TYPES) {
        const re = new RegExp("\\b" + t.replace(/\s+/g, "\\s+") + "\\b", "i");
        if (re.test(text)) return t;
      }
    } catch {}

    return "";
  }

  // Scrapes the comments / notes section. Each comment usually has a
  // timestamp + body. Impact renders these as flat text blocks under
  // an "Add Comments" / "Comments" heading. We collect up to 8 most-
  // recent comments. Returns [{ when: "5/1/2026 7:06 PM", text: "..." }].
  function getLeadComments() {
    const out = [];
    // Find the "Add Comments" / "Comments" heading then walk through
    // sibling elements collecting timestamped comments.
    const headings = [...document.querySelectorAll("*")].filter((el) => {
      if (el.children.length > 0) return false;
      const t = (el.textContent || "").trim();
      return t === "Add Comments" || t === "Comments" || t === "Notes";
    });
    if (headings.length === 0) {
      // Fallback: regex over the full body text. Captures "Comment <date>: text" patterns.
      const text = (document.body?.textContent || "").replace(/\s+/g, " ");
      const re = /Comment\s+(\d{1,2}\/\d{1,2}\/\d{2,4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?\s*[APap][Mm])?)\s+([^]+?)(?=Comment\s+\d{1,2}\/|Status\s|$)/g;
      let m;
      let count = 0;
      while ((m = re.exec(text)) !== null && count < 8) {
        out.push({ when: m[1].trim(), text: m[2].trim().slice(0, 400) });
        count++;
      }
      return out;
    }
    // Walk the heading's parent's subsequent siblings looking for
    // timestamped comment blocks.
    const heading = headings[0];
    const container = heading.closest("div, section, article") || heading.parentElement;
    if (!container) return out;
    const region = container.parentElement || container;
    // Collect text content of the region after the heading
    const rawText = (region.textContent || "").replace(/\s+/g, " ");
    const re = /Comment\s+(\d{1,2}\/\d{1,2}\/\d{2,4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?\s*[APap][Mm])?)\s+([^]+?)(?=Comment\s+\d{1,2}\/|Status\s|$)/g;
    let m;
    let count = 0;
    while ((m = re.exec(rawText)) !== null && count < 8) {
      out.push({ when: m[1].trim(), text: m[2].trim().slice(0, 400) });
      count++;
    }
    return out;
  }

  // ─── Relay-lead detection ─────────────────────────────────────────
  // Scans comments + the lead's contact fields for indicators that this
  // is a deaf-relay user (Sorenson, RelayUS, IP Relay, Convo, Purple,
  // T-Mobile IP Relay, etc.). Relay services need a dramatically
  // different dial profile — operators take 60-180s to bridge the deaf
  // user, AMD misdetects relay greetings as voicemail, voicemail
  // trigger phrases ("you've reached", "please hold") match relay
  // operator scripts.
  //
  // Auto-detection just sets a flag. The actual behavior change happens
  // when the dialer passes `relayMode: true` to the worker, which uses
  // 180s timeout, no AMD, no transcription triggers, immediate bridge.
  function detectRelayLead() {
    const text = (document.body?.textContent || "").replace(/\s+/g, " ");
    if (!text) return false;
    const RELAY_RE = /\b(deaf|hard\s*of\s*hearing|HoH\b|TTY|TDD|IP\s*relay|Sorenson|RelayUS|Convo\s*Relay|Purple\s*VRS|ZVRS|VRS\b|video\s*relay|relay\s*service|relay\s*operator|711\s*relay|sign\s*language)\b/i;
    return RELAY_RE.test(text);
  }

  // ─── Future-appointment detection ────────────────────────────────
  // Scans comments + the visible body text for "scheduled appointment"
  // / "callback on" / "appt" patterns paired with a date that's in the
  // future. If found, returns { matchedText, dateStr, dateMs } so
  // autopilot can skip the lead and log why.
  //
  // Heuristic-only — no NLP. Looks for date patterns within ~60 chars
  // of an appointment-keyword. Date formats tolerated:
  //   M/D/YY, M/D/YYYY, "Jan 5", "January 5", "next Monday" (relative)
  function detectFutureAppointment__impl() {
    // v1.33.9.2 — Planet: appointment-history detection not yet mapped; null = none.
    if (CRM_KIND === "planet") return null;
    const text = (document.body?.textContent || "").replace(/\s+/g, " ");
    if (!text) return null;
    // v1.31.62 — Now reads from worker-config (Layer 3 of the MVP).
    // Pattern is fetched from /api/runtime-config and cached by Layer 2
    // (v1.31.61). If the config is unreachable, the value missing, or
    // the cached pattern doesn't compile, we fall back to the baked-in
    // default (the v1.31.51 narrowed pattern).
    //
    // To change appointment-skip behavior for ALL agents WITHOUT a
    // reinstall: `curl -X POST .../api/admin/runtime-config -d '{"apptRegex":"..."}'`
    // The new pattern is live within 60 min (extension poll cadence).
    //
    // History — v1.31.51: narrowed regex to literal Impact disposition
    // phrases only ("Schedule Virtual Appointment" / "Schedule In Home
    // Appointment"). Prior fuzzy regex false-skipped huge numbers of
    // legitimate dialable leads. Don't widen back to fuzzy keywords
    // without re-reading the rationale.
    let APPT_RE;
    try {
      const cfgPattern = (state.runtimeConfig && state.runtimeConfig.apptRegex) || null;
      APPT_RE = cfgPattern
        ? new RegExp(cfgPattern, "gi")
        : /\bSchedule(?:d)?\s+(?:Virtual|In[\s-]?Home)\s+Appointment\b/gi;
    } catch (_e) {
      // Config pattern didn't compile — fall back to baked-in default
      console.warn("[pd] runtime config apptRegex failed to compile, using baked-in default");
      APPT_RE = /\bSchedule(?:d)?\s+(?:Virtual|In[\s-]?Home)\s+Appointment\b/gi;
    }
    const now = new Date();
    const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();

    let m;
    while ((m = APPT_RE.exec(text)) !== null) {
      const start = Math.max(0, m.index - 20);
      const end   = Math.min(text.length, m.index + m[0].length + 80);
      const window = text.slice(start, end);
      // Try to find a date inside this window
      const parsed = parseAnyDate(window);
      if (parsed && parsed.getTime() >= today0) {
        return {
          matchedText: window.trim(),
          dateStr:     parsed.toLocaleDateString(),
          dateMs:      parsed.getTime(),
          kind:        "appointment",
        };
      }
    }
    // v1.34 — Optional per-agent skip of FUTURE "Call Back appointment on <date>"
    // entries (toggle: state.skipFutureCallbacks, persisted pd_skip_future_callbacks).
    // Impact writes both "Schedule Call Back appointment on <date>" and "After call
    // selected Call Back appointment on <date>" — both contain the exact literal
    // "Call Back appointment on", which is specific enough to never collide with a
    // stray "call back later" note. The date sits right after "on", so the same
    // future-date window logic applies.
    if (state.skipFutureCallbacks) {
      const CB_RE = /Call\s*Back\s+appointment\s+on\b/gi;
      let cm;
      while ((cm = CB_RE.exec(text)) !== null) {
        const cs   = Math.max(0, cm.index - 20);
        const ce   = Math.min(text.length, cm.index + cm[0].length + 80);
        const cwin = text.slice(cs, ce);
        const cparsed = parseAnyDate(cwin);
        if (cparsed && cparsed.getTime() >= today0) {
          return {
            matchedText: cwin.trim(),
            dateStr:     cparsed.toLocaleDateString(),
            dateMs:      cparsed.getTime(),
            kind:        "callback",
          };
        }
      }
    }
    return null;
  }

  function parseAnyDate(window) {
    // M/D/YY or M/D/YYYY
    const md = window.match(/\b(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
    if (md) {
      let yr = md[3] ? parseInt(md[3], 10) : new Date().getFullYear();
      if (yr < 100) yr += yr > 50 ? 1900 : 2000;
      const d = new Date(yr, parseInt(md[1], 10) - 1, parseInt(md[2], 10));
      if (!isNaN(d.getTime())) return d;
    }
    // "Jan 5" or "January 15, 2026"
    const monthMap = {jan:0,feb:1,mar:2,apr:3,may:4,jun:5,jul:6,aug:7,sep:8,oct:9,nov:10,dec:11,
      january:0,february:1,march:2,april:3,june:5,july:6,august:7,september:8,october:9,november:10,december:11};
    const mm = window.match(/\b([A-Z][a-z]{2,8})\.?\s+(\d{1,2})(?:,?\s+(\d{4}))?/i);
    if (mm) {
      const monIdx = monthMap[mm[1].toLowerCase()];
      if (monIdx !== undefined) {
        const day = parseInt(mm[2], 10);
        const yr  = mm[3] ? parseInt(mm[3], 10) : new Date().getFullYear();
        const d = new Date(yr, monIdx, day);
        if (!isNaN(d.getTime())) return d;
      }
    }
    return null;
  }

  // ─── Comment-numbers extractor (call-from-comments mode) ─────────
  // Scrapes phone numbers out of the comments / notes text. Returns an
  // array of E.164-normalized strings (e.g. "+15135551234") in the order
  // they appeared. Used by call-from-comments mode to dial through
  // numbers that aren't on the Mobile/Home buttons.
  function extractCommentPhoneNumbers() {
    // Concentrate on the comments region first, fall back to whole body
    let region = "";
    const heading = [...document.querySelectorAll("*")].find((el) => {
      if (el.children.length > 0) return false;
      const t = (el.textContent || "").trim();
      return t === "Add Comments" || t === "Comments" || t === "Notes";
    });
    if (heading) {
      const container = heading.closest("div, section, article")?.parentElement;
      region = (container?.textContent || "").replace(/\s+/g, " ");
    }
    if (!region) region = (document.body?.textContent || "").replace(/\s+/g, " ");
    // Phone number regex — handles common US/CA formats. Captures the
    // 10 digits we need and reconstructs as +1XXXXXXXXXX.
    const PHONE_RE = /\b(?:\+?1[\s\-\.\(]*)?(\d{3})[\)\s\-\.]+(\d{3})[\s\-\.]+(\d{4})\b/g;
    const out = [];
    const seen = new Set();
    let m;
    while ((m = PHONE_RE.exec(region)) !== null) {
      const digits = m[1] + m[2] + m[3];
      if (digits.length !== 10) continue;
      if (digits.startsWith("0") || digits.startsWith("1")) continue; // not valid US area codes
      const e164 = "+1" + digits;
      if (!seen.has(e164)) {
        seen.add(e164);
        out.push(e164);
      }
    }
    return out;
  }

  function pickNextCommentNumber(leadId) {
    if (!leadId) return null;
    const all = extractCommentPhoneNumbers();
    if (!all.length) return null;
    const tried = new Set((state.commentsDialed && state.commentsDialed[leadId]) || []);
    for (const n of all) {
      if (!tried.has(n)) return n;
    }
    return null;
  }

  function markCommentNumberTried(leadId, number) {
    if (!leadId || !number) return;
    state.commentsDialed = state.commentsDialed || {};
    if (!state.commentsDialed[leadId]) state.commentsDialed[leadId] = [];
    if (!state.commentsDialed[leadId].includes(number)) {
      state.commentsDialed[leadId].push(number);
    }
    try {
      chrome.storage.local.set({ pd_comments_dialed: state.commentsDialed });
    } catch {}
  }

  // Find the most-likely "lead card" image on the page (a scanned
  // beneficiary card / application form). We look for the largest
  // img element whose src isn't an obvious decoration. Then we fetch()
  // it from the content script so it inherits Impact's session cookies,
  // convert to a base64 data URL, and return it. This bypasses
  // chrome.tabs.captureVisibleTab entirely — works regardless of tab
  // focus, no rate limit, cross-origin handled by the page's own auth.
  async function getLeadCardImageDataUrl() {
    const imgs = [...document.querySelectorAll("img")];
    // Filter to plausible lead-card images. Real Impact lead-card scans
    // are typically 800-1200 wide × 1000-1500 tall. Bumped the floor
    // to 400×400 (was 250×200) so we don't accidentally match POS-page
    // decorations (small banners, generic placeholders) and miss the
    // captureTabScreenshot fallback path.
    const candidates = imgs.filter((img) => {
      if (!img.src || !img.complete) return false;
      const w = img.naturalWidth | 0;
      const h = img.naturalHeight | 0;
      if (w < 400 || h < 400) return false;
      if (/icon|logo|avatar|banner|sprite|button|placeholder|stock/i.test(img.src)) return false;
      return true;
    });
    if (candidates.length === 0) return null;
    // Sort by area, biggest first
    candidates.sort((a, b) => (b.naturalWidth * b.naturalHeight) - (a.naturalWidth * a.naturalHeight));
    for (const img of candidates) {
      try {
        const resp = await fetch(img.src, { credentials: "include" });
        if (!resp.ok) continue;
        const blob = await resp.blob();
        // Cap at 4 MB pre-base64 — anything larger is suspicious
        if (blob.size > 4_000_000) continue;
        const dataUrl = await new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onload = () => resolve(reader.result);
          reader.onerror = () => reject(reader.error);
          reader.readAsDataURL(blob);
        });
        return dataUrl;
      } catch {
        // try next candidate
      }
    }
    return null;
  }

  // ─────────────────────────────────────────────────────────────────
  // Mobile companion: lead info + screenshot capture
  // ─────────────────────────────────────────────────────────────────
  // Scrape every visible field on Impact's lead-detail page so the
  // mobile viewer can render a comprehensive card. Best-effort: uses
  // multiple heuristics since Impact's markup isn't standardized.
  // Format-agnostic Label:Value walker. Handles the full Impact stack:
  //
  //  • Union/AD&D leads (BSOIW Local 44, UFCW, Globe Life, childsafe)
  //    → comments contain organization name, lead-card image is a fetchable
  //      <img>, no $ amounts in DOM (they're handwritten in the card scan)
  //
  //  • POS / lapsed-policy leads (UN-CARPENTER affiliation)
  //    → AffiliationName + PolicyNumber + Coverage + StatusCode are explicit
  //      Label:Value pairs in plain text
  //
  // Strategy: walk every leaf DOM node, find any "Label: Value" pattern
  // and stash in fields[]. Then expose convenience aliases (group,
  // benefit, leadType etc) by checking known label names + free-text
  // fallback regexes. Mobile renders ALL of fields[] regardless.
  function scrapeAllLabelValuePairs() {
    const fields = {};
    const blob = (s) => (s || "").replace(/\s+/g, " ").trim();
    // Cell-like elements that often contain a single Label:Value pair
    const allLeaves = [];
    const walk = (el, depth) => {
      if (!el || depth > 12) return;
      if (el.children && el.children.length === 0) {
        allLeaves.push(el);
        return;
      }
      for (const c of (el.children || [])) walk(c, depth + 1);
    };
    walk(document.body, 0);
    // Pass 1: leaf elements containing "Label: Value" together
    for (const el of allLeaves) {
      const t = blob(el.textContent);
      if (!t || t.length > 250) continue;
      // Allow CamelCase ("AffiliationName") + spaced ("Spouse Dob")
      const m = t.match(/^([A-Za-z][A-Za-z0-9]{0,20}(?:\s[A-Za-z][A-Za-z0-9]{0,20}){0,3})\s*[:\-]\s+(.+?)\s*$/);
      if (!m) continue;
      const label = m[1].trim();
      const value = m[2].trim();
      if (label.length > 30 || value.length > 240) continue;
      // Skip values that are just another label
      if (/^[A-Z][A-Za-z ]{2,}:$/.test(value)) continue;
      if (!fields[label]) fields[label] = value;
    }
    // Pass 2: leaf elements that ARE just "Label:" — value comes from
    // next-sibling element. Common Impact pattern.
    for (const el of allLeaves) {
      const t = blob(el.textContent);
      const m = t.match(/^([A-Za-z][A-Za-z0-9]{0,20}(?:\s[A-Za-z][A-Za-z0-9]{0,20}){0,3})\s*[:\-]?\s*$/);
      if (!m) continue;
      const label = m[1].trim();
      if (label.length > 30 || fields[label]) continue;
      // Look at the immediate next sibling for the value
      const sib = el.nextElementSibling;
      if (sib) {
        const v = blob(sib.textContent);
        if (v && v.length < 240 && !v.match(/^[A-Z][A-Za-z ]{2,}:/)) {
          fields[label] = v;
          continue;
        }
      }
      // Or look at parent's last leaf if no sibling
      const p = el.parentElement;
      if (p && p !== document.body) {
        const parentText = blob(p.textContent);
        if (parentText.startsWith(t) && parentText.length > t.length) {
          const v = parentText.slice(t.length).replace(/^[:\-\s]+/, "").trim();
          if (v && v.length < 240) fields[label] = v;
        }
      }
    }
    // Pass 3: regex sweep over the entire body text for anything we missed.
    // Catches patterns like "AffiliationName: UN-CARPENTER" rendered in a
    // monospace pre-formatted block where the leaf-walker doesn't isolate
    // it cleanly.
    const allText = (document.body?.textContent || "").replace(/\s+/g, " ");
    const re = /([A-Z][A-Za-z0-9]{1,25}(?:\s[A-Z][A-Za-z0-9]{1,25}){0,2})\s*:\s*([^\n]{1,160}?)(?=\s{2,}[A-Z][A-Za-z0-9]+\s*:|$)/g;
    let m;
    while ((m = re.exec(allText)) !== null) {
      const label = m[1].trim();
      const value = m[2].trim();
      if (!label || !value || label.length > 30 || value.length > 240) continue;
      if (!fields[label]) fields[label] = value;
    }
    return fields;
  }

  function getLeadDetails() {
    const fields = scrapeAllLabelValuePairs();

    // Normalize phone via existing helpers (catches tel: links specifically)
    const mobileLink = CRM.phoneLink("mobile");
    const homeLink   = CRM.phoneLink("home");
    const mobileFromLink = mobileLink ? (CRM.numberFromLink(mobileLink) || (mobileLink.textContent || "").trim()) : null;
    const homeFromLink   = homeLink   ? (CRM.numberFromLink(homeLink)   || (homeLink.textContent   || "").trim())   : null;

    // Email — prefer mailto: anchor, fall back to scraped field
    const mailLink = document.querySelector('a[href^="mailto:"]');
    const emailFromLink = mailLink ? (mailLink.getAttribute("href") || "").replace(/^mailto:/i, "").split("?")[0] : null;

    // Convenience aliases: try multiple known label names so any of them work
    const pick = (...keys) => {
      for (const k of keys) if (fields[k]) return fields[k];
      return null;
    };

    const details = {
      name:     getLeadName() || null,
      group:    getLeadGroup() || pick("AffiliationName", "Affiliation", "Group", "Source", "Lead Source", "Campaign") || null,
      leadType: pick("StatusCode", "Type", "Lead Type", "Status") || getLeadType() || null,
      benefit:  pick("Coverage", "Benefit", "AnnPrem") || getLeadBenefit() || null,
      comments: getLeadComments() || [],
      mobile:   mobileFromLink || pick("Mobile", "Cell", "Cellular Phone Number") || null,
      home:     homeFromLink   || pick("Home", "Phone", "Home Telephone") || null,
      email:    emailFromLink  || pick("Email") || null,
      address:  pick("Address", "Home Address") || null,
      age:      pick("Age") ? parseInt(pick("Age"), 10) : null,
      beneficiary:    pick("Beneficiary") || null,
      spouseName:     pick("Spouse Name") || null,
      spouseDob:      pick("Spouse Dob", "Spouse DOB") || null,
      spouseGender:   pick("Spouse Gender") || null,
      policyNumber:   pick("PolicyNumber", "Policy Number") || null,
      policyStatus:   pick("StatusCode", "Status") || null,
      coverage:       pick("Coverage") || null,
      annPrem:        pick("AnnPrem", "Annual Premium") || null,
      productsRequested: pick("Products", "Requirements") || null,
      leadSource:     pick("Lead Source", "Source") || null,
      // Full label:value dictionary — mobile renders this when set
      fields,
      raw: fields, // back-compat alias for older mobile.js builds
      // Strong signal that this is a POS / lapsed-policy lead (no card image,
      // structured DOM only). Used by captureAndSendLeadInfo to skip the
      // <img> fetch and go straight to chrome.tabs.captureVisibleTab.
      _isPos: !!(fields.AffiliationName || fields.PolicyNumber || fields.Coverage || fields.StatusCode),
    };
    return details;
  }

  // Asks background.js to capture a JPEG of the active tab using
  // chrome.tabs.captureVisibleTab. Single-viewport capture; the
  // multi-frame full-page version is captureFullPageScreenshot below.
  async function singleCaptureViaBackground() {
    return new Promise((resolve) => {
      try {
        chrome.runtime.sendMessage(
          { type: "pd_capture_screenshot" },
          (resp) => {
            const lastErr = chrome.runtime.lastError?.message;
            if (lastErr) return resolve({ error: lastErr });
            if (resp?.ok && resp.dataUrl) return resolve({ dataUrl: resp.dataUrl });
            resolve({ error: resp?.error || "no dataUrl returned" });
          }
        );
      } catch (err) { resolve({ error: err?.message || String(err) }); }
    });
  }

  // Full-page capture: scrolls the lead detail page top-to-bottom (up to
  // 3 viewports), captures each visible chunk via chrome.tabs.captureVisibleTab,
  // and stitches the JPEGs into a single tall image using a <canvas>.
  // The agent gets the entire lead card (group name, name, address,
  // beneficiary, phones, email) PLUS the notes/comments section below
  // the fold, in one screenshot — no scrolling needed on mobile.
  //
  // We hide our own dialer panel before capture so it doesn't pollute
  // every frame; restore on completion.
  //
  // Returns { dataUrl } on success or { error } on failure (so caller
  // can surface the reason instead of silently dropping the shot).
  async function captureTabScreenshot(opts = {}) {
    const root = document.getElementById("pd-root");
    const rootDisplayWas = root?.style.display;
    if (root) root.style.display = "none";

    const savedX = window.scrollX;
    const savedY = window.scrollY;

    try {
      const docH = Math.max(
        document.body.scrollHeight || 0,
        document.documentElement.scrollHeight || 0,
        window.innerHeight
      );
      const viewH = window.innerHeight;
      // v1.29.93 — Single-frame by default (fast, no scroll disruption).
      // Multi-frame stitching ONLY for POS leads where the full policy
      // list spans multiple viewports. Caller passes { isPosLead: true }
      // from captureAndSendLeadInfo when it has detected a POS lead from
      // the scraped fields. Non-POS leads (the common case) get a clean
      // single-frame capture in ~200ms with zero page scroll.
      //
      // Chrome rate-limits captureVisibleTab to ~2/sec, so multi-frame
      // can take 2-3s and may truncate on tall pages. We bias toward
      // reliability: 3 frames max for POS (covers most policy lists),
      // and accept that an unusually tall POS page may truncate.
      const numFrames = opts.isPosLead
        ? Math.min(3, Math.max(1, Math.ceil(docH / viewH)))
        : 1;

      const frames = []; // { targetY, dataUrl }
      for (let i = 0; i < numFrames; i++) {
        // Last frame: clamp to bottom so we don't scroll past the page
        const targetY = Math.min(i * viewH, Math.max(0, docH - viewH));
        window.scrollTo({ top: targetY, left: 0 });
        // Two rAFs + a short delay is the most reliable "wait for paint"
        await new Promise((r) => requestAnimationFrame(() =>
          requestAnimationFrame(() => setTimeout(r, 60))
        ));
        const r = await singleCaptureViaBackground();
        if (r.dataUrl) {
          frames.push({ targetY, dataUrl: r.dataUrl });
        } else {
          if (i === 0) return { error: r.error || "first capture failed" };
          break; // probably hit Chrome's capture rate limit
        }
      }

      // Single-frame case: just return it directly, no stitching needed.
      if (frames.length === 1) return { dataUrl: frames[0].dataUrl };

      // Multi-frame: stitch via offscreen canvas
      const imgs = await Promise.all(frames.map((f) => new Promise((res, rej) => {
        const im = new Image();
        im.onload = () => res(im);
        im.onerror = () => rej(new Error("image decode failed"));
        im.src = f.dataUrl;
      })));
      const W = imgs[0].width;
      const frameH = imgs[0].height; // device pixels
      // Infer device-pixel-ratio from the captured image height vs viewport
      const dpr = frameH / viewH;
      const totalH = Math.round(docH * dpr);
      const canvas = document.createElement("canvas");
      canvas.width = W;
      canvas.height = totalH;
      const ctx = canvas.getContext("2d");
      // Paint white background so JPEG compression doesn't introduce
      // dark blocks around frame edges
      ctx.fillStyle = "#ffffff";
      ctx.fillRect(0, 0, W, totalH);
      for (let i = 0; i < frames.length; i++) {
        const yPx = Math.round(frames[i].targetY * dpr);
        ctx.drawImage(imgs[i], 0, yPx);
      }
      // 75% JPEG quality on the stitched image — readable on phone but
      // keeps the data URL well within the 5 MB worker cap even for
      // tall pages.
      return { dataUrl: canvas.toDataURL("image/jpeg", 0.75) };
    } catch (err) {
      return { error: err?.message || String(err) };
    } finally {
      // Always restore scroll + panel visibility, even on error
      try { window.scrollTo({ top: savedY, left: savedX }); } catch {}
      if (root) root.style.display = rootDisplayWas || "";
    }
  }

  // Combined helper: scrape details + capture screenshot + send both to
  // the worker via the control WebSocket. Fire-and-forget.
  // v1.31.69 — Away Mode capture. Asks background.js to screenshot the
  // Impact tab, save the PNG to Downloads with a structured filename,
  // AND append a metadata entry to chrome.storage.local pd_away_log so
  // the agent can review the captures from the panel when they get
  // back to the computer.
  async function awayModeCapture({ leadId, leadName, phone }) {
    const ts = Date.now();
    const niceDate = new Date(ts).toISOString().slice(0, 16).replace(/[:T]/g, "-");
    const safeLead = (leadName || "lead").replace(/[^a-z0-9_-]+/gi, "").slice(0, 30) || "lead";
    const safePhone = (phone || "").replace(/[^0-9]+/g, "").slice(-10) || "nophone";
    const filename = `awaymode_${niceDate}_${safeLead}_${safePhone}.jpg`;
    return new Promise((resolve) => {
      chrome.runtime.sendMessage(
        { type: "pd_away_capture_and_save", filename, leadId, leadName, phone, ts },
        (res) => {
          if (!res || !res.ok) {
            log("warn", "Away Mode capture failed: " + (res && res.error));
            resolve(null);
            return;
          }
          // Append to the local Away Log so the agent can review.
          chrome.storage.local.get("pd_away_log").then((data) => {
            const log = Array.isArray(data?.pd_away_log) ? data.pd_away_log : [];
            log.unshift({
              ts, leadId: leadId || null, leadName: leadName || "",
              phone: phone || "", filename, downloadId: res.downloadId || null,
              reviewed: false,
            });
            // Cap at 500 entries to keep storage sane.
            while (log.length > 500) log.pop();
            chrome.storage.local.set({ pd_away_log: log }).then(() => resolve(res));
          }).catch(() => resolve(res));
        }
      );
    });
  }

  async function captureAndSendLeadInfo__impl(leadId) {
    const ws = state.ws;
    if (!ws || ws.readyState !== 1) return;

    // 1. Structured details (name, group, benefit, comments, etc) —
    //    primary payload. Always sent immediately, never blocked on
    //    image work.
    let details = null;
    try { details = getLeadDetails(); } catch (err) {
      console.warn("[pd] getLeadDetails threw:", err?.message);
    }
    if (details) {
      // v1.31.70 — Guard against ws.readyState !== OPEN. captureAndSendLeadInfo
      // can race with session end / lead navigation / network blip; raw send
      // on a closed socket throws InvalidStateError + spams Chrome's Issues
      // panel (GPD agent bug report 2026-06-05).
      if (ws && ws.readyState === WebSocket.OPEN) {
        try {
          ws.send(JSON.stringify({ type: "lead.details", details, leadId }));
          console.log("[pd] lead.details pushed",
            { name: details.name, group: details.group, benefit: details.benefit, comments: details.comments?.length });
        } catch (err) {
          console.warn("[pd] failed to send lead.details:", err?.message);
        }
      } else {
        console.log("[pd] lead.details skipped — ws not OPEN (readyState=" + (ws?.readyState ?? "null") + ")");
      }
    }

    // 2. Lead card IMAGE — two paths:
    //    a) Fetch the largest qualifying <img> directly (Union/AD&D leads
    //       have a scanned card). Floor is now 400×400.
    //    b) If no qualifying <img> OR if the lead is detectably POS
    //       (scraped fields show AffiliationName / PolicyNumber /
    //       Coverage / StatusCode), capture the visible tab via
    //       background.js. POS leads are DOM-only with variable length;
    //       captureTabScreenshot stitches up to 5 viewports.
    let cardImage = null;
    let imageSource = "none";
    let captureError = null;

    // POS detection — if we have any of these scraped fields, we know
    // there's no lead-card image to fetch. Skip the img path entirely.
    const isPosLead = !!(details && (
      details.group === details.group && /UN-CARPENTER|UN-|CARP/i.test(details.group || "") ||
      /^pos[-\/_ ]?lead/i.test(details.leadType || "") ||
      /policy/i.test(details.leadType || "") ||
      // Scraped fields from scrapeAllLabelValuePairs() — captured as
      // convenience aliases on details (group / leadType / benefit
      // pull from these).
      details._isPos === true
    ));

    if (!isPosLead) {
      try { cardImage = await getLeadCardImageDataUrl(); } catch (err) {
        console.warn("[pd] getLeadCardImageDataUrl threw:", err?.message);
      }
      if (cardImage) imageSource = "img-fetch";
    } else {
      console.log("[pd] POS lead detected — skipping img-fetch path");
    }

    if (!cardImage) {
      // v1.29.71 — Don't retry captureTabScreenshot if a prior attempt
      // this session hit the host-permission block. The error fires
      // every dial which spams the Web Store error dashboard and
      // produces nothing actionable (the dialer works fine without
      // the screenshot — it's a mobile-companion nice-to-have).
      // Reset on session start so a fresh sign-in after granting
      // activeTab can try again. See `screenshotPermissionBlocked` flag.
      if (state.screenshotPermissionBlocked) {
        captureError = "screenshot disabled this session (host-permission block)";
      } else {
        try {
          // v1.29.93 — Pass isPosLead so the capture function knows
          // whether to multi-frame stitch (POS lead with long policy
          // list) or single-frame (common case, faster + no scroll).
          const cap = await captureTabScreenshot({ isPosLead });
          if (cap?.dataUrl) {
            cardImage = cap.dataUrl;
            imageSource = isPosLead ? "tab-capture-pos" : "tab-capture-fallback";
            console.log("[pd] used captureTabScreenshot — source=" + imageSource);
          } else if (cap?.error) {
            captureError = cap.error;
            // v1.29.71 — If Chrome reports the host-permission block,
            // flag the session so we don't retry on every dial. Log
            // once with actionable guidance instead of warn-spamming.
            if (/all_urls|activeTab/i.test(cap.error)) {
              state.screenshotPermissionBlocked = true;
              // v1.32.8 — Persist so the next page navigation knows not to
              // re-attempt and re-warn. Cleared at session start.
              try { chrome.storage.local.set({ pd_screenshot_blocked: true }); } catch {}
              // v1.32.8 — Demoted from console.warn → console.log. The warn
              // level was being forwarded to the Chrome Web Store error
              // dashboard, polluting our store standing. It also recommended
              // the agent broaden Site Access to "On all sites" — guidance
              // we no longer want to give (broad host permissions create a
              // trust/security concern for agents). The mobile-companion
              // screenshot is a nice-to-have, not a dialer requirement, so
              // just note it and move on. Diagnostic payload still captures
              // this log line if we need to debug specific agent setups.
              console.log("[pd] Mobile-companion screenshot unavailable this session — dialer unaffected.");
            } else {
              console.warn("[pd] captureTabScreenshot failed:", cap.error);
            }
          }
        } catch (err) {
          captureError = err?.message || String(err);
          console.warn("[pd] captureTabScreenshot threw:", err?.message);
        }
      }
    }

    if (cardImage) {
      // v1.31.70 — readyState guard (see Fix 1 note above).
      if (ws && ws.readyState === WebSocket.OPEN) {
        try {
          ws.send(JSON.stringify({ type: "lead.screenshot", dataUrl: cardImage, leadId, source: imageSource }));
          console.log("[pd] lead screenshot pushed (" + Math.round(cardImage.length / 1024) + " KiB, source=" + imageSource + ")");
        } catch (err) {
          console.warn("[pd] failed to send lead.screenshot:", err?.message);
        }
      } else {
        console.log("[pd] lead.screenshot skipped — ws not OPEN (readyState=" + (ws?.readyState ?? "null") + ")");
      }
    } else {
      // No card image found / fetch failed — tell mobile so it can
      // show "no image available" instead of a stuck loading state.
      // Include the actual capture error so we can debug what went wrong.
      const reason = captureError
        ? `Tab capture failed: ${captureError}`
        : (isPosLead
            ? "POS lead — no image and tab capture didn't return one. Make sure the Impact tab is focused."
            : "No lead-card image found on this page");
      // v1.31.70 — readyState guard (see Fix 1 note above).
      if (ws && ws.readyState === WebSocket.OPEN) {
        try {
          ws.send(JSON.stringify({
            type: "lead.screenshot",
            dataUrl: null,
            leadId,
            error: reason,
          }));
        } catch {}
      } else {
        console.log("[pd] lead.screenshot (no-image) skipped — ws not OPEN");
      }
    }
  }

  // ══════════════════════════════════════════════════════════════════════
  // CRM ADAPTER LAYER (v1.34 — multi-division seam, adapter-refactor branch)
  // ──────────────────────────────────────────────────────────────────────
  // Goal: the dialer CORE talks to the CRM ONLY through `CRM.<method>()`, so a
  // new division = a new adapter object with the same shape. Impact is the
  // first adapter; PlanetCRM (AO Globe Life / The Planet) and SalesforceCRM
  // (Liberty National) get added in the adapter codebase. The Impact methods
  // DELEGATE to the existing, proven functions, so introducing this layer is
  // behavior-preserving — call sites are migrated to CRM.* capability-by-
  // capability in later slices (this slice only defines the seam).
  //
  // Interface every adapter implements:
  //   isOnLeadPage()                → bool     on a dialable lead-detail page?
  //   isOnDispositionPage()         → bool     on the post-call "what happened" page?
  //   leadUrl(leadId)               → string   URL to a lead's detail page
  //   getLeadId()/getLeadName()/getLeadType() → string
  //   phoneLink(label)              → <a>|null the tel: link for "Mobile"/"Home"
  //   numberFromLink(a)             → string   number scraped from a phone link
  //   advanceToNextLead()           → void     click the CRM's next-lead control
  //   detectFutureAppointment()     → hit|null scan notes for future appt/callback
  //   autoDispositionNoAnswer(opts) → bool     set "No Answer" + advance
  //   captureAndSendLeadInfo(id)    → Promise  read lead details (+screenshot) → worker
  // ══════════════════════════════════════════════════════════════════════
  // Build-selected CRM. A per-division adapter script (e.g. liberty-crm.js),
  // loaded as a content_script BEFORE content.js, sets window.__PD_CRM_KIND.
  // Defaults to "impact" when no adapter script is present (the AIL build).
  const CRM_KIND = (typeof window !== "undefined" && window.__PD_CRM_KIND) || "impact";

  const ImpactCRM = {
    kind: "impact",
    // Skip-Lead-Types / Voicemail lead-source categories for AIL/Impact. Single
    // source of truth per division (Liberty overrides via LibertyCRM.leadTypes).
    leadTypes: ["Vendor Lead", "POS", "Referral", "Response Card", "List Lead", "Lapsed POS"],
    // Page/URL state — Impact: /Lead/InboxDetail = lead, /Lead/WhatHappend = post-call.
    isOnLeadPage()        { return /\/Lead\/InboxDetail/i.test(location.pathname); },
    isOnDispositionPage() { return /\/Lead\/WhatHappend/i.test(location.pathname); },
    leadUrl(leadId)       { return `/Lead/InboxDetail?LeadId=${encodeURIComponent(leadId)}&detailId=-1`; },
    // Lead identity.
    getLeadId()   { return getLeadId__impl(); },
    getLeadName() { return getLeadName__impl(); },
    getLeadType() { return getLeadType__impl(); },
    // Phone numbers.
    phoneLink(label)  { return findPhoneLink(label); },
    numberFromLink(a) { return getNumberFromLink(a); },
    // Navigation.
    advanceToNextLead() { return advanceToNextLead__impl(); },
    // Notes / appointments / callbacks.
    detectFutureAppointment() { return detectFutureAppointment__impl(); },
    // Disposition.
    autoDispositionNoAnswer(opts) { return maybeAutoDispositionNoAnswer__impl(opts); },
    // Lead capture (details + screenshot → worker).
    captureAndSendLeadInfo(leadId) { return captureAndSendLeadInfo__impl(leadId); },
  };

  // ── PlanetCRM (AO Globe Life "Planet" — m.planetaltig.com) ──────────────
  // Planet is Impact's structural twin (same /Lead/InboxDetail → /Lead/WhatHappend
  // cycle), so it reuses the ENTIRE Impact machinery: it spreads ImpactCRM (same
  // method delegators → the *__impl functions) and only overrides kind + leadTypes
  // + leadUrl. The DOM differences (MoveNext nav, two-step dial, hidden-input lead
  // data, "No Answer" <a> disposition) live as additive `CRM_KIND === "planet"`
  // branches inside those *__impl functions, so Impact + Liberty are untouched.
  // Selected when planet-crm.js sets window.__PD_CRM_KIND = "planet".
  const PlanetCRM = {
    ...ImpactCRM,
    kind: "planet",
    // v1: getLeadType() returns "" for Planet until the type mapping is verified
    // live, so this list is a placeholder (no type-based skipping happens yet).
    leadTypes: ["List Lead", "Vendor Lead", "POS", "Referral", "Response Card"],
    leadUrl(leadId) { return `/Lead/InboxDetail?LeadId=${encodeURIComponent(leadId)}`; },
  };

  // Active adapter for this build. Per-division switch driven by __PD_CRM_KIND
  // (set by the division's adapter file, e.g. liberty-crm.js / planet-crm.js).
  // let (not const) so the Liberty adapter hot-reload can repoint it to a
  // freshly re-injected window.LibertyCRM without a full page reload.
  let CRM =
    (CRM_KIND === "liberty" && typeof window !== "undefined" && window.LibertyCRM) ? window.LibertyCRM
    : (CRM_KIND === "planet") ? PlanetCRM
    : ImpactCRM;
  void CRM;

  // ── CRM dispatchers ─────────────────────────────────────────────────────
  // These thin wrappers keep the (hundreds of) existing core call sites
  // untouched while routing every CRM-specific operation through the active
  // adapter. The real Impact implementations live in the *__impl functions
  // below (they move into impact-adapter.* when the codebase splits per
  // division). Swapping CRM = PlanetCRM / SalesforceCRM re-points all of these
  // at that division's adapter with zero core edits. Behavior-preserving for
  // Impact: CRM is ImpactCRM, which delegates straight back to *__impl.
  function getLeadId()   { return CRM.getLeadId(); }
  function getLeadName() { return CRM.getLeadName(); }
  function getLeadType() { return CRM.getLeadType(); }
  function advanceToNextLead()    { return CRM.advanceToNextLead(); }
  function detectFutureAppointment() { return CRM.detectFutureAppointment(); }
  function maybeAutoDispositionNoAnswer(opts) { return CRM.autoDispositionNoAnswer(opts); }
  function captureAndSendLeadInfo(leadId)     { return CRM.captureAndSendLeadInfo(leadId); }

  // Liberty (list-model) autopilot driver — instantiated ONLY for the Liberty
  // build (CRM === LibertyCRM, which exposes createLibertyAutopilot). Null in
  // the Impact build, so the guarded branches in maybeAutoDial + prospect.hangup
  // are inert here (CRM.kind === "impact").
  // Factored so the Liberty adapter hot-reload can rebuild the driver from a
  // freshly re-injected adapter without a page reload.
  function buildLibertyAP() {
    if (!(CRM.kind === "liberty" && typeof CRM.createLibertyAutopilot === "function")) return null;
    return CRM.createLibertyAutopilot({
      // Wrap send so EVERY Liberty dial carries the test/relay flags, exactly
      // like the Impact paths. Without this, Test Mode (state.testMode) would
      // be ignored on the Liberty path and place REAL calls.
      send: (type, extra) => {
        if (type === "session.dial" && extra && extra.prospect) {
          extra = { ...extra, prospect: { ...extra.prospect, testMode: !!state.testMode, relayMode: !!state.relayMode } };
        }
        return send(type, extra);
      },
      toast: (m, o) => { try { toast(m, o); } catch (_e) {} },
      log:   (lvl, m) => { try { log(lvl, m); } catch (_e) {} },
      // Lets the Liberty driver's self-chain (dial→dispo→advance→dial) check
      // whether the agent has paused autopilot, so Pause actually stops the
      // cycle instead of running to the next natural stop. 2026-08-10.
      isAutopilotOn: () => !!state.autoDialMode,
      // v1.33.7.6 — TCPA calling-hours check for the Liberty dial path. The
      // Impact path enforces this inside maybeAutoDial, but the Liberty
      // autopilot returns early (maybeAutoDial:~8442) and never reaches that
      // gate — so we inject the check here and the adapter BLOCKS any dial
      // outside the legal window (federal 8am–9pm called-party local time +
      // per-state overrides; default ON). Protects the company, SGA, and agent.
      // Returns { ok: true|false|null }. ok===false → outside window (block).
      withinCallingHours: (number) => {
        try {
          if (!state.callingHoursEnabled) return { ok: true, reason: "feature-disabled" };
          return isWithinCallingHours({ number, state: "" });
        } catch (_e) { return { ok: true, reason: "check-threw" }; }
      },
      // v1.33.7.6 — Skip Lead Types for the Liberty dial path (parity with the
      // Impact gate in maybeAutoDial, which the Liberty early-return skips).
      // Returns true if the agent has excluded this lead type this session.
      shouldSkipLeadType: (leadType) => {
        try { return !!(state.skipLeadTypes && leadType && state.skipLeadTypes[leadType]); }
        catch (_e) { return false; }
      },
      onListComplete: (m) => {
        state.autoDialMode = false;
        try { chrome.storage.local.set({ pd_autodial: false }); } catch (_e) {}
        try { send("autodial.set", { running: false }); } catch (_e) {}
        try { toast(m || "List complete — autopilot stopped.", { ms: 7000, kind: "info" }); } catch (_e) {}
        try { paint(); } catch (_e) {}
      },
    });
  }
  state.libertyAP = buildLibertyAP();

  function findPhoneLink(label) {
    // v1.33.9.2 — Planet: two-step dial. Ensure the phone div is open (OpenPhoneDiv),
    // then return the tel: dial link (a.btnPhoneCall / #phoneCallButtonN). The core
    // parses its href via getNumberFromLink() for the worker, and clicks it to drive
    // Planet's own call record. Home/Cell disambiguated by nearby text when present.
    if (CRM_KIND === "planet") {
      let pa = document.querySelector('a.btnPhoneCall[href^="tel:"]');
      if (!pa) {
        const opener = document.getElementById("btnCall") ||
          document.querySelector('a[onclick*="OpenPhoneDiv"], div[onclick*="OpenPhoneDiv"]');
        if (opener) { try { opener.click(); } catch (_e) {} }
        pa = document.querySelector('a.btnPhoneCall[href^="tel:"]');
      }
      if (label) {
        const lab = String(label).toLowerCase();
        const all = [...document.querySelectorAll('a.btnPhoneCall[href^="tel:"]')];
        const match = all.find((x) =>
          ((x.closest("div,li,td,tr,span")?.textContent || "").toLowerCase()).includes(lab));
        if (match) return match;
      }
      return pa || null;
    }
    const links = document.querySelectorAll('a[href^="tel:"], a[href^="callto:"]');
    for (const a of links) {
      const text = (a.textContent || "").toLowerCase();
      if (text.includes(label.toLowerCase())) return a;
      const block = (a.closest("div, td, li, span")?.textContent || "").toLowerCase();
      if (block.includes(label.toLowerCase())) return a;
    }
    return null;
  }

  function getNumberFromLink(a) {
    if (!a) return null;
    let h = a.getAttribute("href") || "";
    h = h.replace(/^tel:/i, "").replace(/^callto:/i, "");
    h = h.split("?")[0];
    const digits = h.replace(/[^\d+]/g, "");
    if (digits.startsWith("+")) return digits;
    if (digits.length === 10) return "+1" + digits;
    if (digits.length === 11 && digits.startsWith("1")) return "+" + digits;
    return digits ? "+" + digits : null;
  }

  // v1.34 — One-shot label for the auto-disposition click. Default is
  // "no answer"; when a voicemail was actually LEFT (Leave Voicemail on), the
  // hangup handler sets this to "voicemail left" (persisted so it survives the
  // Impact page nav) so Impact reflects the real outcome. Consumed on click.
  let _pdPendingDispositionLabel = null;

  function maybeAutoDispositionNoAnswer__impl({ advance = true } = {}) {
    if (!CRM.isOnDispositionPage()) return false;
    // v1.33.9.2 — Planet: dispositions are <a name="search"> wrapping an <h4>.
    // No Answer = h4 "No Answer" (UpdateCallStatus …,1); a left voicemail maps to
    // "Left Message" (h4). Click the element — the page's own fns are cross-context.
    if (CRM_KIND === "planet") {
      const wl = String(_pdPendingDispositionLabel || "no answer").toLowerCase();
      const want = (wl.includes("voicemail") || wl.includes("message")) ? "left message" : "no answer";
      const pick = (h) => [...document.querySelectorAll('a[name="search"]')]
        .find((a) => { const x = a.querySelector("h4"); return x && x.textContent.trim().toLowerCase() === h; });
      const plink = pick(want) || (want !== "no answer" ? pick("no answer") : null);
      _pdPendingDispositionLabel = null;
      try { chrome.storage.local.remove("pd_pending_disposition_label"); } catch {}
      if (plink) {
        if (advance) { try { chrome.storage.local.set({ pd_pending_advance_at: Date.now() }); } catch {} }
        plink.classList.add("pd-flash");
        setTimeout(() => plink.click(), 250);
        return true;
      }
      log("Planet: No Answer disposition link not found");
      return false;
    }
    const wantLabel = String(_pdPendingDispositionLabel || "no answer").toLowerCase();
    const links = [...document.querySelectorAll("a.list-group-item")];
    let link = links.find(a => (a.textContent || "").trim().toLowerCase().startsWith(wantLabel));
    // Fall back to No Answer if the labeled disposition isn't on the page.
    if (!link && wantLabel !== "no answer") {
      link = links.find(a => (a.textContent || "").trim().toLowerCase().startsWith("no answer"));
    }
    // Consume the one-shot label regardless of outcome.
    _pdPendingDispositionLabel = null;
    try { chrome.storage.local.remove("pd_pending_disposition_label"); } catch {}
    if (link) {
      // Cross-navigation flag: clicking No Answer makes Impact navigate
      // back to the lead area. setTimeout doesn't survive that navigation,
      // so we set a flag in chrome.storage.local that the next boot reads.
      // For double-tap's first attempt we DON'T set the advance flag —
      // we want to stay on the same lead and redial.
      if (advance) {
        try { chrome.storage.local.set({ pd_pending_advance_at: Date.now() }); } catch {}
      }
      link.classList.add("pd-flash");
      setTimeout(() => link.click(), 250);
      return true;
    }
    log("No Answer link not found on this page");
    return false;
  }

  // Decide what happens after a no-answer hangup. With double-tap on, the
  // first hangup for a given number queues a redial via cross-nav flag.
  // After the redial completes, the flag's phase moves to "ringing-2" and
  // the second hangup proceeds with the normal advance.
  //
  // Safety net (dispositionOrAdvance): maybeAutoDispositionNoAnswer
  // returns false when we're not on /Lead/WhatHappend — happens on fast
  // AMD/trigger-detected hangups where Impact's UI never navigated to the
  // disposition page. Without a fallback, NEITHER the No-Answer click NOR
  // the advance flag fires and the panel stalls on the dead lead. So when
  // the click can't run, we still schedule a direct advanceToNextLead.
  // (Restores the safety net the pre-cross-nav-refactor extension had.)
  function dispositionOrAdvance() {
    const clicked = maybeAutoDispositionNoAnswer({ advance: true });
    if (clicked) return;

    // ── v1.33.4.11 — CRASH FIX ────────────────────────────────
    // `leadId` was referenced below but NEVER BOUND in this scope. This
    // function takes no parameters, and there is no module-scope `leadId`
    // — every declaration in this file is a `const` inside some OTHER
    // function (2560, 2571, 2590, 4846, 7520, 12105, 12467). So the guard
    // threw `ReferenceError: leadId is not defined` on every execution
    // that reached it.
    //
    // It only reached it when the WhatHappend nav race was LOST (when won,
    // the line above returns early) — which is exactly the case the
    // recovery code below exists to handle. The throw happened BEFORE
    // pd_pending_no_answer_at was set AND BEFORE the 5s fallback timer
    // armed, so BOTH recovery paths were skipped: no disposition, no
    // advance, agent stranded on the lead, advancing by hand. Same
    // user-visible symptom as the Kevin Holtz DO-hydration bug, but a
    // completely independent cause. Confirmed in prod: kshell 2026-07-13,
    // four times in one session —
    //   "chain hangup-advance failed: leadId is not defined"
    //
    // Introduced by the v1.33.3.2 "null-leadId-guard", meant to stop the
    // flag firing on the WRONG lead. Intent was right; the identifier was
    // never bound. Bind it from the same sources the rest of the file
    // uses, most-authoritative first.
    const leadId =
      (state.prospect && state.prospect.leadId) ||
      (state.activeLeadLock && state.activeLeadLock.leadId) ||
      ((typeof getLeadId === "function") ? getLeadId() : null);

    // Cross-navigation race: the prospect.hangup event can arrive
    // before Impact has finished navigating to /Lead/WhatHappend
    // (Impact's own call leg may still be ringing/connecting). Set a
    // cross-nav flag so the next content.js boot consumes it on the
    // disposition page. Belt-and-suspenders: also fire a fallback
    // advance after 5s in case Impact never reaches WhatHappend at all
    // (fast-AMD path, manual hangup, etc).
    // v1.33.3.2 — Only set the cross-nav flag if we actually have a
    // bound lead. Without one, the flag fires No-Answer on whichever
    // lead Impact happens to be showing on the next page boot.
    if (leadId) {
      log("info", "WhatHappend not visible yet — setting pending-no-answer cross-nav flag");
      try { chrome.storage.local.set({ pd_pending_no_answer_at: Date.now() }); } catch {}
    } else {
      log("warn", "[v1.33.3.2 null-leadId-guard] skipping pd_pending_no_answer_at set — no leadId bound");
    }
    setTimeout(() => {
      if (CRM.isOnDispositionPage()) {
        // We landed here without a fresh boot (rare). Click directly.
        maybeAutoDispositionNoAnswer({ advance: true });
      } else if (CRM.isOnLeadPage()) {
        // Impact never went to WhatHappend (no real call placed, etc).
        // Skip disposition and advance directly.
        advanceToNextLead();
      }
      // If we're somewhere else (Impact's call screen still loading),
      // do nothing — boot-time consumer will fire when we land on
      // /Lead/WhatHappend.
    }, 5000);
  }

  // ─────────────────────────────────────────────────────────────────
  // v1.32.2 — Double-Tap dedicated flow (per locked spec
  // project_double_tap_spec_2026-06-12.md).
  //
  // When state.doubleTap is ON, handleHangupAdvance routes here
  // INSTEAD of the legacy disposition-or-advance path. This is the
  // "completely separate code path that runs INSTEAD OF normal
  // process" Dustin specified.
  //
  // State machine (per-lead): phase ∈ {home-1, home-2, mobile-1,
  // mobile-2, done} + humanContactedThisLead flag. Persisted in
  // chrome.storage.local key `pd_dt_state_{leadId}`.
  //
  // Underlying mechanic: the existing chain queue at maybeAutoDial
  // already pre-stocks `[Home, Home, Mobile, Mobile]` when doubleTap
  // is on (line ~6195). doubleTapFlow's job is the OUTCOME branching:
  //   - no-answer → dequeue next entry (existing chain logic)
  //   - human/VM on Home → SKIP remaining Home entries, jump to Mobile
  //   - human/VM on Mobile → finish lead, advance
  //
  // The phase tracking is for diagnostic clarity. The actual progression
  // is driven by chain queue contents — we just trim the queue based
  // on the disp outcome.
  // ─────────────────────────────────────────────────────────────────
  async function doubleTapFlow(disp, number, leadId) {
    // Defensive: should only be called when toggle is on, but verify.
    if (!state.doubleTap) {
      log("warn", "doubleTapFlow invoked with toggle off — falling through");
      dispositionOrAdvance();
      return;
    }
    // End-Human-Call has its own popup flow that owns post-call
    // disposition. If the agent triggered End-Human-Call, defer to
    // that path entirely (see project_end_human_call_button.md).
    if (state.endHumanCallTriggered) {
      log("info", "doubleTapFlow: End-Human-Call active — yielding to popup flow");
      return;
    }

    // v1.32.2 — Away Mode integration. When Away Mode is on AND the
    // hangup is NOT a no-answer (i.e., human voice / VM ended), capture
    // the lead screenshot to Downloads BEFORE the chain-trim decision.
    // Same fire-and-forget behavior as the legacy non-double-tap path;
    // capture failure doesn't block the chain decision.
    //
    // Why this lives inside doubleTapFlow rather than at the prospect.hangup
    // call site: double-tap routes ALL hangups through here (v1.32.2
    // unified path), so the legacy "if state.awayMode" block at the
    // prospect.hangup handler is bypassed entirely when double-tap is
    // on. Bringing capture inside doubleTapFlow preserves the screenshot
    // behavior while still honoring the spec rule #2 chain-trim logic
    // (Home call → human → Mobile cycle still proceeds, screenshot
    // saved for the agent to review later).
    if (state.awayMode && disp !== "no-answer") {
      const leadName = (state.prospect && state.prospect.leadName) || getLeadName() || "";
      const prospectPhone = (state.prospect && state.prospect.number) || number || "";
      log("info", `doubleTapFlow: Away Mode + human/VM — capturing screenshot for ${leadName || leadId}`);
      try {
        awayModeCapture({ leadId, leadName, phone: prospectPhone }).catch((e) => {
          log("warn", "Away Mode capture failed inside doubleTapFlow: " + (e && e.message));
        });
      } catch (e) {
        log("warn", "Away Mode capture threw inside doubleTapFlow: " + (e && e.message));
      }
      toast(`🚶 Away Mode: ${leadName || "lead"} captured to Downloads`, { ms: 3500, kind: "warn" });
    }

    const dtKey = leadId ? `pd_dt_state_${leadId}` : null;
    let dtState = { phase: "home-1", humanContactedThisLead: false };
    if (dtKey) {
      try {
        const data = await chrome.storage.local.get([dtKey]);
        if (data?.[dtKey] && typeof data[dtKey] === "object") {
          dtState = { ...dtState, ...data[dtKey] };
        }
      } catch {}
    }

    // ── Branch A: no-answer → use chain queue (already handles
    // doubleTap by 2× pre-stocking each number at dial start).
    if (disp === "no-answer") {
      try {
        const data = await chrome.storage.local.get(["pd_lead_chain_queue"]);
        const chain = data?.pd_lead_chain_queue;
        const chainStillValid =
          chain && chain.leadId === leadId
          && Array.isArray(chain.queue)
          && (Date.now() - (chain.at || 0)) < 10 * 60 * 1000;

        if (chainStillValid && chain.queue.length > 0) {
          // v1.32.9 — Daily cap check. runtimeConfig.doubleTapDailyCap
          // (null=unlimited, integer=per-day). pd_dt_count_{YYYY-MM-DD}
          // counter increments each chain-extension. If cap reached:
          // skip the second dial, treat as single-dial → flow falls into
          // the chain-exhausted branch below and advances the lead.
          // Lever for home office to control DT velocity during pilot.
          const cap = state.runtimeConfig?.doubleTapDailyCap;
          if (typeof cap === "number" && cap > 0) {
            const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
            const countKey = `pd_dt_count_${today}`;
            const cdata = await chrome.storage.local.get([countKey]);
            const used = (cdata && cdata[countKey]) | 0;
            if (used >= cap) {
              log("info", `doubleTap[${leadId}]: daily cap reached (${used}/${cap}) — skipping chain extension`);
              toast(`Double-Tap daily cap reached (${cap}). Resumes tomorrow.`, { ms: 4500, kind: "warn" });
              await chrome.storage.local.remove("pd_lead_chain_queue");
              if (dtKey) await chrome.storage.local.set({ [dtKey]: { ...dtState, phase: "done" } });
              dispositionOrAdvance();
              return;
            }
            // Increment the day's count BEFORE the dial fires.
            try { await chrome.storage.local.set({ [countKey]: used + 1 }); } catch {}
          }
          const next = chain.queue.shift();
          // Advance the diagnostic phase tracker.
          const nextPhase = _doubleTapNextPhase(dtState.phase, next.label);
          await chrome.storage.local.set({
            pd_lead_chain_queue: {
              leadId:       chain.leadId,
              queue:        chain.queue,
              queuedNumber: next.number,
              queuedLabel:  next.label,
              queuedAt:     Date.now(),
              at:           Date.now(),
            },
          });
          if (dtKey) {
            await chrome.storage.local.set({ [dtKey]: { ...dtState, phase: nextPhase } });
          }
          log("info", `doubleTap[${leadId}]: phase ${dtState.phase} → ${nextPhase}; queueing ${next.label} ${next.number}`);
          const clicked = maybeAutoDispositionNoAnswer({ advance: false });
          if (!clicked) {
            log("info", "doubleTap: not on WhatHappend yet — setting pd_pending_no_answer_at for cross-nav consumer");
            try { chrome.storage.local.set({ pd_pending_no_answer_at: Date.now() }); } catch {}
            setTimeout(() => {
              try {
                if (CRM.isOnDispositionPage()) {
                  maybeAutoDispositionNoAnswer({ advance: false });
                }
              } catch {}
            }, 1500);
          }
          // v1.33.3.2 — Force-navigate guarantee. Dustin's 2026-06-25
          // smoke: chain dispose was correctly NOT advancing the lead,
          // but the 2nd dial still didn't fire. Root cause: after the
          // No Answer click, Impact's own navigation is unpredictable —
          // sometimes it stays on WhatHappend, sometimes it goes
          // somewhere we don't expect. The chain redial path
          // (maybeConsumePendingAdvance @ ~line 4314) requires the
          // boot to land on `/Lead/InboxDetail` for chain.leadId. If
          // Impact lands anywhere else, the chain gets nuked at
          // ~line 4374 because currentLead !== chain.leadId.
          //
          // Guarantee: after ~2.5s (Impact's click animation + any
          // navigation it does on its own), if we're NOT on InboxDetail
          // for chain.leadId, force-navigate there. This is the same
          // pushState-based nav we use in prospect.dialing's drift
          // resync at line 2172. Once on the right page, the next
          // boot's maybeConsumePendingAdvance reliably fires the
          // queued chain dial via send("session.dial").
          const expectedLeadId = String(chain.leadId);
          const targetUrl = CRM.leadUrl(expectedLeadId);
          setTimeout(() => {
            try {
              const cur = (() => { try { return String(getLeadId() || ""); } catch { return ""; } })();
              const onInboxDetailSameLead =
                CRM.isOnLeadPage() && cur === expectedLeadId;
              if (onInboxDetailSameLead) {
                log("info", `[v1.33.3.2 doubleTap-chain-nav] already on InboxDetail for ${expectedLeadId} — boot consumer will fire next dial`);
                return;
              }
              log("info", `[v1.33.3.2 doubleTap-chain-nav] force-navigating to ${targetUrl} so chain redial path fires`);
              try {
                history.pushState({}, "", targetUrl);
                window.dispatchEvent(new PopStateEvent("popstate"));
              } catch (_e) {
                try { location.href = targetUrl; } catch {}
              }
              // pushState doesn't re-fire content.js, so maybeConsumePendingAdvance
              // won't auto-rerun. Trigger it manually after a brief delay so the
              // URL change settles + getLeadId() resolves the new lead.
              setTimeout(() => {
                try {
                  maybeConsumePendingAdvance();
                } catch (e2) {
                  log("warn", `[v1.33.3.2 doubleTap-chain-nav] manual consume threw: ${e2?.message || e2}`);
                }
              }, 400);
            } catch (e) {
              log("warn", `[v1.33.3.2 doubleTap-chain-nav] threw: ${e?.message || e}`);
            }
          }, 2500);
          return;
        }

        // Chain exhausted — final no-answer of the cycle, advance.
        if (chain) await chrome.storage.local.remove("pd_lead_chain_queue");
        if (dtKey) await chrome.storage.local.set({ [dtKey]: { ...dtState, phase: "done" } });
        log("info", `doubleTap[${leadId}]: chain exhausted, advancing`);
        dispositionOrAdvance();
        return;
      } catch (err) {
        log("warn", "doubleTapFlow no-answer branch threw: " + (err?.message || err));
        dispositionOrAdvance();
        return;
      }
    }

    // ── Branch B: human / voicemail / other terminal disp.
    // Per locked spec rule #2: "Mobile cycle always follows Home cycle
    // when both exist — even if a human was contacted on Home, the
    // Mobile attempt(s) still proceed."
    //
    // Implementation: if the called number was Home → trim remaining
    // Home entries from the queue and proceed with the Mobile entries.
    // If the called number was Mobile → clear chain, advance.
    try {
      const data = await chrome.storage.local.get(["pd_lead_chain_queue"]);
      const chain = data?.pd_lead_chain_queue;
      const chainStillValid =
        chain && chain.leadId === leadId
        && Array.isArray(chain.queue)
        && (Date.now() - (chain.at || 0)) < 10 * 60 * 1000;

      // Figure out which label was just dialed. Prefer chain.queuedLabel
      // (the queue's bookkeeping of what we just fired). Fall back to
      // matching the number against findPhoneLink — best effort.
      let dialedLabel = chain?.queuedLabel || null;
      if (!dialedLabel && number) {
        const homeA = CRM.phoneLink("Home");
        const mobA  = CRM.phoneLink("Mobile");
        if (homeA && CRM.numberFromLink(homeA) === number) dialedLabel = "Home";
        else if (mobA && CRM.numberFromLink(mobA) === number) dialedLabel = "Mobile";
      }

      if (dtKey) {
        await chrome.storage.local.set({
          [dtKey]: { ...dtState, humanContactedThisLead: true },
        });
      }

      if (chainStillValid && dialedLabel === "Home") {
        // Trim remaining Home entries; keep Mobile entries.
        const remaining = chain.queue.filter((q) => q.label !== "Home");
        if (remaining.length > 0) {
          await chrome.storage.local.set({
            pd_lead_chain_queue: {
              leadId:   chain.leadId,
              queue:    remaining,
              at:       Date.now(),
            },
          });
          log("info", `doubleTap[${leadId}]: human on Home → skipping ${chain.queue.length - remaining.length} remaining Home, ${remaining.length} Mobile remain`);
          // Let dispositionOrAdvance handle the Impact disposition; the
          // chain queue's remaining Mobile entries will be picked up by
          // the next dial after Impact returns to /Lead/InboxDetail.
          // (autopilot's maybeAutoDial sees pd_lead_chain_queue and
          // dequeues from there before starting a fresh chain.)
          // BUT — maybeAutoDial currently rebuilds the chain from
          // scratch. So we need a different mechanism: persist a
          // "queued next dial" so the cross-nav consumer fires it.
          await chrome.storage.local.set({
            pd_lead_chain_queue: {
              leadId:       chain.leadId,
              queue:        remaining.slice(1),
              queuedNumber: remaining[0].number,
              queuedLabel:  remaining[0].label,
              queuedAt:     Date.now(),
              at:           Date.now(),
            },
          });
        } else {
          // No Mobile entries — just advance.
          await chrome.storage.local.remove("pd_lead_chain_queue");
        }
        dispositionOrAdvance();
        return;
      }

      // Mobile (or unknown label) — finish lead.
      if (chain) await chrome.storage.local.remove("pd_lead_chain_queue");
      if (dtKey) await chrome.storage.local.set({ [dtKey]: { ...dtState, phase: "done", humanContactedThisLead: true } });
      log("info", `doubleTap[${leadId}]: human on ${dialedLabel || "?"} → finishing lead`);
      dispositionOrAdvance();
      return;
    } catch (err) {
      log("warn", "doubleTapFlow terminal branch threw: " + (err?.message || err));
      dispositionOrAdvance();
    }
  }

  // Diagnostic phase progression — pure function for log clarity.
  function _doubleTapNextPhase(currentPhase, nextLabel) {
    if (nextLabel === "Home") {
      return currentPhase === "home-1" ? "home-2" : "home-1";
    }
    if (nextLabel === "Mobile") {
      // Coming from Home cycle → mobile-1; coming from mobile-1 → mobile-2
      return currentPhase === "mobile-1" ? "mobile-2" : "mobile-1";
    }
    return currentPhase;
  }

  async function handleHangupAdvance(disp, number, leadId, _deferAttempt = 0) {
    // v1.33.3.2 — Null-leadId early return. If we got here with no
    // leadId, the call did not belong to a known lead (drift, race, or
    // the worker omitted msg.leadId on prospect.connected). We MUST NOT
    // continue into the legacy chain path, which would set
    // pd_pending_no_answer_at with no target — that flag then leaks to
    // the next page nav and fires a No-Answer click on whichever lead
    // Impact is showing then ("marking leads no answer before I get a
    // chance to talk" — Casey/Rabeckie/Teryn nightly diags).
    if (!leadId) {
      try {
        log("warn", "[v1.33.3.2 null-leadId-guard] handleHangupAdvance called with no leadId — aborting chain logic");
      } catch {}
      try { _pdAutoSubmitWithReason("auto:null-leadid-hangup"); } catch {}
      // Clear any stale cross-nav flag a previous run may have left.
      try { await chrome.storage.local.remove(["pd_pending_no_answer_at", "pd_pending_advance_at"]); } catch {}
      return;
    }

    // v1.33.3.2 — Premature-hangup defer, fixed gate. v1.33.2.4 read
    // ONLY state.desktopStatus.state (the offscreen SDK mirror). That
    // mirror only flipped back to "ready" when Telnyx emitted a
    // JS-level callUpdate hangup — but when the WORKER drives hangup
    // (every ring-timeout, every server-side cleanup), Telnyx didn't
    // always emit one. The mirror stayed "active" forever. Every
    // subsequent prospect.hangup then deferred 3.6s, expired, and ran
    // the legacy code path with stale state. Casey saw 100+ such
    // expirations tonight.
    //
    // New gate: require BOTH state.prospect.status AND the SDK mirror
    // to indicate a live call. state.prospect.status is our own truth,
    // set when the worker tells us a call is live and cleared when it
    // tells us it ended. Defaulting to "don't defer" when uncertain is
    // safer than the v1.33.2.4 "defer on any mirror staleness" rule.
    //
    // offscreen.js v1.33.3.2 also adds an activeCall watchdog that
    // force-resets currentState→"ready" when activeCall is null, so
    // the mirror is now self-healing as a secondary signal.
    const _prospectStatus = state.prospect?.status;
    const _sdkState = state.desktopStatus?.state;
    const _prospectLive = _prospectStatus === "active" || _prospectStatus === "connected";
    const _sdkStillActive = _sdkState === "active" || _sdkState === "ringing";
    const _shouldDefer = _prospectLive && _sdkStillActive;
    const _DEFER_CAP = 6;
    if (_shouldDefer && _deferAttempt < _DEFER_CAP) {
      if (_deferAttempt === 0) {
        try {
          log("warn", `[v1.33.3.2 premature-hangup-defer] lead ${leadId} prospect=${_prospectStatus} sdk=${_sdkState} — deferring`);
        } catch {}
        try { _pdAutoSubmitWithReason("auto:premature-hangup"); } catch {}
      }
      setTimeout(() => {
        handleHangupAdvance(disp, number, leadId, _deferAttempt + 1).catch(() => {});
      }, 600);
      return;
    }
    if (_deferAttempt > 0) {
      try {
        log("info", `[v1.33.3.2] resuming hangup processing after ${_deferAttempt} defer cycle(s), prospect=${_prospectStatus} sdk=${_sdkState}`);
      } catch {}
    }

    // ── v1.32.2 — Double-Tap dedicated flow. When state.doubleTap is
    // ON, route to doubleTapFlow INSTEAD of the legacy chain-queue +
    // legacy-fallback path. This is the locked-spec "separate code
    // path" rebuild. Per-lead phase tracking lives in pd_dt_state_{id}.
    if (state.doubleTap) {
      // Same stale-flag clear as below so doubleTapFlow starts clean.
      try {
        await chrome.storage.local.remove(["pd_pending_no_answer_at", "pd_pending_advance_at"]);
      } catch {}
      return doubleTapFlow(disp, number, leadId);
    }

    // ── v1.31.99 — Clear stale cross-nav pending flags at the top of
    // every hangup. The flags pd_pending_no_answer_at + pd_pending_advance_at
    // exist as a "fire-on-next-boot" fallback for the case where
    // prospect.hangup arrives BEFORE Impact has navigated to /Lead/WhatHappend.
    // The bug pattern (Lannie 27% Thu miss, Rebecca's "ringing AND
    // advancing", Jason's "skip-ahead stuck"): a flag from the PRIOR
    // hangup leaks into the NEXT call's boot consumer at maybeConsumePending-
    // NoAnswerClick:3461 → fires a No Answer click on the WRONG lead
    // → Impact disposes wrong lead, advance fires, dialer races ahead.
    //
    // Fix: clear at function entry. Each path below RE-SETS only the flag
    // it actually needs (chain fallback at 3235, dispositionOrAdvance
    // cross-nav at 3167). Stale flags from prior hangups never leak forward.
    //
    // Discovery context: [[rebecca-drift-2026-06-12]] +
    // [[lannie-dial-count-mismatch-2026-06-12]] + [[jason-ebaugh-tester]]
    // all hit this. See [[marathon-session-2026-06-11]] for why v1.31.91's
    // original cross-nav fallback created the leak.
    try {
      await chrome.storage.local.remove(["pd_pending_no_answer_at", "pd_pending_advance_at"]);
    } catch {}

    if (disp !== "no-answer") {
      // Human conversation, voicemail-drop, or other terminal disposition.
      // Clear the chain so the agent's Continue-modal flow doesn't get
      // pre-empted by a queued mobile dial. Then fall through to the
      // normal advance path.
      try { await chrome.storage.local.remove("pd_lead_chain_queue"); } catch {}
      dispositionOrAdvance();
      return;
    }

    // ── v1.31.66: Chain-aware no-answer handling ──────────────────────
    // If a chain queue exists for THIS lead with more numbers to try,
    // shift the next item and queue it for cross-nav dial. Otherwise
    // fall through to legacy double-tap behavior (for leads where
    // chain wasn't initialized — manual-dial leads, edge cases).
    try {
      const data = await chrome.storage.local.get(["pd_lead_chain_queue", "pd_double_tap_state"]);
      const chain = data?.pd_lead_chain_queue;
      const chainStillValid =
        chain && chain.leadId === leadId
        && Array.isArray(chain.queue)
        && (Date.now() - (chain.at || 0)) < 10 * 60 * 1000;

      if (chainStillValid && chain.queue.length > 0) {
        // More numbers to try on this lead.
        const next = chain.queue.shift();
        await chrome.storage.local.set({
          pd_lead_chain_queue: {
            leadId:        chain.leadId,
            queue:         chain.queue,           // remaining after this shift
            queuedNumber:  next.number,           // for boot consumer
            queuedLabel:   next.label,
            queuedAt:      Date.now(),
            at:            Date.now(),
          },
        });
        // Click "No Answer" WITHOUT setting the advance flag so Impact
        // navigates back to this same lead (where the boot consumer can
        // pick up queuedNumber and fire the next dial).
        const clicked = maybeAutoDispositionNoAnswer({ advance: false });
        if (!clicked) {
          // v1.31.90 — Fallback. prospect.hangup can fire BEFORE Impact
          // navigates to /Lead/WhatHappend, so the No-Answer click is
          // silently dropped. Without this fallback, double-tap freezes
          // after Nth call: chain queue advanced, queued dial waiting,
          // but Impact never went to WhatHappend so disposition never
          // landed → never navigated back → boot consumer never fired.
          // The dispose flag is consumed by maybeConsumePendingNoAnswerClick
          // when content.js next boots on WhatHappend (it already exists
          // as a cross-nav flag for this exact race).
          log("info", "chain dispose: not on WhatHappend yet — setting pd_pending_no_answer_at for cross-nav consumer");
          try { chrome.storage.local.set({ pd_pending_no_answer_at: Date.now() }); } catch {}
          // Belt-and-suspenders: re-attempt after 1.5s in case Impact
          // navigates fast (still in this tab).
          setTimeout(() => {
            try {
              if (CRM.isOnDispositionPage()) {
                maybeAutoDispositionNoAnswer({ advance: false });
              }
            } catch {}
          }, 1500);
        }
        return;
      }

      // Chain is finished (empty queue) — Dustin 2026-06-09: on the
      // FINAL chain dial, auto-dispose. The previous code fell through
      // to the legacy double-tap path which added TWO MORE dials
      // (because pd_double_tap_state wasn't set during chain dials, so
      // isSecondAttempt always failed and the dialer kept retrying).
      // Now: when chain ran AND is empty, dispose immediately.
      if (chain) {
        await chrome.storage.local.remove("pd_lead_chain_queue");
        log("info", "chain queue empty — auto-dispositioning lead " + leadId);
        dispositionOrAdvance();
        return;
      }
    } catch (err) {
      log("warn", "chain hangup-advance failed: " + (err?.message || err));
    }

    // ── Legacy double-tap path (preserved for non-chain manual dials) ──
    if (!state.doubleTap || !number) {
      dispositionOrAdvance();
      return;
    }
    try {
      const data = await chrome.storage.local.get("pd_double_tap_state");
      const dt = data?.pd_double_tap_state;
      const isSecondAttempt =
        dt && dt.number === number && dt.phase === "ringing-2"
        && Date.now() - (dt.at || 0) < 5 * 60000;

      if (isSecondAttempt) {
        await chrome.storage.local.remove("pd_double_tap_state");
        dispositionOrAdvance();
        return;
      }
      await chrome.storage.local.set({
        pd_double_tap_state: {
          number, leadId: leadId || null, phase: "queued-redial", at: Date.now(),
        },
      });
      maybeAutoDispositionNoAnswer({ advance: false });
    } catch {
      dispositionOrAdvance();
    }
  }

  // Read the cross-navigation advance flag on boot. If set within the last
  // 15 seconds and we're on a lead-detail page, click the down arrow once.
  // The flag is consumed (deleted) on read so we never advance twice.
  async function maybeConsumePendingAdvance() {
    try {
      const data = await chrome.storage.local.get([
        "pd_pending_advance_at",
        "pd_double_tap_state",
        "pd_pending_human_advance",
        "pd_lead_chain_queue",
      ]);

      // ── v1.31.66 — Chain queued redial (home→mobile fallback) ──
      // Highest priority: if the previous no-answer queued a next-number
      // dial on the SAME lead, fire it now. Clears queuedNumber after
      // dialing so the next no-answer either advances the queue further
      // or terminates the chain.
      const chain = data?.pd_lead_chain_queue;
      if (chain && chain.queuedNumber && CRM.isOnLeadPage()) {
        const currentLead = getLeadId();
        const age = Date.now() - (chain.queuedAt || 0);
        if (age <= 300000 && String(currentLead) === String(chain.leadId)) {
          const dialNumber = chain.queuedNumber;
          const dialLabel  = chain.queuedLabel || "";
          // Clear the queued-dial slot but KEEP the remaining queue so
          // the next no-answer can still progress.
          const restChain = {
            leadId: chain.leadId,
            queue:  chain.queue || [],
            at:     Date.now(),
          };
          await chrome.storage.local.set({ pd_lead_chain_queue: restChain });
          // v1.31.88 — Poll for ready state instead of fire-and-hope after
          // 2s. Previously this used a single setTimeout(2000) check, which
          // silently dropped the redial if the WebSocket hadn't reconnected
          // by then (slow network, hibernate-resume, Impact page race).
          // Jason's "system doesn't do the second resolution" report.
          // Now: retry every 500ms for up to 15s. Bails if call starts
          // (real human picked up between hangup and redial) or chain
          // expires.
          const chainReadyDeadline = Date.now() + 15000;
          const tryChainRedial = () => {
            if (state.prospect) {
              log("info", "chain redial skipped — a call is now active");
              return;
            }
            if (state.session !== "parked") {
              if (Date.now() < chainReadyDeadline) {
                setTimeout(tryChainRedial, 500);
                return;
              }
              log("warn", "chain redial timed out waiting for parked state (15s)");
              return;
            }
            toast(`Chain: dialing ${dialLabel || dialNumber} for lead ${chain.leadId}`);
            send("session.dial", {
              force: true, // bypass worker's same-lead+number guard
              prospect: {
                number: dialNumber,
                leadId: chain.leadId,
                testMode:  !!state.testMode,
                relayMode: !!state.relayMode,
              },
            });
            state.lastAutoDialAt = Date.now();
            try {
              chrome.storage.local.set({
                [pdGuardKey("pd_last_dialed_lead")]: chain.leadId,
                [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
              });
            } catch {}
            // v1.33.3.2 — Click Impact's actual tel: link so Impact's
            // own onclick handler navigates the page to WhatHappend.
            // Without this, the worker dials but Impact stays on
            // InboxDetail — the agent sees the call ring through but
            // Impact's UI never moves to the disposition page.
            // Dustin 2026-06-25: "it's calling the person, but it's
            // staying on the main lead page. You're just not hitting
            // the button." This mirrors what dial() does at line ~7125.
            // The tel:/callto: click interceptor catches the
            // programmatic click and preventDefault's the Chrome
            // "Open Pick an app?" popup. The _programmaticTelClick
            // flag tells the interceptor not to fire session.dial
            // twice (we already sent it above).
            setTimeout(() => {
              try {
                const a = (dialLabel && CRM.phoneLink(dialLabel))
                       || CRM.phoneLink("Mobile")
                       || CRM.phoneLink("Home");
                if (!a) {
                  log("warn", `[v1.33.3.2 chain-redial] no tel link found for label='${dialLabel}' — Impact won't navigate to WhatHappend`);
                  return;
                }
                try { a.classList.add("pd-flash"); } catch {}
                state._programmaticTelClick = true;
                a.click();
                log("info", `[v1.33.3.2 chain-redial] clicked Impact tel link for ${dialLabel} → WhatHappend should follow`);
              } catch (e) {
                log("warn", `[v1.33.3.2 chain-redial] tel link click failed: ${e?.message || e}`);
              } finally {
                setTimeout(() => { state._programmaticTelClick = false; }, 0);
              }
            }, 200);
          };
          setTimeout(tryChainRedial, 2000);
          return;  // chain takes priority — skip other flags this boot
        }
        // Stale, wrong lead, or no queued number — drop the chain so it
        // can't fire incorrectly later.
        if (age > 300000 || (currentLead && String(currentLead) !== String(chain.leadId))) {
          await chrome.storage.local.remove("pd_lead_chain_queue");
        }
      }

      // ── Double-tap redial takes priority over normal advance ──
      const dt = data?.pd_double_tap_state;
      if (dt && dt.number && dt.phase === "queued-redial") {
        const age = Date.now() - (dt.at || 0);
        // v1.31.28 — Bumped from 60s to 5min. Impact's nav from /Lead/WhatHappend
        // back to /Lead/InboxDetail can take 30-90s on slow networks/large lead lists,
        // and the 60s window was silently dropping the redial flag when it expired before
        // page nav completed. 5 min is well past any realistic Impact lag.
        if (age <= 300000 && CRM.isOnLeadPage()) {
          // Transition phase BEFORE dialing so the next hangup knows it's
          // attempt #2 and won't loop again.
          await chrome.storage.local.set({
            pd_double_tap_state: { ...dt, phase: "ringing-2", at: Date.now() },
          });
          setTimeout(() => {
            if (state.session !== "parked" || state.prospect) {
              log("info", "double-tap redial skipped — session not parked or call active");
              return;
            }
            toast("Double-tap: redialing " + dt.number);
            send("session.dial", {
              // v1.31.44 — force:true bypasses worker's same-lead+number
              // guard. Double-tap is BY DEFINITION a same-number redial
              // after a no-answer; agent explicitly wants the retry.
              force: true,
              prospect: { number: dt.number, leadId: dt.leadId || null, testMode: !!state.testMode, relayMode: !!state.relayMode },
            });
            state.lastAutoDialAt = Date.now();
          }, 2500);
          return;
        }
        // Stale or wrong page — drop the flag.
        await chrome.storage.local.remove("pd_double_tap_state");
      }

      // ── Human-call Continue prompt (v1.29.76) ──
      // After a human-connected call, the agent picks a disposition manually
      // in Impact (notes, schedule, etc.). Once Impact navigates back to
      // /Lead/InboxDetail we show a "Continue?" modal instead of auto-
      // advancing. Reason: pre-v1.29.76 the dialer would auto-click the
      // down-arrow advance, sometimes BEFORE the agent finished typing
      // notes — blowing past leads they meant to dwell on. The modal
      // makes the transition explicit and offers three choices:
      //
      //   • Resume autopilot — advance + flip autopilot back on
      //   • Manual continue  — advance only, keep autopilot paused
      //   • Stay here        — no advance (re-read or re-dial manually)
      const hu = data?.pd_pending_human_advance;
      if (hu) {
        const huAge = Date.now() - (hu.at || 0);
        const onInbox = CRM.isOnLeadPage();
        const onWhatHappend = CRM.isOnDispositionPage();
        if (huAge > 10 * 60 * 1000) {
          await chrome.storage.local.remove("pd_pending_human_advance");
          log("info", `human-continue flag too old (${Math.round(huAge/1000)}s); skipping`);
        } else if (onInbox) {
          await chrome.storage.local.remove("pd_pending_human_advance");
          // Show the modal as soon as the panel + Impact UI have settled
          // (700ms matches the auto-advance debounce we used to have).
          log("info", `human-continue flag fired (${Math.round(huAge/1000)}s old) — showing Continue modal`);
          setTimeout(() => showContinueModal({ leadId: hu.leadId }), 700);
        } else if (onWhatHappend) {
          // v1.33.4.8 — DO NOT consume the flag here. The agent is
          // dispositioning the human call; the Continue modal must still
          // fire when Impact returns to /Lead/InboxDetail. The old "always
          // consume" ate the flag on this intermediate page, so the modal
          // never fired on the round-trip. Leave it (10-min TTL guards
          // staleness) and show a compact pill so the agent knows the
          // dialer is paused on purpose, not frozen.
          log("info", "human-continue: on WhatHappend — leaving flag, showing waiting pill");
          setTimeout(() => showDispoWaitingPill(), 400);
        } else {
          await chrome.storage.local.remove("pd_pending_human_advance");
          log("info", `human-continue flag set but on ${location.pathname}; clearing`);
        }
      }

      // ── Normal advance flag ──
      const ts = data?.pd_pending_advance_at || 0;
      if (!ts) return;
      await chrome.storage.local.remove("pd_pending_advance_at");
      const age = Date.now() - ts;
      if (age > 15000) { log("info", `pending-advance flag too old (${age}ms); skipping`); return; }
      if (!CRM.isOnLeadPage()) {
        log("info", `pending-advance flag set but not on InboxDetail (${location.pathname}); skipping`);
        return;
      }
      log("info", `pending-advance flag fired (${age}ms old) — clicking down arrow`);
      setTimeout(() => advanceToNextLead(), 700);
    } catch {}
  }

  // Cross-nav consumer for the no-answer-click flag. Set in
  // dispositionOrAdvance() when prospect.hangup arrives while content.js
  // is on the wrong page (mid-navigation, Impact's call screen, etc).
  // When the next content.js boots on /Lead/WhatHappend, this fires and
  // clicks "No Answer" — which itself sets pd_pending_advance_at so the
  // boot AFTER that advances to the next lead.
  async function maybeConsumePendingNoAnswerClick() {
    try {
      if (!CRM.isOnDispositionPage()) return;
      // v1.33.3.2 — Read the chain queue too. Previously this fn was
      // hardcoded advance:true regardless of context — which silently
      // broke DoubleTap's 2nd attempt: the chain dispose path sets
      // advance:false (to keep Impact on the same lead so the boot
      // consumer can fire the queued 2nd dial), but if Impact arrives
      // at WhatHappend AFTER the in-tab click attempt, this cross-nav
      // consumer fires with advance:true → Impact skips to the next
      // lead → currentLead != chain.leadId → chain.queuedNumber gets
      // nuked at line 4374 → 2nd attempt NEVER fires → agents see
      // "moving forward without dispositioning". This is the core
      // DoubleTap-not-resolving-second-attempt bug Dustin reported
      // 2026-06-25.
      const both = await chrome.storage.local.get([
        "pd_pending_no_answer_at",
        "pd_lead_chain_queue",
        "pd_pending_disposition_label",  // v1.34 — "voicemail left" if a VM was left
      ]);
      const ts = both?.pd_pending_no_answer_at || 0;
      if (!ts) return;
      await chrome.storage.local.remove("pd_pending_no_answer_at");
      // v1.34 — Restore the disposition label the click should use (survives
      // the Impact page nav that reloaded this content script).
      if (both?.pd_pending_disposition_label) _pdPendingDispositionLabel = both.pd_pending_disposition_label;
      const age = Date.now() - ts;
      if (age > 30000) { log("info", `pending-no-answer flag too old (${age}ms); skipping`); return; }
      // Decide advance vs stay-on-lead based on chain state.
      // If the chain has a queuedNumber AND it's still fresh AND it's
      // for the lead Impact is currently dispositioning, we MUST NOT
      // advance — Impact needs to navigate back to InboxDetail for the
      // same lead so the queued chain redial can fire.
      const chain = both?.pd_lead_chain_queue;
      let shouldAdvance = true;
      if (chain && chain.queuedNumber) {
        const chainAge = Date.now() - (chain.queuedAt || 0);
        const chainFresh = chainAge < 300000;
        // We don't know the lead from /WhatHappend URL directly, but the
        // chain was queued for chain.leadId — if it's fresh, the dispose
        // path set it intentionally for this very lead. Honor it.
        if (chainFresh) {
          shouldAdvance = false;
          log("info", `[v1.33.3.2 chain-aware-dispose] No-Answer click with advance=false — chain queued ${chain.queuedLabel || ''} ${chain.queuedNumber} for lead ${chain.leadId}`);
        }
      }
      log("info", `pending-no-answer flag fired (${age}ms old) — clicking No Answer (advance=${shouldAdvance})`);
      // Small delay so Impact's WhatHappend DOM is fully rendered.
      setTimeout(() => maybeAutoDispositionNoAnswer({ advance: shouldAdvance }), 600);
    } catch {}
  }

  // Diagnostic: hover over the actual "next lead" arrow in Impact, open
  // DevTools, run `__pdFindNext()` from the console — it'll dump every
  // candidate the extension considers next-lead-ish so we can pin the
  // right selector. Also exposes `__pdAdvance()` to manually trigger.
  window.__pdFindNext = function () {
    const out = [];
    // Material Icons by ligature
    [...document.querySelectorAll("i.material-icons")]
      .filter(i => (i.textContent || "").trim() === "keyboard_arrow_down")
      .forEach(i => {
        const clickable = i.closest("button, a, [role='button'], [onclick]") || i;
        out.push({ selector: "material-icons:keyboard_arrow_down", outerHTML: clickable.outerHTML.slice(0, 200) });
      });
    const sels = [
      'button[onclick*="MoveNext"]', 'a[onclick*="MoveNext"]',
      'button[onclick*="Next"]', 'a[onclick*="Next"]',
      'button[title*="next" i]', 'button[title*="down" i]',
      'a[title*="next" i]', 'a[title*="down" i]',
      'button[aria-label*="next" i]', 'button[aria-label*="down" i]',
      '.fa-chevron-down', '.fa-arrow-down', '.fa-caret-down', '.fa-angle-down',
      '.glyphicon-chevron-down', '.glyphicon-arrow-down',
      '.btn-next', '.next-lead', '.lead-next', '.move-next', '.btn-move-next',
      '#btnMoveNext', '#btnNext', '#NextLead', '#MoveNext',
    ];
    for (const s of sels) {
      const els = document.querySelectorAll(s);
      els.forEach(el => out.push({ selector: s, outerHTML: el.outerHTML.slice(0, 200) }));
    }
    console.table(out);
    return out;
  };
  window.__pdAdvance = function () { advanceToNextLead(); };

  // Advance Impact to the next lead. Impact uses Material Icons — verified
  // via the diagnostic helper: the next-lead button wraps
  //   <i class="material-icons">keyboard_arrow_down</i>
  // (back-to-inbox uses keyboard_arrow_left, prev-lead uses keyboard_arrow_up).
  // We match the icon by its ligature text first, then walk up to the
  // clickable button. Other selectors stay as fallbacks for non-Impact pages.
  // ── v1.31.81 — Lead-lock helpers (Thomas + Ramin drift bug) ────────
  // The lock captures the lead context at prospect.connected time so
  // it can't drift mid-call. Released on prospect.hangup or session
  // end, with 5-min TTL safety net for orphaned locks (agent crash,
  // missed hangup event, etc.).
  function isLeadLocked() {
    const L = state.activeLeadLock;
    if (!L) return false;
    // TTL safety: a stuck lock older than 5 min is treated as expired.
    if (Date.now() - L.lockedAt > 5 * 60 * 1000) {
      log("warn", "lead-lock expired (>5min) — auto-releasing");
      releaseActiveLeadLock("ttl-expired");
      return false;
    }
    return true;
  }
  function acquireActiveLeadLock(msg, _storageLeadId) {
    // v1.31.90 — Trust the WORKER's leadId (msg.leadId) over the DOM's
    // current getLeadId(). Reason: between the time the dial fires and
    // the time prospect.connected arrives, Impact may have navigated to
    // a different lead (agent click, autopilot pre-advance, page glitch).
    // If we read getLeadId() at that moment, the lock binds to the WRONG
    // lead and drift detection fails. The worker knows which lead it's
    // dialing; we trust that. Fallback to DOM only if msg.leadId missing.
    const msgLeadId = msg && msg.leadId != null ? String(msg.leadId) : null;
    const domLeadId = (() => { try { return getLeadId(); } catch { return null; } })();
    // v1.33.2.4 — Tertiary fallback to pd_last_dialed_lead. dgholtz
    // captured the failure mode on 2026-06-22: the bridge fired from
    // pathname:/home, so DOM had no leadId, AND the worker omitted
    // msg.leadId from the prospect.connected payload — lock returned
    // silently and drift went unprotected. The agent had just clicked
    // a dial button so the lead they THOUGHT they were calling is in
    // localStorage. Use that as a third option. We do NOT count this
    // as a drift indicator because the absence of both msg + DOM
    // signals means we never had a strong source to begin with.
    // v1.33.4.2 FIX (bug 3A) — pd_last_dialed_lead is written to
    // chrome.storage.local, NOT localStorage. The old
    // localStorage.getItem("pd_last_dialed_lead") here ALWAYS returned
    // null, so this tertiary fallback never actually fired. chrome.storage
    // is async, so when we have no immediate identity (msg + DOM both
    // empty) we fetch the key and re-invoke once via _storageLeadId. The
    // common path (msg or DOM present) stays fully synchronous.
    if (!msgLeadId && !domLeadId && _storageLeadId === undefined) {
      try {
        chrome.storage.local.get(pdGuardKey("pd_last_dialed_lead"), (d) => {
          const v = (d && d[pdGuardKey("pd_last_dialed_lead")]) ? String(d[pdGuardKey("pd_last_dialed_lead")]) : null;
          try { acquireActiveLeadLock(msg, v); } catch (_e) {}
        });
      } catch (_e) {
        try { acquireActiveLeadLock(msg, null); } catch (_e2) {}
      }
      return;
    }
    const lastDialedFromStorage = (_storageLeadId !== undefined) ? (_storageLeadId || null) : null;
    const leadId = msgLeadId || domLeadId || lastDialedFromStorage;
    if (!leadId) {
      // v1.33.3.2 — Weak-lock fallback. Brandon's 2026-06-24 capture
      // showed three consecutive "lock NOT acquired" warnings firing
      // from pathname:/Policy/Index, with msg omitted leadId, DOM at a
      // non-Lead page, and no pd_last_dialed_lead. Without a lock the
      // v1.33.2.4 _activeCallToOtherLead drift guard can't fire — the
      // drift continued unchecked. We use msg.callControlId as a weak
      // identity: it's unique to this call leg, and any subsequent
      // hangup/connected for the same leg can be cross-checked. If
      // later acquireActiveLeadLock fires with a real leadId for the
      // same callControlId, it will overwrite this weak lock.
      const ccid = msg && msg.callControlId ? String(msg.callControlId) : null;
      if (ccid) {
        state.activeLeadLock = {
          leadId: "weak:" + ccid,
          lockedAt: Date.now(),
          callControlId: ccid,
          weak: true,
        };
        log("warn", `[v1.33.3.2 weak-lock] no real leadId — acquired weak lock on callControlId=${ccid}`);
        try { _pdAutoSubmitWithReason("auto:weak-lock-acquired"); } catch (_e) {}
        return;
      }
      log("warn", "acquireActiveLeadLock: no msg.leadId AND no DOM leadId AND no pd_last_dialed_lead AND no callControlId; lock NOT acquired");
      return;
    }
    if (!msgLeadId && !domLeadId && lastDialedFromStorage) {
      log("info", `lead-lock: tertiary fallback to pd_last_dialed_lead=${lastDialedFromStorage} (no msg.leadId, no DOM leadId)`);
    }
    if (msgLeadId && domLeadId && msgLeadId !== domLeadId) {
      log("warn", `lead-lock: worker says lead ${msgLeadId} but DOM shows lead ${domLeadId} — using worker's (drift indicator)`);
      // v1.33.2.4 — Auto-submit diagnostic on drift indicator. Rabecca's
      // 2026-06-22 23:30 capture showed three consecutive drift
      // indicators followed by three ring-no-answers — exactly the
      // class of bug we want to root-cause with full session state.
      // _pdAutoSubmitWithReason is self-throttled (one per reason per
      // session via _pdAutoSubmitted).
      try { _pdAutoSubmitWithReason("auto:drift-indicator"); } catch (_e) {}
    }
    state.activeLeadLock = {
      leadId: String(leadId),
      lockedAt: Date.now(),
      callControlId: (msg && msg.callControlId) || null,
    };
    log("info", "lead-lock ACQUIRED on lead " + leadId);
    // Start drift detection: every 2 sec, compare Impact's current
    // leadId to the locked leadId. If they diverge (Impact navigated
    // away mid-call), surface a banner so the agent can correct.
    if (state.activeLeadLockDriftTimer) clearInterval(state.activeLeadLockDriftTimer);
    state.activeLeadLockDriftTimer = setInterval(() => {
      try {
        if (!isLeadLocked()) {
          clearInterval(state.activeLeadLockDriftTimer);
          state.activeLeadLockDriftTimer = null;
          return;
        }
        const live = String(getLeadId() || "");
        const locked = String(state.activeLeadLock.leadId);
        if (live && live !== locked) {
          log("warn", "🚨 LEAD DRIFT detected: locked=" + locked + " live=" + live);
          try { toast("⚠ Lead drift detected — current call is on lead " + locked + ", but Impact shows lead " + live, { ms: 5000 }); } catch {}
        }
      } catch (_e) {}
    }, 2000);
  }
  function releaseActiveLeadLock(reason) {
    if (!state.activeLeadLock) return;
    const wasOn = state.activeLeadLock.leadId;
    state.activeLeadLock = null;
    if (state.activeLeadLockDriftTimer) {
      clearInterval(state.activeLeadLockDriftTimer);
      state.activeLeadLockDriftTimer = null;
    }
    log("info", "lead-lock RELEASED (reason=" + reason + ") was on lead " + wasOn);
    // Drain pending-advance if autopilot deferred one during the call.
    if (state.pendingAdvanceAfterHangup) {
      state.pendingAdvanceAfterHangup = false;
      // v1.31.92 — DOUBLE-ADVANCE GUARD per Ricky 2026-06-11
      // "extension drift, always being the lead BEFORE it because it
      // disposes instantly and goes to the lead AFTER it instantly".
      //
      // Root cause: while the call was active, Impact ALREADY navigated
      // past the locked lead (manual click by agent OR Impact-side
      // auto-advance). The lock correctly blocked the dialer's auto-
      // advance during the call, but on release we'd run advanceToNext-
      // Lead() anyway — which JUMPS again from where Impact is now.
      // Net: one lead is silently skipped.
      //
      // Fix: compare the locked leadId to the live DOM leadId at release
      // time. If they differ, Impact already advanced for us — just
      // disposition the locked lead, DON'T trigger another advance.
      const liveLeadId = (() => { try { return String(getLeadId() || ""); } catch { return ""; } })();
      if (liveLeadId && liveLeadId !== String(wasOn)) {
        log("info", `🛑 deferred advance SKIPPED — Impact already moved from ${wasOn} → ${liveLeadId} during call. Preventing double-advance (skipped-lead drift).`);
        try { toast(`⚠ Lead ${wasOn} → ${liveLeadId} already advanced during call — not advancing again`, { ms: 3500 }); } catch {}
      } else {
        log("info", "draining deferred advance post-hangup");
        setTimeout(() => {
          try { advanceToNextLead(); } catch (err) {
            log("warn", "deferred advance failed: " + (err && err.message));
          }
        }, 500);
      }
    }
  }
  // Expose for diagnostics.
  window.__pdLeadLockStatus = function() {
    return { locked: isLeadLocked(), state: state.activeLeadLock, pendingAdvance: state.pendingAdvanceAfterHangup };
  };

  // v1.31.93 — Skip Lead Types popout modal. Opens from the link header
  // in the toggles drawer. Per-session state — checkbox edits flow into
  // state.skipLeadTypes (cleared on endSession). Click outside or × to
  // close; ESC also closes. Re-opening preserves checked state.
  function openSkipLeadTypesModal() {
    // Don't double-open
    if (document.getElementById("pd-skip-types-modal")) return;
    // v1.32.5 — Real lead-source categories (Dustin). Per-division now: sourced
    // from the active CRM adapter (CRM.leadTypes) so Liberty shows its own
    // Salesforce lead-type vocabulary and AIL shows its set. Falls back to the
    // AIL/Impact list if an adapter doesn't declare one. Keep in sync with
    // getLeadType() outputs for the active division.
    const TYPES = (CRM && Array.isArray(CRM.leadTypes) && CRM.leadTypes.length)
      ? CRM.leadTypes.slice()
      : ["Vendor Lead", "POS", "Referral", "Response Card", "List Lead", "Lapsed POS"];
    const root = document.createElement("div");
    root.id = "pd-skip-types-modal";
    root.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.55);font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
    const rowsHtml = TYPES.map((t) => {
      const safeId = "pd-skip-modal-" + t.toLowerCase().replace(/[^a-z0-9]+/g, "-");
      const on = !!(state.skipLeadTypes && state.skipLeadTypes[t]);
      return `<label style="display:flex !important;align-items:center !important;gap:10px !important;padding:10px 12px !important;border-radius:6px !important;background:${on ? "rgba(45,171,95,0.25)" : "rgba(255,255,255,0.08)"} !important;border:1px solid ${on ? "rgba(45,171,95,0.6)" : "rgba(255,255,255,0.18)"} !important;cursor:pointer !important;margin-bottom:6px !important;">
        <input type="checkbox" class="pd-skip-modal-cb" data-type="${escapeHtml(t)}" id="${safeId}" ${on ? "checked" : ""} style="width:18px !important;height:18px !important;cursor:pointer !important;accent-color:#2dab5f !important;"/>
        <span style="font-size:14px !important;color:#ffffff !important;font-weight:700 !important;flex:1 !important;">${escapeHtml(t)}</span>
      </label>`;
    }).join("");
    const activeCount = state.skipLeadTypes ? Object.keys(state.skipLeadTypes).length : 0;
    root.innerHTML = `
      <div style="width:380px !important;max-width:calc(100vw - 32px) !important;background:#0d1f2d !important;color:#ffffff !important;border-radius:14px !important;border:3px solid #F2C94C !important;box-shadow:0 20px 60px rgba(0,0,0,0.85) !important;overflow:hidden !important;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif !important;">
        <div style="background:#F2C94C !important;color:#1B4332 !important;padding:12px 18px !important;font-weight:900 !important;letter-spacing:1.2px !important;font-size:14px !important;text-transform:uppercase !important;display:flex !important;align-items:center !important;justify-content:space-between !important;">
          <span>⏭ SKIP LEAD TYPES</span>
          <button id="pd-skip-modal-close" type="button" aria-label="Close" style="background:transparent !important;border:0 !important;color:#1B4332 !important;font-size:24px !important;line-height:1 !important;cursor:pointer !important;padding:0 2px !important;font-weight:900 !important;">×</button>
        </div>
        <div style="padding:14px 16px 8px !important;font-size:13px !important;color:#ffffff !important;line-height:1.45 !important;font-weight:600 !important;">
          Check any lead types you want autopilot to <strong style="color:#F2C94C !important;">skip</strong> for the rest of this session.
          <strong style="color:#F2C94C !important;">Cleared automatically</strong> when you End Session.
        </div>
        <div style="padding:6px 16px 12px !important;max-height:60vh !important;overflow-y:auto !important;">
          ${rowsHtml}
        </div>
        <div style="padding:12px 16px 16px !important;display:flex !important;gap:8px !important;justify-content:space-between !important;align-items:center !important;border-top:1px solid rgba(255,255,255,0.15) !important;">
          <span id="pd-skip-modal-count" style="font-size:12px !important;color:#ffffff !important;font-weight:700 !important;">${activeCount} type${activeCount===1?"":"s"} active</span>
          <div style="display:flex !important;gap:8px !important;">
            <button id="pd-skip-modal-clear" type="button" style="background:transparent !important;color:#ffffff !important;border:1px solid rgba(255,255,255,0.4) !important;padding:8px 12px !important;border-radius:6px !important;font-size:13px !important;font-weight:700 !important;cursor:pointer !important;font-family:inherit !important;">Clear all</button>
            <button id="pd-skip-modal-done" type="button" style="background:#F2C94C !important;color:#1B4332 !important;border:0 !important;padding:8px 18px !important;border-radius:6px !important;font-size:14px !important;font-weight:900 !important;cursor:pointer !important;font-family:inherit !important;">Done</button>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    const closeModal = () => {
      try { root.remove(); } catch {}
      document.removeEventListener("keydown", onKeyDown);
      // Re-paint so the header link's "(N active)" hint updates.
      try { paint(); } catch {}
    };
    const onKeyDown = (e) => { if (e.key === "Escape") closeModal(); };
    document.addEventListener("keydown", onKeyDown);

    // Click outside the inner card → close (but not when clicking the
    // card itself)
    root.addEventListener("click", (e) => { if (e.target === root) closeModal(); });
    root.querySelector("#pd-skip-modal-close").addEventListener("click", closeModal);
    root.querySelector("#pd-skip-modal-done").addEventListener("click", closeModal);

    // Checkbox wiring — mirrors the old inline behavior exactly so
    // state.skipLeadTypes stays the single source of truth.
    root.querySelectorAll(".pd-skip-modal-cb").forEach((cb) => {
      cb.addEventListener("change", () => {
        const t = cb.getAttribute("data-type");
        if (!t) return;
        state.skipLeadTypes = state.skipLeadTypes || {};
        if (cb.checked) state.skipLeadTypes[t] = true;
        else delete state.skipLeadTypes[t];
        // v1.33.4.8 — persist so selections survive Impact navigations.
        try { chrome.storage.local.set({ pd_skip_lead_types: state.skipLeadTypes }); } catch (e) {}
        const active = Object.keys(state.skipLeadTypes).length;
        const countEl = document.getElementById("pd-skip-modal-count");
        if (countEl) countEl.textContent = `${active} type${active === 1 ? "" : "s"} active`;
        // Visual highlight on the row
        const row = cb.closest("label");
        if (row) {
          row.style.background = cb.checked ? "rgba(45,171,95,0.18)" : "rgba(255,255,255,0.04)";
          row.style.borderColor = cb.checked ? "rgba(45,171,95,0.5)" : "rgba(255,255,255,0.08)";
        }
        toast(cb.checked
          ? `⏭ Will skip "${t}" leads this session (${active} type${active === 1 ? "" : "s"} active)`
          : `${t} skip removed (${active} type${active === 1 ? "" : "s"} still active)`,
          { ms: 2500 });
      });
    });

    // Clear-all button — wipes state.skipLeadTypes + unchecks all boxes
    root.querySelector("#pd-skip-modal-clear").addEventListener("click", () => {
      const had = state.skipLeadTypes ? Object.keys(state.skipLeadTypes).length : 0;
      state.skipLeadTypes = {};
      // v1.33.4.8 — persist the cleared state too.
      try { chrome.storage.local.set({ pd_skip_lead_types: {} }); } catch (e) {}
      root.querySelectorAll(".pd-skip-modal-cb").forEach((cb) => {
        cb.checked = false;
        const row = cb.closest("label");
        if (row) {
          row.style.background = "rgba(255,255,255,0.04)";
          row.style.borderColor = "rgba(255,255,255,0.08)";
        }
      });
      const countEl = document.getElementById("pd-skip-modal-count");
      if (countEl) countEl.textContent = "0 types active";
      if (had > 0) toast(`Cleared ${had} skip type${had === 1 ? "" : "s"}`, { ms: 2500 });
    });
  }
  window.__pdOpenSkipLeadTypes = openSkipLeadTypesModal;

  // v1.31.93 — TZ-unknown popup. Fires when calling hours is enabled but
  // the lead's address can't be scraped (so we can't determine which
  // state's TCPA window applies). Agent picks Proceed or Skip — there's
  // no smart default since unknown TZ is an irreducible uncertainty.
  // Returns: nothing — onProceed / onSkip callbacks drive the next step.
  function showTzUnknownModal({ leadId, onProceed, onSkip }) {
    // Idempotent — never stack two modals on top of each other.
    if (document.getElementById("pd-tz-unknown-modal")) return;
    const root = document.createElement("div");
    root.id = "pd-tz-unknown-modal";
    root.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.65);font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
    const leadLabel = (leadId == null) ? "this lead" : "Lead " + String(leadId);
    // v1.31.93 — Per Dustin's video, the modal text blended into the
    // background on classic + branded skins. Root cause was light-blue-on-
    // dark-gradient (#cfe0fa) being too low-contrast at small sizes,
    // PLUS skin CSS occasionally cascading into modal text colors. Fix:
    // pure white !important on ALL text, bolder weight, opaque solid
    // dark background instead of gradient (gradient was the source of
    // bleed). Buttons get explicit !important on their text colors too.
    root.innerHTML = `
      <div role="dialog" aria-modal="true" aria-labelledby="pd-tz-title" style="width:420px !important;max-width:calc(100vw - 32px) !important;background:#0d1f2d !important;color:#ffffff !important;border-radius:14px !important;border:3px solid #F2C94C !important;box-shadow:0 20px 60px rgba(0,0,0,0.85) !important;overflow:hidden !important;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif !important;">
        <div id="pd-tz-title" style="background:#F2C94C !important;color:#1B4332 !important;padding:13px 18px !important;font-weight:900 !important;letter-spacing:1.2px !important;font-size:14px !important;text-transform:uppercase !important;text-align:center !important;">
          ⏰ TIMEZONE COULD NOT BE VERIFIED
        </div>
        <div style="padding:20px 22px 8px !important;font-size:14px !important;line-height:1.55 !important;color:#ffffff !important;">
          <p style="margin:0 0 12px !important;color:#ffffff !important;font-weight:800 !important;font-size:15px !important;">${escapeHtml(leadLabel)}</p>
          <p style="margin:0 0 12px !important;color:#ffffff !important;font-weight:600 !important;">We couldn't determine the prospect's state from this lead's address, so we can't verify whether the call is within their TCPA calling hours.</p>
          <p style="margin:0 0 8px !important;color:#F2C94C !important;font-weight:800 !important;font-size:15px !important;">Proceed with dial or skip?</p>
          <p style="margin:0 0 10px !important;font-size:12px !important;color:#ffffff !important;opacity:0.85 !important;font-weight:500 !important;">If you proceed, this single dial bypasses the calling-hours check — verify it's a safe time to call before continuing.</p>
        </div>
        <div style="padding:6px 22px 20px !important;display:flex !important;gap:10px !important;">
          <button id="pd-tz-skip" type="button" style="flex:1 !important;background:transparent !important;color:#ff8a93 !important;border:2px solid #ff8a93 !important;padding:12px 14px !important;border-radius:8px !important;font-size:14px !important;font-weight:800 !important;cursor:pointer !important;font-family:inherit !important;">⏭ Skip Lead</button>
          <button id="pd-tz-proceed" type="button" style="flex:1 !important;background:#F2C94C !important;color:#1B4332 !important;border:0 !important;padding:12px 14px !important;border-radius:8px !important;font-size:14px !important;font-weight:900 !important;cursor:pointer !important;font-family:inherit !important;">📞 Proceed with Dial</button>
        </div>
      </div>
    `;
    document.body.appendChild(root);
    const close = () => {
      try { root.remove(); } catch {}
      document.removeEventListener("keydown", onKey);
    };
    const onKey = (e) => {
      if (e.key === "Escape") { close(); try { onSkip && onSkip(); } catch {} }
    };
    document.addEventListener("keydown", onKey);
    root.querySelector("#pd-tz-proceed").addEventListener("click", () => {
      close();
      try { onProceed && onProceed(); } catch (e) { log("warn", "tz-modal proceed callback threw: " + (e && e.message)); }
    });
    root.querySelector("#pd-tz-skip").addEventListener("click", () => {
      close();
      try { onSkip && onSkip(); } catch (e) { log("warn", "tz-modal skip callback threw: " + (e && e.message)); }
    });
    // NOTE: deliberately NO outside-click-to-dismiss. This is a real
    // compliance decision the agent should make — not something to
    // dismiss accidentally by tapping the page. ESC = skip (safer default).
  }
  window.__pdShowTzUnknownModal = showTzUnknownModal;

  // ─────────────────────────────────────────────────────────────────
  // v1.31.97 — End-Human-Call popup.
  //
  // Fires when:
  //   1. Agent presses End Human Call (sets state.endHumanCallTriggered = true)
  //   2. Call hangs up, Impact navigates to /Lead/WhatHappend
  //   3. Agent dispositions, Impact returns to /Lead/InboxDetail
  //   4. URL watcher (watchForBackToImpact, installed at boot) detects the
  //      WhatHappend → InboxDetail transition
  //   5. Watcher checks state.endHumanCallTriggered === true → fires popup
  //
  // The popup is anchored next to #pd-root with a small arrow pointing at
  // the panel. NOT a full-screen modal — agent keeps Impact context visible.
  //
  // See [[end-human-call-button]] for the locked design + [[gpd-call-cycle]]
  // for the full call lifecycle context.
  // ─────────────────────────────────────────────────────────────────
  function showEndCallPopup() {
    // Idempotent — never stack two popups on top of each other.
    if (document.getElementById("pd-endcall-popup")) return;
    // Find the extension panel so we can anchor next to it.
    const root = document.getElementById("pd-root");
    const popup = document.createElement("div");
    popup.id = "pd-endcall-popup";
    // Anchor position: try right-of-panel; fall back to fixed center-right.
    let anchorStyle = "";
    if (root) {
      const r = root.getBoundingClientRect();
      // Position to the LEFT of the panel by default (panels usually float
      // on the right edge). If panel is already left-side, swap to right.
      const placeOnRight = (r.left < (window.innerWidth - r.right));
      const top = Math.max(12, Math.min(window.innerHeight - 240, r.top));
      anchorStyle = placeOnRight
        ? `top:${top}px;left:${Math.min(window.innerWidth - 360, r.right + 16)}px;`
        : `top:${top}px;left:${Math.max(12, r.left - 360 - 16)}px;`;
    } else {
      // Panel not in DOM yet — center the popup as a safe fallback.
      anchorStyle = `top:80px;right:24px;`;
    }
    popup.style.cssText = `position:fixed !important;${anchorStyle}z-index:2147483647 !important;width:320px !important;max-width:calc(100vw - 32px) !important;background:#0d1f2d !important;color:#ffffff !important;border-radius:14px !important;border:3px solid #F2C94C !important;box-shadow:0 20px 60px rgba(0,0,0,0.85) !important;overflow:visible !important;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif !important;`;
    popup.innerHTML = `
      <div style="background:#F2C94C !important;color:#1B4332 !important;padding:11px 16px !important;font-weight:900 !important;letter-spacing:1.0px !important;font-size:13px !important;text-transform:uppercase !important;text-align:center !important;border-top-left-radius:11px !important;border-top-right-radius:11px !important;">
        📞 What's next?
      </div>
      <div style="padding:16px 18px 6px !important;font-size:13px !important;line-height:1.5 !important;color:#ffffff !important;font-weight:600 !important;">
        Call ended. Pick a path forward:
      </div>
      <div style="padding:6px 16px 18px !important;display:flex !important;flex-direction:column !important;gap:8px !important;">
        <button id="pd-endcall-continue" type="button" style="background:#5DADE2 !important;color:#000000 !important;border:0 !important;padding:11px 12px !important;border-radius:8px !important;font-size:14px !important;font-weight:900 !important;cursor:pointer !important;font-family:inherit !important;text-align:left !important;display:flex !important;align-items:center !important;gap:8px !important;">
          <span>▶</span><span>Continue dialing</span>
        </button>
        <button id="pd-endcall-stay" type="button" style="background:transparent !important;color:#ffffff !important;border:2px solid rgba(255,255,255,0.4) !important;padding:11px 12px !important;border-radius:8px !important;font-size:14px !important;font-weight:800 !important;cursor:pointer !important;font-family:inherit !important;text-align:left !important;display:flex !important;align-items:center !important;gap:8px !important;">
          <span>⏸</span><span>Stay paused</span>
        </button>
        <button id="pd-endcall-end" type="button" style="background:transparent !important;color:#ff8a93 !important;border:2px solid #ff8a93 !important;padding:11px 12px !important;border-radius:8px !important;font-size:14px !important;font-weight:800 !important;cursor:pointer !important;font-family:inherit !important;text-align:left !important;display:flex !important;align-items:center !important;gap:8px !important;">
          <span>✕</span><span>End session</span>
        </button>
      </div>
    `;
    document.body.appendChild(popup);
    state.endCallPopupEl = popup;

    const close = () => {
      try { popup.remove(); } catch {}
      state.endCallPopupEl = null;
      document.removeEventListener("keydown", onKey);
    };
    const onKey = (e) => {
      // ESC = stay paused (safe default — no irreversible action)
      if (e.key === "Escape") { close(); }
    };
    document.addEventListener("keydown", onKey);

    popup.querySelector("#pd-endcall-continue").addEventListener("click", () => {
      log("info", "📞 End-call popup: Continue dialing");
      close();
      // v1.32.18 — Respect explicit pause (Dustin's report 2026-06-18 from
      // (323) 793-9110: "keeps calling when I've hit pause or end session").
      // Previously the Continue button ALWAYS set autoDialMode=true,
      // overriding the agent's explicit Pause. Now we advance to the next
      // lead but ONLY re-enable autopilot if the agent hadn't deliberately
      // paused. state.userPausedAt is stamped by the Pause button click.
      const recentlyPausedByUser = state.userPausedAt && (Date.now() - state.userPausedAt < 30 * 60 * 1000);
      if (!state.autoDialMode && !recentlyPausedByUser) {
        state.autoDialMode = true;
        // v1.33.4.2 FIX (bug 3B) — persist to pd_autodial (the canonical
        // key read on boot), NOT pd_auto_dial_mode which nothing reads.
        try { chrome.storage.local.set({ pd_autodial: true }); } catch {}
      } else if (recentlyPausedByUser) {
        log("info", "📞 Continue clicked but agent had explicitly paused — advancing without unpausing");
      }
      try { paint(); } catch {}
      try { advanceToNextLead(); } catch (e) { log("warn", "popup-continue advance threw: " + (e && e.message)); }
    });

    popup.querySelector("#pd-endcall-stay").addEventListener("click", () => {
      log("info", "📞 End-call popup: Stay paused");
      close();
      // Force autopilot off so the agent isn't surprised by a dial after closing.
      if (state.autoDialMode) {
        state.autoDialMode = false;
        // v1.33.4.2 FIX (bug 3B) — persist to pd_autodial, not orphan key.
        try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
        try { paint(); } catch {}
      }
    });

    popup.querySelector("#pd-endcall-end").addEventListener("click", () => {
      log("info", "📞 End-call popup: End session (clicked)");
      close();
      // v1.33.2.6 — Route through endSessionFromButton so the confirm
      // dialog catches misclicks. This popup auto-appears in the visual
      // area where the Hangup button was during the prior call —
      // Dustin's diagnosis 2026-06-23: "it hoovers around the hangup
      // button" — agent's mouse is still hovering near where they just
      // clicked Hangup, and the End session button (red, ✕) lands
      // under their next click. Without the confirm guard, that
      // misclick wiped skip-lead types + autopilot state silently.
      try { endSessionFromButton(); } catch (e) { log("warn", "popup-end session threw: " + (e && e.message)); }
    });
  }
  window.__pdShowEndCallPopup = showEndCallPopup;

  // v1.31.97 — URL watcher for End-Human-Call popup trigger.
  //
  // Polls location.pathname every 400ms. When the path transitions OFF
  // /Lead/WhatHappend (meaning the agent saved a disposition and Impact
  // navigated back to /Lead/InboxDetail), checks state.endHumanCallTriggered.
  // If true → fires the popup, then resets the flag.
  //
  // Auto-dispositioned outcomes (no-answer, VM, attendant-hangup) NEVER set
  // the flag, so they bypass the popup silently. Only End-Human-Call clicks
  // arm this trigger.
  //
  // Idempotent — only installs the interval once, even across paint() calls.
  let _pdLastPath = null;
  let _pdUrlWatcherInstalled = false;
  function watchForBackToImpact() {
    if (_pdUrlWatcherInstalled) return;
    _pdUrlWatcherInstalled = true;
    _pdLastPath = (location.pathname + location.search);
    _pdNoteNav();  // v1.32.0 — seed nav buffer with initial path
    setInterval(() => {
      const now = location.pathname + location.search;
      if (now === _pdLastPath) return;
      const prev = _pdLastPath;
      _pdLastPath = now;
      _pdNoteNav();  // v1.32.0 — capture every nav into diag buffer
      // Did we just leave /Lead/WhatHappend?
      const wasOnDispo = /\/Lead\/WhatHappend/i.test(prev);
      const stillOnDispo = /\/Lead\/WhatHappend/i.test(now);
      if (wasOnDispo && !stillOnDispo && state.endHumanCallTriggered) {
        // v1.32.18 — Guards against popup over-firing (Dustin's report
        // 2026-06-18 from (323) 793-9110: "bottom up option keeps
        // activating without my doing it"):
        //
        //   Guard A: STALENESS. The trigger flag persists in state until
        //   consumed. If the agent armed it 30 minutes ago and is just
        //   now navigating off /Lead/WhatHappend (e.g. clicked a
        //   different lead from the queue), don't fire the popup —
        //   they've moved on. 5-minute window.
        //
        //   Guard B: SESSION STATE. If session is idle / ending, the
        //   agent has ended their session. Don't pop a popup that
        //   suggests they keep dialing.
        //
        //   Guard C: PROSPECT STATE. If state.prospect is still set,
        //   the call hasn't actually ended cleanly — the popup is
        //   premature.
        const triggeredAt = state.endHumanCallTriggeredAt || 0;
        const stale = triggeredAt && (Date.now() - triggeredAt > 5 * 60 * 1000);
        const sessionEnded = (state.session === "idle" || state.session === "ending" || !state.session);
        const prospectStillSet = !!state.prospect;

        if (stale) {
          log("info", `📞 URL watcher: ignoring stale endHumanCall trigger (${Math.round((Date.now() - triggeredAt) / 1000)}s ago)`);
          state.endHumanCallTriggered = false;
          state.endHumanCallTriggeredAt = 0;
        } else if (sessionEnded) {
          log("info", "📞 URL watcher: session already ended, skipping popup");
          state.endHumanCallTriggered = false;
          state.endHumanCallTriggeredAt = 0;
        } else if (prospectStillSet) {
          log("info", "📞 URL watcher: prospect still active, skipping popup");
          // DO NOT clear the flag — we'll re-check on next URL transition
        } else {
          log("info", "📞 URL watcher: WhatHappend → " + now + " — firing popup");
          state.endHumanCallTriggered = false;
          state.endHumanCallTriggeredAt = 0;
          try { showEndCallPopup(); } catch (e) { log("warn", "endcall popup threw: " + (e && e.message)); }
        }
      }
    }, 400);
  }

  function advanceToNextLead__impl() {
    // v1.31.81 — Lead-lock guard. Refuse to advance during an active
    // call (Thomas + Ramin drift bug). Queue the advance to fire after
    // hangup so autopilot doesn't lose its place.
    if (isLeadLocked()) {
      log("info", "advanceToNextLead blocked — active call on lead " + state.activeLeadLock.leadId + "; deferring to post-hangup");
      state.pendingAdvanceAfterHangup = true;
      try { toast("⏸ Advance deferred — finish current call first", { ms: 3000 }); } catch {}
      return;
    }
    // v1.32.8 — Double-Tap state cleanup. The pd_dt_state_{leadId} key
    // tracks the chain phase (home-1 → home-2 → mobile-1 → mobile-2)
    // for the current lead. Once we're advancing OFF this lead, that
    // state is stale — if the same lead re-enters the queue (re-list,
    // second campaign, agent revisit), the leftover phase would cause
    // Double-Tap to resume from "mobile-1" instead of starting fresh.
    // Remove it BEFORE the advance click so the next view of this lead
    // is a clean slate. Best-effort; no-op if there was no Double-Tap
    // activity on this lead.
    try {
      const currentLeadId = getLeadId();
      if (currentLeadId) {
        chrome.storage.local.remove(`pd_dt_state_${currentLeadId}`);
      }
    } catch (_e) {}
    // v1.33.9.2 — Planet: advance by navigating (no arrow DOM). ▶ = MoveNext (down);
    // ◀ = MovePrevious (up, when bottom-to-top). The lead-lock guard + double-tap
    // cleanup above still run first, so this is behavior-preserving for those.
    if (CRM_KIND === "planet") {
      const u = state.bottomToTop ? "/Lead/MovePrevious" : "/Lead/MoveNext";
      try { log("info", "Planet advanceToNextLead → " + u); } catch (_e) {}
      location.href = u;
      return;
    }
    let target = null;
    let matchedBy = null;

    // Bottom-to-Top mode: walk the list backward (click UP arrow). Lets
    // two agents work the same lead list without colliding — one goes
    // top→bottom, the other bottom→top.
    const targetIconText = state.bottomToTop ? "keyboard_arrow_up" : "keyboard_arrow_down";

    // ── Primary: Material Icons (Impact mobile + similar apps) ──
    const arrowIcon = [...document.querySelectorAll("i.material-icons")]
      .find(i => (i.textContent || "").trim() === targetIconText);
    if (arrowIcon) {
      const clickable = arrowIcon.closest("button, a, [role='button'], [onclick]");
      if (clickable && !clickable.disabled) {
        target = clickable;
        matchedBy = "material-icons:" + targetIconText;
      }
    }

    // ── Fallback selectors for non-Impact apps or future Impact UI changes ──
    const selectors = [
      'button[onclick*="MoveNext"]',
      'a[onclick*="MoveNext"]',
      'button[onclick*="Next"]',
      'a[onclick*="Next"]',
      'button[title*="next" i]',
      'button[title*="down" i]',
      'a[title*="next" i]',
      'a[title*="down" i]',
      'button[aria-label*="next" i]',
      'button[aria-label*="down" i]',
      '.fa-chevron-down', '.fa-arrow-down', '.fa-caret-down', '.fa-angle-down',
      '.glyphicon-chevron-down', '.glyphicon-arrow-down',
      '.btn-next', '.next-lead', '.lead-next', '.move-next', '.btn-move-next',
      '#btnMoveNext', '#btnNext', '#NextLead', '#MoveNext',
    ];

    if (!target) for (const sel of selectors) {
      const el = document.querySelector(sel);
      if (!el) continue;
      const clickable = el.closest("button, a, [role='button'], [onclick]") || el;
      if (clickable.disabled) continue;
      const cs = window.getComputedStyle(clickable);
      if (cs.display === "none" || cs.visibility === "hidden") continue;
      target = clickable;
      matchedBy = sel;
      break;
    }

    // Fall back to text content scan: any button/anchor whose visible text
    // is exactly "Next" or contains "Next Lead".
    if (!target) {
      const candidates = [...document.querySelectorAll("button, a, [role='button']")];
      target = candidates.find(el => {
        const t = (el.textContent || "").trim().toLowerCase();
        return t === "next" || t === "next lead" || t === "next ›" || t === ">";
      }) || null;
      if (target) matchedBy = "text-scan";
    }

    if (target) {
      log("advanceToNextLead: clicking", matchedBy, target);
      try { target.classList.add("pd-flash"); } catch {}
      // v1.31.44 — Watchdog: if Impact's leadId in the URL doesn't change
      // within 3 seconds after the click, the click was ineffective.
      // Set pd_advance_failed_at so maybeAutoDial's Guard #3 blocks the
      // dial loop. Toast the agent so they know to MoveNext manually.
      const leadIdBefore = getLeadId();
      setTimeout(() => {
        try { target.click(); } catch (err) { log("warn", "click failed:", err.message); }
        try { target.classList.remove("pd-flash"); } catch {}
      }, 180);
      setTimeout(() => {
        const leadIdAfter = getLeadId();
        const stillSameLead = leadIdBefore && leadIdAfter && String(leadIdBefore) === String(leadIdAfter);
        if (stillSameLead && CRM.isOnLeadPage()) {
          // v1.31.82 — Bidirectional bounce (Dustin 2026-06-09).
          // Couldn't advance in current direction = we hit the end of
          // the lead list. Auto-flip direction and try once more before
          // giving up. Works in BOTH demo and non-demo. _bounceAt is a
          // per-bounce timestamp so two flips in a row (true 1-lead
          // list) fall through to the manual warning.
          const lastBounce = state._lastBounceAt || 0;
          const recentBounce = (Date.now() - lastBounce) < 5000;
          if (!recentBounce) {
            state._lastBounceAt = Date.now();
            state.bottomToTop = !state.bottomToTop;
            // v1.33.4.2 — persistent UI cue: this flip was automatic
            // (end-of-list bounce), not a glitch. Badge auto-clears in 60s.
            state._autoFlippedAt = Date.now();
            try { chrome.storage.local.set({ pd_bottom_to_top: state.bottomToTop }); } catch {}
            log("info", `🔄 End of lead list — bouncing direction (now ${state.bottomToTop ? "bottom→top" : "top→bottom"})`);
            try {
              toast(`🔄 End of list — reversing direction (${state.bottomToTop ? "bottom→top" : "top→bottom"})`, { ms: 4000 });
            } catch {}
            setTimeout(() => {
              try { advanceToNextLead(); } catch (err) {
                log("warn", "bidirectional bounce retry failed: " + (err && err.message));
              }
            }, 500);
            return;
          }
          // Already bounced once and STILL stuck — give up + warn.
          log("warn", `advanceToNextLead: clicked but leadId did not change (${leadIdBefore}). Setting pd_advance_failed_at.`);
          try {
            chrome.storage.local.set({ [pdGuardKey("pd_advance_failed_at")]: Date.now() });
          } catch {}
          try {
            toast(`⚠️ Couldn't advance from lead ${leadIdBefore}. Click Impact's MoveNext arrow manually.`, { ms: 8000, kind: "warn" });
          } catch {}
        } else if (leadIdBefore && leadIdAfter && String(leadIdBefore) !== String(leadIdAfter)) {
          // Successful advance → clear the bounce timestamp so the next
          // end-of-list event triggers a fresh bounce.
          state._lastBounceAt = 0;
        }
      }, 3000);
      return true;
    }

    // Last resort: keyboard shortcuts. Impact may bind ArrowDown, "n", or "j".
    // v1.31.44 — Also set pd_advance_failed_at since the fallback rarely
    // works on Impact's React-based UI. If keyboard fallback DOES navigate
    // away, the leadId check in maybeAutoDial will pass (different lead).
    log("warn", "advanceToNextLead: no button matched; firing keyboard fallbacks");
    const leadIdBefore = getLeadId();
    const keys = [
      { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
      { key: "n",         code: "KeyN",      keyCode: 78 },
      { key: "j",         code: "KeyJ",      keyCode: 74 },
    ];
    for (const k of keys) {
      ["keydown", "keypress", "keyup"].forEach(type => {
        document.dispatchEvent(new KeyboardEvent(type, { ...k, bubbles: true }));
        if (document.body) document.body.dispatchEvent(new KeyboardEvent(type, { ...k, bubbles: true }));
      });
    }
    setTimeout(() => {
      const leadIdAfter = getLeadId();
      const stillSameLead = leadIdBefore && leadIdAfter && String(leadIdBefore) === String(leadIdAfter);
      if (stillSameLead && CRM.isOnLeadPage()) {
        log("warn", `advanceToNextLead: keyboard fallback didn't navigate either. Setting pd_advance_failed_at.`);
        try { chrome.storage.local.set({ [pdGuardKey("pd_advance_failed_at")]: Date.now() }); } catch {}
        try {
          toast(`⚠️ Couldn't advance from lead ${leadIdBefore}. Click Impact's MoveNext arrow manually.`, { ms: 8000, kind: "warn" });
        } catch {}
      }
    }, 3000);
    toast("Couldn't find next-lead button — open DevTools console and check [pd] logs");
    return false;
  }

  // ─────────────────────────────────────────────────────────────────
  // User-facing actions
  // ─────────────────────────────────────────────────────────────────
  async function startSession() {
    const p = activeProfile();
    // ── Pre-flight gates with specific, actionable error messages ───
    // Order: worker URL → signed-in → reachable → token-valid → cell-on-file → WS.
    // Each branch tells the agent exactly what to fix.
    const tok = (p.authToken || "").trim();
    if (!state.workerBase) {
      toast("Worker URL missing — open the extension popup and verify the Worker base URL.", { error: true });
      return;
    }
    if (!tok) {
      toast("Not signed in. Click the Power Dialer toolbar icon and sign in with your email.", { error: true });
      return;
    }

    // v1.31.87 — Removed v1.31.86 split-screen mobile launcher. Auto-
    // opening chrome.windows.create on Start Session triggered a window
    // resize event in the main Impact tab, which fired through a stale
    // onboarding-tour resize listener and crashed positionStep with
    // "Cannot read properties of null (reading 'style')" — froze the
    // live demo. Recorded-screencast use case unaffected (presenter can
    // manually open /mobile in a second window if needed).

    // ── Auto-ping pre-flight ────────────────────────────────────────
    // 1. Hit /healthz to verify the worker is reachable.
    // 2. Hit /api/me to verify the token works AND auto-pull the latest
    //    agentCell + vmDropUrl from the server (so a freshly-signed-in
    //    user doesn't need to also configure agent cell in two places).
    // 3. Make sure the WebSocket is open.
    toast("Connecting…");
    const base = state.workerBase.replace(/\/+$/, "");

    // Step 1: healthz — distinguishes "worker down" from "auth bad"
    try {
      const hz = await fetch(base + "/healthz", { cache: "no-store" });
      if (!hz.ok) {
        toast(`Worker is up but /healthz returned HTTP ${hz.status}. Try again in a moment.`, { error: true });
        return;
      }
    } catch (err) {
      toast("Can't reach the worker (" + (err.message || "network error") + "). Check your internet connection or worker URL in the popup.", { error: true });
      return;
    }

    // Step 2: /api/me — also refreshes agent cell from server
    try {
      const meRes = await fetch(base + "/api/me?token=" + encodeURIComponent(tok), { cache: "no-store" });
      const meJson = await meRes.json().catch(() => ({}));
      if (meRes.status === 401 || (meJson && meJson.error && /invalid|unauthor/i.test(meJson.error))) {
        toast("Your sign-in expired. Open the popup and sign in again with your email.", { error: true });
        return;
      }
      if (!meRes.ok || !meJson.ok) {
        toast("Sign-in check failed: " + (meJson.error || `HTTP ${meRes.status}`) + ". Open the popup and sign in again.", { error: true });
        return;
      }
      // Pull agent cell + vmDrop from server, cache locally so panel uses them
      const u = meJson.user || {};
      const profiles = state.profiles || {};
      profiles[state.activeProfile] = {
        ...(profiles[state.activeProfile] || {}),
        agentCell:       u.agentCell       || (profiles[state.activeProfile]?.agentCell || ""),
        // v1.33.7.2 — Phone identifier (the WS routing key). LOCAL LOGIN
        // TRUMPS: keep the number THIS device logged in with; only fall
        // back to the server value for a fresh install that has none yet.
        // This is what isolates two agents who share one account but log
        // in with different numbers — each device keeps its own ?phone=
        // and lands in its own Durable Object, instead of /api/me
        // overwriting it with the account-level (last-writer) phone. To
        // change a device's routing phone, re-login on that device.
        phone:           (profiles[state.activeProfile]?.phone || "") || u.phone,
        vmDropUrl:       u.vmDropUrl       || (profiles[state.activeProfile]?.vmDropUrl || ""),
        name:            u.name            || (profiles[state.activeProfile]?.name || ""),
        // v1.29.50 — Cache the hold-music slug so the in-panel settings
        // drawer's picker shows the right station the next time it opens.
        holdMusicPreset: u.holdMusicPreset || (profiles[state.activeProfile]?.holdMusicPreset || "off"),
      };
      // v1.33 Phase 4 — Cache the agent's full device list + active
      // mobile pair on `state` so the Setup drawer's Mobile App chooser
      // can populate without an extra fetch. Refreshed on every /api/me.
      state.userDevices = Array.isArray(u.devices) ? u.devices : [];
      state.userPhones  = Array.isArray(u.phones)  ? u.phones  : [];
      state.activeMobilePairDeviceId = u.activeMobilePairDeviceId || "";
      // v1.34 — Cache the agent's own DIDs for the manual "dial from" chooser
      // (spam-test each number individually). Persisted so it survives Impact
      // page navigations without another /api/me round-trip.
      state.myNumbers = Array.isArray(u.myNumbers) ? u.myNumbers : [];
      try { chrome.storage.local.set({ pd_my_numbers: state.myNumbers }); } catch {}
      state.profiles = profiles;
      try { chrome.storage.local.set({ pd_profiles: profiles }); } catch {}

      // Refresh the admin-rotatable test-mode passcode. Cache locally so
      // the toggle works even if /api/me hasn't been hit yet on a future
      // page load.
      if (typeof meJson.testerPasscode === "string" && meJson.testerPasscode.trim()) {
        state.testerPasscode = meJson.testerPasscode.trim();
        try { chrome.storage.local.set({ pd_tester_passcode: state.testerPasscode }); } catch {}
      }
      // v1.32.2 — Tester role for Double-Tap gating. Cache locally so the
      // toggle visibility decision is stable even if /api/me hasn't been
      // hit yet (e.g. on a fresh boot before the round-trip). The toggle
      // also requires runtimeConfig.doubleTapEnabled to be true (which is
      // refreshed via the runtime-config endpoint on its own cadence).
      state.userIsTester = !!u.isTester;
      try { chrome.storage.local.set({ pd_user_is_tester: state.userIsTester }); } catch {}
      // v1.29.14 — Only adopt the server's activeParkSource if we don't
      // already have one locally. Previously, this block would OVERWRITE
      // the user's freshly-clicked Mobile App selection with whatever
      // stale value the server had (often "desktop"), then the preflight
      // below would route into the Desktop offscreen check and refuse to
      // start. The mode-row click handler is responsible for pushing
      // changes UP to the server; this pull-down is only useful as a
      // first-time bootstrap for a fresh device that has never picked a
      // mode locally.
      if (!state.activeParkSource &&
          typeof u.activeParkSource === "string" &&
          (u.activeParkSource === "mobile_web" || u.activeParkSource === "desktop")) {
        state.activeParkSource = u.activeParkSource;
        state.pendingParkSource = u.activeParkSource;
        try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
      }
    } catch (err) {
      toast("Sign-in check failed: " + (err.message || "network error"), { error: true });
      return;
    }

    // Re-fetch profile in case auto-ping just synced agentCell from server.
    const p2 = activeProfile();
    // v1.29.11 — Read activeParkSource (the real mode), NOT the legacy
    // state.dialMode which is hardcoded "desktop" since v1.29.0. Without
    // this, the Desktop-readiness gate below ALWAYS runs even in Mobile
    // App mode, blocking Start Session with the "Desktop client isn't
    // ready" toast. Mirrors the v1.29.6 fix to the paint() preflight at
    // line ~4136 and the send() call at line ~2118 — finally closes
    // the gap between them.
    const isDesktopMode = (state.activeParkSource || "desktop") === "desktop";
    // v1.29.13 — Removed the agentCell preflight. Both modes are WebRTC now:
    // Desktop uses the extension's offscreen Telnyx client; Mobile App uses
    // the /mobile companion site's Telnyx client. Neither needs a PSTN call
    // to the agent's cell, so agentCell is irrelevant for Start Session.
    // (It's still used elsewhere — e.g., for outbound CallerID display —
    // but it's no longer a gating requirement.)
    if (isDesktopMode) {
      // v1.33.4.4 — Don't trust the cached desktopStatus mirror. Chrome can
      // silently reclaim the offscreen (or a service-worker restart loses it)
      // while the mirror still reads "ready" — then the worker dials into a
      // dead audio client and the agent gets a silent, no-audio call. So we
      // ACTIVELY ensure the offscreen is alive + SIP-registered before telling
      // the worker to dial: ask background to recreate it if it's missing or a
      // zombie, then poll FRESH status until it reports ready (or time out).
      const tok = (p2.authToken || "").trim() || null;
      try {
        await new Promise((resolve) => {
          try {
            chrome.runtime.sendMessage(
              { type: "pd_ensure_offscreen_live", token: tok, workerBase: state.workerBase || "https://app.goalpowerdialer.com" },
              () => { void chrome.runtime.lastError; resolve(); }
            );
          } catch { resolve(); }
        });
      } catch {}
      const dsReady = await new Promise((resolve) => {
        const ensureStart = Date.now();
        let settled = false;
        const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
        const hardStop = setTimeout(() => finish(false), 12000);
        const tick = () => {
          if (settled) return;
          try {
            chrome.runtime.sendMessage({ type: "pd_desktop_status" }, (resp) => {
              void chrome.runtime.lastError;
              if (settled) return;
              const st = (resp && resp.status && resp.status.state) || "absent";
              if (st === "ready" || st === "ringing" || st === "active") { clearTimeout(hardStop); return finish(true); }
              if (Date.now() - ensureStart > 10000) { clearTimeout(hardStop); return finish(false); }
              setTimeout(tick, 300);
            });
          } catch {
            if (Date.now() - ensureStart > 10000) { clearTimeout(hardStop); return finish(false); }
            setTimeout(tick, 300);
          }
        };
        tick();
      });
      if (!dsReady) {
        toast("Reconnecting your audio — the desktop client wasn't ready. Give it a few seconds and click Start Session again. If it keeps happening, open Setup → Grant microphone access.", { error: true });
        return;
      }
    }

    // Step 3: WS — auto-reconnect and wait for OPEN
    if (state.wsStatus !== "connected") {
      connectWs();
      const waitOk = await new Promise((resolve) => {
        const start = Date.now();
        const tick = () => {
          if (state.wsStatus === "connected") return resolve(true);
          if (Date.now() - start > 6000) return resolve(false);
          setTimeout(tick, 100);
        };
        tick();
      });
      if (!waitOk) {
        toast("Couldn't open the live connection to the worker after 6 s. Reload the Impact tab and try again.", { error: true });
        return;
      }
    }

    // v1.29.6 — send activeParkSource as dialMode so the worker can route
    // the SIP call to the right WebRTC client (mobile_web → mobile site,
    // desktop → extension offscreen). Previously this sent state.dialMode
    // which is hardcoded "desktop", so Mobile App mode was always routing
    // to the desktop WebRTC client even when the user picked Mobile App.
    send("session.start", {
      config: {
        agentCell:   p2.agentCell,
        vmDropUrl:   p2.vmDropUrl || "",
        profileName: p2.name || state.activeProfile,
        userToken:   (p2.authToken || "").trim() || null,
        dialMode:    state.activeParkSource || "desktop",
        // v1.32.2 — Ring Timeout SPLIT. The "Custom connection timeout"
        // toggle now ONLY controls the connection phase (call placed →
        // carrier ringing). The ring-wait phase (carrier ringing →
        // prospect answer) is hardcoded ~21s on the worker. Send both
        // field names for back-compat: worker accepts whichever it sees
        // first (connectionTimeoutSecs > ringTimeoutSecs).
        connectionTimeoutSecs: state.ringTimeoutEnabled ? state.ringTimeoutSecs : null,
        ringTimeoutSecs:       state.ringTimeoutEnabled ? state.ringTimeoutSecs : null,
      },
    });
    state.session = "starting";
    // v1.29.12 — Always start with autopilot OFF. Previously, if the
    // user had autopilot ON when they left a previous session (without
    // explicitly ending it), state.autoDialMode would be restored from
    // chrome.storage on boot. When session.parked arrived, maybeAutoDial()
    // would fire immediately, surprising the user with an auto-dial they
    // didn't ask for. Now the agent must explicitly click "Start Autopilot"
    // after every Start Session — symmetric and predictable.
    state.autoDialMode = false;
    try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
    // Mark session start so the End-Session summary can compute deltas.
    state.sessionStartedAt = Date.now();
    state.sessionStartCounters = { ...state.counters };
    state.callLog = [];
    state.skippedLeads = [];
    state.dispositions = {};
    // v1.33.5.1 — Reset the session-scoped "already-called" lead set on every
    // fresh Start Session. This is autopilot's cross-navigation memory of
    // which leads it already worked, so it can SKIP them if Impact loops it
    // back to the top of the inbox (Nathan Gay 2026-07-20: No-Answer leads
    // stay in the inbox, so re-entering from the top re-dialed the same ~10).
    // Plain key (not pdGuardKey) so it's shared across tabs under
    // last-fire-wins ownership handoff.
    try { chrome.storage.local.set({ pd_session_dialed_leads: { s: state.sessionStartedAt, ids: {}, skipStreak: 0 } }); } catch {}
    // v1.34 — AUTO-OFF Leave Voicemail on every fresh Start Session so it can
    // never be silently left on across sessions. The agent consciously flips
    // it on when they intend to leave voicemails this session. Verified write
    // + broadcast so the worker's _lastToggles reflects it immediately.
    if (state.leaveVoicemail) {
      state.leaveVoicemail = false;
      try { _pdWriteToggleVerified("pd_leave_voicemail", false); } catch {}
      try { broadcastToggleState(); } catch {}
    }
    // v1.29.71 — Reset the per-session screenshot block flag. If the user
    // fixed their Chrome site-access setting between sessions, the new
    // session should try captureVisibleTab again before giving up.
    // v1.32.8 — Also clear persisted flag so the retry actually happens.
    state.screenshotPermissionBlocked = false;
    try { chrome.storage.local.set({ pd_screenshot_blocked: false }); } catch {}
    if (isExtensionAlive()) {
      try {
        chrome.storage.local.set({
          pd_dispositions: {},
          pd_dispositions_session: state.sessionStartedAt,
        });
      } catch {}
    }
    // User just clicked a button → AudioContext can be created legally now.
    ensureAudioCtx();
    connectAudioWs();
    paint();
  }

  // v1.33.2.4 — Confirmation wrapper around endSession() for button
  // click handlers. paint() rebuilds critical buttons on every WS-
  // status flap, and the offscreen-unavailable cohort has visible
  // reconnect storms — the End Session button moves + disappears
  // during render cycles, and a misclick on the wrong rendered
  // element instantly nukes session state (skipLeadTypes wipe,
  // autopilot off, prospect cleared, session=idle). We bypass the
  // confirm when state.session is already "idle" (no work to lose)
  // or when there's literally nothing to confirm (no autopilot,
  // no skip types, no active call). Use this anywhere we wire a
  // button click → endSession; the unguarded function endSession()
  // itself stays callable for programmatic flows (popup callbacks,
  // signed-out cleanup, etc).
  function endSessionFromButton() {
    try {
      if (state.session === "idle") { endSession(); return; }
      const hasSkips = state.skipLeadTypes && Object.keys(state.skipLeadTypes).length > 0;
      const autoOn   = !!state.autoDialMode;
      const inCall   = !!state.prospect;
      if (!hasSkips && !autoOn && !inCall) { endSession(); return; }
      const bits = [];
      if (inCall)   bits.push("end the active call");
      if (autoOn)   bits.push("turn off Autopilot");
      if (hasSkips) bits.push(`clear ${Object.keys(state.skipLeadTypes).length} active skip-lead type(s)`);
      const msg = "End the dialer session?\n\nThis will " + bits.join(", ") + ".";
      if (!confirm(msg)) {
        try { log("info", "[v1.33.2.4] End Session click — agent canceled at confirm"); } catch {}
        return;
      }
    } catch {}
    endSession();
  }

  // v1.33.3 — Wake Lock helpers. Hold a screen wake lock while a
  // session is active to keep the desktop from sleeping mid-shift
  // (which would drop the WS heartbeat + Telnyx WebRTC). The Wake
  // Lock auto-releases when the tab is hidden — we re-acquire it
  // on visibilitychange-to-visible if the session is still running.
  // Idempotent: re-calling acquire while held is a no-op.
  async function _pdAcquireWakeLock() {
    if (!state.wakeLockEnabled) return;
    if (state._wakeLockSentinel) return;
    if (!navigator.wakeLock || typeof navigator.wakeLock.request !== "function") {
      return;
    }
    try {
      state._wakeLockSentinel = await navigator.wakeLock.request("screen");
      try { log("info", "[v1.33.3] Wake Lock acquired — desktop won't sleep during this session"); } catch {}
      state._wakeLockSentinel.addEventListener("release", () => {
        state._wakeLockSentinel = null;
        try { log("info", "[v1.33.3] Wake Lock released by browser (likely tab hidden)"); } catch {}
      });
    } catch (e) {
      try { log("warn", "[v1.33.3] Wake Lock request failed: " + (e?.message || e)); } catch {}
    }
  }
  function _pdReleaseWakeLock() {
    if (state._wakeLockSentinel) {
      try { state._wakeLockSentinel.release(); } catch {}
      state._wakeLockSentinel = null;
      try { log("info", "[v1.33.3] Wake Lock released (session ended)"); } catch {}
    }
  }
  // Re-acquire on tab return if session is still active.
  try {
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" &&
          state.session && state.session !== "idle" &&
          state.wakeLockEnabled) {
        _pdAcquireWakeLock();
      }
    });
  } catch {}

  function endSession() {
    // v1.33.2.4 — Stamp the agent's explicit-end moment so the
    // session.ended WS handler can tell THIS End Session apart from
    // a worker-hibernation phantom (which has no user intent and
    // shouldn't zero state.session). 60s window in the handler.
    state.userEndedSessionAt = Date.now();
    // v1.33.3 — Release Wake Lock so desktop can sleep again.
    try { _pdReleaseWakeLock(); } catch {}
    // Snapshot stats BEFORE we send session.end, so we can show the summary
    // even if the WebSocket drops mid-tear-down.
    showSessionSummary();
    persistSessionRecord();
    recordSessionStats();   // v1.33.9.4 — accrue all-time + push to 8-day local history
    // Send the worker the end signal best-effort, then aggressively reset
    // local state. Even if the WS is dead or the worker's DO got blocked
    // by Telnyx, the local UI returns to idle so the agent isn't stuck.
    try { send("session.end"); } catch {}
    // v1.33.7.9 — Storm-proof End Session. Also force-end over plain HTTP so the
    // session tears down even if the control WS is mid-reconnect-storm and the
    // WS "session.end" above is dropped (Ray'mona, Desktop, LNL 2026-08-15:
    // storm right after her last call → End Session did nothing, parked leg rang
    // on). The worker resolves the SAME DO (identical routing to /session) and
    // runs endSession() server-side, which hangs up the parked leg. keepalive so
    // it still fires if the tab is closing.
    try {
      const _endBase = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      const _aid = agentId();
      let _endUrl = _endBase + "/api/session/end" + tokenQueryString();
      if (_aid) _endUrl += (_endUrl.includes("?") ? "&" : "?") + "agentId=" + encodeURIComponent(_aid);
      fetch(_endUrl, { method: "POST", keepalive: true, cache: "no-store" }).catch(() => {});
    } catch (_e) {}
    state.session = "idle";
    state.prospect = null;
    state.callStartedAt = 0;
    state.autoDialMode = false;       // turn off autopilot — they explicitly ended
    try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
    // v1.32.18 — Clear End-Human-Call popup trigger so the URL watcher
    // doesn't fire a popup after the agent ended their session (Dustin's
    // report 2026-06-18: "keeps calling when I've hit end session").
    state.endHumanCallTriggered = false;
    state.endHumanCallTriggeredAt = 0;
    // v1.32.18 — Stamp explicit pause so any subsequent Continue popup
    // doesn't auto-resume an autopilot session the agent explicitly ended.
    state.userPausedAt = Date.now();
    // v1.31.90 — Reset per-session lead-type skip toggles. These are
    // intentionally NOT persisted so each new session starts clean.
    const hadSkips = state.skipLeadTypes && Object.keys(state.skipLeadTypes).length > 0;
    state.skipLeadTypes = {};
    // v1.33.4.8 — also clear the persisted copy so the next session
    // starts clean (skips are per-session by design).
    try { chrome.storage.local.remove("pd_skip_lead_types"); } catch (e) {}
    if (hadSkips) {
      try { log("info", "session end — cleared all lead-type skip toggles"); } catch {}
    }
    // v1.33.3 — Wipe the 3-last-dial ring buffer on session end so the
    // next session starts with a clean strip. Per-session by design.
    state.lastDials = [];
    paint();
  }

  // POST a session summary to the worker so it shows up in the dashboard
  // Sessions tab. Best-effort — failures don't block the user; the local
  // slide-out summary stays visible regardless.
  async function persistSessionRecord() {
    try {
      const startedAt = state.sessionStartedAt || 0;
      if (!startedAt) return;
      const now  = Date.now();
      const tok  = (activeProfile().authToken || "").trim();
      const base = (state.workerBase || "").replace(/\/+$/, "");
      if (!tok || !base) return;
      const startC = state.sessionStartCounters || {};
      const c = state.counters;
      const delta = {
        dials:      Math.max(0, (c.dials      | 0) - (startC.dials      | 0)),
        contacts:   Math.max(0, (c.contacts   | 0) - (startC.contacts   | 0)),
        voicemails: Math.max(0, (c.voicemails | 0) - (startC.voicemails | 0)),
        noAnswers:  Math.max(0, (c.noAnswers  | 0) - (startC.noAnswers  | 0)),
        badNumbers: Math.max(0, (c.badNumbers | 0) - (startC.badNumbers | 0)),
        answered:   Math.max(0, (c.answered   | 0) - (startC.answered   | 0)),
        completed:  Math.max(0, (c.completed  | 0) - (startC.completed  | 0)),
      };
      await fetch(base + "/api/sessions", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          token: tok,
          sessionStart: startedAt,
          sessionEnd:   now,
          durationMs:   now - startedAt,
          counters:     delta,
          callLog:      state.callLog || [],
          skippedLeads: state.skippedLeads || [],
        }),
      });
    } catch (err) {
      log("warn", "session-save failed:", err.message);
    }
  }

  // v1.33.9.4 — Local 3-tier stats persistence. Feeds the jersey's session
  // history recall + the all-time counter. Additive and fully best-effort —
  // it must NEVER throw into endSession(). Two keys:
  //   pd_alltime         : cumulative counters across ALL sessions (+ sessions
  //                        count + `since` timestamp). Reset button zeroes it.
  //   pd_session_history : rolling 8-day array of session records, each holding
  //                        enough to REOPEN its Match Summary later
  //                        ({start,end,durationMs,delta,callLog,skippedLeads}).
  const PD_STAT_FIELDS = ["dials", "contacts", "voicemails", "noAnswers", "badNumbers", "answered", "completed"];
  function sessionDelta() {
    const startC = state.sessionStartCounters || {};
    const c = state.counters || {};
    const d = {};
    PD_STAT_FIELDS.forEach((k) => { d[k] = Math.max(0, (c[k] | 0) - (startC[k] | 0)); });
    return d;
  }
  function recordSessionStats() {
    try {
      const startedAt = state.sessionStartedAt || 0;
      if (!startedAt) return;
      const endedAt = Date.now();
      const delta = sessionDelta();
      const any = PD_STAT_FIELDS.reduce((a, k) => a + (delta[k] | 0), 0);
      if (!any) return; // don't record an empty session
      chrome.storage.local.get(["pd_alltime", "pd_session_history"], (data) => {
        try {
          // ── All-time accrual ──
          const at = {};
          PD_STAT_FIELDS.forEach((k) => { at[k] = ((data.pd_alltime && data.pd_alltime[k]) | 0) + (delta[k] | 0); });
          at.sessions = ((data.pd_alltime && data.pd_alltime.sessions) | 0) + 1;
          at.since    = (data.pd_alltime && data.pd_alltime.since) || startedAt;
          at.updatedAt = endedAt;
          // ── 8-day rolling history ──
          const rec = {
            start: startedAt,
            end: endedAt,
            durationMs: endedAt - startedAt,
            delta,
            // v1.33.9.4 — snapshot EVERYTHING the Match Summary shows, so the
            // recall reproduces the full screen (not just the stat grid):
            dispositions: Object.assign({}, state.dispositions || {}),
            monthly: { used: state.monthlyDials | 0, target: (state.monthlyTarget | 0) || 5000 },
            callLog: (state.callLog || []).slice(-200),
            skippedLeads: (state.skippedLeads || []).slice(-100),
          };
          const cutoff = endedAt - 8 * 24 * 60 * 60 * 1000;
          const hist = (Array.isArray(data.pd_session_history) ? data.pd_session_history : [])
            .filter((r) => (r.end || r.start || 0) >= cutoff);
          hist.push(rec);
          while (hist.length > 100) hist.shift(); // defensive cap
          chrome.storage.local.set({ pd_alltime: at, pd_session_history: hist });
        } catch (e) { try { log("warn", "recordSessionStats inner: " + e.message); } catch {} }
      });
    } catch (e) { try { log("warn", "recordSessionStats: " + e.message); } catch {} }
  }

  // ── Session stats slide-out ──────────────────────────────────────────
  // Pops out from the left edge of the screen on End Session, showing
  // session deltas (this run only, not all-time). Dismissable.
  // v1.33.9.4 — Accepts an optional history `record` so a PAST session's
  // Match Summary can be REOPENED from the stats hub. Live mode (no arg)
  // computes deltas from current state exactly as before.
  function showSessionSummary(record) {
    const reopened = !!(record && record.delta);
    const startedAt = reopened ? (record.start || Date.now()) : (state.sessionStartedAt || Date.now());
    const c = state.counters || {};
    let delta;
    if (reopened) {
      delta = Object.assign(
        { dials: 0, contacts: 0, voicemails: 0, noAnswers: 0, badNumbers: 0, answered: 0, completed: 0 },
        record.delta
      );
    } else {
      const startC = state.sessionStartCounters || { dials: 0, contacts: 0, voicemails: 0, noAnswers: 0, badNumbers: 0, answered: 0, completed: 0 };
      delta = {
        dials:      Math.max(0, (c.dials      | 0) - (startC.dials      | 0)),
        contacts:   Math.max(0, (c.contacts   | 0) - (startC.contacts   | 0)),
        voicemails: Math.max(0, (c.voicemails | 0) - (startC.voicemails | 0)),
        noAnswers:  Math.max(0, (c.noAnswers  | 0) - (startC.noAnswers  | 0)),
        badNumbers: Math.max(0, (c.badNumbers | 0) - (startC.badNumbers | 0)),
        answered:   Math.max(0, (c.answered   | 0) - (startC.answered   | 0)),
        completed:  Math.max(0, (c.completed  | 0) - (startC.completed  | 0)),
      };
    }
    const elapsedMs = reopened ? Math.max(0, record.durationMs | 0) : Math.max(0, Date.now() - startedAt);
    const elapsedMin = Math.floor(elapsedMs / 60000);
    const elapsedSec = Math.floor((elapsedMs % 60000) / 1000);
    const elapsedStr = elapsedMin >= 60
      ? `${Math.floor(elapsedMin/60)}h ${elapsedMin % 60}m`
      : `${elapsedMin}m ${String(elapsedSec).padStart(2,"0")}s`;

    // v1.29.70 — Pickup rate uses `answered` (the new canonical "human picked up"
    // counter). Falls back to `contacts` when answered is 0 to stay back-compat
    // on rolling-over sessions that were started under the old counter scheme.
    const answeredDelta = delta.answered || delta.contacts || 0;
    const contactRate = delta.dials > 0
      ? `${Math.round((answeredDelta / delta.dials) * 100)}%`
      : "—";

    // v1.33.9.4 — reopen-aware sources (history record vs live state)
    const dispSrc    = reopened ? (record.dispositions || {}) : (state.dispositions || {});
    const skippedSrc = reopened ? (record.skippedLeads || []) : (state.skippedLeads || []);
    const footHtml   = reopened
      ? `Session on ${escapeHtml(new Date(startedAt).toLocaleString())}`
      : `Today's totals: ${c.dials | 0} dials · ${(c.answered | 0) || (c.contacts | 0)} answered · ${c.voicemails | 0} VMs`;
    const headLabel  = reopened
      ? `📊 Session recap · ${escapeHtml(new Date(startedAt).toLocaleDateString())}`
      : `📊 Match summary`;

    // Remove any previous summary first
    document.getElementById("pd-session-summary")?.remove();

    const wrap = document.createElement("div");
    wrap.id = "pd-session-summary";
    wrap.className = "pd-session-summary";
    wrap.innerHTML = `
      <div class="pd-ssum-head">
        <span>${headLabel}</span>
        <button class="pd-iconbtn" id="pd-ssum-close" title="Close">×</button>
      </div>
      <div class="pd-ssum-body">
        <div class="pd-ssum-meta">
          <span class="pd-ssum-time">${escapeHtml(elapsedStr)}</span>
          <span class="pd-ssum-rate">${escapeHtml(contactRate)} pickup rate</span>
        </div>
        <div class="pd-ssum-grid">
          <div class="pd-ssum-stat">
            <div class="pd-ssum-num">${delta.dials}</div>
            <div class="pd-ssum-lbl">Total dials</div>
          </div>
          <div class="pd-ssum-stat pd-ssum-stat-good">
            <div class="pd-ssum-num">${answeredDelta}</div>
            <div class="pd-ssum-lbl">Answered</div>
          </div>
          <div class="pd-ssum-stat">
            <div class="pd-ssum-num">${delta.noAnswers}</div>
            <div class="pd-ssum-lbl">No answer</div>
          </div>
          <div class="pd-ssum-stat pd-ssum-stat-vm">
            <div class="pd-ssum-num">${delta.voicemails}</div>
            <div class="pd-ssum-lbl">Voicemails (incl. above)</div>
          </div>
          <div class="pd-ssum-stat pd-ssum-stat-bad">
            <div class="pd-ssum-num">${delta.badNumbers}</div>
            <div class="pd-ssum-lbl">Bad numbers</div>
          </div>
        </div>
        ${renderMonthlyUsage(reopened ? record.monthly : null)}
        ${renderResultsBreakdown(delta)}
        ${renderDispositionsBreakdown(dispSrc)}
        ${renderSkippedList(skippedSrc)}
        <div class="pd-ssum-foot">${footHtml}</div>
      </div>
    `;
    document.body.appendChild(wrap);
    // Trigger slide-in
    requestAnimationFrame(() => wrap.classList.add("pd-open"));
    document.getElementById("pd-ssum-close").addEventListener("click", () => {
      wrap.classList.remove("pd-open");
      setTimeout(() => wrap.remove(), 350);
    });
    // Auto-dismiss after 30s — live end-of-session only. A REOPENED recap
    // stays put until the user closes it (they asked to see it).
    if (!reopened) {
      setTimeout(() => {
        if (wrap.isConnected) {
          wrap.classList.remove("pd-open");
          setTimeout(() => wrap.remove(), 350);
        }
      }, 30000);
    }
  }

  // ── Helpers for the enhanced end-of-session report ─────────────
  function renderMonthlyUsage(snap) {
    // v1.33.9.4 — snap = a stored {used,target} from a recalled session; falls
    // back to live state for the end-of-session summary (and old records).
    const used = snap ? (snap.used | 0) : (state.monthlyDials | 0);
    const cap  = snap ? ((snap.target | 0) || 5000) : ((state.monthlyTarget | 0) || 5000);
    const pct  = Math.min(100, Math.round((used / cap) * 100));
    const fmt  = (n) => n.toLocaleString();
    return `
      <div class="pd-ssum-section">MONTHLY USAGE</div>
      <div class="pd-ssum-monthly">
        <div class="pd-ssum-monthly-track">
          <div class="pd-ssum-monthly-fill" style="width:${pct}%"></div>
        </div>
        <div class="pd-ssum-monthly-meta">${fmt(used)} / ${fmt(cap)} dials · ${pct}%</div>
      </div>
    `;
  }

  function renderDispositionsBreakdown(disps) {
    const entries = Object.entries(disps || {});
    if (!entries.length) return "";
    entries.sort((a, b) => b[1] - a[1]);
    const rows = entries.map(([name, n]) => `
      <div class="pd-ssum-disp-row">
        <span class="pd-ssum-disp-name">${escapeHtml(name)}</span>
        <span class="pd-ssum-disp-count">${n}</span>
      </div>`).join("");
    return `
      <div class="pd-ssum-section">DISPOSITIONS</div>
      <div class="pd-ssum-disp">${rows}</div>
    `;
  }

  function renderResultsBreakdown(delta) {
    // v1.29.70 — Bars now use Answered + NoAnswers (which sum to Dials).
    // Voicemails shown as informational, since they're a subset of NoAnswers.
    const answered = (delta.answered | 0) || (delta.contacts | 0);
    const total = answered + (delta.noAnswers | 0);
    if (!total) return "";
    const pct = (n) => Math.round((n / total) * 100);
    const row = (label, n, kls) => `
      <div class="pd-ssum-bar-row">
        <div class="pd-ssum-bar-lbl">${label}</div>
        <div class="pd-ssum-bar-track">
          <div class="pd-ssum-bar-fill ${kls}" style="width:${pct(n)}%"></div>
        </div>
        <div class="pd-ssum-bar-pct">${n} · ${pct(n)}%</div>
      </div>`;
    return `
      <div class="pd-ssum-section">RESULTS</div>
      ${row("Answered",  answered, "pd-bar-good")}
      ${row("No answer", delta.noAnswers | 0, "pd-bar-noans")}
      ${row("Voicemails (subset of No-Ans)", delta.voicemails | 0, "pd-bar-vm")}
    `;
  }

  function renderSkippedList(skipped) {
    if (!skipped.length) return "";
    const rows = skipped.map(s => `
      <div class="pd-ssum-skipped-row">
        <span class="pd-ssum-skipped-name">${escapeHtml(s.leadName || "(unknown)")}</span>
        <span class="pd-ssum-skipped-reason">${escapeHtml(s.reason || "skipped")}</span>
      </div>`).join("");
    return `
      <div class="pd-ssum-section">SKIPPED LEADS (${skipped.length})</div>
      <div class="pd-ssum-skipped">${rows}</div>
    `;
  }

  // v1.33.9.4 — Stats hub: the all-time counter (+ Reset) and the rolling
  // 8-day session history. Each history row is a RECALL link that reopens
  // that session's Match Summary via showSessionSummary(record). Opened from
  // the jersey dropdown's "History & all-time" button. Read-only over the two
  // storage keys written by recordSessionStats(); never throws.
  function openStatsHub() {
    document.getElementById("pd-stats-hub")?.remove();
    chrome.storage.local.get(["pd_alltime", "pd_session_history"], (data) => {
      try {
        const at = data.pd_alltime || {};
        const hist = (Array.isArray(data.pd_session_history) ? data.pd_session_history : [])
          .slice()
          .sort((a, b) => (b.end || b.start || 0) - (a.end || a.start || 0));
        const num = (n) => (n | 0).toLocaleString();
        const durStr = (ms) => {
          const m = Math.floor((ms | 0) / 60000);
          return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
        };
        const wrap = document.createElement("div");
        wrap.id = "pd-stats-hub";
        wrap.className = "pd-session-summary pd-stats-hub";
        const sinceStr = at.since ? new Date(at.since).toLocaleDateString() : "";
        wrap.innerHTML = `
          <div class="pd-ssum-head">
            <span>📈 Your stats</span>
            <button class="pd-iconbtn" id="pd-stats-hub-close" title="Close">×</button>
          </div>
          <div class="pd-ssum-body">
            <div class="pd-ssum-section">ALL-TIME${at.since ? ` · since ${escapeHtml(sinceStr)}` : ""}</div>
            <div class="pd-ssum-grid">
              <div class="pd-ssum-stat"><div class="pd-ssum-num">${num(at.dials)}</div><div class="pd-ssum-lbl">Dials</div></div>
              <div class="pd-ssum-stat pd-ssum-stat-good"><div class="pd-ssum-num">${num((at.answered | 0) || (at.contacts | 0))}</div><div class="pd-ssum-lbl">Answered</div></div>
              <div class="pd-ssum-stat pd-ssum-stat-vm"><div class="pd-ssum-num">${num(at.voicemails)}</div><div class="pd-ssum-lbl">Voicemails</div></div>
              <div class="pd-ssum-stat"><div class="pd-ssum-num">${num(at.sessions)}</div><div class="pd-ssum-lbl">Sessions</div></div>
            </div>
            <button class="pd-stats-reset" id="pd-stats-reset">Reset all-time</button>
            <div class="pd-ssum-section">RECENT SESSIONS · LAST 8 DAYS (${hist.length})</div>
            <div class="pd-stats-hist" id="pd-stats-hist"></div>
          </div>
        `;
        document.body.appendChild(wrap);
        requestAnimationFrame(() => wrap.classList.add("pd-open"));

        const close = () => { wrap.classList.remove("pd-open"); setTimeout(() => wrap.remove(), 350); };
        document.getElementById("pd-stats-hub-close").addEventListener("click", close);

        // Reset all-time (history is kept)
        document.getElementById("pd-stats-reset").addEventListener("click", () => {
          if (!confirm("Reset your all-time totals to zero? Your session history is kept.")) return;
          chrome.storage.local.set({ pd_alltime: {} }, () => { close(); });
        });

        // History rows — each REOPENS that session's Match Summary
        const histEl = document.getElementById("pd-stats-hist");
        if (!hist.length) {
          histEl.innerHTML = `<div class="pd-stats-empty">No sessions in the last 8 days yet.</div>`;
        } else {
          hist.forEach((rec) => {
            const d = rec.delta || {};
            const when = new Date(rec.start || rec.end || Date.now());
            const row = document.createElement("button");
            row.className = "pd-stats-hist-row";
            row.innerHTML = `
              <span class="pd-stats-hist-when">${escapeHtml(when.toLocaleDateString([], { month: "short", day: "numeric" }))} · ${escapeHtml(when.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }))}</span>
              <span class="pd-stats-hist-stat">${d.dials | 0} dials · ${(d.answered | 0) || (d.contacts | 0)} ans · ${durStr(rec.durationMs)}</span>
              <span class="pd-stats-hist-view">View &rarr;</span>
            `;
            row.addEventListener("click", () => showSessionSummary(rec));
            histEl.appendChild(row);
          });
        }
      } catch (e) { try { log("warn", "openStatsHub: " + e.message); } catch {} }
    });
  }

  // ─────────────────────────────────────────────────────────────────
  // Desktop offscreen status — pulled from the background service worker.
  // The offscreen doc broadcasts pd_off_status whenever its WebRTC
  // connection state changes; background re-broadcasts to all content
  // scripts. We cache the latest in state.desktopStatus so paint() can
  // render a status pip in the panel header when in desktop mode.
  // ─────────────────────────────────────────────────────────────────
  state.desktopStatus = state.desktopStatus || { state: "absent", error: null };

  // v1.33.4.1 — Big mic-permission escalation modal for the Sadie class.
  // Sadie Matteson refund 2026-06-25 traced to dismissing Chrome's mic
  // prompt; the buried in-panel banner didn't surface loudly enough so
  // her first impression of the product was a dead dialer. This modal
  // is fired by the pd_off_status listener whenever SDK errors with a
  // mic-permission pattern. Idempotent + throttled per session via
  // sessionStorage so a returning agent who already saw it isn't
  // pestered every reconnect.
  function showMicPermissionModal() {
    if (document.getElementById("pd-mic-permission-modal")) return; // idempotent
    try {
      if (sessionStorage.getItem("pd_mic_permission_modal_dismissed") === "1") return;
    } catch (_e) {}
    const wrap = document.createElement("div");
    wrap.id = "pd-mic-permission-modal";
    wrap.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.78);font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
    wrap.innerHTML = `
      <div style="width:460px;max-width:calc(100vw - 32px);background:linear-gradient(180deg,#1B4332,#16263d);color:#fff;border-radius:14px;border:2px solid #ff8a92;box-shadow:0 24px 60px rgba(0,0,0,0.7);overflow:hidden;">
        <div style="background:#ff8a92;color:#1B4332;padding:11px 22px;font-weight:800;letter-spacing:1.5px;font-size:12px;text-transform:uppercase;text-align:center;">
          ⚠️ Microphone permission needed
        </div>
        <div style="padding:24px 26px;">
          <h2 style="margin:0 0 12px;font-size:21px;color:#fff;">The dialer can't hear you yet</h2>
          <p style="margin:0 0 14px;font-size:14px;line-height:1.55;color:#cfe0fa;">
            Chrome asked for permission to use your microphone and the prompt was either dismissed or denied.
            The Desktop dialer can't route call audio through your headset until you grant it.
          </p>
          <p style="margin:0 0 18px;font-size:13px;line-height:1.5;color:#a8c1e6;">
            <strong style="color:#F2C94C;">Quick fix:</strong> click the button below — a small Chrome window will open and ask for mic permission. Pick <strong>Allow</strong>. The window closes automatically.
          </p>
          <div style="display:flex;gap:10px;justify-content:flex-end;">
            <button id="pd-mic-permission-snooze" style="background:transparent;border:1px solid rgba(255,255,255,0.2);color:#a8c1e6;padding:9px 16px;border-radius:6px;font-size:12px;cursor:pointer;font-family:inherit;">Not now</button>
            <button id="pd-mic-permission-grant-btn" style="background:#F2C94C;border:1px solid #C49B12;color:#1B4332;padding:9px 18px;border-radius:6px;font-size:13px;cursor:pointer;font-weight:700;font-family:inherit;">Grant microphone</button>
          </div>
          <p style="margin:14px 0 0;font-size:11px;color:#728cb0;text-align:center;">
            Already on mobile? Switch to Mobile App mode in the dialer — no mic needed.
          </p>
        </div>
      </div>
    `;
    document.body.appendChild(wrap);
    document.getElementById("pd-mic-permission-snooze")?.addEventListener("click", () => {
      try { sessionStorage.setItem("pd_mic_permission_modal_dismissed", "1"); } catch (_e) {}
      wrap.remove();
    });
    document.getElementById("pd-mic-permission-grant-btn")?.addEventListener("click", () => {
      const btn = document.getElementById("pd-mic-permission-grant-btn");
      if (btn) { btn.disabled = true; btn.textContent = "Opening prompt…"; }
      try {
        chrome.runtime.sendMessage({ type: "open-mic-grant" }, (resp) => {
          void chrome.runtime.lastError;
          // v1.34 — resp only confirms the grant TAB opened; it does NOT carry
          // the grant outcome (that arrives async via pd_mic_granted_broadcast,
          // which dismisses this modal on success / re-enables it on failure).
          // Previously this checked resp.granted (never set) and always showed
          // "permission failed", leaving agents stuck even after they Allowed.
          if (resp && resp.ok === false) {
            if (btn) { btn.disabled = false; btn.textContent = "Grant microphone"; }
            try { toast("Couldn't open the mic window: " + ((resp && resp.error) || "unknown error"), { error: true }); } catch (_e) {}
          } else {
            // Tab opened — wait for the user to click Allow. The broadcast closes this.
            if (btn) { btn.textContent = "Waiting — click Allow…"; }
          }
        });
      } catch (e) {
        if (btn) { btn.disabled = false; btn.textContent = "Grant microphone"; }
      }
    });
  }

  // ── Proactive FIRST-LOGIN mic prompt (v1.33.6) ──────────────────────
  // showMicPermissionModal() above is REACTIVE — it only fires after an
  // SDK mic error, i.e. after a dial has already failed. New Desktop-mode
  // agents never hit that until their first call produces silence, so
  // they think the dialer is broken. This shows a welcoming ONE-TIME
  // prompt on first login (signed in + Desktop mode + mic not yet granted)
  // so the mic is set up BEFORE the first Start Session. Gated once-ever
  // via chrome.storage.pd_mic_first_prompt_done so returning agents who
  // chose "later" aren't nagged every page load.
  function showFirstLoginMicModal() {
    if (document.getElementById("pd-mic-permission-modal")) return;  // reactive one already up
    if (document.getElementById("pd-mic-firstlogin-modal")) return;  // idempotent
    const wrap = document.createElement("div");
    wrap.id = "pd-mic-firstlogin-modal";
    wrap.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.78);font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
    wrap.innerHTML = `
      <div style="width:460px;max-width:calc(100vw - 32px);background:linear-gradient(180deg,#1B4332,#16263d);color:#fff;border-radius:14px;border:2px solid #F2C94C;box-shadow:0 24px 60px rgba(0,0,0,0.7);overflow:hidden;">
        <div style="background:#F2C94C;color:#1B4332;padding:11px 22px;font-weight:800;letter-spacing:1.5px;font-size:12px;text-transform:uppercase;text-align:center;">
          🎤 One quick setup step
        </div>
        <div style="padding:24px 26px;">
          <h2 style="margin:0 0 12px;font-size:21px;color:#fff;">Enable your microphone to start dialing</h2>
          <p style="margin:0 0 14px;font-size:14px;line-height:1.55;color:#cfe0fa;">
            Welcome! The Desktop dialer routes call audio through your computer headset, so it needs microphone access before your first call.
          </p>
          <p style="margin:0 0 18px;font-size:13px;line-height:1.5;color:#a8c1e6;">
            <strong style="color:#F2C94C;">Set it up now:</strong> click below — a small Chrome window opens and asks for mic permission. Pick <strong>Allow</strong>. It closes on its own, and you're ready to Start Session.
          </p>
          <div style="display:flex;gap:10px;justify-content:flex-end;">
            <button id="pd-mic-firstlogin-later" style="background:transparent;border:1px solid rgba(255,255,255,0.2);color:#a8c1e6;padding:9px 16px;border-radius:6px;font-size:12px;cursor:pointer;font-family:inherit;">I'll do it later</button>
            <button id="pd-mic-firstlogin-grant" style="background:#F2C94C;border:1px solid #C49B12;color:#1B4332;padding:9px 18px;border-radius:6px;font-size:13px;cursor:pointer;font-weight:700;font-family:inherit;">Enable microphone</button>
          </div>
          <p style="margin:14px 0 0;font-size:11px;color:#728cb0;text-align:center;">
            Prefer your phone? Switch to Mobile App mode in the dialer — no mic needed.
          </p>
        </div>
      </div>
    `;
    document.body.appendChild(wrap);
    document.getElementById("pd-mic-firstlogin-later")?.addEventListener("click", () => { wrap.remove(); });
    document.getElementById("pd-mic-firstlogin-grant")?.addEventListener("click", () => {
      const btn = document.getElementById("pd-mic-firstlogin-grant");
      if (btn) { btn.disabled = true; btn.textContent = "Opening prompt…"; }
      try {
        chrome.runtime.sendMessage({ type: "open-mic-grant" }, (resp) => {
          void chrome.runtime.lastError;
          // v1.34 — see note in showMicPermissionModal: the response only
          // confirms the tab opened; the grant outcome comes via
          // pd_mic_granted_broadcast (which closes this modal on success).
          if (resp && resp.ok === false) {
            if (btn) { btn.disabled = false; btn.textContent = "Enable microphone"; }
            try { toast("Couldn't open the mic window: " + ((resp && resp.error) || "unknown error"), { error: true }); } catch (_e) {}
          } else {
            if (btn) { btn.textContent = "Waiting — click Allow…"; }
          }
        });
      } catch (e) {
        if (btn) { btn.disabled = false; btn.textContent = "Enable microphone"; }
      }
    });
  }

  async function maybeShowFirstLoginMicPrompt() {
    try {
      const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
      if (!tok) return;                                                 // not signed in
      if ((state.activeParkSource || "desktop") !== "desktop") return;  // mobile mode: no mic
      if (state.micGranted) return;                                     // already granted (cached)
      // Double-check the live browser permission so a cleared cache flag
      // doesn't re-prompt an agent who actually granted it in Chrome.
      try {
        const st = await navigator.permissions.query({ name: "microphone" });
        if (st && st.state === "granted") {
          state.micGranted = true;
          try { chrome.storage.local.set({ pd_mic_granted: true }); } catch {}
          return;
        }
      } catch (_e) {}
      // Once-ever gate (persistent; distinct from the reactive modal's
      // per-session snooze) so returning agents aren't nagged each login.
      const g = await chrome.storage.local.get(["pd_mic_first_prompt_done"]);
      if (g && g.pd_mic_first_prompt_done) return;
      try { await chrome.storage.local.set({ pd_mic_first_prompt_done: true }); } catch {}
      showFirstLoginMicModal();
    } catch (_e) {}
  }

  function paintDesktopStatusBanner(s) {
    const banner = document.getElementById("pd-desk-status");
    if (!banner) return;
    const lbl = banner.querySelector(".pd-desk-label");
    const pip = banner.querySelector(".pd-desk-pip");
    const grant = document.getElementById("pd-desk-grant");
    let text = "Loading…", kind = "";
    let showGrant = false;
    switch (s.state) {
      case "absent":     text = "Off — flip Desktop on to enable"; break;
      case "idle":       text = "Initializing…"; break;
      case "connecting": text = "Connecting to Telnyx…"; break;
      case "ready":      text = "✓ Ready — calls land in your headset"; kind = "ok"; break;
      case "ringing":    text = "📞 Incoming call"; kind = "ok"; break;
      case "active":     text = "📞 On call"; kind = "ok"; break;
      case "error":      text = "Error: " + (s.error || "unknown"); kind = "err";
                         // If error is mic-related, prompt user to open popup
                         if (/mic/i.test(s.error || "")) showGrant = true;
                         break;
    }
    if (lbl) lbl.textContent = text;
    if (pip) {
      pip.classList.remove("ok", "err");
      if (kind) pip.classList.add(kind);
    }
    if (grant) grant.style.display = showGrant ? "inline-block" : "none";
  }

  function refreshDesktopStatusInDrawer() {
    try {
      chrome.runtime.sendMessage({ type: "pd_desktop_status" }, (resp) => {
        void chrome.runtime.lastError;
        const s = resp?.status || { state: "absent" };
        state.desktopStatus = s;
        paintDesktopStatusBanner(s);
      });
    } catch {}
  }

  // Listen for live broadcasts from background. Idempotent — multiple
  // listeners are fine, but we only need one.
  if (!state._desktopListenerBound) {
    state._desktopListenerBound = true;
    try {
      chrome.runtime.onMessage.addListener((msg) => {
        if (!msg || msg.type !== "pd_off_status") return;
        state.desktopStatus = {
          state: msg.state,
          error: msg.error || null,
          callerNumber: msg.callerNumber || null,
        };
        paintDesktopStatusBanner(state.desktopStatus);
        // Also re-paint the panel so the header pip updates in real time
        try { paint(); } catch {}
        // v1.33.4.1 — Mic-permission-dismissed escalation modal.
        // Sadie Matteson refund (2026-06-25) traced back to her
        // dismissing Chrome's mic permission prompt; the dialer
        // then errored with "Mic not granted... (Permission
        // dismissed)" but the in-panel banner was buried in the
        // Setup drawer's status row and she never saw it. As a
        // brand new agent her first impression was a dead dialer
        // and she walked. The existing showGrant button at
        // line ~5920 was the right idea, just not loud enough.
        //
        // This escalates: if the SDK reports a mic-permission
        // error AND we're NOT already in Mobile App mode (where
        // the agent doesn't need extension mic), pop a big
        // dismissible modal with a "Grant Microphone" CTA. Auto-
        // closes when mic gets granted. Throttled to once per
        // session via sessionStorage so we don't pester a user
        // who already saw it and is mid-task.
        try {
          const _isMobileMode = (state.activeParkSource || "") === "mobile_web";
          const _isMicErr = state.desktopStatus.state === "error"
                         && /mic.*not granted|permission.*dismissed/i.test(state.desktopStatus.error || "");
          if (_isMicErr && !_isMobileMode) {
            showMicPermissionModal();
          } else if (state.micGranted) {
            // Auto-close if the user fixed it via another flow
            try { document.getElementById("pd-mic-permission-modal")?.remove(); } catch {}
          }
        } catch (_e) {}
      });
    } catch {}
  }

  // ─────────────────────────────────────────────────────────────────
  // Settings drawer — full popup-equivalent UI inline inside the dialer
  // so the agent doesn't have to click the toolbar extension icon to
  // change their cell number / VM drop / skin / worker URL.
  // ─────────────────────────────────────────────────────────────────
  function toggleSettingsDrawer() {
    const existing = document.getElementById("pd-settings-drawer");
    if (existing) {
      existing.classList.remove("pd-open");
      setTimeout(() => existing.remove(), 320);
      return;
    }
    renderSettingsDrawer();
  }

  async function renderSettingsDrawer() {
    document.getElementById("pd-settings-drawer")?.remove();
    const wrap = document.createElement("div");
    wrap.id = "pd-settings-drawer";
    wrap.className = "pd-settings-drawer";
    const profile = activeProfile();
    const tok = (profile.authToken || "").trim();
    const cell = profile.agentCell || "";
    const vmDrop = profile.vmDropUrl || "";
    const workerBase = state.workerBase || "https://app.goalpowerdialer.com";
    const skin = state.skin || "phone";
    const signedIn = !!tok;
    // Pre-fill from cached profile so the card has real values immediately,
    // even before the live /api/me round-trip completes.
    const cachedEmail = profile.email || "";
    const cachedName  = profile.name  || "";
    wrap.innerHTML = `
      <div class="pd-settings-head">
        <span>⚙ Settings</span>
        <button class="pd-iconbtn" id="pd-settings-close" title="Close">×</button>
      </div>
      <div class="pd-settings-body">
        <div class="pd-settings-section-title">ACCOUNT</div>
        <!-- Signed-OUT pane: dual login (Password + Email code).
             v1.30.3 — Matches /login, /dashboard, /admin, popup. Password
             tab is the default; "Forgot password?" link drops back to the
             code flow. -->
        <div id="pd-signin-pane" style="display: ${signedIn ? "none" : "block"}">
          <!-- v1.33.8 — Email-code sign-in retired; password is the only method. -->

          <!-- Method 1: Email + Password -->
          <form id="pd-signin-form-pw" autocomplete="on" name="login" style="margin:0;">
            <input type="email" class="pd-settings-input" id="pd-signin-pw-email" name="username" autocomplete="username" placeholder="you@yourcompany.com" style="margin-bottom:6px;"/>
            <input type="password" class="pd-settings-input" id="pd-signin-pw-password" name="password" autocomplete="current-password" placeholder="••••••••" style="margin-bottom:6px;"/>
            <!-- v1.33 Phase 2 — Login Phone (WS routing identifier).
                 Required so this ext lands in the phone_<digits> Durable
                 Object that matches the agent's mobile companion. NOT
                 the same as Agent Cell below (which is the PSTN
                 callback number — different purpose). -->
            <input type="tel" class="pd-settings-input" id="pd-signin-pw-phone" name="phone" autocomplete="off" placeholder="YOUR cell (10-digit)"/>
            <div style="text-align:right;font-size:11px;margin:4px 0 6px;">
              <a href="#" id="pd-forgot-pw" style="color:rgba(255,255,255,0.6);text-decoration:none;">Forgot or need to set your password?</a>
            </div>
            <button class="pd-btn pd-btn-start pd-full" id="pd-pw-submit" type="submit">Sign in</button>
            <div class="pd-signin-msg" id="pd-signin-pw-msg"></div>
          </form>

          <!-- Forgot / set password (emailed reset link) — v1.33.8 -->
          <div id="pd-signin-forgot" style="display:none; margin-top:8px">
            <input type="email" class="pd-settings-input" id="pd-forgot-email" placeholder="you@yourcompany.com" autocomplete="email"/>
            <div class="pd-settings-hint">We'll email you a secure link to set a new password — works whether or not you have one yet.</div>
            <button class="pd-btn pd-btn-start pd-full" id="pd-forgot-send" style="margin-top:10px">Email me a reset link</button>
            <button class="pd-btn pd-btn-ghost pd-full" id="pd-forgot-back" style="margin-top:6px">← Back to sign in</button>
            <div class="pd-signin-msg" id="pd-forgot-msg"></div>
          </div>
        </div>
        <!-- Signed-IN pane: live account info. -->
        <div id="pd-signedin-pane" style="display: ${signedIn ? "block" : "none"}">
          <div class="pd-settings-account" id="pd-settings-account">
            <div class="pd-settings-row"><span>Status</span><strong id="pd-settings-status">${signedIn ? "Signed in" : "Not signed in"}</strong></div>
            <div class="pd-settings-row"><span>Email</span><strong id="pd-settings-email">${escapeHtml(cachedEmail) || "—"}</strong></div>
            <div class="pd-settings-row"><span>Name</span><strong id="pd-settings-name">${escapeHtml(cachedName) || "—"}</strong></div>
            <div class="pd-settings-row"><span>Balance</span><strong id="pd-settings-balance">—</strong></div>
            <div class="pd-settings-row"><span>Activation</span><strong id="pd-settings-activation">—</strong></div>
          </div>
          <!-- v1.31.89 — Sign-out button restyled to be visible. Was
               pd-btn-ghost (very low contrast border-only on dark bg);
               agents missed it. Now red outline + bold text + icon. -->
          <button class="pd-btn pd-btn-signout pd-full" id="pd-settings-signout" style="margin-top:8px;background:rgba(230,57,70,0.12);border:1px solid rgba(230,57,70,0.6);color:#ff8a93;font-weight:700;padding:9px 14px;border-radius:8px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;gap:6px;">
            <span aria-hidden="true">🚪</span><span>Sign out</span>
          </button>
        </div>

        <!-- v1.29.6 — DIAL MODE section removed. Phone/Cell parking is no
             longer supported (v1.29.0). Mode selection (Mobile App vs Desktop)
             lives in the mode-row pills in the panel itself, not in Settings. -->

        <!-- v1.33 Phase 6 — Setup drawer YOUR DEVICES picker REMOVED.
             The device chooser belongs in the Mobile App mode pane,
             not in Setup. Setup is for one-time account config; device
             selection is operational (changes per call). The dashboard
             "My Devices" card (renderMyDevicesCard) is the canonical
             place to view/rename/unregister all devices. We keep the
             agent's nickname auto-derived from User-Agent during
             /api/devices/register; agents can override it from the
             dashboard. -->
        ${false ? "" : ""}
        <!-- v1.31.90 — TCPA Calling Hours per timezone. When enabled,
             before every dial the dialer derives the prospect's timezone
             from their phone number / state, computes the prospect's
             local time, and SKIPS the dial if outside the configured
             window for that zone. End-of-session does NOT reset hours
             (they're an account-wide compliance setting). -->
        <div class="pd-settings-section-title">⏰ CALLING HOURS <span class="pd-settings-opt">(TCPA — STANDARD ON, prospect-local time)</span></div>
        <label class="pd-toggle-row${state.callingHoursEnabled ? " pd-toggle-on" : ""}" style="margin-bottom:8px;">
          <input type="checkbox" id="pd-calling-hours-enabled" ${state.callingHoursEnabled ? "checked" : ""}/>
          <span class="pd-toggle-label">Calling Hours Protection (uncheck only to disable)</span>
          <button type="button" class="pd-toggle-info" data-help-title="⏰ Calling Hours — STANDARD ON" data-help="STANDARD ON for every agent — TCPA compliance protection. Before every dial, the prospect's timezone is derived from their physical address (or state/phone area code as fallback). If the current time in their zone is OUTSIDE the configured window, the dial is SKIPPED and autopilot advances to the next lead. Unchecking this disables protection FOR YOU ONLY — you assume responsibility for staying within legal calling hours. Federal TCPA bounds calls to 8am-9pm prospect-local; conservative defaults are 9am-9pm M-F and 10am-7pm S-S.">ⓘ</button>
        </label>
        <!-- v1.32.21 — REMOVED the 5-row per-timezone agent grid. Agents
             calling permissive states at 8:10 PM CST were being blocked
             because the CST row auto-populated with TX's restrictive
             Sunday rule (most-restrictive-in-zone), so Sunday CST calls
             to non-TX states inherited the TX block. Runtime now uses
             the prospect's state mandate ONLY — no agent override.
             Setup UI is now a single state lookup panel: pick a state,
             see exactly the window the dialer will enforce when calling
             a prospect in that state. -->
        <div style="border:1px solid rgba(108,179,255,0.30); border-radius:8px; padding:12px 14px; margin-bottom:10px; background:linear-gradient(180deg, rgba(40,80,140,0.18), rgba(40,80,140,0.08));">
          <div style="font-size:11px; font-weight:700; color:#cfe0fa; letter-spacing:0.5px; margin-bottom:8px;">📋 STATE LOOKUP <span style="color:rgba(255,255,255,0.5); font-weight:400; text-transform:none; letter-spacing:0;">(runtime enforces the prospect's state mandate automatically)</span></div>
          <select id="pd-state-mandate-picker" style="width:100%; padding:7px 10px; border-radius:6px; border:1px solid rgba(255,255,255,0.20); background:rgba(0,0,0,0.40); color:#fff; font-family:inherit; font-size:13px; color-scheme:dark;">
            <option value="">Pick a state to see its TCPA window…</option>
            ${Object.keys(STATE_TZ).sort().map((st) => `<option value="${st}" style="background:#0a2218;color:#fff;">${st} — ${STATE_TZ[st].name || st}</option>`).join("")}
          </select>
          <!-- v1.32.21 — Auto-fill display. Populated by the document-level
               delegated change handler at the bottom of content.js (see
               "v1.32.21 — Delegated change listener for the per-state
               TCPA mandate dropdown"). Shows the state's full M-F + S/S
               window plus whether it's federal-default or a state override. -->
          <div id="pd-state-mandate-display" style="margin-top:10px; padding:10px 12px; font-size:12px; color:rgba(255,255,255,0.85); line-height:1.55; display:none; background:rgba(0,0,0,0.30); border-radius:6px; border-left:3px solid #6ce8a0;"></div>
        </div>

        <div class="pd-settings-hint" style="font-size:11px; line-height:1.5;">
          The dialer enforces TCPA based on the <strong>prospect's state</strong>, not your timezone. Federal default is 8:00 AM–9:00 PM prospect-local. States like TX, FL/MA, AL, LA have stricter windows that override the federal default for that state only. Use the lookup above to verify a state's window any time.
        </div>

        <div class="pd-settings-section-title">AGENT CELL <span class="pd-settings-opt">(for callback routing)</span></div>
        <input type="tel" class="pd-settings-input" id="pd-settings-cell" value="${escapeHtml(cell)}" placeholder="YOUR cell (10-digit)" autocomplete="off"/>
        <div class="pd-settings-hint">E.164 format. Used so prospects that call back any of our numbers route directly to your phone (not used for parking — that's via WebRTC now).</div>

        ${(state.myNumbers && state.myNumbers.length) ? `
        <div class="pd-settings-section-title">MY NUMBERS <span class="pd-settings-opt">(outbound caller ID)</span></div>
        <div class="pd-settings-hint">Uncheck a number to stop the dialer calling out from it (e.g. if it's showing as spam). It stays on your account and still takes callbacks — it's just benched until you re-check it. At least one must stay on.</div>
        <div id="pd-mynumbers-list" style="margin:6px 0 2px;">
          ${state.myNumbers.map((n) => {
            const e = (n.e164 || "");
            const d = e.replace(/^\+1/, "");
            const f = d.length === 10 ? "(" + d.slice(0,3) + ") " + d.slice(3,6) + "-" + d.slice(6) : e;
            const meta = [n.areaCode ? ("AC " + n.areaCode) : "", n.state || ""].filter(Boolean).join(" · ");
            const on = !n.disabled;
            return `<label class="pd-mynum-row" style="display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.10);margin-bottom:5px;cursor:pointer;">
              <input type="checkbox" class="pd-mynum-cb" data-e164="${escapeHtml(e)}" ${on ? "checked" : ""} style="width:16px;height:16px;cursor:pointer;accent-color:#2dab5f;flex:0 0 auto;"/>
              <span style="flex:1;font-size:13px;font-weight:600;color:#fff;">${escapeHtml(f)}</span>
              <span style="font-size:11px;color:rgba(255,255,255,0.55);">${escapeHtml(meta)}</span>
            </label>`;
          }).join("")}
        </div>
        <div class="pd-settings-hint" id="pd-mynumbers-status" style="min-height:14px;"></div>
        ` : ""}

        <div class="pd-settings-section-title">VOICEMAIL</div>
        <div class="pd-settings-hint">Record or upload per-lead-type voicemails in the <strong>Voicemail Studio</strong> on your dashboard, then turn on the <strong>📩 Leave Voicemail</strong> toggle to have the dialer leave them automatically on detected voicemails.</div>

        <!-- v1.29.50 — Hold music picker. Plays on the agent's MOBILE page
             during the dial window so they hear confirmation audio. Only
             slugs from the curated server-side preset list are accepted
             — there is no free-text URL entry, so no path for someone
             to accidentally hot-link a paid-bandwidth source.
             ▲/▼ preview the selection. "Choose" commits it (saves to the
             server). The mobile page re-fetches its dial-fill URL on
             every prospect.dialing, so the new station takes effect on
             the very next dial — no refresh needed. -->
        <div class="pd-settings-section-title">🎶 HOLD MUSIC WHILE DIALING</div>
        <div id="pd-settings-hm-picker" style="display:flex; align-items:stretch; gap:6px; margin: 4px 0 4px;">
          <div style="display:flex; flex-direction:column; gap:4px;">
            <button type="button" class="pd-btn pd-btn-ghost" id="pd-settings-hm-up"   style="padding:2px 12px; min-height:0; line-height:1; font-size:14px;">▲</button>
            <button type="button" class="pd-btn pd-btn-ghost" id="pd-settings-hm-down" style="padding:2px 12px; min-height:0; line-height:1; font-size:14px;">▼</button>
          </div>
          <div id="pd-settings-hm-display" style="flex:1; display:flex; align-items:center; justify-content:center; padding:8px 10px; border:1px solid rgba(255,255,255,0.12); border-radius:6px; background:rgba(0,0,0,0.25); font-size:12px; min-height:40px;">
            <span id="pd-settings-hm-display-text">Loading…</span>
          </div>
        </div>
        <div class="pd-settings-hint">Streams free public stations (SomaFM &amp; a sample) directly to your phone during the dialing window. No Telnyx cost — pure agent-side audio. Pick "Off" for silence. ▲/▼ to preview, "Choose" to save.</div>
        <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin: 6px 0 0;">
          <button type="button" class="pd-btn pd-btn-start" id="pd-settings-hm-choose" style="padding:4px 14px; min-height:0; font-size:11px;">✓ Choose this station</button>
          <button type="button" class="pd-btn pd-btn-ghost" id="pd-settings-hm-audition"      style="padding:4px 10px; min-height:0; font-size:11px;">▶ Audition</button>
          <button type="button" class="pd-btn pd-btn-ghost" id="pd-settings-hm-audition-stop" style="padding:4px 10px; min-height:0; font-size:11px; display:none;">■ Stop</button>
          <span class="pd-settings-hint" id="pd-settings-hm-audition-status" style="margin-left:4px; margin-top:0;"></span>
          <audio id="pd-settings-hm-audition-audio" preload="none" style="display:none;"></audio>
        </div>

        <!-- v1.32.8 — Live-View Email section REMOVED from dialer Settings.
             The opt-in lives on the dashboard now (since v1.32.2 move).
             Per Dustin 2026-06-16: redundant in dialer drawer. -->

        <div class="pd-settings-section-title">WORKER BASE URL</div>
        <input type="url" class="pd-settings-input" id="pd-settings-worker" value="${escapeHtml(workerBase)}" placeholder="https://app.goalpowerdialer.com"/>
        <div class="pd-settings-hint">Defaults to https://app.goalpowerdialer.com. Override only if self-hosting.</div>

        <div class="pd-settings-section-title">THEME
          <button type="button" class="pd-help-q" data-help-title="Theme" data-help="Phone Theme renders the dialer as a Samsung Galaxy phone in full view and a 2.5″ Tally-style compact dashboard in mini view. Soccer Theme renders the dialer as a goal frame in full view and a 3″ soccer-ball watermark with overlaid controls in mini view. The 'O' button in the dialer header flips between full and mini.">?</button>
        </div>
        <div class="pd-settings-skin">
          <label class="pd-settings-skin-opt ${(skin === "phone" || skin === "medium") ? "selected" : ""}">
            <input type="radio" name="pd-skin" value="phone" ${(skin === "phone" || skin === "medium") ? "checked" : ""}/>
            <span>📱 Phone Theme</span>
          </label>
          <label class="pd-settings-skin-opt ${(skin === "goal" || skin === "soccer-medium") ? "selected" : ""}">
            <input type="radio" name="pd-skin" value="goal" ${(skin === "goal" || skin === "soccer-medium") ? "checked" : ""}/>
            <span>⚽ Soccer Theme</span>
          </label>
        </div>

        <!-- v1.31.80 fix-up — Setup-screen skin chooser bar (mirrors
             the top skin bar). Uses same .pd-skin-bar-btn classes +
             data-action attributes, so the existing delegated click
             handler on pd-root picks them up automatically. Visible
             active state is driven by parent classes on pd-root via
             the same CSS rules as the top bar. -->
        <div class="pd-settings-skinbar-wrap">
          <div class="pd-settings-hint" style="margin-bottom: 6px;">Or pick from any of the 5 skins:</div>
          <div class="pd-skin-bar pd-skin-bar-settings" role="toolbar" aria-label="Skin chooser">
            <button type="button" class="pd-skin-bar-btn" data-action="minimize" title="Minimize" aria-label="Minimize">
              <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
                <rect x="5" y="11" width="14" height="2" rx="1" fill="currentColor"/>
              </svg>
            </button>
            <button type="button" class="pd-skin-bar-btn" data-action="phone" title="Branded phone skin" aria-label="Branded phone skin">
              <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/phone-32.png") || ""}" alt="" aria-hidden="true" draggable="false"/>
            </button>
            <!-- v1.32.9 — None-Branded phone skin (clean blank screen). -->
            <button type="button" class="pd-skin-bar-btn" data-action="classic" title="Classic phone skin" aria-label="Classic phone skin">
              <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/classic-32.png") || ""}" alt="" aria-hidden="true" draggable="false"/>
            </button>
            <!-- v1.31.89 — Goal skin removed from top bar per Dustin. Still
                 available in the Setup drawer's skin radio chooser below. -->
            <button type="button" class="pd-skin-bar-btn" data-action="jersey" title="Jersey skin" aria-label="Jersey skin">
              <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/jersey-32.png") || ""}" alt="" aria-hidden="true" draggable="false"/>
            </button>
            <!-- v1.31.80 — 5th symbol links back to the large active ball skin. -->
            <button type="button" class="pd-skin-bar-btn" data-action="ball" title="Soccer ball skin" aria-label="Soccer ball skin">
              <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/soccerball-32.png") || ""}" alt="" aria-hidden="true" draggable="false"/>
            </button>
          </div>
        </div>

        <!-- v1.32.5 — View Tutorial button. Lets agents re-launch the
             onboarding tour anytime from Setup. Auto-show on first
             login is unchanged (gated by pd_onboarding_seen_v1 flag). -->
        <div class="pd-settings-section-title">📖 TUTORIAL</div>
        <div class="pd-settings-hint" style="margin-bottom:8px">
          Quick interactive walkthrough of the dialer panel. Auto-shows
          once on first sign-in; click below to re-watch anytime.
        </div>
        <button class="pd-btn pd-btn-ghost pd-full" id="pd-settings-tutorial" style="background:rgba(93,173,226,0.15); border:1px solid rgba(93,173,226,0.5); color:#5DADE2; font-weight:600;">
          📖 View Tutorial
        </button>

        <div class="pd-settings-section-title">ADVANCED</div>
        <details>
          <summary class="pd-settings-hint" style="cursor:pointer">Paste an existing auth token</summary>
          <input type="text" class="pd-settings-input" id="pd-settings-token" value="${escapeHtml(tok)}" placeholder="tok_xxxxxxxxxxxxxx" autocomplete="off" style="margin-top:8px"/>
          <div class="pd-settings-hint">Found on your dashboard. Used as a fallback if email-code sign-in isn't working.</div>
        </details>

        <!-- v1.31.15-stable — Self-diagnostic cache clear. If the extension
             is misbehaving (won't connect, stuck status, stale device data),
             this wipes everything cached locally — deviceId, settings, last-
             active flags, mic-granted flag — WITHOUT touching the user's
             account on the server. Auth profile is preserved so the user
             doesn't need to sign in again. After clearing, the page reloads
             automatically so content.js boots fresh.
             v1.32.1 — SEND DIAGNOSTICS button moved into this same section
             (was previously rendered on the main dialer card under the
             toggles area, which surfaced it to every agent without context). -->
        <div class="pd-settings-section-title">SELF-DIAGNOSTIC</div>
        <div class="pd-settings-hint" style="margin-bottom:8px">
          If calls aren't connecting or the dialer feels stuck, hit Send
          Diagnostics — it ships a self-collected snapshot (SDK state,
          Telnyx events, JWT credentials) to the support team. No prospect
          or lead data is included.
        </div>
        <a class="pd-btn pd-full" id="pd-settings-spam-help" target="_blank" rel="noopener" style="background:#e63946;color:#fff;font-weight:700;letter-spacing:0.4px;border:1px solid rgba(0,0,0,0.3);box-shadow:0 1px 3px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;gap:6px;text-decoration:none;margin-bottom:8px;" title="Numbers showing 'Spam Likely'? Step-by-step guide: confirm it, register your numbers free, dialing habits that keep numbers clean, and $2 swaps for stubborn ones.">🚨 Suspect spam? Open the fix-it guide</a>
        <button class="pd-btn pd-full" id="pd-settings-send-diagnostics" style="background:#9D6BD3;color:#000;font-weight:700;letter-spacing:0.4px;border:1px solid rgba(0,0,0,0.3);box-shadow:0 1px 3px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;gap:6px;">
          <span aria-hidden="true">🩺</span><span>SEND DIAGNOSTICS</span>
        </button>
        <div class="pd-settings-hint" id="pd-diag-status" style="margin-top:6px; font-size:11px"></div>
        <div class="pd-settings-hint" style="margin:14px 0 6px">
          Or if the dialer is stuck and you just want to start fresh —
          clear the local cache (your sign-in stays put).
        </div>
        <button class="pd-btn pd-btn-ghost pd-full" id="pd-settings-clearcache" style="background:rgba(230,57,70,0.12); border:1px solid rgba(230,57,70,0.35); color:#ff8a92">
          🧹 Clear extension cache + reload
        </button>
        <div class="pd-settings-hint" id="pd-settings-clearcache-hint" style="margin-top:6px; font-size:11px"></div>

        <div class="pd-settings-saved" id="pd-settings-saved">Saved</div>
      </div>
    `;
    document.body.appendChild(wrap);
    requestAnimationFrame(() => wrap.classList.add("pd-open"));
    document.getElementById("pd-settings-close").addEventListener("click", () => toggleSettingsDrawer());

    // Wire field changes
    const flash = () => {
      const el = document.getElementById("pd-settings-saved");
      if (!el) return;
      el.classList.add("pd-show");
      clearTimeout(flash._t);
      flash._t = setTimeout(() => el.classList.remove("pd-show"), 1100);
    };
    const persistField = (key, value) => {
      const profiles = state.profiles || {};
      profiles[state.activeProfile] = { ...(profiles[state.activeProfile] || {}), [key]: value };
      state.profiles = profiles;
      try {
        chrome.storage.local.set({
          pd_profiles: profiles,
          // Mirror to pd_account for the popup's storage layout
          pd_account: { ...(profiles[state.activeProfile]) },
        });
      } catch {}
      flash();
    };

    document.getElementById("pd-settings-cell").addEventListener("input", (e) => {
      persistField("agentCell", e.target.value.trim());
    });
    document.getElementById("pd-settings-cell").addEventListener("blur", () => syncCellAndVmDropToServer());
    // v1.34 — Old single Voicemail Drop URL field removed; per-lead-type
    // recordings live in the dashboard Voicemail Studio now.

    // v1.33.8 — Number Chooser. Checkbox change → build the desired benched
    // list (all unchecked), guard ≥1 stays on, POST /api/me/disabled-numbers,
    // then mirror the saved state locally + to storage so re-opening the
    // drawer and the next /api/me agree. Reverts the toggle on any failure.
    (function wireMyNumbers() {
      const listEl = document.getElementById("pd-mynumbers-list");
      if (!listEl) return;
      const statusEl = document.getElementById("pd-mynumbers-status");
      const setStatus = (t, err) => {
        if (!statusEl) return;
        statusEl.textContent = t || "";
        statusEl.style.color = err ? "#e6892b" : "rgba(255,255,255,0.55)";
      };
      listEl.querySelectorAll(".pd-mynum-cb").forEach((cb) => {
        cb.addEventListener("change", async () => {
          const boxes = [...listEl.querySelectorAll(".pd-mynum-cb")];
          if (boxes.filter((b) => b.checked).length < 1) {
            cb.checked = true;   // never allow zero enabled
            setStatus("At least one number must stay on.", true);
            return;
          }
          const disabled = boxes.filter((b) => !b.checked)
            .map((b) => b.getAttribute("data-e164")).filter(Boolean);
          const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
          const tok = (activeProfile().authToken || "").trim();
          boxes.forEach((b) => { b.disabled = true; });
          setStatus("Saving…");
          try {
            const r = await fetch(base + "/api/me/disabled-numbers", {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({ token: tok, disabled }),
            });
            const j = await r.json().catch(() => ({}));
            if (!r.ok || !j.ok) throw new Error(j.error || ("HTTP " + r.status));
            const benched = new Set((j.disabledNumbers || disabled).map(String));
            state.myNumbers = (state.myNumbers || []).map((n) => ({ ...n, disabled: benched.has(String(n.e164)) }));
            try { chrome.storage.local.set({ pd_my_numbers: state.myNumbers }); } catch {}
            const offN = benched.size;
            setStatus(offN ? (offN + " benched · " + (boxes.length - offN) + " active for outbound") : "All numbers active");
          } catch (err) {
            cb.checked = !cb.checked;   // revert on failure
            setStatus("Couldn't save: " + (err.message || err), true);
          } finally {
            boxes.forEach((b) => { b.disabled = false; });
          }
        });
      });
    })();

    // v1.31.89 — Device picker wiring. Only present in DOM when
    // isMultiDeviceEnv() returns true. Nickname change → persist to
    // chrome.storage + POST /api/devices/register. Picker change →
    // persist "primary device" choice (just storage for now; future
    // worker integration to route inbound calls to chosen device).
    const nickInput = document.getElementById("pd-device-nickname");
    if (nickInput) {
      const commitNick = async () => {
        const v = nickInput.value.trim();
        state.deviceNickname = v;
        try { await chrome.storage.local.set({ pd_device_nickname: v }); } catch {}
        try { document.getElementById("pd-this-device-label").textContent = v || "—"; } catch {}
        registerThisDevice().catch(() => {});
        refreshDevicesDropdown();
      };
      nickInput.addEventListener("change", commitNick);
      nickInput.addEventListener("blur", commitNick);
    }
    const picker = document.getElementById("pd-device-picker");
    if (picker) {
      picker.addEventListener("change", async () => {
        const chosen = picker.value || "";
        state.primaryDeviceId = chosen;
        try { await chrome.storage.local.set({ pd_primary_device: chosen }); } catch {}
        toast(`Primary device set: ${picker.options[picker.selectedIndex]?.textContent || chosen}`, { ms: 2500 });
      });
      // Auto-load devices list when the drawer opens.
      refreshDevicesDropdown();
    }

    // v1.29.50 — Hold music picker. Fetches the curated preset catalog
    // from /api/public/settings; ▲/▼ PREVIEW the selection (no save),
    // and ✓ Choose commits — persists the slug locally and pushes it to
    // the server. The mobile page's fetchDialFillUrl is now cache-free
    // so the next prospect.dialing picks up the new station with no
    // page refresh required.
    (function wireInPanelHoldMusicPicker() {
      const displayEl = document.getElementById("pd-settings-hm-display-text");
      const upBtn     = document.getElementById("pd-settings-hm-up");
      const dnBtn     = document.getElementById("pd-settings-hm-down");
      const chooseBtn = document.getElementById("pd-settings-hm-choose");
      const playBtn   = document.getElementById("pd-settings-hm-audition");
      const stopBtn   = document.getElementById("pd-settings-hm-audition-stop");
      const audioEl   = document.getElementById("pd-settings-hm-audition-audio");
      const statusEl  = document.getElementById("pd-settings-hm-audition-status");
      if (!displayEl || !upBtn || !dnBtn) return;

      let presets     = [];
      let savedSlug   = (profile.holdMusicPreset || "off");  // last committed
      let currentSlug = savedSlug;                            // currently shown (may be unsaved preview)

      const fallbackOff = () => [{ slug: "off", emoji: "🔇", label: "Off (silence)", url: "" }];
      const render = () => {
        if (!presets.length) { displayEl.textContent = "Loading…"; return; }
        const p = presets.find((x) => x.slug === currentSlug) || presets[0];
        currentSlug = p.slug;
        displayEl.textContent = `${p.emoji || ""} ${p.label || p.slug}`.trim();
        // Visual hint that there's an unsaved change: subtle outline on
        // the display + Choose button highlighted. Reset when saved.
        const unsaved = currentSlug !== savedSlug;
        const displayWrap = document.getElementById("pd-settings-hm-display");
        if (displayWrap) {
          displayWrap.style.borderColor = unsaved ? "rgba(255,200,80,0.75)" : "rgba(255,255,255,0.12)";
        }
        if (chooseBtn) {
          chooseBtn.disabled = !unsaved;
          chooseBtn.style.opacity = unsaved ? "1" : "0.55";
        }
      };
      const stopAudition = () => {
        if (audioEl) { try { audioEl.pause(); audioEl.currentTime = 0; } catch (_e) {} }
        if (playBtn) playBtn.style.display = "";
        if (stopBtn) stopBtn.style.display = "none";
        if (statusEl) statusEl.textContent = "";
      };
      const commitChoice = async () => {
        if (currentSlug === savedSlug) return;
        savedSlug = currentSlug;
        persistField("holdMusicPreset", currentSlug);
        // Push to server. Same pattern as the live-view toggle.
        const tok = (activeProfile().authToken || "").trim();
        if (!tok || !state.workerBase) { render(); return; }
        try {
          const base = state.workerBase.replace(/\/+$/, "");
          await fetch(base + "/api/me/update", {
            method: "POST",
            headers: { "content-type": "application/json" },
            body: JSON.stringify({ token: tok, holdMusicPreset: currentSlug }),
          });
          flash();
          if (statusEl) {
            const p = presets.find((x) => x.slug === currentSlug);
            statusEl.textContent = `Saved · ${p ? (p.emoji + " " + p.label) : currentSlug}`;
            statusEl.style.color = "";
            // Clear status after a few seconds so it doesn't pile up.
            clearTimeout(commitChoice._t);
            commitChoice._t = setTimeout(() => { if (statusEl) statusEl.textContent = ""; }, 3000);
          }
        } catch (err) {
          if (statusEl) { statusEl.textContent = "Save failed — retry"; statusEl.style.color = "#ff8a92"; }
        }
        render();
      };
      const cycle = (delta) => {
        if (!presets.length) return;
        const i = Math.max(0, presets.findIndex((p) => p.slug === currentSlug));
        const n = presets.length;
        currentSlug = presets[(i + delta + n) % n].slug;
        render();
        stopAudition();
        // No auto-save — agent must press ✓ Choose to commit.
      };

      upBtn.addEventListener("click", () => cycle(-1));
      dnBtn.addEventListener("click", () => cycle(+1));
      if (chooseBtn) chooseBtn.addEventListener("click", commitChoice);
      if (playBtn && audioEl) {
        playBtn.addEventListener("click", () => {
          const p = presets.find((x) => x.slug === currentSlug);
          const url = p && p.url;
          if (!url) {
            if (statusEl) statusEl.textContent = "Nothing to play (Off)";
            return;
          }
          try {
            audioEl.src = url;
            audioEl.load();
            const pr = audioEl.play();
            if (pr && typeof pr.then === "function") {
              pr.then(() => {
                playBtn.style.display = "none";
                stopBtn.style.display = "";
                if (statusEl) { statusEl.textContent = "Playing…"; statusEl.style.color = ""; }
              }).catch((err) => {
                stopAudition();
                if (statusEl) { statusEl.textContent = "Couldn't play: " + (err?.message || "blocked"); statusEl.style.color = "#ff8a92"; }
              });
            }
          } catch (_e) { stopAudition(); }
        });
      }
      if (stopBtn) stopBtn.addEventListener("click", stopAudition);
      if (audioEl) {
        audioEl.addEventListener("ended", stopAudition);
        audioEl.addEventListener("error", () => {
          stopAudition();
          if (statusEl) { statusEl.textContent = "Stream failed — try another station"; statusEl.style.color = "#ff8a92"; }
        });
      }

      // Kick off catalog fetch — runs alongside the rest of the drawer init.
      (async () => {
        try {
          const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
          const r = await fetch(base + "/api/public/settings", { cache: "no-store" });
          const j = await r.json().catch(() => ({}));
          const list = (j && j.settings && j.settings.holdMusicPresets) || [];
          presets = (Array.isArray(list) && list.length) ? list : fallbackOff();
        } catch (_e) {
          presets = fallbackOff();
        }
        if (!presets.find((p) => p.slug === currentSlug)) currentSlug = "off";
        render();
      })();
    })();
    // Live-view link opt-out toggle. Stored on the user record so the
    // preference follows the agent across machines. POST /api/me/update
    // with the boolean.
    const linkToggle = document.getElementById("pd-settings-sms");
    if (linkToggle) {
      linkToggle.addEventListener("change", () => {
        const disabled = !linkToggle.checked; // checked = email enabled
        state._liveViewLinkDisabled = disabled;
        const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
        if (!tok) { toast("Sign in first, then toggle this."); return; }
        const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
        fetch(base + "/api/me/update", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ token: tok, liveViewLinkDisabled: disabled }),
        }).then((r) => {
          if (r.ok) {
            flash();
            toast(disabled
              ? "Live-view email disabled — you won't get a link emailed on session start."
              : "Live-view email enabled — your inbox will get a tap-to-open link on session start.");
          } else {
            toast("Couldn't save live-view preference (HTTP " + r.status + ").");
          }
        }).catch((err) => {
          toast("Couldn't save live-view preference: " + (err?.message || err));
        });
      });
    }
    document.getElementById("pd-settings-worker").addEventListener("input", (e) => {
      const v = e.target.value.trim();
      state.workerBase = v;
      try { chrome.storage.local.set({ pd_worker_base: v }); } catch {}
      flash();
    });
    document.getElementById("pd-settings-token").addEventListener("input", (e) => {
      persistField("authToken", e.target.value.trim());
    });
    // v1.29.6 — Dial-mode radio handlers removed along with the radios.
    // Mode selection (Mobile App vs Desktop) is via the mode-row pills in
    // the panel; the Desktop WebRTC offscreen doc is enabled automatically
    // when the user picks Desktop in those pills.

    document.querySelectorAll('input[name="pd-skin"]').forEach(r => {
      r.addEventListener("change", () => {
        if (!r.checked) return;
        state.skin = (r.value === "goal") ? "goal" : "phone";
        try { chrome.storage.local.set({ pd_skin: state.skin }); } catch {}
        // Restyle the live panel
        const root = document.getElementById("pd-root");
        if (root) {
          root.classList.remove("pd-skin-phone", "pd-skin-goal", "pd-skin-ball", "pd-skin-jersey", "pd-skin-medium", "pd-skin-soccer-medium");
          root.classList.add("pd-skin-" + state.skin);
        }
        // v1.31.73 — Load the new skin's saved scale + pos
        reloadGeometryForCurrentSkin();
        // Update selected styling on the radio cards
        document.querySelectorAll(".pd-settings-skin-opt").forEach(el =>
          el.classList.toggle("selected", el.querySelector("input").value === state.skin));
        flash();
      });
    });
    document.getElementById("pd-settings-signout")?.addEventListener("click", async () => {
      if (!confirm("Sign out? You'll need your email to sign back in.")) return;
      const profiles = state.profiles || {};
      profiles[state.activeProfile] = { authToken: "", email: "", name: "", agentCell: "", vmDropUrl: "" };
      state.profiles = profiles;
      try {
        chrome.storage.local.set({
          pd_profiles: profiles,
          pd_account: { authToken: "", email: "", name: "", agentCell: "", vmDropUrl: "" },
        });
      } catch {}
      // Re-render the drawer in signed-out mode rather than closing it
      renderSettingsDrawer();
      paint();
      toast("Signed out");
    });

    // v1.32.1 — Send Diagnostics button (moved into Settings drawer from
    // the toggles area). Fires __pdSendDiagnostics which asks the offscreen
    // sandbox to collect its SDK / Telnyx / JWT state and POST it to
    // /api/diag/submit. Updates the inline status text so the agent sees
    // confirmation. See project_self_diagnostics memory for payload details.
    // v1.33.5 — Suspect Spam guide link. href set at render time from the
    // configured worker base so staging opens staging's copy.
    (() => {
      const a = document.getElementById("pd-settings-spam-help");
      if (a) a.href = (state.workerBase || "https://app.goalpowerdialer.com") + "/spam-help";
    })();
    document.getElementById("pd-settings-send-diagnostics")?.addEventListener("click", async (e) => {
      e.preventDefault();
      const btn = document.getElementById("pd-settings-send-diagnostics");
      const statusEl = document.getElementById("pd-diag-status");
      const updateStatus = (txt, color) => {
        if (statusEl) {
          statusEl.textContent = txt;
          statusEl.style.color = color || "";
        }
      };
      if (btn) btn.disabled = true;
      updateStatus("Collecting…", "");
      try {
        const r = await __pdSendDiagnostics({ reason: "manual:settings-drawer" });
        if (r && r.ok) {
          updateStatus("✓ Sent (key: " + (r.key ? r.key.slice(-10) : "?") + ")", "#7fd99c");
          toast("Diagnostics sent — thank you", { ms: 3500 });
        } else {
          const why = r && (r.reason || r.body?.error || ("HTTP " + r.status)) || "unknown";
          updateStatus("⚠ Failed: " + why, "#ff8a92");
          toast("Send failed: " + why, { ms: 4500 });
        }
      } catch (err) {
        updateStatus("⚠ Error: " + (err?.message || err), "#ff8a92");
        toast("Diagnostics error: " + (err?.message || err), { ms: 4500 });
      } finally {
        if (btn) btn.disabled = false;
      }
    });

    // v1.32.5 — View Tutorial button. Re-launches the onboarding tour
    // on demand. Doesn't reset the seen-flag, so the auto-show on first
    // login still works correctly for new agents.
    document.getElementById("pd-settings-tutorial")?.addEventListener("click", () => {
      try { toggleSettingsDrawer(); } catch (_e) {}
      // Small delay so the drawer-close animation finishes before the
      // tour starts positioning its spotlight against the dialer panel.
      setTimeout(() => {
        try { startOnboarding({ force: true }); } catch (_e) {}
      }, 320);
    });

    // v1.31.15-stable — Self-diagnostic cache clear. Wipes everything in
    // chrome.storage EXCEPT the user's sign-in (pd_profiles + pd_active_profile).
    // After clearing, reloads the Impact CRM tab so content.js boots fresh.
    document.getElementById("pd-settings-clearcache")?.addEventListener("click", async () => {
      const hint = document.getElementById("pd-settings-clearcache-hint");
      const btn  = document.getElementById("pd-settings-clearcache");
      if (!confirm(
        "Clear extension cache?\n\n" +
        "This wipes cached device ID, settings, mic-granted flag, mode preference, and other local state.\n\n" +
        "Your sign-in is preserved — you stay logged in.\n\n" +
        "The page will reload automatically after.\n\nProceed?"
      )) return;
      if (btn) { btn.disabled = true; btn.textContent = "🧹 Clearing…"; }
      if (hint) hint.textContent = "";
      try {
        // Step 1 — snapshot the keys we want to keep (auth profile + active selector)
        const keep = await new Promise((r) => {
          try {
            chrome.storage.local.get(["pd_profiles", "pd_active_profile", "pd_account"], (d) => r(d || {}));
          } catch { r({}); }
        });
        // Step 2 — nuke everything in chrome.storage.local
        await new Promise((r) => {
          try { chrome.storage.local.clear(() => r()); } catch { r(); }
        });
        // Step 3 — restore the auth-related keys
        await new Promise((r) => {
          try { chrome.storage.local.set(keep, () => r()); } catch { r(); }
        });
        // Step 4 — clear any session storage on this tab
        try { sessionStorage.clear(); } catch {}
        // Step 5 — close the WS so the next boot opens cleanly
        try { if (state.ws) state.ws.close(); } catch {}
        try { disconnectAudioWs(); } catch {}
        if (hint) hint.textContent = "✓ Cache cleared. Reloading in 1.5s…";
        // Step 6 — reload the tab fresh after a brief moment
        setTimeout(() => {
          try { window.location.reload(); } catch {}
        }, 1500);
      } catch (err) {
        if (hint) hint.textContent = "Couldn't clear cache: " + err.message;
        if (btn) { btn.disabled = false; btn.textContent = "🧹 Clear extension cache + reload"; }
      }
    });

    // ── Email-code sign-in flow (shown when no token cached) ──
    let pendingEmail = "";
    const setMsg = (id, text, kind) => {
      const el = document.getElementById(id);
      if (!el) return;
      el.textContent = text || "";
      el.classList.remove("ok", "err");
      if (kind) el.classList.add(kind);
    };
    const showCodeStep = (email) => {
      pendingEmail = email;
      const t = document.getElementById("pd-code-target");
      if (t) t.textContent = email;
      document.getElementById("pd-signin-step-email").style.display = "none";
      document.getElementById("pd-signin-step-code").style.display = "block";
      setTimeout(() => document.getElementById("pd-signin-code")?.focus(), 50);
    };
    const showEmailStep = () => {
      pendingEmail = "";
      const c = document.getElementById("pd-signin-code");
      if (c) c.value = "";
      document.getElementById("pd-signin-step-email").style.display = "block";
      document.getElementById("pd-signin-step-code").style.display = "none";
      setMsg("pd-verify-msg", "");
      setTimeout(() => document.getElementById("pd-signin-email")?.focus(), 50);
    };

    const sendCode = async (email) => {
      if (!email || !email.includes("@")) {
        setMsg("pd-signin-msg", "Enter a valid email address.", "err");
        return false;
      }
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      setMsg("pd-signin-msg", "Sending code…");
      try {
        const res = await fetch(base + "/api/auth/code/start", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ email }),
        });
        const j = await res.json().catch(() => ({}));
        if (!res.ok || !j.ok) {
          setMsg("pd-signin-msg", j.error || `Couldn't send (HTTP ${res.status})`, "err");
          return false;
        }
        setMsg("pd-signin-msg", "Code sent — check your email.", "ok");
        return true;
      } catch (err) {
        setMsg("pd-signin-msg", "Network error: " + (err.message || "couldn't reach worker"), "err");
        return false;
      }
    };

    // v1.33.4 — Phone qualifier added. Server (handleLoginCodeVerify)
    // already accepted phone since Phase 2.8; this just lights it up on
    // the ext UI side. Without phone the agent signs in but multi-device
    // routing has no phone scope, so calls don't route to their devices.
    const verifyCode = async (email, code, phone) => {
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      setMsg("pd-verify-msg", "Verifying…");
      try {
        const res = await fetch(base + "/api/auth/code/verify", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ email, code, phone: phone || "" }),
        });
        const j = await res.json().catch(() => ({}));
        if (!res.ok || !j.ok) {
          setMsg("pd-verify-msg", j.error || `Couldn't verify (HTTP ${res.status})`, "err");
          return false;
        }
        const u = j.user;
        const profiles = state.profiles || {};
        profiles[state.activeProfile] = {
          authToken: u.token || "",
          email:     u.email || "",
          name:      u.name  || "",
          // v1.33.4 — Persist the server-normalized phone identifier so
          // session.start can scope WS routing + DO selection by phone.
          phone:     u.phone || "",
          agentCell: u.agentCell || "",
          vmDropUrl: u.vmDropUrl || "",
        };
        state.profiles = profiles;
        try {
          chrome.storage.local.set({
            pd_profiles: profiles,
            pd_account:  { ...profiles[state.activeProfile] },
          });
        } catch {}
        toast("Signed in as " + u.email);
        // Re-render drawer in signed-in mode + repaint dialer
        renderSettingsDrawer();
        paint();
        return true;
      } catch (err) {
        setMsg("pd-verify-msg", "Network error: " + (err.message || "couldn't reach worker"), "err");
        return false;
      }
    };

    document.getElementById("pd-send-code")?.addEventListener("click", async () => {
      const email = (document.getElementById("pd-signin-email").value || "").trim().toLowerCase();
      const ok = await sendCode(email);
      if (ok) showCodeStep(email);
    });
    document.getElementById("pd-signin-email")?.addEventListener("keydown", (e) => {
      if (e.key === "Enter") document.getElementById("pd-send-code").click();
    });
    document.getElementById("pd-verify-code")?.addEventListener("click", async () => {
      const code = (document.getElementById("pd-signin-code").value || "").trim();
      if (!/^\d{6}$/.test(code)) {
        setMsg("pd-verify-msg", "Enter the 6-digit code from your email.", "err");
        return;
      }
      // v1.33.4 — Read phone identifier added to the Email-code tab. Soft-
      // block if blank so the agent confirms they really want to sign in
      // without it (would leave multi-device routing un-attributed).
      const phone = (document.getElementById("pd-signin-code-phone")?.value || "").trim();
      if (!phone) {
        const ok = window.confirm("Phone number is blank. Without it, this device won't route to your specific phone for calls. Sign in anyway?");
        if (!ok) return;
      }
      await verifyCode(pendingEmail, code, phone);
    });
    document.getElementById("pd-signin-code")?.addEventListener("input", () => {
      const el = document.getElementById("pd-signin-code");
      const v = el.value.replace(/\D/g, "").slice(0, 6);
      el.value = v;
      // v1.33.4 — Only auto-submit on 6-digit fill when phone is present.
      // Otherwise the soft-block confirm() dialog would surprise the agent.
      const phoneVal = (document.getElementById("pd-signin-code-phone")?.value || "").trim();
      if (v.length === 6 && phoneVal) setTimeout(() => document.getElementById("pd-verify-code").click(), 50);
    });
    document.getElementById("pd-resend-code")?.addEventListener("click", async () => {
      if (!pendingEmail) return showEmailStep();
      setMsg("pd-verify-msg", "Resending…");
      const ok = await sendCode(pendingEmail);
      if (ok) setMsg("pd-verify-msg", "New code sent.", "ok");
    });
    document.getElementById("pd-change-email")?.addEventListener("click", showEmailStep);

    // ── v1.30.3 — Dual-login tab toggle + password flow ─────────────
    // Matches popup/login/admin: Password tab default, Email-code tab
    // secondary. "Forgot password" link drops back to code flow.
    const setMethod = (name) => {
      const tabPw   = document.getElementById("pd-tab-pw");
      const tabCode = document.getElementById("pd-tab-code");
      const formPw  = document.getElementById("pd-signin-form-pw");
      const stepEm  = document.getElementById("pd-signin-step-email");
      const stepCd  = document.getElementById("pd-signin-step-code");
      const baseStyle = "flex:1;padding:7px 10px;border:0;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;";
      const activeStyle = "background:rgba(255,255,255,0.08);color:#fff;";
      const idleStyle   = "background:transparent;color:rgba(255,255,255,0.6);";
      if (name === "password") {
        if (tabPw)   tabPw.style.cssText   = baseStyle + activeStyle;
        if (tabCode) tabCode.style.cssText = baseStyle + idleStyle;
        if (formPw) formPw.style.display = "";
        if (stepEm) stepEm.style.display = "none";
        if (stepCd) stepCd.style.display = "none";
        setTimeout(() => document.getElementById("pd-signin-pw-email")?.focus(), 50);
      } else {
        if (tabPw)   tabPw.style.cssText   = baseStyle + idleStyle;
        if (tabCode) tabCode.style.cssText = baseStyle + activeStyle;
        if (formPw) formPw.style.display = "none";
        if (stepEm) stepEm.style.display = "";
        if (stepCd) stepCd.style.display = "none";
        setTimeout(() => document.getElementById("pd-signin-email")?.focus(), 50);
      }
      try { localStorage.setItem("gpd_settings_login_method", name); } catch {}
    };
    // v1.33.8 — Password is the only sign-in method here. "Forgot / set
    // password" reveals a reset-link panel (POST /api/auth/forgot-password),
    // matching /login + /dashboard. Email-code UI + auto-restore removed.
    const _showForgot = (show) => {
      const formPw = document.getElementById("pd-signin-form-pw");
      const panel  = document.getElementById("pd-signin-forgot");
      if (!formPw || !panel) return;
      if (show) {
        const v = (document.getElementById("pd-signin-pw-email")?.value || "").trim();
        const fe = document.getElementById("pd-forgot-email");
        if (v && fe && !fe.value) fe.value = v;
        formPw.style.display = "none";
        panel.style.display = "block";
        setTimeout(() => document.getElementById("pd-forgot-email")?.focus(), 50);
      } else {
        panel.style.display = "none";
        formPw.style.display = "";
        setTimeout(() => document.getElementById("pd-signin-pw-email")?.focus(), 50);
      }
    };
    document.getElementById("pd-forgot-pw")?.addEventListener("click", (e) => { e.preventDefault(); _showForgot(true); });
    document.getElementById("pd-forgot-back")?.addEventListener("click", () => _showForgot(false));
    document.getElementById("pd-forgot-send")?.addEventListener("click", async () => {
      const email = (document.getElementById("pd-forgot-email").value || "").trim().toLowerCase();
      if (!email || !email.includes("@")) { setMsg("pd-forgot-msg", "Enter a valid email address.", "err"); return; }
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      setMsg("pd-forgot-msg", "Sending…");
      try {
        const res = await fetch(base + "/api/auth/forgot-password", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ email }),
        });
        const j = await res.json().catch(() => ({}));
        if (res.ok && j.ok) {
          setMsg("pd-forgot-msg", (j.message || "If that email is on file, a reset link is on its way.") + " Check your inbox.", "ok");
        } else {
          setMsg("pd-forgot-msg", j.error || `Couldn't send reset link (HTTP ${res.status})`, "err");
        }
      } catch (err) {
        setMsg("pd-forgot-msg", "Network error: " + (err.message || "couldn't reach worker"), "err");
      }
    });

    // Password sign-in. Calls /api/auth/login, then reuses verifyCode's
    // post-signin handoff (cache profile + repaint).
    const signInWithPassword = async (email, password, phone) => {
      if (!email || !email.includes("@")) {
        setMsg("pd-signin-pw-msg", "Enter a valid email.", "err");
        return false;
      }
      if (!password) {
        setMsg("pd-signin-pw-msg", "Enter your password.", "err");
        return false;
      }
      // v1.33 Phase 2 — Phone identifier required. Routes this ext's
      // WS to phone_<digits> DO; mobile companion that signed in with
      // the same phone lands in the same DO. Separate from Agent Cell
      // (which is the PSTN callback # configured below).
      if (!phone || String(phone).replace(/[^\d]/g, "").length < 10) {
        setMsg("pd-signin-pw-msg", "Enter your Phone identifier (10+ digits — this is the WS routing key, separate from Agent Cell).", "err");
        return false;
      }
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      setMsg("pd-signin-pw-msg", "Signing in…");
      try {
        const res = await fetch(base + "/api/auth/login", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ email, password, phone }),
        });
        const j = await res.json().catch(() => ({}));
        if (!res.ok || !j.ok) {
          let m = j.error || `Wrong email or password (HTTP ${res.status})`;
          if (/password/i.test(m) && /not set|magic-link|don't have/i.test(m)) {
            m = "No password on file. Use Email code, then set a password from your dashboard.";
          }
          setMsg("pd-signin-pw-msg", m, "err");
          return false;
        }
        const u = j.user;
        const profiles = state.profiles || {};
        profiles[state.activeProfile] = {
          authToken: u.token || "",
          email:     u.email || "",
          name:      u.name  || "",
          agentCell: u.agentCell || "",
          // v1.33 Phase 2 — Phone identifier persisted alongside cell.
          // tokenQueryString() appends ?phone=X to WS URLs from this.
          phone:     u.phone || "",
          vmDropUrl: u.vmDropUrl || "",
        };
        state.profiles = profiles;
        try {
          chrome.storage.local.set({
            pd_profiles: profiles,
            pd_account:  { ...profiles[state.activeProfile] },
          });
        } catch {}
        toast("Signed in as " + u.email);
        renderSettingsDrawer();
        paint();
        return true;
      } catch (err) {
        setMsg("pd-signin-pw-msg", "Network error: " + (err.message || "couldn't reach worker"), "err");
        return false;
      }
    };
    document.getElementById("pd-signin-form-pw")?.addEventListener("submit", (e) => {
      e.preventDefault();
      const email    = (document.getElementById("pd-signin-pw-email").value || "").trim().toLowerCase();
      const password = document.getElementById("pd-signin-pw-password").value || "";
      const phone    = (document.getElementById("pd-signin-pw-phone")?.value || "").trim();
      signInWithPassword(email, password, phone);
    });

    // Populate the live account card by hitting /api/me with the saved token.
    // Falls back to the production URL if state.workerBase hasn't been written
    // to chrome.storage yet (first install scenario).
    if (tok) {
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      // If state.workerBase was empty, set it now so other code paths work too.
      if (!state.workerBase) {
        state.workerBase = base;
        try { chrome.storage.local.set({ pd_worker_base: base }); } catch {}
      }
      try {
        const url = base + "/api/me?token=" + encodeURIComponent(tok);
        const res = await fetch(url, { cache: "no-store" });
        const j = await res.json().catch(() => ({}));
        if (res.ok && j.ok && j.user) {
          const u = j.user;
          document.getElementById("pd-settings-status").textContent = "Signed in";
          document.getElementById("pd-settings-email").textContent = u.email || "—";
          document.getElementById("pd-settings-name").textContent = u.name || "(no name on file)";
          document.getElementById("pd-settings-balance").textContent = "$" + ((u.balanceCents | 0) / 100).toFixed(2);
          const activated = u.setupFeePaid || u.setupFeeWaived;
          document.getElementById("pd-settings-activation").textContent = activated
            ? (u.setupFeeWaived ? "✓ Waived" : "✓ Paid")
            : "Owes setup fee";
          // Sync the live-view-email toggle from the server. Both new
          // (liveViewLinkDisabled) and legacy (smsLinkDisabled) field
          // names checked for backward compat with v1.17.1.
          const linkDisabled = !!(u.liveViewLinkDisabled || u.smsLinkDisabled);
          state._liveViewLinkDisabled = linkDisabled;
          const linkCb = document.getElementById("pd-settings-sms");
          if (linkCb) linkCb.checked = !linkDisabled;
          // Refresh cached profile from server so subsequent re-renders have
          // the live email / name / cell / vmDrop without another fetch.
          const profiles = state.profiles || {};
          profiles[state.activeProfile] = {
            ...(profiles[state.activeProfile] || {}),
            authToken: tok,
            email:     u.email     || profiles[state.activeProfile]?.email     || "",
            name:      u.name      || profiles[state.activeProfile]?.name      || "",
            agentCell: u.agentCell || profiles[state.activeProfile]?.agentCell || "",
            vmDropUrl: u.vmDropUrl || profiles[state.activeProfile]?.vmDropUrl || "",
          };
          state.profiles = profiles;
          try {
            chrome.storage.local.set({
              pd_profiles: profiles,
              pd_account:  { ...profiles[state.activeProfile] },
            });
          } catch {}
        } else {
          document.getElementById("pd-settings-status").textContent = "Token invalid — sign in again";
        }
      } catch (err) {
        document.getElementById("pd-settings-status").textContent = "Couldn't reach server";
      }
    } else {
      document.getElementById("pd-settings-status").textContent = "Not signed in";
    }
  }

  // Push agent cell + VM drop changes from the drawer up to the user's
  // server record so they follow them across machines (same behavior as
  // the toolbar popup).
  async function syncCellAndVmDropToServer() {
    try {
      const p = activeProfile();
      const tok = (p.authToken || "").trim();
      if (!tok || !state.workerBase) return;
      const base = state.workerBase.replace(/\/+$/, "");
      await fetch(base + "/api/me/update", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          token: tok,
          agentCell: p.agentCell || "",
          vmDropUrl: p.vmDropUrl || "",
          // v1.29.50 — Also re-assert the hold-music slug on cell/vmdrop
          // blur in case it got stale. Slug-only — server validates.
          holdMusicPreset: p.holdMusicPreset || "off",
        }),
      });
    } catch {}
  }

  // ─────────────────────────────────────────────────────────────────
  // Mid-session History panel — list of every call from the active
  // session, each tagged with a coloured result chip. Slides in from
  // the left edge, mirror to the summary slide-out.
  // ─────────────────────────────────────────────────────────────────
  function toggleHistoryPanel() {
    const existing = document.getElementById("pd-history-panel");
    if (existing) {
      existing.classList.remove("pd-open");
      setTimeout(() => existing.remove(), 350);
      return;
    }
    renderHistoryPanel();
  }

  function renderHistoryPanel() {
    document.getElementById("pd-history-panel")?.remove();
    const wrap = document.createElement("div");
    wrap.id = "pd-history-panel";
    wrap.className = "pd-history-panel";

    const log = [...(state.callLog || [])].reverse(); // most recent first
    const skipped = state.skippedLeads || [];
    const rows = log.length
      ? log.map(e => historyRowHtml(e)).join("")
      : `<div class="pd-history-empty">No calls yet this session.</div>`;
    const skippedRows = skipped.length
      ? skipped.slice().reverse().map(s => `
          <div class="pd-history-row pd-history-row-skip">
            <div class="pd-history-name">${escapeHtml(s.leadName || "(unknown)")}</div>
            <div class="pd-history-meta">${escapeHtml(s.reason || "skipped")} · ${escapeHtml(formatClock(s.time))}</div>
          </div>`).join("")
      : "";

    // Build a deep-link to the dashboard's full Sessions list, pre-authed
    // with the user's dashboardKey so the agent doesn't have to re-sign-in.
    // Falls back to the basic /dashboard URL if we don't have a key cached.
    const profile = activeProfile();
    const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
    const tokenQs = profile.authToken ? "?token=" + encodeURIComponent(profile.authToken) : "";
    const fullHistoryUrl = base + "/dashboard" + tokenQs + "#sessions";

    wrap.innerHTML = `
      <div class="pd-history-head">
        <span>📋 Session history (${log.length})</span>
        <button class="pd-iconbtn" id="pd-history-close" title="Close">×</button>
      </div>
      <div class="pd-history-body">
        ${rows}
        ${skipped.length ? `<div class="pd-history-section">Skipped leads (${skipped.length})</div>${skippedRows}` : ""}
        <a href="${fullHistoryUrl}" target="_blank" rel="noopener" class="pd-history-fulllink"
           title="Opens your dashboard in a new tab — full session list with date filters, costs, and ratios">
          ↗ Full history (today · week · month · quarter · annual)
        </a>
      </div>
    `;
    document.body.appendChild(wrap);
    requestAnimationFrame(() => wrap.classList.add("pd-open"));
    document.getElementById("pd-history-close").addEventListener("click", () => toggleHistoryPanel());
  }

  function historyRowHtml(e) {
    const dur = e.durationMs ? formatDur(e.durationMs) : "";
    const name = e.leadName || (e.number ? e.number : "(unknown)");
    const lead = e.leadId ? `Lead #${e.leadId}` : "";
    return `
      <div class="pd-history-row">
        <div class="pd-history-chip pd-history-chip-${e.result}">${resultLabel(e.result)}</div>
        <div class="pd-history-main">
          <div class="pd-history-name">${escapeHtml(name)}</div>
          <div class="pd-history-meta">${escapeHtml(lead)} ${lead && dur ? "·" : ""} ${escapeHtml(dur)} · ${escapeHtml(formatClock(e.endedAt))}</div>
        </div>
      </div>`;
  }

  function resultLabel(r) {
    switch (r) {
      case "human":     return "Human";
      case "voicemail": return "Voicemail";
      case "no_answer": return "No Answer";
      case "ended":     return "Ended";
      case "skip":      return "Skipped";
      default: return r ? r[0].toUpperCase() + r.slice(1) : "—";
    }
  }
  function formatClock(t) {
    if (!t) return "";
    const d = new Date(t);
    return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  }
  function formatDur(ms) {
    const s = Math.max(0, Math.floor(ms / 1000));
    if (s < 60) return s + "s";
    const m = Math.floor(s / 60), r = s % 60;
    return `${m}m ${String(r).padStart(2,"0")}s`;
  }

  // ─────────────────────────────────────────────────────────────────
  // Jersey-shaped stats dropdown — slides DOWN from the top bar with
  // today's totals on a soccer-jersey SVG. Triggered by the ▼ icon in
  // the panel header, or auto-shown when End Session fires.
  // ─────────────────────────────────────────────────────────────────
  function toggleJerseyDropdown() {
    const existing = document.getElementById("pd-jersey-drop");
    if (existing) {
      existing.classList.remove("pd-open");
      setTimeout(() => existing.remove(), 350);
      return;
    }
    renderJerseyDropdown();
  }

  // v1.29.54 — If the jersey dropdown is currently mounted, re-render
  // it in place so its numbers stay current with state.counters. Called
  // after every counters update (worker broadcast, local increment, or
  // hard reset). No-op when the dropdown isn't open.
  function refreshJerseyIfOpen() {
    if (document.getElementById("pd-jersey-drop")) {
      try { renderJerseyDropdown(); } catch (_e) {}
    }
  }

  function renderJerseyDropdown() {
    document.getElementById("pd-jersey-drop")?.remove();
    const c = state.counters;
    const startC = state.sessionStartCounters || c;
    const delta = {
      dials:      Math.max(0, c.dials      - (startC.dials      | 0)),
      contacts:   Math.max(0, c.contacts   - (startC.contacts   | 0)),
      voicemails: Math.max(0, c.voicemails - (startC.voicemails | 0)),
      noAnswers:  Math.max(0, c.noAnswers  - (startC.noAnswers  | 0)),
      answered:   Math.max(0, (c.answered | 0) - (startC.answered | 0)),
    };
    // v1.29.70 — Pickup rate = Answered / Dials (humans who picked up
    // out of total dials). Previously used `contacts` which is the same
    // count in steady state, but `answered` is the new canonical field.
    const rate = delta.dials > 0
      ? Math.round((delta.answered / delta.dials) * 100)
      : 0;

    const wrap = document.createElement("div");
    wrap.id = "pd-jersey-drop";
    wrap.className = "pd-jersey-drop";
    wrap.innerHTML = `
      <button class="pd-iconbtn pd-jersey-close" id="pd-jersey-close" title="Close">×</button>
      <svg class="pd-jersey-svg" viewBox="0 0 200 240" aria-hidden="true">
        <defs>
          <linearGradient id="pd-j-shirt" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%"  stop-color="#1d6a3d"/>
            <stop offset="100%" stop-color="#0f3d22"/>
          </linearGradient>
        </defs>
        <path d="M40,30 L78,18 Q100,6 122,18 L160,30 L184,70 L160,90 L154,82 L154,210 Q154,222 142,222 L58,222 Q46,222 46,210 L46,82 L40,90 L16,70 Z"
              fill="url(#pd-j-shirt)" stroke="#0a0a0a" stroke-width="2" stroke-linejoin="round"/>
        <path d="M78,18 Q100,40 122,18" fill="none" stroke="#0a0a0a" stroke-width="2"/>
        <text x="100" y="118" text-anchor="middle" font-size="56" font-weight="800" fill="#fff" font-family="Impact, sans-serif">${delta.dials}</text>
        <text x="100" y="148" text-anchor="middle" font-size="12" font-weight="600" fill="#cfe6d6" letter-spacing="2">DIALS</text>
        <text x="100" y="180" text-anchor="middle" font-size="13" font-weight="700" fill="#fff">${delta.answered} ANSWERED</text>
        <text x="100" y="200" text-anchor="middle" font-size="13" font-weight="700" fill="#fff">${rate}% PICKUP RATE</text>
      </svg>
      <div class="pd-jersey-stats">
        <div class="pd-jersey-row"><span>Answered</span><strong>${delta.answered}</strong></div>
        <div class="pd-jersey-row"><span>No Answer</span><strong>${delta.noAnswers}</strong></div>
        <div class="pd-jersey-row"><span>Voicemails</span><strong>${delta.voicemails}</strong></div>
        <div class="pd-jersey-row pd-jersey-row-foot">Today: ${c.dials} dials · ${c.answered | 0} answered</div>
      </div>
      <button class="pd-jersey-hub-btn" id="pd-jersey-hub" title="Past sessions & lifetime totals">📈 History &amp; all-time</button>
    `;
    // Mount inside the panel root so it positions relative to the dialer.
    const root = document.getElementById("pd-root");
    if (!root) return;
    root.appendChild(wrap);
    requestAnimationFrame(() => wrap.classList.add("pd-open"));
    document.getElementById("pd-jersey-close").addEventListener("click", () => toggleJerseyDropdown());
    // v1.33.9.4 — open the stats hub (8-day history recall + all-time + reset)
    document.getElementById("pd-jersey-hub")?.addEventListener("click", () => { toggleJerseyDropdown(); openStatsHub(); });
  }

  // v1.31.44 — opts.manual defaults to TRUE so the safe default when the
  // function is called from the panel's Mobile/Home buttons is to bypass
  // the worker's same-lead+number guard (agent explicitly clicked). The
  // autopilot path explicitly passes { manual: false } so the guard
  // engages when needed.
  // v1.33.4.1 — NANP area-code validator.
  // Milik (mbowe) reported pre-refund: dialer locks up on bad numbers.
  // Some agencies cloak DNC numbers by replacing the real area code
  // with an invalid prefix (e.g. 000-, 001-, N11 codes), so the agent
  // can SEE the lead in Impact but the dialer can't actually place a
  // call — Telnyx returns 403 D11 / D50 and our worker emits synthetic
  // hangup. Each such failed dial wastes ~2 seconds of autopilot cycle
  // and N agents in a row hitting these slows the whole queue.
  //
  // Cheaper to validate client-side BEFORE we even ask the worker.
  // Sub-millisecond cost. Only catches obvious invalids (NANP rule:
  // first digit 2-9, no N11 service codes). Doesn't false-positive
  // on legitimate 10-digit US/CA numbers. International numbers
  // (any country code other than 1) bypass validation entirely.
  function isValidNANPAreaCode(phoneE164) {
    if (!phoneE164 || typeof phoneE164 !== "string") {
      return { valid: false, reason: "missing-number" };
    }
    const digits = phoneE164.replace(/[^\d]/g, "");
    // Non-NANP (international) — let it through, Telnyx will route or reject
    if (digits.length !== 11 || digits[0] !== "1") {
      return { valid: true, reason: "non-NANP" };
    }
    const ac = digits.slice(1, 4);
    // NANP rule: area code first digit must be 2-9
    if (ac[0] === "0" || ac[0] === "1") {
      return { valid: false, reason: `area code ${ac} invalid (starts with ${ac[0]})` };
    }
    // NANP rule: N11 codes are service codes, not area codes
    if (/^[2-9]11$/.test(ac)) {
      return { valid: false, reason: `area code ${ac} is a service code (N11)` };
    }
    return { valid: true, reason: null };
  }

  function dial(label, opts) {
    if (state.session !== "parked") { toast("Start a session first"); return; }
    if (state.prospect) { toast("Already on a call — Skip first"); return; }
    const a = CRM.phoneLink(label);
    const num = CRM.numberFromLink(a);
    if (!num) { toast(`No ${label} link on this page`); return; }
    // v1.33.4.1 — Area-code gate. Reject the dial BEFORE asking the
    // worker so we don't burn a Telnyx round-trip on a guaranteed 403.
    // Failed dials still don't cost money (v1.33.3.2's synthetic path
    // skips wallet.logCallCost), but they DO eat ~2s of autopilot
    // cycle. Catching here is faster + cleaner.
    const _ac = isValidNANPAreaCode(num);
    if (!_ac.valid) {
      const leadIdNow = (() => { try { return getLeadId(); } catch { return null; } })();
      try {
        log("warn", `[v1.33.4.1 area-code-gate] skipping invalid number ${num} on lead ${leadIdNow}: ${_ac.reason}`);
      } catch {}
      try { toast(`⚠️ Skipping ${label} — ${_ac.reason}`, { ms: 3500, kind: "warn" }); } catch {}
      // Count as a bad-number skip in counters so admin reports show it.
      try {
        state.counters = state.counters || {};
        state.counters.badNumbers = (state.counters.badNumbers | 0) + 1;
        persistCounters();
      } catch {}
      // Also track as the "last dialed" target so the same-lead guard
      // applies (no instant re-attempt at the same number on next paint).
      try {
        chrome.storage.local.set({
          [pdGuardKey("pd_last_dialed_lead")]: leadIdNow,
          [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
        });
      } catch {}
      // If on autopilot, advance to next lead so the dialing cycle
      // doesn't stall on this lead. Manual dial = just toast, let
      // the agent decide.
      const _wasAutopilot = opts && opts.manual === false;
      if (_wasAutopilot && state.autoDialMode) {
        setTimeout(() => advanceToNextLead(), 700);
      }
      return;
    }
    const leadId = getLeadId();
    const isManual = !opts || opts.manual !== false;
    // Push lead info to mobile NOW (before the dial fires) so the
    // screenshot capture happens while focus is guaranteed to be on
    // the Impact tab — chrome.tabs.captureVisibleTab requires the
    // capture target to be the active tab in a focused window.
    captureAndSendLeadInfo(leadId).catch(() => {});
    send("session.dial", {
      force: isManual,
      prospect: { number: num, leadId, testMode: !!state.testMode, relayMode: !!state.relayMode },
    });
    state.lastAutoDialAt = Date.now();
    // Persist so the autopilot guard survives Impact's page navigations
    // (content.js reloads on every page change).
    try {
      chrome.storage.local.set({
        [pdGuardKey("pd_last_dialed_lead")]: leadId,
        [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
      });
    } catch {}
    // Click Impact's actual tel: link AFTER we tell the worker — Impact's
    // own onclick handler navigates the page to /Lead/WhatHappend, which
    // is where the disposition list lives. Tiny delay so the WS message
    // flushes before navigation tears down this content script.
    //
    // The tel:/callto: click interceptor (registered at document level)
    // catches this programmatic click and preventDefault's it to block
    // Chrome's "Open Pick an app?" popup. The flag below tells the
    // interceptor we're already dialing through the worker, so it
    // doesn't fire session.dial twice.
    setTimeout(() => {
      try { a.classList.add("pd-flash"); } catch {}
      try {
        state._programmaticTelClick = true;
        a.click();
      } catch (err) {
        log("warn", `tel link click failed: ${err.message}`);
      } finally {
        // Clear the flag after the click event has fully propagated
        setTimeout(() => { state._programmaticTelClick = false; }, 0);
      }
    }, 120);
  }

  // ─────────────────────────────────────────────────────────────────
  // Autopilot mode — when on /Lead/InboxDetail with no active prospect,
  // auto-fire 📱 Mobile so the agent doesn't have to click each lead.
  // Guards:
  //   • must be on /Lead/InboxDetail
  //   • session must be parked, no prospect already in flight
  //   • autoDialMode must be on
  //   • current leadId must be DIFFERENT from the last lead we dialed
  //     (prevents the loop when Impact doesn't actually advance to the
  //     next lead after a disposition)
  //   • at least 4s must have passed since the last dial (defensive)
  // ─────────────────────────────────────────────────────────────────
  // v1.33.4.3 (3C) — Per-tab namespace for the autopilot guard keys.
  // pd_last_dialed_lead/at, pd_last_call_was_human and pd_advance_failed_at
  // were GLOBAL chrome.storage.local keys, so two Impact tabs both running
  // autopilot corrupted each other's same-lead / human-connected /
  // advance-failed guards (multi-tab "drift"). We now suffix them with a
  // per-tab id kept in sessionStorage — tab-isolated, and it survives the
  // content.js reboot that happens on every in-tab navigation, which is
  // exactly the lifetime these guards need.
  function pdTabNs() {
    try {
      let ns = sessionStorage.getItem("pd_tab_ns");
      if (!ns) {
        ns = (typeof crypto !== "undefined" && crypto.randomUUID)
          ? crypto.randomUUID()
          : (String(Date.now()) + Math.random().toString(36).slice(2, 10));
        sessionStorage.setItem("pd_tab_ns", ns);
      }
      return ns;
    } catch (_e) { return "default"; }
  }
  function pdGuardKey(base) { return base + "__" + pdTabNs(); }

  // v1.33.5.1 — Session-scoped "already-called" lead memory (cross-navigation,
  // cross-tab). Autopilot's per-lead chain (pd_lead_chain_queue) is deleted
  // when a lead completes, so nothing recorded that a lead was ever worked.
  // When Impact looped back to the inbox top (No-Answer leads stay listed),
  // autopilot re-dialed the same block. This set persists worked leadIds for
  // the life of the session so autopilot SKIPS them on re-encounter. Plain
  // key (not pdGuardKey) so it survives last-fire-wins tab handoff.
  const PD_SESSION_DIALED_KEY = "pd_session_dialed_leads";
  // v1.33.5.2 — ROLLING window, not whole-session memory. Casey England
  // 2026-07-22: the original set remembered every lead dialed all session and
  // never forgot, so a deliberate second pass over a list "just skipped through
  // and wouldn't call again." The purpose is only to break a tight auto-loop
  // (Impact returning to the inbox top and re-dialing the same handful). A
  // rolling window of the most-recently-dialed leads does that AND naturally
  // forgets, so a genuine re-pass works once the window rolls past. 25 covers
  // the largest observed loop (Nathan 17) with margin.
  const MAX_SESSION_DIALED = 25;
  function recordSessionDialedLead(leadId) {
    if (!leadId) return;
    try {
      chrome.storage.local.get([PD_SESSION_DIALED_KEY]).then((d) => {
        const cur = (d && d[PD_SESSION_DIALED_KEY]) || { s: state.sessionStartedAt || Date.now(), ids: {}, skipStreak: 0 };
        if (!cur.ids) cur.ids = {};
        cur.ids[String(leadId)] = Date.now();
        cur.skipStreak = 0;   // a real dial breaks any skip streak
        // Rolling cap — keep only the most-recently-dialed MAX_SESSION_DIALED,
        // evicting oldest by timestamp. Duplicates refresh the timestamp above,
        // so a re-dialed lead stays "recent."
        const keys = Object.keys(cur.ids);
        if (keys.length > MAX_SESSION_DIALED) {
          keys.sort((a, b) => cur.ids[a] - cur.ids[b]);
          for (let i = 0; i < keys.length - MAX_SESSION_DIALED; i++) delete cur.ids[keys[i]];
        }
        chrome.storage.local.set({ [PD_SESSION_DIALED_KEY]: cur });
      }).catch(() => {});
    } catch (_e) {}
  }
  // v1.33.5.2 — Wipe the rolling memory so the next autopilot pass treats every
  // lead as fresh. Called when the agent EXPLICITLY (re)starts autopilot — the
  // recovery path for "it skipped everything and won't call again."
  function clearSessionDialedMemory(reason) {
    try {
      chrome.storage.local.set({
        [PD_SESSION_DIALED_KEY]: { s: state.sessionStartedAt || Date.now(), ids: {}, skipStreak: 0 },
      });
      log("info", `session-dialed memory cleared (${reason || "manual"})`);
    } catch (_e) {}
  }

  function maybeAutoDial() {
    if (state.autoDialTimer) clearTimeout(state.autoDialTimer);
    if (!state.autoDialMode) return;
    if (state.session !== "parked") return;
    if (state.prospect) return;
    // Liberty (list model): the driver owns top-lead read, dedup, dial, and
    // pagination — bypass the Impact URL/lead-lock/cross-nav logic below.
    if (CRM.kind === "liberty") { if (state.libertyAP) state.libertyAP.autoDial(); return; }
    if (!CRM.isOnLeadPage()) return;
    // v1.31.81 — Lead-lock guard. Don't fire autopilot if we're still
    // in an active call (Thomas + Ramin drift bug). Lock-release in
    // prospect.hangup will trigger the deferred advance.
    if (isLeadLocked()) {
      log("info", "maybeAutoDial blocked — lead-lock active");
      return;
    }

    const currentLeadId = getLeadId();
    if (!currentLeadId) {
      log("warn", "Autopilot: no leadId in URL on InboxDetail; skipping");
      return;
    }

    // Cross-navigation guard: pull the last-dialed lead from storage so we
    // don't redial the same one if Impact didn't advance us.
    //
    // v1.31.44 — Hardened against Ramin's "dialer keeps calling previous
    // lead" bug. Previous 30-second guard was inadequate for human-connected
    // calls where the agent talks for 60-90s, then dispositions for another
    // 30-60s. Total elapsed > 30s → guard released → autopilot re-dialed the
    // SAME lead because Impact hadn't actually advanced (advanceToNextLead
    // silently failed to click the down-arrow). Triple defense now:
    //   1. Extend same-lead window from 30s → 5 minutes (covers any normal
    //      human conversation + disposition timing).
    //   2. Check pd_last_call_was_human — if the last call connected to a
    //      real human, the lead MUST change before autopilot dials again
    //      (no time-based release). Agent must click "MoveNext" or pick a
    //      disposition that advances Impact.
    //   3. Check pd_advance_failed_at — if advanceToNextLead() recently
    //      reported it couldn't find/click Impact's MoveNext button, hold
    //      autopilot until the agent intervenes.
    chrome.storage.local.get([
      pdGuardKey("pd_last_dialed_lead"), pdGuardKey("pd_last_dialed_at"),
      pdGuardKey("pd_last_call_was_human"), pdGuardKey("pd_advance_failed_at"),
      PD_SESSION_DIALED_KEY,
    ]).then((_gk) => {
      const sessionDialed          = _gk[PD_SESSION_DIALED_KEY] || { ids: {}, skipStreak: 0 };
      const pd_last_dialed_lead    = _gk[pdGuardKey("pd_last_dialed_lead")];
      const pd_last_dialed_at      = _gk[pdGuardKey("pd_last_dialed_at")];
      const pd_last_call_was_human = _gk[pdGuardKey("pd_last_call_was_human")];
      const pd_advance_failed_at   = _gk[pdGuardKey("pd_advance_failed_at")];
      const sameLead = pd_last_dialed_lead && String(pd_last_dialed_lead) === String(currentLeadId);
      const sinceLast = Date.now() - (pd_last_dialed_at || 0);

      // v1.31.44 Guard #3 — recent advance failure. If we just tried to move
      // forward and Impact didn't go anywhere, refuse to dial until the
      // agent clicks MoveNext (or navigates manually). 10-minute fence.
      const sinceAdvFail = Date.now() - (pd_advance_failed_at || 0);
      if (pd_advance_failed_at && sinceAdvFail < 10 * 60 * 1000) {
        // Only block if we're still on the same lead the advance failed for.
        // If the agent manually navigated away, sameLead === false and we proceed.
        if (sameLead) {
          toast(`Autopilot paused — couldn't advance from lead ${currentLeadId}. Click Impact's MoveNext arrow manually.`, { ms: 6000, kind: "warn" });
          log("info", `Autopilot blocked: advance failed ${Math.round(sinceAdvFail/1000)}s ago, still on same lead`);
          return;
        }
      }

      // v1.31.44 Guard #2 — last call was a human conversation. Require an
      // ACTUAL lead change before dialing again. No time-based release.
      if (pd_last_call_was_human && sameLead) {
        toast(`Autopilot paused — you just talked to lead ${currentLeadId}. Advance to a new lead first.`, { ms: 6000, kind: "warn" });
        log("info", `Autopilot blocked: previous call was human-connected on same lead ${currentLeadId}`);
        return;
      }

      // v1.31.44 Guard #1 — extended same-lead time window (30s → 5 min).
      if (sameLead && sinceLast < 5 * 60 * 1000) {
        toast(`Autopilot paused — Impact still showing lead ${currentLeadId} from ${Math.round(sinceLast/1000)}s ago. Click MoveNext or pick a fresh lead.`, { ms: 6000, kind: "warn" });
        log("info", `Autopilot skip: leadId=${currentLeadId} matches last dial ${sinceLast}ms ago (5min window)`);
        return;
      }
      // Defensive gap: only block consecutive dials of the SAME lead. When
      // chaining bad-number skips across different leads we need autopilot
      // to fire immediately on the new lead, even if the previous skip was
      // milliseconds ago.
      if (sameLead && sinceLast < 1500) return;

      // Brief delay so Impact's UI finishes rendering.
      state.autoDialTimer = setTimeout(() => {
        if (!state.autoDialMode || state.session !== "parked" || state.prospect) return;
        if (!CRM.isOnLeadPage()) return;
        if (getLeadId() !== currentLeadId) return; // navigated away mid-wait

        // v1.33.5.1 — Session already-called skip. If autopilot looped back
        // onto a lead it already worked this session (Impact returned to the
        // inbox top), skip + advance instead of re-dialing. The Home→Mobile
        // chain for the CURRENT lead does NOT route through here (it consumes
        // pd_lead_chain_queue in the hangup handler), so a hit is a genuine
        // re-encounter, not the second leg of a chain.
        const _dialedIds = (sessionDialed && sessionDialed.ids) || {};
        if (_dialedIds[String(currentLeadId)]) {
          const worked = Object.keys(_dialedIds).length;
          const streak = (sessionDialed.skipStreak || 0) + 1;
          // Once we've skipped more in a row than we've worked (plus margin),
          // the inbox is exhausted / looping — stop rather than spin forever
          // clicking the down-arrow through the same block.
          const cap = Math.max(15, worked + 3);
          if (streak >= cap) {
            toast(`Autopilot finished — worked the last ${worked} leads. Click ▶ Start Autopilot again to run the list from the top, or load a new list.`, { ms: 8000, kind: "warn" });
            log("info", `Autopilot exhausted: session-skip streak ${streak} >= cap ${cap} (worked ${worked})`);
            state.autoDialMode = false;
            try { chrome.storage.local.set({ pd_autodial: false }); } catch {}
            paint();
            return;
          }
          try {
            chrome.storage.local.set({
              [PD_SESSION_DIALED_KEY]: { ...sessionDialed, ids: _dialedIds, skipStreak: streak },
              [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
              [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
            });
          } catch {}
          toast(`⏭ Already called lead ${currentLeadId} this session — advancing`, { ms: 2500 });
          log("info", `session-dialed skip: lead ${currentLeadId} already worked this session (streak ${streak}/${cap})`);
          advanceToNextLead();
          return;
        }

        // ── Skip-on-future-appointment (always-on default behavior) ──
        // If the comments mention a scheduled appointment / callback at
        // a date in the future, don't waste a dial — skip and advance.
        const apptHit = detectFutureAppointment();
        if (apptHit) {
          const _kindLabel = apptHit.kind === "callback" ? "future callback" : "future appointment";
          toast(`Autopilot: lead ${currentLeadId} has a ${_kindLabel} (${apptHit.matchedText.slice(0, 60)}) — skipping`);
          log("info", `Autopilot skip: ${_kindLabel} detected — ${apptHit.matchedText}`);
          state.counters.badNumbers++;
          persistCounters();
          state.skippedLeads.push({
            leadId:   currentLeadId,
            leadName: getLeadName() || "",
            number:   "",
            reason:   (apptHit.kind === "callback" ? "Future callback: " : "Future appointment: ") + apptHit.matchedText.slice(0, 80),
            at:       Date.now(),
          });
          try {
            chrome.storage.local.set({
              [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
              [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
            });
          } catch {}
          advanceToNextLead();
          return;
        }

        // ── Call-only-from-comments mode ──
        // When the toggle's on, ignore Mobile/Home and dial each phone
        // number found in the comments section, one at a time. Useful
        // for working through "bad number" lead piles where Mobile/Home
        // are DO NOT CALL but a more recent number is in the comments.
        if (state.callFromComments) {
          const next = pickNextCommentNumber(currentLeadId);
          if (next) {
            toast(`Autopilot: dialing ${next} from comments on lead ${currentLeadId}`);
            log("info", `Autopilot comments-dial: ${next}`);
            markCommentNumberTried(currentLeadId, next);
            recordSessionDialedLead(currentLeadId);   // v1.33.5.1 session memory
            captureAndSendLeadInfo(currentLeadId).catch(() => {});
            send("session.dial", { prospect: { number: next, leadId: currentLeadId, testMode: !!state.testMode, relayMode: !!state.relayMode } });
            state.lastAutoDialAt = Date.now();
            try {
              chrome.storage.local.set({
                [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
                [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
              });
            } catch {}
            return;
          }
          // Exhausted all comment numbers — skip to next lead
          toast(`Autopilot: no fresh comment numbers on lead ${currentLeadId} — advancing`);
          state.skippedLeads.push({
            leadId:   currentLeadId,
            leadName: getLeadName() || "",
            number:   "",
            reason:   "Comments-mode: no fresh numbers in comments",
            at:       Date.now(),
          });
          try {
            chrome.storage.local.set({
              [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
              [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
            });
          } catch {}
          advanceToNextLead();
          return;
        }

        // v1.31.90 — Lead-type skip toggles. If this lead's type matches
        // an active toggle, skip immediately and advance. Per-session
        // (state.skipLeadTypes is reset on endSession).
        const activeSkipTypes = state.skipLeadTypes ? Object.keys(state.skipLeadTypes) : [];
        if (activeSkipTypes.length > 0) {
          let leadType = "";
          try { leadType = getLeadType() || ""; } catch {}
          if (leadType && state.skipLeadTypes[leadType]) {
            toast(`⏭ Skipping ${leadType} lead ${currentLeadId} (type-skip active)`);
            log("info", `lead-type skip: ${leadType} matched on lead ${currentLeadId}`);
            try {
              chrome.storage.local.set({
                [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
                [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
              });
            } catch {}
            advanceToNextLead();
            return;
          }
        }

        // v1.31.66 — Home → Mobile dial chain.
        // Build the per-lead queue of attempts at autopilot start time.
        // Default sequence: Home (×1 or ×2 if double-tap), then Mobile
        // (×1 or ×2). Front of queue is the current dial; remaining items
        // are consumed via pd_lead_chain_queue on subsequent no-answer
        // hangups (handleHangupAdvance / maybeConsumePendingAdvance).
        const homeA   = CRM.phoneLink("Home");
        const mobileA = CRM.phoneLink("Mobile");
        const homeNum   = homeA   ? CRM.numberFromLink(homeA)   : null;
        const mobileNum = mobileA ? CRM.numberFromLink(mobileA) : null;

        // v1.31.90 — TCPA calling hours check. Before placing the dial,
        // verify the prospect's local time is within the configured
        // window for their timezone. Out-of-window → silent skip + log
        // for audit.
        const probeNum = homeNum || mobileNum;
        if (probeNum && state.callingHoursEnabled) {
          const hours = isWithinCallingHours({ number: probeNum, state: (typeof getLeadState === "function" ? getLeadState() : "") });
          // v1.31.93 — Three distinct outcomes:
          //   hours.ok === true  → within window, dial proceeds
          //   hours.ok === false → outside window, silent skip + log
          //   hours.ok === null + needsPrompt → TZ couldn't be derived
          //     from address; show modal asking agent to Proceed or Skip
          if (hours.ok === false) {
            toast(`⏰ Skipping lead ${currentLeadId} — outside calling hours (${hours.tzKey || "?"} ${hours.localTime || ""}, window ${hours.window || ""})`, { ms: 3500 });
            log("info", `calling-hours skip: lead ${currentLeadId} tz=${hours.tzKey} local=${hours.localTime} window=${hours.window}`);
            try {
              chrome.storage.local.set({
                [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
                [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
              });
            } catch {}
            advanceToNextLead();
            return;
          }
          if (hours.ok === null && hours.needsPrompt) {
            // v1.31.93 — Address couldn't be derived. Prompt the agent.
            log("info", `calling-hours: TZ unknown for lead ${currentLeadId} — prompting agent`);
            showTzUnknownModal({
              leadId: currentLeadId,
              onProceed: () => {
                // Set bypass flag so the next isWithinCallingHours call
                // for THIS lead returns ok:true. Then re-trigger
                // maybeAutoDial — which will re-build the chain and
                // dial normally.
                state.bypassCallingHoursOnce = currentLeadId;
                log("info", `agent confirmed PROCEED despite unknown TZ on lead ${currentLeadId}`);
                try { maybeAutoDial(); } catch (e) {
                  log("warn", "post-modal maybeAutoDial threw: " + (e && e.message));
                }
              },
              onSkip: () => {
                log("info", `agent chose SKIP due to unknown TZ on lead ${currentLeadId}`);
                try {
                  chrome.storage.local.set({
                    [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
                    [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
                  });
                } catch {}
                advanceToNextLead();
              },
            });
            return;  // Exit maybeAutoDial; one of the callbacks will continue the flow.
          }
        }

        const dtMul = state.doubleTap ? 2 : 1;
        const chainQueue = [];
        if (homeNum)   for (let i = 0; i < dtMul; i++) chainQueue.push({ number: homeNum,   label: "Home"   });
        if (mobileNum) for (let i = 0; i < dtMul; i++) chainQueue.push({ number: mobileNum, label: "Mobile" });
        const firstDial = chainQueue.shift() || null;
        let label = firstDial ? firstDial.label : "Mobile";
        let a     = firstDial ? CRM.phoneLink(label) : null;
        if (a && CRM.numberFromLink(a)) {
          toast(`Autopilot dialing ${label} for lead ${currentLeadId}…`);
          // v1.31.44 — Clear human-call + advance-failed flags since we're
          // about to successfully dial a (different) lead. If this dial
          // ends with another human-connected, the flag will be re-set
          // by the prospect.hangup handler.
          try {
            chrome.storage.local.remove([pdGuardKey("pd_last_call_was_human"), pdGuardKey("pd_advance_failed_at")]);
          } catch {}
          // v1.31.66 — Persist the remaining queue so no-answer hangups
          // can pick up where we left off (Mobile after Home, or the
          // doubled retries). Even an empty queue is written so the
          // hangup handler can distinguish "chain ran out — advance"
          // from "no chain active — legacy double-tap path".
          try {
            chrome.storage.local.set({
              pd_lead_chain_queue: {
                leadId: currentLeadId,
                queue:  chainQueue,   // remaining after firstDial
                at:     Date.now(),
              },
            });
          } catch {}
          // v1.33.5.1 — Remember we worked this lead this session, so if
          // Impact later loops us back to it, autopilot skips instead of
          // re-dialing (Nathan Gay 2026-07-20).
          recordSessionDialedLead(currentLeadId);
          // v1.31.44 — manual:false → worker's same-lead+number guard
          // engages. Autopilot must respect the guard; only humans can
          // bypass.
          dial(label, { manual: false });
        } else {
          // No Mobile, no Home — nothing to dial here. Skip this lead and
          // let autopilot pick up on the next one.
          toast(`Autopilot: no number on lead ${currentLeadId} — skipping`);
          log("info", `Autopilot skip: lead ${currentLeadId} has no Mobile or Home, advancing`);
          // Mark as "dialed" so the same-lead guard prevents a loop if Impact
          // doesn't move us forward.
          try {
            chrome.storage.local.set({
              [pdGuardKey("pd_last_dialed_lead")]: currentLeadId,
              [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
            });
          } catch {}
          advanceToNextLead();
        }
      }, 1200);
    }).catch(() => {});
  }

  function normalizeNumber(raw) {
    if (!raw) return null;
    const digits = String(raw).replace(/[^\d+]/g, "");
    if (digits.startsWith("+")) {
      const rest = digits.slice(1);
      if (/^\d{10,15}$/.test(rest)) return digits;
      return null;
    }
    if (digits.length === 10) return "+1" + digits;
    if (digits.length === 11 && digits.startsWith("1")) return "+" + digits;
    if (digits.length >= 8 && digits.length <= 15) return "+" + digits;
    return null;
  }

  function manualDial() {
    if (state.session !== "parked") { toast("Start a session first"); return; }
    if (state.prospect) { toast("Already on a call — Skip first"); return; }
    const input = document.getElementById("pd-manual-input");
    const raw = input ? input.value.trim() : "";
    const num = normalizeNumber(raw);
    if (!num) { toast("Enter a valid phone number"); return; }
    // Push lead info immediately (manual dial → no leadId; mostly just
    // captures the screenshot of whatever Impact page the agent has up).
    captureAndSendLeadInfo(null).catch(() => {});
    // v1.34 — "dial from" chooser: pin a specific owned number for this
    // manual call (spam-check). Empty value = Auto (local-presence pick).
    const fromOverride = (document.getElementById("pd-manual-from")?.value || "").trim();
    send("session.dial", { prospect: { number: num, leadId: null, testMode: !!state.testMode, relayMode: !!state.relayMode, fromOverride } });
    if (fromOverride) toast("Dialing from " + fromOverride);
    if (input) input.value = "";
  }

  // Manual-dial entry point used by the mini-mode card. Same gating and
  // payload as manualDial(); takes the number directly so we don't need
  // to also wire up the full panel's input element.
  function manualDialFromMini(raw) {
    if (state.session !== "parked") { toast("Start a session first — click ⤢ to expand and Start Session"); return; }
    if (state.prospect) { toast("Already on a call — End it first"); return; }
    const num = normalizeNumber(raw);
    if (!num) { toast("Enter a valid phone number"); return; }
    captureAndSendLeadInfo(null).catch(() => {});
    send("session.dial", { prospect: { number: num, leadId: null, testMode: !!state.testMode, relayMode: !!state.relayMode } });
    const inp = document.getElementById("pd-mini-num");
    if (inp) inp.value = "";
  }

  function skipProspect() {
    // v1.33.7 — Casey England 2026-08: End Call failed silently when THIS
    // surface's local prospect state was missing (dual-tab / event desync),
    // forcing the agent to End Session. Honor the tap: if a session is live,
    // send the hangup regardless. The worker holds the authoritative prospect
    // leg and hangupProspect() no-ops if there's genuinely nothing to end.
    if (!state.session || state.session === "idle") { try { toast("No active session to end."); } catch (_e) {} return; }
    send("session.skip");
    if (!state.prospect) { try { toast("Ending call…"); } catch (_e) {} }
  }
  function bridgeNow()   { if (state.prospect) send("session.bridge"); }
  function dropVmNow()   {
    if (!state.prospect) return;
    // v1.33.5.1 — Mark this as an EXPLICIT manual VM drop. The agent has
    // declared "this is voicemail" by clicking the button, so the
    // prospect.hangup handler must NOT re-classify it as human-connected
    // via the AMD-human override (which was pausing for a manual "No
    // Answer" click — agent 920-255-7913, 2026-07-xx). Manual VM drop is
    // a definitive no-answer that auto-dispositions + advances.
    state.prospect._manualVmDrop = true;
    send("session.dropvm");
  }

  // v1.31.98 + v1.32.0 — Send Self-Diagnostics with full client+offscreen payload.
  //
  // Bundles content.js client-side state into `extras.clientDiag`, then
  // asks the offscreen sandbox to merge it with SDK/Telnyx state and POST
  // to /api/diag/submit. Returns the offscreen response:
  //   { ok: true, key } on success
  //   { ok: false, reason / status / body } on failure.
  //
  // Client diag includes (v1.32.0 expansion):
  //   - state snapshot (session, prospect, lock, endHumanCallTriggered)
  //   - all toggle values
  //   - chrome.storage.local snapshot (limited keys, no PII)
  //   - Last 50 log() events from _pdLogBuffer
  //   - Last 20 URL nav events from _pdNavBuffer
  //   - Last 5 WS messages received from _pdWsBuffer
  //   - current pathname/search
  //
  // Exposed on window for DevTools manual trigger:
  //   await window.__pdSendDiagnostics()
  //   await window.__pdSendDiagnostics({ reason: "manual:something" })
  async function _pdCollectClientDiag() {
    let storageSnapshot = null;
    try {
      // Only pull the keys we KNOW are diag-relevant. Never dump the full
      // storage (would contain prospect numbers, etc).
      const keys = [
        "pd_pending_no_answer_at", "pd_pending_advance_at",
        "pd_double_tap_state", "pd_lead_chain_queue",
        "pd_double_tap", "pd_auto_attendant_hangup", "pd_manual_disposition",
        "pd_bottom_to_top", "pd_call_from_comments", "pd_away_mode",
        "pd_test_mode", "pd_ring_timeout_enabled", "pd_ring_timeout_secs",
        "pd_calling_hours_enabled", "pd_calling_hours",
        // v1.32.2 — REMOVED pd_sticky_auto_attend from diag dump.
        "pd_last_dialed_lead", "pd_last_dialed_at",
        "pd_last_call_was_human", "pd_advance_failed_at",
        "pd_monthly", "pd_monthly_key",
        // v1.32.19 — Worker / device identity in the diag so we can tell at
        // a glance which environment (prod/staging/lab) the extension is
        // actually talking to, and whether a stale deviceId is lurking.
        // No PII; pd_worker_base is the configured base URL, pd_device_id
        // is the multi-device DID anchor.
        "pd_worker_base", "pd_device_id",
      ];
      storageSnapshot = await new Promise((resolve) => {
        try {
          chrome.storage.local.get(keys, (data) => {
            void chrome.runtime.lastError;
            resolve(data || null);
          });
        } catch { resolve(null); }
      });
    } catch {}
    return {
      capturedAt: Date.now(),
      pathname:   location.pathname,
      search:     location.search.slice(0, 200),
      sessionState: state.session,
      prospect:   state.prospect ? {
        leadId:        state.prospect.leadId,
        status:        state.prospect.status,
        amd:           state.prospect.amd,
        _wasLive:      !!state.prospect._wasLive,
        detectedVoicemail: !!state.prospect.detectedVoicemail,
      } : null,
      activeLeadLock: state.activeLeadLock,
      endHumanCallTriggered: !!state.endHumanCallTriggered,
      pendingAdvanceAfterHangup: !!state.pendingAdvanceAfterHangup,
      lastError: state.lastError ? String(state.lastError).slice(0, 300) : null,
      callStartedAt: state.callStartedAt || 0,
      skin:       state.skin || null,
      autoDialMode: !!state.autoDialMode,
      toggles: {
        doubleTap:           !!state.doubleTap,
        manualDisposition:   !!state.manualDisposition,
        bottomToTop:         !!state.bottomToTop,
        callFromComments:    !!state.callFromComments,
        autoAttendantHangup: !!state.autoAttendantHangup,
        // v1.32.2 — REMOVED stickyAutoAttendOverride from diag broadcast.
        awayMode:            !!state.awayMode,
        testMode:            !!state.testMode,
        ringTimeoutEnabled:  !!state.ringTimeoutEnabled,
        ringTimeoutSecs:     state.ringTimeoutSecs || 0,
        callingHoursEnabled: !!state.callingHoursEnabled,
      },
      storageSnapshot,
      logBuffer:  _pdLogBuffer.slice(),
      navBuffer:  _pdNavBuffer.slice(),
      wsBuffer:   _pdWsBuffer.slice(),
      autoSubmittedReasons: [..._pdAutoSubmitted],
    };
  }

  async function __pdSendDiagnostics(opts) {
    const reason = (opts && opts.reason) || "manual:setup-drawer";
    // v1.33.2 — Paired diagnostics. correlationId pairs THIS snapshot
    // with the matching mobile snapshot triggered via diag.fanout.
    // If we were triggered by the other surface (pairedTrigger=true),
    // reuse the inbound correlationId. Otherwise mint a new one and
    // fan it out after submit (handled in the per-path code below).
    const pairedTrigger = !!(opts && opts.pairedTrigger);
    const correlationId = (opts && opts.correlationId)
                        || (`diag_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
    const originSurface = (opts && opts.originSurface) || null;
    let clientDiag;
    try { clientDiag = await _pdCollectClientDiag(); } catch (e) { clientDiag = { collectError: String(e) }; }
    // Try offscreen first — it has the SDK/Telnyx/JWT state that's
    // valuable for Desktop-mode debugging. If offscreen isn't reachable
    // (Mobile App mode, fresh session before Desktop init, ext just
    // reloaded), fall back to a content.js-side submit so the agent
    // still gets confirmation and we still get the client diag bundle.
    const offscreenResult = await new Promise((resolve) => {
      try {
        chrome.runtime.sendMessage(
          { type: "pd_off_collect_diag", extras: { reason, clientDiag } },
          (r) => {
            void chrome.runtime.lastError;
            resolve(r || { ok: false, reason: "no-response (offscreen may be inactive)" });
          }
        );
      } catch (err) {
        resolve({ ok: false, reason: "sendMessage threw: " + (err?.message || err) });
      }
    });

    // v1.32.14 — If offscreen didn't respond, the document is probably
    // dead (Chrome reclaimed it). Ask background to resurrect it. Fire-
    // and-forget — we still fall through to the direct-submit fallback
    // for THIS diagnostic submission, but the next request the user
    // makes (dial, etc.) will hit a fresh offscreen.
    if (!offscreenResult?.ok &&
        offscreenResult?.reason?.includes("no-response")) {
      try {
        chrome.runtime.sendMessage(
          { type: "pd_request_offscreen_resurrect" },
          (_r) => { void chrome.runtime.lastError; }
        );
      } catch {}
    }

    if (offscreenResult && offscreenResult.ok) return offscreenResult;

    // v1.32.9 — Direct fallback (Jason Ebaugh report 2026-06-15: "Send
    // diagnostics failed on this rev" — root cause was Mobile App mode
    // users have no offscreen-side token, so the offscreen path always
    // returned no-token-or-base). Submit directly from content.js using
    // the auth token we already have. Payload schema matches what
    // offscreen would have sent — adminer endpoints work either way.
    try {
      const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      if (!tok) {
        return { ok: false, reason: "no-auth-token (sign in first)" };
      }
      const payload = {
        schemaVersion: 1,
        capturedAt: new Date().toISOString(),
        // v1.33.2 — surface + pairing fields, mirrors mobile.js
        surface:             "extension",
        correlationId,
        pairedTrigger,
        pairedTriggerOrigin: originSurface,
        extension: { version: (chrome?.runtime?.getManifest?.()?.version) || null },
        env: {
          userAgent: navigator.userAgent || null,
          navigatorConn: navigator.connection ? {
            effectiveType: navigator.connection.effectiveType,
            downlink: navigator.connection.downlink,
            rtt: navigator.connection.rtt,
            saveData: navigator.connection.saveData,
          } : null,
          sdkLoadError: "(offscreen-unavailable — direct content.js submit)",
          hasTelnyxRTC: typeof window.TelnyxRTC !== "undefined",
        },
        sdk: { currentState: "n/a (offscreen-unavailable)", currentError: null, hasRtcClient: false, hasActiveCall: false, hasMicStream: false },
        jwt: { claimsSafe: null, workerResp: null },
        diagEvents: [],
        extras: { reason: "manual:setup-drawer-direct-fallback", extras: { reason, clientDiag, offscreenFailReason: offscreenResult?.reason || offscreenResult?.body?.error || ("status=" + offscreenResult?.status) || "unknown" } },
      };
      const r = await fetch(`${base}/api/diag/submit?token=${encodeURIComponent(tok)}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ payload }),
      });
      let body = null;
      try { body = await r.json(); } catch {}
      if (!r.ok) {
        return { ok: false, status: r.status, body, reason: "direct-submit-failed: HTTP " + r.status };
      }
      // v1.33.2 — Fanout to mobile via DO WS. Only when WE initiated
      // (not when we were already triggered). Best effort.
      if (!pairedTrigger && typeof __pdFanoutDiagRequest === "function") {
        try { __pdFanoutDiagRequest(correlationId); } catch (_e) {}
      }
      return { ok: true, key: body?.key || null, viaDirect: true, correlationId, pairedTrigger };
    } catch (err) {
      return { ok: false, reason: "direct-submit-threw: " + (err?.message || err) };
    }
  }
  window.__pdSendDiagnostics = __pdSendDiagnostics;

  // v1.33.2 — WS fanout for paired diagnostics. Called after a
  // successful ext-side submit to tell the DO to broadcast
  // diag.request to all attached WS clients (the mobile PWA).
  // Implemented as a thin wrapper that picks up the active WS off
  // window.__pdWS if the panel set one. Best effort — if WS is
  // closed, the OTHER surface just doesn't get triggered; our
  // snapshot still went through.
  function __pdFanoutDiagRequest(correlationId) {
    try {
      const w = window.__pdWS;
      if (!w || w.readyState !== 1 /* OPEN */) return;
      w.send(JSON.stringify({
        type:          "diag.fanout",
        correlationId,
        originSurface: "extension",
      }));
    } catch (_e) {}
  }
  window.__pdFanoutDiagRequest = __pdFanoutDiagRequest;

  // v1.33.2 — Receive paired diagnostic requests from the mobile PWA.
  // Called from the WS message handler. Fires our own snapshot tagged
  // with the SAME correlationId so admin can pair them in the viewer.
  // Guards against self-echo + back-to-back duplicates.
  let __pdLastPairedCid = null;
  let __pdLastPairedAt  = 0;
  function __pdHandleDiagRequest(msg) {
    if (!msg) return;
    if (msg.originSurface === "extension") return;  // self-echo
    const cid = String(msg.correlationId || "");
    if (!cid) return;
    if (__pdLastPairedCid === cid && (Date.now() - __pdLastPairedAt) < 10_000) return;
    __pdLastPairedCid = cid;
    __pdLastPairedAt  = Date.now();
    if (typeof __pdSendDiagnostics === "function") {
      __pdSendDiagnostics({
        reason:         "paired:from-" + (msg.originSurface || "mobile-pwa"),
        correlationId:  cid,
        pairedTrigger:  true,
        originSurface:  msg.originSurface || "mobile-pwa",
      }).catch((err) => log && log("warn", "[diag] paired send failed: " + (err?.message || err)));
    }
  }
  window.__pdHandleDiagRequest = __pdHandleDiagRequest;

  // v1.32.0 — Nightly auto-send via chrome.alarms.
  // Fires once every 24h. Default firing time approximates 3am EDT (7am UTC)
  // when most agents are offline; if the alarm fires while the agent IS
  // active, no harm — just an extra data point.
  //
  // The chrome.alarms API runs in the SERVICE WORKER context, not the
  // content script. So we install a listener in background.js that, when
  // the alarm fires, broadcasts to all impact.ailife.com tabs asking for
  // a diagnostic submit. Content.js receives the broadcast and calls
  // __pdSendDiagnostics({ reason: "auto:nightly-heartbeat" }).
  //
  // Hook below — listens for the broadcast and submits.
  try {
    chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
      if (msg && msg.type === "pd_request_nightly_diag") {
        __pdSendDiagnostics({ reason: "auto:nightly-heartbeat" })
          .then((r) => sendResponse(r))
          .catch((e) => sendResponse({ ok: false, reason: "nightly: " + (e?.message || e) }));
        return true;  // async response
      }
      // Liberty adapter hot-reload: background re-injected liberty-crm.js into
      // this tab; repoint CRM at the fresh adapter and rebuild the driver — no
      // page reload, so the Salesforce/Geopointe layers stay intact.
      if (msg && msg.type === "pd_liberty_adapter_reloaded") {
        try {
          if (typeof window !== "undefined" && window.LibertyCRM) CRM = window.LibertyCRM;
          state.libertyAP = buildLibertyAP();
          toast("🔁 Liberty adapter updated (v" + (msg.version || "?") + ") — no page reload needed.", { ms: 4500, kind: "info" });
        } catch (e) {
          try { toast("Adapter reload failed: " + (e?.message || e), { ms: 6000, kind: "warn" }); } catch (_e) {}
        }
        sendResponse && sendResponse({ ok: true });
        return true;
      }
    });
  } catch (_e) {}

  // v1.31.97 — End Human Call.
  // Triggered when agent finishes a live conversation. Sets the popup-arming
  // flag, then performs a normal hangup. The URL watcher (watchForBackToImpact)
  // will then catch the WhatHappend → InboxDetail transition once the agent
  // saves the disposition, and show the 3-button decision popup.
  //
  // Auto-dispositioned outcomes (no-answer, VM, attendant-hangup) DON'T call
  // this function — they go through skipProspect directly, leaving the flag
  // false, which means the URL watcher's check `state.endHumanCallTriggered`
  // fails silently for those cases. That's by design — only manual
  // human-call endings get the popup.
  function endHumanCall() {
    // v1.33.7 — Casey England 2026-08: don't silently swallow the tap when
    // this surface's local prospect state is missing (dual-tab / event
    // desync) — that forced agents to End Session. Honor it and send the
    // hangup; the worker holds the authoritative leg and no-ops if there's
    // nothing live. Popup-arming stays best-effort (only with local prospect).
    if (!state.session || state.session === "idle") { try { toast("No active session to end."); } catch (_e) {} return; }
    if (state.prospect) {
      state.endHumanCallTriggered = true;
      // v1.32.18 — Stamp the trigger with a timestamp so the URL watcher
      // can ignore stale triggers (Dustin's report 2026-06-18: agents see
      // the popup keep activating without their input — root cause is the
      // flag persisting indefinitely + URL changes hours later still fire).
      state.endHumanCallTriggeredAt = Date.now();
    }
    log("info", "📞 End-Human-Call clicked — popup " + (state.prospect ? "armed" : "skipped (no local prospect)") + ", sending hangup");
    send("session.skip");
    if (!state.prospect) { try { toast("Ending call…"); } catch (_e) {} }
    // Existing v1.29.76 flow: when the call ends + manualDisposition is
    // implicitly required for a live call, autopilot pauses naturally.
    // No explicit pause here — that path is already in the hangup handler.
    // v1.33.4.7 — TEST MODE: a synthesized call never drives Impact's real
    // WhatHappend → InboxDetail navigation, so watchForBackToImpact never
    // fires the Continue/Stay/End popup. Fire it directly once the test call
    // clears, so test mode follows the SAME protocol as a real live call.
    if (state.testMode && state.prospect) {
      const _armedAt = Date.now();
      const _tryTestPopup = () => {
        if (!state.endHumanCallTriggered) return;                 // already handled
        if (state.session === "idle" || state.session === "ending" || !state.session) return;
        if (state.prospect) {                                     // call not cleared yet — wait (max 6s)
          if (Date.now() - _armedAt > 6000) return;
          setTimeout(_tryTestPopup, 300);
          return;
        }
        state.endHumanCallTriggered = false;
        state.endHumanCallTriggeredAt = 0;
        try { showEndCallPopup(); } catch (e) { log("warn", "test-mode endcall popup threw: " + (e && e.message)); }
      };
      setTimeout(_tryTestPopup, 500);
    }
  }
  // Auto-Attendant override — agent recognizes an IVR / call menu and
  // tells the worker to suppress the post-bridge VM auto-hangup for
  // the rest of THIS call. Flag resets when we move to the next prospect.
  function autoAttendantOverride() {
    if (!state.prospect) return;
    state.overrideActive = true;
    send("session.auto-attendant-override");
    log("info", "🛡️ Auto-Attendant override clicked — VM auto-hangup disabled for this call");
    paint();  // v1.31.15-stable: was render() (undefined → ReferenceError)
  }

  // v1.29.29 — Desktop mic mute. Routes through background → offscreen,
  // which calls the Telnyx SDK's muteAudio()/unmuteAudio() on the
  // active agent leg. Only meaningful in Desktop WebRTC mode (offscreen
  // is closed in Mobile App mode). Local state is mirrored for the
  // button UI; offscreen broadcasts confirmation back.
  function toggleDesktopMicMute() {
    state.deskMicMuted = !state.deskMicMuted;
    chrome.runtime.sendMessage({
      type:  "pd_off_mute",
      muted: !!state.deskMicMuted,
    }, () => { void chrome.runtime.lastError; });
    log("info", state.deskMicMuted ? "🔇 Desktop mic muted" : "🎙️ Desktop mic unmuted");
    paint();  // v1.31.15-stable: was render() (undefined → ReferenceError)
  }

  // ─────────────────────────────────────────────────────────────────
  // Toast
  // ─────────────────────────────────────────────────────────────────
  let toastTimer = null;
  function toast(text, opts = {}) {
    let el = document.getElementById("pd-toast");
    if (!el) {
      el = document.createElement("div");
      el.id = "pd-toast";
      el.className = "pd-toast";
      document.body.appendChild(el);
    }
    el.textContent = text;
    // v1.29.75 — Severity-styled toasts. `kind` accepts ok/info/warn/err.
    // Backwards compat: opts.error still flips err styling. Classes are
    // mutually exclusive — strip them all before applying the new one.
    el.classList.remove("pd-toast-err", "pd-toast-warn", "pd-toast-ok");
    const kind = opts.kind || (opts.error ? "err" : null);
    if (kind === "err")  el.classList.add("pd-toast-err");
    if (kind === "warn") el.classList.add("pd-toast-warn");
    if (kind === "ok")   el.classList.add("pd-toast-ok");
    el.style.display = "block";
    clearTimeout(toastTimer);
    const ms = opts.ms || (kind === "err" || opts.error ? 7000 : 3500);
    toastTimer = setTimeout(() => { el.style.display = "none"; }, ms);
  }

  // ─────────────────────────────────────────────────────────────────
  // Panel DOM
  // ─────────────────────────────────────────────────────────────────
  function buildPanel() {
    if (document.getElementById("pd-root")) return;
    // v1.32.16 — Respect the agent's per-tab "close dialer" choice.
    // Without this gate, paint() would re-create pd-root milliseconds
    // after pdCloseDialerOnThisTab removed it, making the dialer flicker
    // and reappear (Dustin's report 2026-06-17).
    try {
      if (sessionStorage.getItem("pd_dialer_closed") === "1") return;
    } catch {}
    const root = document.createElement("div");
    root.id = "pd-root";
    // v1.31.80 — Classic restored from storage: apply pd-skin-phone +
    // marker class + data attribute (NOT pd-skin-classic on its own —
    // there's no CSS for that, and we want phone-skin layout).
    if (state.skin === "classic") {
      root.classList.add("pd-skin-phone");
      root.classList.add("pd-classic-mode");
      root.dataset.skinVariant = "classic";
    } else {
      root.classList.add("pd-skin-" + (state.skin || "phone"));
    }
    if (state.collapsed) root.classList.add("pd-collapsed");
    if (state.maximized) root.classList.add("pd-maximized");
    if (state.pos) {
      if (state.pos.top != null) root.style.top = state.pos.top + "px";
      if (state.pos.right != null) root.style.right = state.pos.right + "px";
    }

    // Galaxy S26-style phone frame. The screen content (#pd-screen) holds
    // the existing header/body/footer with all original IDs intact, so the
    // rest of the JS keeps working without changes.
    root.innerHTML = `
      <div class="pd-phone">
        <!-- v1.31.74 — Universal skin switcher. Position-absolute strip at
             the top of the panel, visible in EVERY size mode and skin.
             Clicks delegated through pd-root's click listener (wired once
             in paint()). Active state is driven by parent classes via CSS;
             no per-button class management needed.

             Per Dustin's design 2026-06-05: replaces the buried Settings →
             skin radio chooser. Agents switch skins from anywhere.

             Placeholders today: jersey → sizeMode="mini" + ball →
             sizeMode="ball". v1.31.75 introduces real pd-skin-jersey +
             pd-skin-ball visuals that override the placeholder layouts. -->
        <div class="pd-skin-bar" id="pd-skin-bar" role="toolbar" aria-label="Skin switcher">
          <button type="button" class="pd-skin-bar-btn" data-action="refresh"
                  title="Refresh — reconnect worker + reload account state" aria-label="Refresh">
            <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
              <path d="M20 11a8 8 0 1 0-.6 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
              <path d="M20 4v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </button>
          <button type="button" class="pd-skin-bar-btn" data-action="minimize"
                  title="Minimize" aria-label="Minimize">
            <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
              <rect x="5" y="11" width="14" height="2" rx="1" fill="currentColor"/>
            </svg>
          </button>
          <button type="button" class="pd-skin-bar-btn" data-action="phone"
                  title="Branded phone skin" aria-label="Branded phone skin">
            <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/phone-32.png") || ""}"
                 alt="" aria-hidden="true" draggable="false"/>
          </button>
          <!-- v1.32.9 — None-Branded phone skin (clean blank screen). -->
          <button type="button" class="pd-skin-bar-btn" data-action="classic"
                  title="Classic phone skin" aria-label="Classic phone skin">
            <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/classic-32.png") || ""}"
                 alt="" aria-hidden="true" draggable="false"/>
          </button>
          <!-- v1.31.89 — Goal skin removed from top bar per Dustin. Still
               available in the Setup drawer's skin radio chooser below. -->
          <button type="button" class="pd-skin-bar-btn" data-action="jersey"
                  title="Jersey skin" aria-label="Jersey skin">
            <!-- v1.31.79 — Replace SVG with Dustin's jersey reference PNG
                 (recolored to GPD grass-green brand palette). -->
            <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/jersey-32.png") || ""}"
                 alt="" aria-hidden="true" draggable="false"/>
          </button>
          <!-- v1.31.80 — 5th symbol links back to the large active ball skin. -->
          <button type="button" class="pd-skin-bar-btn" data-action="ball"
                  title="Soccer ball skin" aria-label="Soccer ball skin">
            <img class="pd-skin-bar-img" src="${chrome?.runtime?.getURL?.("icons/soccerball-32.png") || ""}"
                 alt="" aria-hidden="true" draggable="false"/>
          </button>
        </div>
        <!-- v1.31.75 — Ball + jersey skin face. Display:none unless
             pd-skin-ball or pd-skin-jersey is active on pd-root.
             Decorative center graphic + maximize + drawer toggle.
             All real controls live in pd-skin-drawer below. -->
        <div class="pd-skin-face" id="pd-skin-face">
          <div class="pd-skin-face-graphic" id="pd-skin-face-graphic" aria-hidden="true"></div>
          <!-- v1.31.80 — Blue shoulder ball on the jersey skin. Transparent
               overlay positioned over the ball graphic in jersey-large.png.
               Click switches to the active ball skin. Hidden on non-jersey. -->
          <button type="button" class="pd-jersey-shoulder-ball" id="pd-jersey-shoulder-ball"
                  title="Switch to Soccer Ball skin" aria-label="Switch to Soccer Ball skin"></button>
          <button type="button" class="pd-skin-face-max" id="pd-skin-face-max"
                  title="Maximize — return to large skin" aria-label="Maximize">
            <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
              <path d="M4 4 L10 4 M4 4 L4 10 M20 4 L14 4 M20 4 L20 10 M4 20 L10 20 M4 20 L4 14 M20 20 L14 20 M20 20 L20 14"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>
            </svg>
          </button>
          <button type="button" class="pd-skin-face-drawer-toggle" id="pd-skin-face-drawer-toggle"
                  title="Open menu" aria-label="Open menu">
            <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
              <path d="M9 6 L15 12 L9 18" stroke="currentColor" stroke-width="2.5"
                    stroke-linecap="round" stroke-linejoin="round" fill="none"/>
            </svg>
          </button>
        </div>
        <!-- v1.31.75 — Shared slide-out drawer used by ball + jersey
             skins. Contains all action buttons (delegated to the existing
             main-panel button IDs), toggles synced with the real ones,
             live dial stats, and a permissions section. -->
        <div class="pd-skin-drawer" id="pd-skin-drawer" aria-label="Skin drawer">
          <div class="pd-skin-drawer-header">
            <span class="pd-skin-drawer-title">Controls</span>
            <button type="button" class="pd-skin-drawer-close" id="pd-skin-drawer-close"
                    title="Close menu" aria-label="Close menu">
              <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
                <path d="M15 6 L9 12 L15 18" stroke="currentColor" stroke-width="2.5"
                      stroke-linecap="round" stroke-linejoin="round" fill="none"/>
              </svg>
            </button>
          </div>
          <div class="pd-skin-drawer-actions">
            <button type="button" class="pd-drawer-btn pd-drawer-start" data-act="start">
              <span class="pd-drawer-icon">▶</span><span>Start Session</span>
            </button>
            <button type="button" class="pd-drawer-btn pd-drawer-endsess" data-act="endsess">
              <span class="pd-drawer-icon">⏹</span><span>End Session</span>
            </button>
            <button type="button" class="pd-drawer-btn pd-drawer-autopilot pd-drawer-wide" data-act="autopilot">
              <span class="pd-drawer-icon">⏸</span><span class="pd-drawer-autopilot-label">Autopilot</span>
            </button>
            <button type="button" class="pd-drawer-btn pd-drawer-endcall" data-act="endcall">
              <span class="pd-drawer-icon">📞</span><span>End Call</span>
            </button>
            <button type="button" class="pd-drawer-btn pd-drawer-autoattend" data-act="autoattend">
              <span class="pd-drawer-icon">🛡️</span><span>Auto-attend</span>
            </button>
          </div>
          <div class="pd-skin-drawer-section">
            <div class="pd-skin-drawer-h">Toggles</div>
            <label class="pd-drawer-row">
              <input type="checkbox" id="pd-drawer-auto-attend-cb"/>
              <span>Auto-attendant hangup</span>
            </label>
            <label class="pd-drawer-row">
              <input type="checkbox" id="pd-drawer-call-from-comments-cb"/>
              <span>Call from comments</span>
            </label>
          </div>
          <div class="pd-skin-drawer-section">
            <div class="pd-skin-drawer-h">Dial Tracker</div>
            <div class="pd-skin-drawer-stats" id="pd-drawer-stats">0 dials · 0 contacts</div>
          </div>
          <div class="pd-skin-drawer-section">
            <div class="pd-skin-drawer-h">Permissions</div>
            <button type="button" class="pd-drawer-perm" id="pd-drawer-perm-mic">
              🎤 Request microphone
            </button>
          </div>
        </div>
        <div class="pd-side pd-side-power" aria-hidden="true"></div>
        <div class="pd-side pd-side-volup" aria-hidden="true"></div>
        <div class="pd-side pd-side-voldn" aria-hidden="true"></div>

        <!-- v1.31.80 — Bottom drawer + FAB removed per Dustin.
             Settings panel + Options button cover the same controls. -->

        <!-- Goal skin only: ball mid-flight into the top-right of the goal -->
        <div class="pd-goal-ball" aria-hidden="true">
          <div class="pd-goal-ball-trail pd-goal-ball-trail-1"></div>
          <div class="pd-goal-ball-trail pd-goal-ball-trail-2"></div>
          <div class="pd-goal-ball-trail pd-goal-ball-trail-3"></div>
          ${realisticBallSvg({ idSuffix: "goal", panelColor: "#0a0a0a", size: 96 })}
        </div>

        <div class="pd-screen" id="pd-screen">
          <div class="pd-statusbar" id="pd-drag">
            <span class="pd-sb-time" id="pd-sb-time">9:41</span>
            <span class="pd-sb-camera" aria-hidden="true"></span>
            <span class="pd-sb-icons">
              <svg class="pd-sb-ico" viewBox="0 0 16 12" width="14" height="11" aria-hidden="true">
                <rect x="0"  y="8" width="2" height="4" rx="0.5"/>
                <rect x="4"  y="6" width="2" height="6" rx="0.5"/>
                <rect x="8"  y="3" width="2" height="9" rx="0.5"/>
                <rect x="12" y="0" width="2" height="12" rx="0.5"/>
              </svg>
              <svg class="pd-sb-ico" viewBox="0 0 16 12" width="14" height="11" aria-hidden="true">
                <path d="M8 11.5 L9.6 9.9 A 2.3 2.3 0 0 0 6.4 9.9 Z"/>
                <path d="M8 8.5 L11.3 5.2 A 4.7 4.7 0 0 0 4.7 5.2 Z" opacity="0.75"/>
                <path d="M8 4.7 L13.0 -0.3 A 7.1 7.1 0 0 0 3.0 -0.3 Z" opacity="0.5"/>
              </svg>
              <span class="pd-sb-bat"><span class="pd-sb-bat-fill"></span></span>
            </span>
          </div>

          <div class="pd-header">
            <div class="pd-title">
              <span class="pd-dot"></span>
              <span class="pd-title-text">Power Dialer</span>
              <button class="pd-iconbtn pd-skin-toggle" id="pd-skin-toggle" title="Switch between phone and goal themes">📱</button>
            </div>
            <div class="pd-header-btns">
              <button class="pd-iconbtn" id="pd-refresh" title="Hard reset — reconnect worker &amp; clear a stuck autopilot, without reloading your CRM page">↻</button>
              <button class="pd-iconbtn" id="pd-collapse" title="Minimize to soccer ball">–</button>
              <!-- v1.31.80 — Summary O, Maximize ⛶, Hide × removed per Dustin
                   (new skin chooser bar covers their functionality). -->

            </div>
          </div>

          <!-- v1.34 — Prominent Setup CTA for the phone Start screen so new
               agents click it first. Hidden except on the phone Start screen
               (.pd-session-idle) via CSS; wired to the same settings drawer as
               the small Setup pill. -->
          <div id="pd-setup-cta"><button type="button" id="pd-setup-cta-btn">⚙ Setup — sign in &amp; set up</button></div>

          <!-- Inline action pills — moved out of the header so the soccer ball
               in the goal-skin upper-right corner can't sit on top of them. -->
          <div class="pd-inline-actions">
            <button class="pd-pill-btn" id="pd-history-btn" title="Session call history">
              <span class="pd-pill-icon">≡</span>
              <span class="pd-pill-lbl">History</span>
            </button>
            <button class="pd-pill-btn" id="pd-jersey-btn" title="Today's stats jersey">
              <span class="pd-pill-icon">▼</span>
              <span class="pd-pill-lbl">Stats jersey</span>
            </button>
            <button class="pd-pill-btn" id="pd-setup-btn" title="Open setup / settings">
              <span class="pd-pill-icon">⚙</span>
              <span class="pd-pill-lbl">Setup</span>
            </button>
          </div>

          <!-- ── Mode row (v1.28.0) ──────────────────────────────────────
               Three big buttons that select the audio source for this
               session. Active button gets a highlight ring. Selection
               persists across sessions in chrome.storage.local and the
               user's account record. Below the row is the mode-info
               banner that swaps content based on the active mode.

               IMPORTANT: this only changes WHERE audio goes (cell/mobile/
               desktop). All the call controls (Start Auto, Pause Auto,
               dial input, End Call, Bridge, Auto-Attendant, toggles)
               live BELOW this in the shared dialer body.
          -->
          <!-- v1.29.0: Cell mode REMOVED. Only Mobile + Desktop (both
               WebRTC) are supported. Saves ~$0.019/dial in PSTN agent-leg
               costs and ~$0.004/min in media streaming we no longer need. -->
          <div class="pd-mode-row pd-mode-row-2col" id="pd-mode-row">
            <button class="pd-mode-btn" data-mode="mobile_web" id="pd-mode-mobile"  title="Audio on /mobile in your phone or tablet browser">
              <span class="pd-mode-emoji">📲</span>
              <span class="pd-mode-lbl">Mobile App</span>
            </button>
            <button class="pd-mode-btn" data-mode="desktop"    id="pd-mode-desktop" title="Audio on your laptop headset via embedded WebRTC">
              <span class="pd-mode-emoji">🖥️</span>
              <span class="pd-mode-lbl">Desktop</span>
            </button>
          </div>

          <!-- Mode-info banner — content swaps based on .pd-mode-btn.active.
               Shows the audio-source destination + park status for the
               selected mode. Each mode owns one .pd-mode-pane div; only
               the active pane is visible. -->
          <div class="pd-mode-info" id="pd-mode-info">
            <!-- Cell pane REMOVED in v1.29.0 — Phone parking is gone. -->
            <!-- v1.29.72 — Compacted source rows. Dropped the "Audio →"
                 prefix (redundant with the mode-row above) and the verbose
                 multi-line hint. Pip + label are inline with the icon
                 + link, on a single 22px-tall row. -->
            <!-- Mobile pane -->
            <!-- v1.33 Phase 4 — Dynamic chooser of mobile devices that
                 signed in with this agent's phone identifier. Lets the
                 agent select which mobile receives the audio. The
                 worker uses activeMobilePairDeviceId for SIP routing
                 when parkSource=mobile_web.

                 v1.33 Phase 6 — When the list is empty, the chooser is
                 hidden and a prominent first-run callout (#pd-mobile-
                 setup-popup) takes its place explaining how to register
                 a phone. pdRefreshModeRow() swaps between the two
                 states based on state.userDevices. -->
            <div class="pd-mode-pane" data-mode="mobile_web" id="pd-mode-pane-mobile" style="display:none">
              <div class="pd-mode-source-row">
                <span class="pd-mode-source-icon">📲</span>
                <span class="pd-mode-park-pip" id="pd-mode-mobile-pip"></span>
                <span class="pd-mode-source-text">
                  <span id="pd-mode-mobile-lbl">not connected</span>
                </span>
              </div>

              <!-- v1.33.4 — Setup wizard popup. Now opens on-demand via
                   the "Get mobile app" button (any time) in addition to
                   first-run auto-display when no devices are paired.
                   Copy updated per Dustin's spec:
                     1. visit /mobile on phone
                     2. sign in with same PHONE NUMBER (not email)
                     3. tap Start Session on mobile → ext responds,
                        confirming the pair is wired
                   The "tap Start Session" instruction doubles as a
                   pairing sanity check — if ext responds, agents know
                   they're communicating before they're stuck on a real
                   call. -->
              <div id="pd-mobile-setup-popup" style="display:none; margin-top:8px; padding:10px 12px; background:linear-gradient(180deg, rgba(108,179,255,0.16), rgba(108,179,255,0.06)); border:1px solid rgba(108,179,255,0.45); border-radius:10px;">
                <div style="font-size:12px; font-weight:700; color:#cfe0fa; letter-spacing:0.4px; margin-bottom:6px;">📱 GET THE MOBILE APP</div>
                <div style="font-size:12px; color:rgba(255,255,255,0.85); line-height:1.5; margin-bottom:6px;">
                  Use your phone to take call audio. On your phone:
                </div>
                <ol style="font-size:12px; color:rgba(255,255,255,0.85); line-height:1.5; margin:0 0 8px 16px; padding:0;">
                  <li>Visit <strong id="pd-mobile-setup-host" style="color:#cfe0fa;">app.goalpowerdialer.com/mobile</strong></li>
                  <li>Sign in with the <strong>same phone number</strong> you used here: <strong id="pd-mobile-setup-phone" style="color:#cfe0fa; font-family:monospace;">—</strong></li>
                  <li>Tap <strong>Start Session</strong> on the mobile site — your extension will respond, confirming the connection</li>
                </ol>
                <div style="display:flex; gap:6px;">
                  <button type="button" class="pd-btn pd-full" id="pd-mobile-setup-refresh" style="background:rgba(108,179,255,0.20); border:1px solid rgba(108,179,255,0.55); color:#cfe0fa; font-weight:700;">
                    🔄 Refresh
                  </button>
                  <button type="button" class="pd-btn pd-full" id="pd-mobile-setup-copy" style="background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.20); color:#fff;">
                    📋 Copy mobile URL
                  </button>
                </div>
                <div id="pd-mobile-setup-msg" style="font-size:11px; color:rgba(255,255,255,0.7); margin-top:6px; min-height:14px;"></div>
              </div>

              <!-- Chooser: shown when at least one mobile_pwa device is
                   registered under this phone identifier.
                   v1.33.4 — Tightened spacing. Unlink + Get mobile app
                   now render as real <button>s with matching button
                   styling instead of the prior text-link look. The
                   "Open /mobile" anchor → "Get mobile app" button now
                   toggles #pd-mobile-setup-popup so agents can re-open
                   the wizard any time. -->
              <div id="pd-mobile-chooser-wrap" style="margin-top:6px; display:none;">
                <!-- v1.33.4.6 — "Send audio to" dropdown removed; phone auto-binds (most-recent). -->
                <div style="display:flex; gap:6px; margin-top:6px;">
                  <button type="button" class="pd-btn pd-full" id="pd-mobile-unlink" style="background:rgba(255,102,114,0.15); border:1px solid rgba(255,102,114,0.45); color:#ff8d99; font-weight:600;">
                    Unlink
                  </button>
                  <button type="button" class="pd-btn pd-full" id="pd-mobile-getapp" style="background:rgba(45,171,95,0.18); border:1px solid rgba(45,171,95,0.55); color:#7ceca6; font-weight:600;">
                    📱 Get mobile app
                  </button>
                </div>
                <div id="pd-mobile-chooser-msg" style="font-size:11px; color:rgba(255,255,255,0.6); margin-top:4px;"></div>
              </div>
              <div class="pd-mode-hint" style="margin-top:4px;">Your phone connects automatically. Tap Get mobile app for setup, or Unlink to switch phones.</div>
            </div>
            <!-- Desktop pane -->
            <div class="pd-mode-pane" data-mode="desktop" id="pd-mode-pane-desktop" style="display:none">
              <div class="pd-mode-source-row">
                <span class="pd-mode-source-icon">🖥️</span>
                <span class="pd-mode-park-pip" id="pd-mode-desk-pip"></span>
                <span class="pd-mode-source-text">
                  <strong id="pd-mode-desk-status">this browser (WebRTC)</strong> ·
                  <span id="pd-mode-desk-lbl">not connected</span>
                </span>
              </div>
              <!-- v1.29.73 — Audio settings button got its label back.
                   v1.29.72 dropped it to just an icon to save space, but
                   the 🎛️ glyph doesn't render on most Windows fonts
                   (showed as a placeholder square) AND with no label
                   nobody knew what the button did. Now: clearer icon
                   (⚙️ gear, universally supported), label "Devices",
                   chevron on the right. Lives in a side-by-side row
                   with the mic button so we don't lose v1.29.72's
                   space savings. -->
              <div class="pd-mode-pane-desktop-actions">
                <button class="pd-mode-mic-btn" id="pd-mode-mic-btn" title="One-click mic permission — plug & play">
                  🎤 Enable mic
                </button>
                <button class="pd-audio-settings-btn" id="pd-audio-settings-btn" title="Choose which microphone and speaker the dialer uses">
                  <span class="pd-audio-settings-icon">⚙️</span>
                  <span class="pd-audio-settings-lbl">Devices</span>
                  <span class="pd-audio-settings-chev">›</span>
                </button>
              </div>
              <div class="pd-mode-hint" id="pd-mode-desk-hint">Plug in your headset, click to grant mic, and the dialer routes audio through this browser. No phone needed.</div>

              <!-- v1.29.32 — Slide-out drawer for mic + speaker pickers.
                   Positioned absolute over the desktop mode pane. Hidden
                   by default; .open class slides it in from the right. -->
              <div class="pd-audio-drawer" id="pd-audio-drawer">
                <div class="pd-audio-drawer-backdrop" id="pd-audio-drawer-backdrop"></div>
                <div class="pd-audio-drawer-panel">
                  <div class="pd-audio-drawer-head">
                    <span class="pd-audio-drawer-title">🎛️ Audio devices</span>
                    <button class="pd-audio-drawer-close" id="pd-audio-drawer-close" type="button" aria-label="Close audio settings">×</button>
                  </div>
                  <div class="pd-audio-drawer-body">
                    <div class="pd-mic-picker-row" id="pd-mic-picker-row" style="display:none;">
                      <label class="pd-mic-picker-lbl" for="pd-mic-picker">🎙️ Input device:</label>
                      <select id="pd-mic-picker" class="pd-mic-picker"></select>
                      <div class="pd-mic-current" id="pd-mic-current"></div>
                    </div>
                    <div class="pd-mic-picker-row" id="pd-spk-picker-row" style="display:none;">
                      <label class="pd-mic-picker-lbl" for="pd-spk-picker">🔊 Output device:</label>
                      <select id="pd-spk-picker" class="pd-mic-picker"></select>
                      <div class="pd-mic-current" id="pd-spk-current"></div>
                    </div>
                    <!-- v1.33.3 — Audio test (mic + speaker loopback) -->
                    <div class="pd-audio-test-row" id="pd-audio-test-row" style="margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.08);">
                      <button type="button" id="pd-audio-test-btn" class="pd-audio-test-btn" style="width:100%;background:rgba(45,171,95,0.25) !important;color:#aaf3c3 !important;border:1px solid rgba(45,171,95,0.6) !important;border-radius:6px !important;padding:8px 12px !important;font-size:13px !important;font-weight:600 !important;cursor:pointer !important;font-family:inherit !important;">
                        🎙 Test mic &amp; speaker (5 sec loopback)
                      </button>
                      <div id="pd-audio-test-meter" style="display:none;margin-top:8px;height:14px;background:rgba(0,0,0,0.4);border-radius:4px;overflow:hidden;border:1px solid rgba(255,255,255,0.1);">
                        <div id="pd-audio-test-fill" style="height:100%;width:0%;background:linear-gradient(90deg,#2dab5f,#f2c94c 70%,#ff6b6b);transition:width 100ms ease;"></div>
                      </div>
                      <div id="pd-audio-test-status" style="margin-top:6px;font-size:11px;line-height:1.4;color:rgba(255,255,255,0.7);"></div>
                    </div>
                    <!-- v1.33.3 — Earphone troubleshooting button (orange).
                         Click expands a help panel with headset recommendations + the
                         Bluetooth-profile mic-out fix. Designed to live next to the
                         audio test so agents who fail the test immediately see the
                         help. -->
                    <button type="button" id="pd-earphone-issues-btn" style="width:100%;margin-top:10px;background:#F2994A !important;color:#1a1206 !important;border:0 !important;border-radius:6px !important;padding:9px 12px !important;font-size:12px !important;font-weight:700 !important;cursor:pointer !important;font-family:inherit !important;letter-spacing:0.3px !important;box-shadow:0 2px 6px rgba(242,153,74,0.35) !important;">
                      🎧 Having earphone issues?
                    </button>
                    <div id="pd-earphone-issues-panel" style="display:none;margin-top:8px;padding:10px 12px;background:rgba(242,153,74,0.08);border:1px solid rgba(242,153,74,0.35);border-radius:6px;font-size:11px;line-height:1.5;color:rgba(255,255,255,0.85);">
                      <p style="margin:0 0 8px;"><strong style="color:#F2C94C;">Best for desktop:</strong> wired USB or USB-dongle Bluetooth. Examples: Jabra Evolve 30 II ($80), Plantronics Blackwire 5220 ($100), Logitech H390 ($40).</p>
                      <p style="margin:0 0 8px;"><strong style="color:#F2C94C;">Best for phone (PWA):</strong> wired earbuds OR Jabra Evolve2 65 ($200, USB+Bluetooth combo). Avoid generic Bluetooth — see below.</p>
                      <p style="margin:0 0 8px;"><strong style="color:#ff8a93;">Bluetooth mic-out symptom?</strong> If prospects hear silence from you, your phone likely locked to A2DP profile (music) instead of HSP/HFP (call). Fix: disconnect + reconnect headset, OR use a wired/USB-dongle headset. iOS is especially sticky.</p>
                      <p style="margin:0;"><strong style="color:#aaf3c3;">Run the audio test above</strong> to verify your selection works. Silent results = mic profile problem.</p>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <!-- Desktop-mode status pip — only rendered in desktop dial-mode.
               Tells the agent at a glance whether the embedded WebRTC sandbox
               is ready to accept incoming prospect bridges. -->
          <div class="pd-desk-pip-row" id="pd-desk-pip-row" style="display:none">
            <span class="pd-desk-mini-pip" id="pd-desk-mini-pip"></span>
            <span class="pd-desk-mini-lbl" id="pd-desk-mini-lbl">Desktop: …</span>
          </div>

          <!-- Test mode banner — bright yellow stripe across the panel
               whenever the agent has flipped Test mode on. Hard to miss. -->
          <div class="pd-test-banner" id="pd-test-banner" style="display:none">
            🧪 TEST MODE — no real prospect calls
          </div>

          <div class="pd-body" id="pd-body">
            <div class="pd-profile-row">
              <span class="pd-pill" id="pd-profile-name">Me</span>
              <span class="pd-cell" id="pd-cell">+1 ...</span>
            </div>

            <div class="pd-status-row">
              <span class="pd-status-pip" id="pd-ws-pip"></span>
              <span id="pd-ws-text">Connecting…</span>
              <span class="pd-status-spacer"></span>
              <span class="pd-session-pill" id="pd-session-pill">idle</span>
            </div>

            <div class="pd-stats">
              <div class="pd-stat"><div class="pd-stat-num" id="pd-c-dials">0</div><div class="pd-stat-lbl">Dials</div></div>
              <div class="pd-stat"><div class="pd-stat-num" id="pd-c-noans">0</div><div class="pd-stat-lbl">No Ans</div></div>
              <div class="pd-stat"><div class="pd-stat-num" id="pd-c-done">0</div><div class="pd-stat-lbl">Answered</div></div>
              <!-- v1.29.54 — Reset all three counters to 0. Sends
                   session.counters.reset over the WS; worker zeros its
                   state and emits a counters.reset broadcast that this
                   panel + the mobile companion both pick up. -->
              <button type="button" class="pd-stat-reset" id="pd-c-reset" title="Reset Dials / No Ans / Complete to 0">↺</button>
            </div>

            <!-- Park-source mini-card REMOVED in v1.28.0 — replaced by
                 the .pd-mode-row + .pd-mode-info banner above the body.
                 Park status for the active mode now shows inside that
                 banner; mode is locked at session start. -->

            <div id="pd-session-area"></div>

            <div id="pd-prospect-area"></div>

            <!-- v1.29.21 — Toggles moved here, BELOW the in-call view
                 (Skip/VM Drop/Bridge/Auto-Attd buttons). Was previously
                 inside pd-session-area between the dial section and End
                 Session; user requested they sit under the call controls
                 so end-of-call settings are right where the eye lands
                 when adjusting per-call behavior. -->
            <div id="pd-toggles-area"></div>

            <div class="pd-info" id="pd-info" style="display:none"></div>
          </div>

          <div class="pd-footer">
            <span id="pd-footer-msg"><span id="pd-footer-version">v${(typeof chrome !== "undefined" && chrome.runtime?.getManifest?.()?.version) || "?"}</span> · drag top bar to move</span>
            <span class="pd-link" id="pd-open-tour" title="Show 1-minute tour">?</span>
          </div>

          <div class="pd-gesture" aria-hidden="true"></div>
        </div>
        <!-- Corner resize handle (goal skin only). Drag to scale the
             whole panel up or down, like a window. -->
        <div class="pd-resize-handle" id="pd-resize" title="Drag to resize"></div>

        <!-- Compact "O" mini view: a 2.5″ Tally-style dashboard on Phone
             theme, or a 3″ soccer-ball watermark with overlaid controls
             on Soccer theme. Same DOM, skin CSS reshapes the container.
             Both are scalable via the resize handle. -->
        <div class="pd-mini-card" id="pd-mini-card">
          <!-- Ball watermark layer (Soccer theme only — hidden on Phone) -->
          <div class="pd-mini-ball-bg" aria-hidden="true"></div>
          <!-- Foreground content stays above the watermark -->
          <div class="pd-mini-fg">
            <div class="pd-mini-head">
              <div class="pd-mini-brand">
                <button class="pd-mini-logo" id="pd-mini-logo" title="Click to expand to full view" aria-label="Expand to full view">⚽</button>
                <span class="pd-mini-ver" id="pd-mini-ver">v${(typeof chrome !== "undefined" && chrome.runtime?.getManifest?.()?.version) || "?"}</span>
                <!-- Skin toggle: hidden on Phone theme via CSS, shown on Soccer -->
                <button class="pd-mini-iconbtn pd-mini-skin-toggle" id="pd-mini-skin-toggle" title="Switch between Phone and Soccer themes">📱</button>
              </div>
              <div class="pd-mini-head-btns">
                <!-- History: pill with label on Phone, icon-only on Soccer -->
                <button class="pd-mini-headbtn" id="pd-mini-history" title="Session history">
                  <span class="pd-mini-btn-icon">≡</span><span class="pd-mini-btn-label">History</span>
                </button>
                <!-- Restore: icon-only, hidden on Phone (logo serves that role) -->
                <button class="pd-mini-iconbtn pd-mini-restore-btn" id="pd-mini-restore" title="Expand to full view">⤢</button>
                <!-- End: pill with label on Phone, × icon on Soccer -->
                <button class="pd-mini-headbtn pd-mini-headbtn-end" id="pd-mini-endsess" title="End session">
                  <span class="pd-mini-btn-icon pd-mini-btn-icon-end">⏹</span><span class="pd-mini-btn-label">End</span>
                </button>
              </div>
            </div>
            <div class="pd-mini-stats3">
              <div class="pd-mini-stat">
                <div class="pd-mini-stat-lbl">Current Session</div>
                <div class="pd-mini-stat-val" id="pd-mini-timer">00:00:00</div>
              </div>
              <div class="pd-mini-stat">
                <div class="pd-mini-stat-lbl">Total Dials</div>
                <div class="pd-mini-stat-val" id="pd-mini-dials">0</div>
              </div>
              <div class="pd-mini-stat">
                <div class="pd-mini-stat-lbl">Skipped</div>
                <div class="pd-mini-stat-val" id="pd-mini-skipped">0</div>
              </div>
            </div>
            <!-- v1.31.80 — Start Session button. Proxies to #pd-start. -->
            <button class="pd-mini-startsess" id="pd-mini-startsess" title="Start a dialing session">
              <span>▶</span><span>Start Session</span>
            </button>
            <!-- v1.31.80 — Autopilot toggle (relabeled from "Start Auto-Click").
                 Same handler — label flips based on state.autoDialMode. -->
            <button class="pd-mini-autoclick" id="pd-mini-pause" title="Start / pause autopilot">▶ Autopilot</button>
            <!-- v1.31.80 — Auto Attend quick toggle. Mirrors the
                 #pd-auto-attendant-hangup checkbox in the large skin. -->
            <button class="pd-mini-autoattend" id="pd-mini-autoattend" title="Toggle auto-attendant hangup">
              <span>🛡️</span><span id="pd-mini-autoattend-lbl">Auto Attend OFF</span>
            </button>
            <div class="pd-mini-dialrow">
              <input type="tel" id="pd-mini-num" class="pd-mini-num" placeholder="Client Number to Call" />
              <button class="pd-mini-call" id="pd-mini-call" title="Place call">
                <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92V20a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3.09a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.36a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2z"/></svg>
              </button>
              <button class="pd-mini-endcall" id="pd-mini-endcall" title="End current call">
                <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
              </button>
            </div>
            <div class="pd-mini-status" id="pd-mini-status">Idle</div>
            <div class="pd-mini-bottom-row">
              <!-- v1.31.80 — Renamed Settings → Setup to match the large
                   skin's existing "Setup" button label. Same handler. -->
              <button class="pd-mini-settings" id="pd-mini-settings" title="Open setup">⚙ Setup</button>
              <!-- v1.31.80 — Options button (replaces the old ▶ drawer toggle).
                   Styled identically to Setup, opens the same device-settings
                   panel. Black slide-out drawer + bottom drawer + FAB all
                   removed per Dustin (duplicates of the settings panel). -->
              <button class="pd-mini-settings" id="pd-mini-options" title="Open device options">⚙ Options</button>
            </div>
          </div>
        </div>

        <!-- True minimize ("-" button) — collapses to a tiny 56-px ball icon. -->
        <div class="pd-ball-bubble" id="pd-ball-bubble" title="Click to expand">
          ${realisticBallSvg({ idSuffix: "bubble", panelColor: "#0a0a0a", size: 56 })}
          <span class="pd-ball-badge" id="pd-ball-badge"></span>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    // v1.33.4.1 — CSS race-guard. Mike Wallis reported 2026-06-25
    // and Dustin saw twice: pd-root renders in the DOM but its skin
    // styling is missing — only the in-call overlay shows because
    // those styles are inline. Root cause: content.css and content.js
    // both load at document_idle per manifest, and in some load
    // orders (cold-start Impact tab, slow network re-validation of
    // the CSS file) Chrome hasn't finished parsing content.css by
    // the time we insert pd-root. The agent sees an unstyled panel
    // and reinstalls the extension thinking it's broken.
    //
    // Probe getComputedStyle().position against the sentinel rule
    // `#pd-root { position: fixed }` (content.css line 7). If it
    // reports "static" (browser default), CSS didn't load yet —
    // hide pd-root via inline style, poll every 100ms, unhide when
    // the rule applies (max 10 attempts = 1s). Belt-and-suspenders
    // fallback after 1s: unhide anyway + auto-submit a diag so we
    // can see how often it fires fleet-wide.
    try {
      const _initialPos = getComputedStyle(root).position;
      if (_initialPos !== "fixed") {
        root.style.visibility = "hidden";
        let _cssAttempts = 0;
        const _cssCheck = () => {
          if (!document.getElementById("pd-root")) return; // panel removed
          const pos = getComputedStyle(root).position;
          if (pos === "fixed") {
            root.style.visibility = "";
            try { log("info", `[v1.33.4.1 css-race] content.css applied after ${_cssAttempts * 100}ms — panel now visible`); } catch {}
            return;
          }
          _cssAttempts++;
          if (_cssAttempts >= 10) {
            // Give up — un-hide so the agent at least sees SOMETHING.
            root.style.visibility = "";
            try { log("warn", "[v1.33.4.1 css-race] content.css still not applied after 1s — un-hiding pd-root as last resort, panel may render without skin"); } catch {}
            try { _pdAutoSubmitWithReason("auto:css-not-loaded-1s"); } catch {}
            return;
          }
          setTimeout(_cssCheck, 100);
        };
        setTimeout(_cssCheck, 100);
      }
    } catch (_e) { /* defensive — getComputedStyle is universally supported but be safe */ }

    // ↻ Refresh — reconnect worker WS + reload config from storage +
    // refresh desktop status + repaint. The "instant fix" button when the
    // panel feels stale (which happens occasionally after Impact navigates
    // a few times in quick succession or the cell wakes from sleep).
    $("pd-refresh")?.addEventListener("click", () => {
      const btn = $("pd-refresh");
      if (btn) {
        btn.classList.add("pd-spinning");
        setTimeout(() => btn.classList.remove("pd-spinning"), 800);
      }
      // v1.33.7.11 — HARD RESET. Previously ↻ only rebuilt the WS/audio
      // connection. Now it ALSO clears the client-side wedge that a full page
      // reload (Ctrl+Shift+R) clears — stuck dial/reconnect timers, a stale
      // lead-lock, a leftover prospect, cross-nav advance/double-tap flags, and
      // the WS reconnect/supersede counters — so a wedged autopilot recovers
      // WITHOUT rebuilding Geopointe (which is slow + hits the CRM hard). An
      // ACTIVE or in-flight prospect call is PRESERVED (we never hang up a live
      // conversation). maybeAutoDial() self-gates on state.autoDialMode + parked
      // + no prospect, so re-arming is a no-op when autopilot is off / a call is live.
      const _live = !!(state.prospect &&
        ["dialing", "ringing", "answered", "connected", "active"].includes(state.prospect.status));
      try {
        // 1. Kill pending client timers that could fire stale after reconnect.
        for (const k of ["autoDialTimer", "dialChainTimer", "wsStableTimer", "wsReconnectTimer"]) {
          try { clearTimeout(state[k]); } catch (_e) {}
          state[k] = null;
        }
        // 2. Clean WS reconnect/supersede state so the fresh connect owns the session.
        state.wsReconnectAttempts = 0;
        state.wsSuperseded = false;
        // 3. Release a stuck lead-lock + stale prospect ONLY when there's no live call.
        if (!_live) {
          try { releaseActiveLeadLock("hard-reset"); } catch (_e) {}
          state.prospect = null;
          state.callStartedAt = 0;
        }
        // 4. Clear cross-navigation flags that can wedge the advance / double-tap loop.
        try { chrome.storage.local.remove(["pd_pending_advance_at", "pd_double_tap_state"]); } catch (_e) {}
      } catch (_e) {}
      try {
        if (state.ws) try { state.ws.close(); } catch {}
        state.ws = null;
        try { disconnectAudioWs(); } catch {}
      } catch {}
      loadConfig().then(() => {
        try { connectWs(); } catch {}
        try { connectAudioWs(); } catch {}
        try { refreshDesktopStatusInDrawer(); } catch {}
        paint();
        // 5. Re-arm autopilot if it was on (self-gated — no-op otherwise / when a call is live).
        if (!_live) setTimeout(() => { try { maybeAutoDial(); } catch (_e) {} }, 1800);
        toast(_live ? "🔄 Worker reconnected (call preserved)" : "🔄 Hard reset — worker + autopilot recovered", { ms: 1800 });
      }).catch(() => {});
    });

    // Each header button toggles its own mode. Re-clicking restores full.
    //   – = minimize to soccer ball (~0.5" icon)
    //   O = mini summary strip with Pause / End Call / End buttons
    //   ⛶ = maximize / restore (existing handler below)
    $("pd-collapse").addEventListener("click", () => {
      setSizeMode(state.sizeMode === "ball" ? "full" : "ball");
    });
    $("pd-summary")?.addEventListener("click", () => {
      setSizeMode(state.sizeMode === "mini" ? "full" : "mini");
    });
    // Ball-bubble click → expand back to full.
    $("pd-ball-bubble")?.addEventListener("click", () => setSizeMode("full"));
    // Mini strip: restore + three action buttons
    $("pd-mini-restore")?.addEventListener("click", () => {
      // v1.31.80 — Maximize returns to the LAST LARGE SKIN the user
      // selected (phone or goal), not just whatever skin is currently
      // active. Without this, "maximize" on a jersey watermark would
      // leave you stranded on goal-skin large even if you came from phone.
      const target = state.lastLargeSkin === "goal" ? "goal" : "phone";
      // Clear jersey watermark when going back to large.
      const root = document.getElementById("pd-root");
      if (root) {
        root.classList.remove("pd-jersey-bg");
        state.jerseyBg = false;
        try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
      }
      setSkin(target);
      setSizeMode("full");
    });
    $("pd-mini-pause")?.addEventListener("click", () => {
      // Same toggle as the inline Autopilot button — only meaningful when parked
      if (state.session !== "parked") {
        toast("Start a session first");
        return;
      }
      state.autoDialMode = !state.autoDialMode;
      try { chrome.storage.local.set({ pd_autodial: state.autoDialMode }); } catch {}
      if (state.autoDialMode) clearSessionDialedMemory("mini toggle");  // v1.33.5.2 fresh pass
      toast(state.autoDialMode ? "Autopilot resumed" : "Autopilot paused");
      paint();
    });
    $("pd-mini-endcall")?.addEventListener("click", () => {
      if (state.prospect) skipProspect();
      else toast("No active call");
    });
    $("pd-mini-endsess")?.addEventListener("click", () => {
      // v1.31.80 — Force-kill any stuck local audio FIRST, regardless of
      // state.session. Rana's "can't stop ringing while idle" symptom
      // traced to this handler bailing on idle without touching the
      // ringback / dial-fill audio that was still running locally.
      try { stopRingback(); } catch {}
      try { if (typeof stopSyntheticRingback === "function") stopSyntheticRingback(); } catch {}
      try {
        document.querySelectorAll("audio").forEach((a) => {
          try { a.pause(); a.currentTime = 0; a.loop = false; } catch {}
        });
      } catch {}
      if (state.session === "idle") { setSizeMode("full"); return; }
      // v1.33.2.4 — Route through the confirm wrapper. paint() rebuilds
      // togglesArea + mini buttons on every WS-status change, and the
      // offscreen-unavailable cohort has WS reconnect storms (visible
      // in tail as canceled /session/<phone> spans — 19/12/9
      // cancellations per agent in 9-min windows). Misclick on the
      // wrong rendered element used to nuke session state without
      // recovery; now we confirm when there's anything to lose.
      endSessionFromButton();
    });
    // New mini elements: History panel, Settings drawer, Call number input
    $("pd-mini-history")?.addEventListener("click", () => {
      try { toggleHistoryPanel(); } catch {}
    });
    // v1.31.80 — Options popup. Opens a focused modal with the Devices
    // button (proxies to #pd-audio-settings-btn) + the 6 real toggles
    // (double-tap, away, AA hangup, call-from-comments, bottom-to-top,
    // manual disposition) bidirectionally synced with the large-skin
    // checkboxes. Built lazily on first open so we don't bloat the
    // initial paint() string.
    $("pd-mini-options")?.addEventListener("click", () => {
      let popup = document.getElementById("pd-options-popup");
      if (!popup) {
        popup = document.createElement("div");
        popup.id = "pd-options-popup";
        popup.className = "pd-options-popup";
        popup.innerHTML = `
          <div class="pd-options-popup-card">
            <div class="pd-options-popup-head">
              <span>Options</span>
              <button type="button" class="pd-options-popup-close" aria-label="Close">×</button>
            </div>
            <button type="button" class="pd-options-popup-devices">
              <span>⚙️</span><span>Devices</span><span>›</span>
            </button>
            <div class="pd-options-popup-toggles">
              <div class="pd-options-popup-h">Toggles</div>
              <label class="pd-options-popup-toggle"><input type="checkbox" data-pd-sync="pd-double-tap"/><span>Double-tap End</span></label>
              <!-- v1.33.4.5 — Away Mode toggle removed for now. -->
              <label class="pd-options-popup-toggle"><input type="checkbox" data-pd-sync="pd-auto-attendant-hangup"/><span>Auto-attendant hangup</span></label>
              <label class="pd-options-popup-toggle"><input type="checkbox" data-pd-sync="pd-call-from-comments"/><span>Call from Comments</span></label>
              <label class="pd-options-popup-toggle"><input type="checkbox" data-pd-sync="pd-bottom-to-top"/><span>Bottom to Top</span></label>
              <label class="pd-options-popup-toggle"><input type="checkbox" data-pd-sync="pd-manual-disposition"/><span>Manual disposition</span></label>
            </div>
          </div>
        `;
        document.body.appendChild(popup);
        popup.querySelector(".pd-options-popup-close").addEventListener("click", () => {
          popup.classList.remove("open");
        });
        popup.addEventListener("click", (ev) => {
          if (ev.target === popup) popup.classList.remove("open");
        });
        popup.querySelector(".pd-options-popup-devices").addEventListener("click", () => {
          popup.classList.remove("open");
          // v1.31.80 — Try clicking the real button first (it refreshes
          // the device picker labels). On ball/jersey skins that button
          // is hidden, so offsetParent is null — fall back to opening
          // the audio drawer directly via its .open class.
          const real = document.getElementById("pd-audio-settings-btn");
          const audioDrawer = document.getElementById("pd-audio-drawer");
          if (real && real.offsetParent !== null) {
            try { real.click(); } catch {}
          } else if (audioDrawer) {
            audioDrawer.classList.add("open");
          } else {
            try { toggleSettingsDrawer(); } catch {}
          }
        });
        popup.querySelectorAll("input[data-pd-sync]").forEach((cb) => {
          cb.addEventListener("change", () => {
            const realId = cb.getAttribute("data-pd-sync");
            const real = document.getElementById(realId);
            if (real && real.checked !== cb.checked) {
              real.checked = cb.checked;
              real.dispatchEvent(new Event("change", { bubbles: true }));
            }
          });
        });
      }
      popup.querySelectorAll("input[data-pd-sync]").forEach((cb) => {
        const realId = cb.getAttribute("data-pd-sync");
        const real = document.getElementById(realId);
        if (real) cb.checked = real.checked;
      });
      popup.classList.add("open");
    });
    $("pd-mini-settings")?.addEventListener("click", () => {
      try { toggleSettingsDrawer(); } catch {}
    });
    $("pd-mini-call")?.addEventListener("click", () => {
      const inp = $("pd-mini-num");
      const num = inp ? inp.value.trim() : "";
      if (!num) { toast("Type a number first"); return; }
      try { manualDialFromMini(num); } catch (e) { toast("Dial failed: " + (e.message || "unknown"), { error: true }); }
    });
    const miniNum = $("pd-mini-num");
    if (miniNum) {
      miniNum.addEventListener("keydown", (e) => {
        if (e.key === "Enter") { e.preventDefault(); $("pd-mini-call")?.click(); }
      });
    }
    applySizeMode();
    // v1.31.80 — pd-max + pd-hide handlers kept guard-only since the
    // buttons were removed from the header (skin chooser bar replaces
    // their functionality). Optional-chaining the addEventListener so
    // the missing elements don't throw.
    $("pd-max")?.addEventListener("click", () => {
      state.maximized = !state.maximized;
      root.classList.toggle("pd-maximized", state.maximized);
      if (state.maximized && state.collapsed) {
        state.collapsed = false;
        root.classList.remove("pd-collapsed");
      }
      chrome.storage.local.set({
        pd_maximized: state.maximized,
        pd_collapsed: state.collapsed,
      });
    });
    $("pd-hide")?.addEventListener("click", () => { root.style.display = "none"; });
    // Setup pill (next to History / Stats jersey) opens the in-panel
    // Settings drawer. Replaces the old footer gear ⚙ icon.
    $("pd-setup-btn")?.addEventListener("click", () => toggleSettingsDrawer());
    $("pd-setup-cta-btn")?.addEventListener("click", () => toggleSettingsDrawer());
    $("pd-open-tour")?.addEventListener("click", () => startOnboarding({ force: true }));
    $("pd-history-btn")?.addEventListener("click", () => toggleHistoryPanel());
    // v1.29.54 — Counter reset. Confirm to avoid accidental clicks,
    // then send session.counters.reset to the worker. The worker zeros
    // its counters and broadcasts counters.reset to every surface,
    // which wipes the local state.counters here AND the mobile's view.
    $("pd-c-reset")?.addEventListener("click", () => {
      if (!confirm("Reset Dials / No Ans / Complete back to 0?")) return;
      // v1.33.2.4 — Wipe local + storage IMMEDIATELY, regardless of WS
      // state. The previous behavior was: if WS open, send the reset
      // message and wait for the worker's counters.reset broadcast to
      // come back and wipe local. But agents who hit Reset then
      // refresh the browser before the broadcast arrived (Brandon
      // Keesler 2026-06-23: "I hit it and refresh the browser and it
      // doesn't reset") got reloaded into stale storage values
      // (136 dials / 130 no-ans / 6 answered survived the reset).
      // chrome.storage.local.set is async, but content.js's boot reads
      // pd_counters synchronously from storage during init — so we
      // need the storage write committed BEFORE any reload. Wipe
      // local state + storage now, then ALSO notify the worker (when
      // WS exists) so it zeros its in-memory counters too.
      state.counters = {
        dials: 0, contacts: 0, voicemails: 0,
        noAnswers: 0, badNumbers: 0, answered: 0, completed: 0,
      };
      state.sessionStartCounters = { ...state.counters };
      persistCounters();
      try { paint && paint(); } catch (_e) {}
      if (state.ws && state.ws.readyState === 1) {
        try { state.ws.send(JSON.stringify({ type: "session.counters.reset" })); } catch (_e) {}
      }
      try { toast("🔄 Counters reset to 0", { ms: 2500 }); } catch {}
    });
    $("pd-jersey-btn")?.addEventListener("click", () => toggleJerseyDropdown());
    // Theme toggle — flips phone ↔ goal skin in place. Used by both the
    // full-panel header button and the mini-card header button.
    function flipSkin() {
      const next = state.skin === "goal" ? "phone" : "goal";
      state.skin = next;
      try { chrome.storage.local.set({ pd_skin: next }); } catch {}
      const r = document.getElementById("pd-root");
      if (r) {
        _pdResetSkinGeometry(r);
        r.classList.remove("pd-skin-phone", "pd-skin-goal", "pd-skin-ball", "pd-skin-jersey", "pd-skin-medium", "pd-skin-soccer-medium");
        r.classList.add("pd-skin-" + next);
      }
      // v1.31.73 — Apply the destination skin's saved scale + pos
      reloadGeometryForCurrentSkin();
      if (r) {
      }
      // Sync icon + tooltip on BOTH skin-toggle buttons
      const sync = (btn) => {
        if (!btn) return;
        btn.textContent = next === "goal" ? "⚽" : "📱";
        btn.title = next === "goal"
          ? "Currently Soccer — click to switch to Phone"
          : "Currently Phone — click to switch to Soccer";
      };
      sync(document.getElementById("pd-skin-toggle"));
      sync(document.getElementById("pd-mini-skin-toggle"));
    }
    $("pd-skin-toggle")?.addEventListener("click", flipSkin);
    $("pd-mini-skin-toggle")?.addEventListener("click", flipSkin);

    // ── Mode-row wiring (v1.28.0) ─────────────────────────────────────
    // Three buttons select the audio source for this session. Active
    // button gets ".active" class. Below the row, the matching
    // .pd-mode-pane is shown, the other two hidden.
    //
    // Mode persists to chrome.storage.local (pd_dial_mode) AND to the
    // v1.29.8 — Dual SIP credential architecture: the offscreen Desktop
    // WebRTC client and the mobile site each have their OWN Telnyx SIP
    // credential, so they can stay registered concurrently without
    // fighting. The mode pill no longer needs to tear anything down —
    // it just tells the worker which credential to route the agent leg
    // to at session.start time.
    //
    // We still keep the offscreen client ALIVE in both modes so it's
    // ready to take a call instantly if the agent flips back to Desktop
    // mid-session. Calling pd_desktop_enable on EVERY mode change is
    // idempotent (background.js no-ops if already running) and ensures
    // the offscreen is up after extension reload.
    // v1.29.31 — Mic picker: refresh the dropdown from offscreen, restore
    // the saved selection from chrome.storage, and wire change events.
    // Called on panel mount, after mode switches to Desktop, and after a
    // successful mic grant (when device labels first become readable).
    async function refreshMicPicker() {
      const row    = $("pd-mic-picker-row");
      const sel    = $("pd-mic-picker");
      const status = $("pd-mic-current");
      if (!row || !sel) return;
      // Only show in Desktop WebRTC mode.
      if ((state.activeParkSource || "desktop") !== "desktop") {
        row.style.display = "none";
        return;
      }
      try {
        const resp = await new Promise((resolve) => {
          chrome.runtime.sendMessage({ type: "pd_off_list_mics" }, (r) => {
            void chrome.runtime.lastError;
            resolve(r);
          });
        });
        if (!resp?.ok || !Array.isArray(resp.mics) || resp.mics.length === 0) {
          row.style.display = "none";
          return;
        }
        // Saved selection wins; otherwise default to OS default (empty value).
        const data = await new Promise((r) => chrome.storage.local.get(["pd_desktop_mic_id"], r));
        const savedId = data.pd_desktop_mic_id || "";
        sel.innerHTML = "";
        const def = document.createElement("option");
        def.value = "";
        def.textContent = "Default (OS-selected)";
        sel.appendChild(def);
        for (const m of resp.mics) {
          const opt = document.createElement("option");
          opt.value = m.deviceId;
          opt.textContent = m.label;
          sel.appendChild(opt);
        }
        sel.value = savedId;
        row.style.display = "";
        // Status line below the dropdown shows the human-readable label.
        const currentLabel = sel.options[sel.selectedIndex]?.textContent || "Default";
        if (status) status.textContent = `In use: ${currentLabel}`;
      } catch (err) {
        console.warn("[pd] refreshMicPicker failed:", err);
      }
    }
    window.__pdRefreshMicPicker = refreshMicPicker;

    // Persist + push the new selection to the offscreen. The offscreen
    // uses replaceTrack() to swap mics without a SIP re-INVITE.
    async function onMicPickerChange() {
      const sel    = $("pd-mic-picker");
      const status = $("pd-mic-current");
      if (!sel) return;
      const newId = sel.value || "";
      try {
        await new Promise((r) => chrome.storage.local.set({ pd_desktop_mic_id: newId }, r));
      } catch (_e) {}
      chrome.runtime.sendMessage({
        type:     "pd_off_set_mic",
        deviceId: newId || null,
      }, (resp) => {
        void chrome.runtime.lastError;
        const currentLabel = sel.options[sel.selectedIndex]?.textContent || "Default";
        if (status) {
          status.textContent = resp?.ok
            ? `✓ In use: ${currentLabel}` + (resp.switched ? " (switched live)" : "")
            : `⚠ Selection saved, will apply on next call (${resp?.error || ""})`;
        }
      });
    }

    // v1.29.31 — Speaker picker (output device). Mirrors the mic picker
    // pattern. Routes the inbound prospect audio via setSinkId() on the
    // offscreen <audio id="remote-audio"> element.
    async function refreshSpeakerPicker() {
      const row    = $("pd-spk-picker-row");
      const sel    = $("pd-spk-picker");
      const status = $("pd-spk-current");
      if (!row || !sel) return;
      if ((state.activeParkSource || "desktop") !== "desktop") {
        row.style.display = "none";
        return;
      }
      try {
        const resp = await new Promise((resolve) => {
          chrome.runtime.sendMessage({ type: "pd_off_list_speakers" }, (r) => {
            void chrome.runtime.lastError;
            resolve(r);
          });
        });
        if (!resp?.ok || !Array.isArray(resp.speakers) || resp.speakers.length === 0) {
          row.style.display = "none";
          return;
        }
        const data = await new Promise((r) => chrome.storage.local.get(["pd_desktop_speaker_id"], r));
        const savedId = data.pd_desktop_speaker_id || "";
        sel.innerHTML = "";
        const def = document.createElement("option");
        def.value = "";
        def.textContent = "Default (system)";
        sel.appendChild(def);
        for (const s of resp.speakers) {
          const opt = document.createElement("option");
          opt.value = s.deviceId;
          opt.textContent = s.label;
          sel.appendChild(opt);
        }
        sel.value = savedId;
        row.style.display = "";
        const currentLabel = sel.options[sel.selectedIndex]?.textContent || "Default";
        if (status) status.textContent = `In use: ${currentLabel}`;
      } catch (err) {
        console.warn("[pd] refreshSpeakerPicker failed:", err);
      }
    }
    window.__pdRefreshSpeakerPicker = refreshSpeakerPicker;

    async function onSpeakerPickerChange() {
      const sel    = $("pd-spk-picker");
      const status = $("pd-spk-current");
      if (!sel) return;
      const newId = sel.value || "";
      try {
        await new Promise((r) => chrome.storage.local.set({ pd_desktop_speaker_id: newId }, r));
      } catch (_e) {}
      chrome.runtime.sendMessage({
        type:     "pd_off_set_speaker",
        deviceId: newId || null,
      }, (resp) => {
        void chrome.runtime.lastError;
        const currentLabel = sel.options[sel.selectedIndex]?.textContent || "Default";
        if (status) {
          status.textContent = resp?.ok
            ? `✓ In use: ${currentLabel}`
            : `⚠ Couldn't switch: ${resp?.error || "unknown error"}`;
        }
      });
    }

    function applyOffscreenWebRtcForMode(mode) {
      try {
        const tok = (state.profiles?.[state.activeProfile]?.authToken
                  || state.account?.authToken
                  || "").trim();
        const base = state.workerBase
                  || state.account?.workerBase
                  || "https://app.goalpowerdialer.com";
        // v1.29.26 — Even though we have distinct _mobile and _desktop SIP
        // credentials, in practice both clients answering the same INVITE
        // has been observed (likely a stale Telnyx registration from a
        // prior mode). The safe rule: in Mobile App mode, TEAR DOWN the
        // offscreen entirely so the computer mic CANNOT be in the audio
        // path. In Desktop mode, keep it alive so the offscreen takes
        // the call.
        if (mode === "mobile_web") {
          chrome.runtime.sendMessage({ type: "pd_desktop_disable" }, () => {
            void chrome.runtime.lastError;
          });
        } else {
          chrome.runtime.sendMessage({
            type: "pd_desktop_enable",
            token: tok,
            workerBase: base,
          }, () => { void chrome.runtime.lastError; });
          // v1.29.31 — Push the saved mic selection right after enable so
          // a fresh offscreen boots with the right device. Small delay so
          // the offscreen has acquired mic (enumerateDevices needs that).
          setTimeout(() => {
            chrome.storage.local.get(["pd_desktop_mic_id"], (data) => {
              const savedId = data?.pd_desktop_mic_id || null;
              if (!savedId) return;
              chrome.runtime.sendMessage({
                type:     "pd_off_set_mic",
                deviceId: savedId,
              }, () => { void chrome.runtime.lastError; });
            });
          }, 1200);
        }
      } catch (_e) {}
    }

    // server (/api/me/update activeParkSource) so all surfaces see it.
    //
    // No mid-session switching — clicking a different mode mid-session
    // shows a soft warning that the change takes effect next session.
    state.activeParkSource = state.activeParkSource || "desktop";

    function pdLabelForMode(s) {
      // v1.29.0: "cell" is deprecated — coerce to desktop for any leftover state
      return s === "mobile_web" ? "Mobile App" : "Desktop";
    }
    function pdRefreshModeRow() {
      // v1.29.0: only mobile_web / desktop are valid. Coerce any legacy
      // "cell" stored from earlier versions to "desktop".
      if (state.activeParkSource === "cell" || !state.activeParkSource) {
        state.activeParkSource = "desktop";
      }
      const src = state.activeParkSource;
      // Active button highlight
      document.querySelectorAll(".pd-mode-btn").forEach((b) => {
        b.classList.toggle("active", b.dataset.mode === src);
      });
      // Show only the active pane
      document.querySelectorAll(".pd-mode-pane").forEach((p) => {
        p.style.display = (p.dataset.mode === src) ? "" : "none";
      });
      // Refresh each pane's source-text + park pip
      try {
        // Park status pips — green when session=parked, gray otherwise
        const parked = state.session === "parked" || state.session === "connected" || state.session === "bridging";
        const pipFor = (id, lbl, parkedTxt, idleTxt) => {
          const pip = $(id);
          const text = $(lbl);
          if (pip)  pip.classList.toggle("ok", parked);
          if (text) text.textContent = parked ? parkedTxt : idleTxt;
        };
        pipFor("pd-mode-mobile-pip", "pd-mode-mobile-lbl", "connected",  "not connected");
        pipFor("pd-mode-desk-pip",   "pd-mode-desk-lbl",   "connected",  "not connected");
        // v1.33 Phase 4 — Populate the Mobile App chooser dropdown
        // from state.userDevices (set by /api/me sync). Filters to
        // mobile_pwa devices. Selects the activeMobilePairDeviceId.
        // v1.33 Phase 6 — When mobiles is empty, hide the chooser and
        // show #pd-mobile-setup-popup with first-run instructions
        // (phone identifier + mobile URL + Refresh button).
        try {
          const chooser    = $("pd-mobile-chooser");
          const chooserWrap = $("pd-mobile-chooser-wrap");
          const setupPopup  = $("pd-mobile-setup-popup");
          const setupPhone  = $("pd-mobile-setup-phone");
          // v1.33.7.8 — Mobile URL must follow the DIVISION worker base, not a
          // hardcoded app.goalpowerdialer.com. Liberty = lnl.goalpowerdialer.com/mobile.
          const setupHost   = $("pd-mobile-setup-host");
          if (setupHost) {
            setupHost.textContent = (state.workerBase || "https://app.goalpowerdialer.com")
              .replace(/^https?:\/\//, "").replace(/\/+$/, "") + "/mobile";
          }
          if (chooser || setupPopup) {
            const myPhone = (activeProfile().phone || "").replace(/[^\d]/g, "");
            const mobiles = (state.userDevices || []).filter((d) => {
              if (!d || d.type !== "mobile_pwa") return false;
              // v1.33.4.6 — 14h freshness: ignore phones not seen in 14 hours
              // (overnight reset) so auto-bind + status never pick a stale phone.
              if (Date.now() - (d.lastSeenAt || 0) > 14 * 60 * 60 * 1000) return false;
              // If the agent has a phone identifier, only show mobiles
              // that match. If they don't (legacy account), show all.
              if (!myPhone) return true;
              const dPhone = String(d.phone || "").replace(/[^\d]/g, "");
              return !dPhone || dPhone === myPhone;
            });
            let activeId = state.activeMobilePairDeviceId || "";

            // v1.33.2.2 — Auto-pick when there's exactly one mobile_pwa
            // device AND nothing is currently paired. Eliminates the
            // "no device picked" busywork — most agents only have one
            // phone, and forcing them to manually select from a 1-item
            // dropdown was confusing (especially since command-plane WS
            // bridge worked WITHOUT a pair, masking the fact that audio
            // routing required it). Auto-pair only when (a) one mobile,
            // (b) no current activeId, (c) we haven't tried auto-pairing
            // this device in this paint cycle (guard against re-fire).
            if (!activeId && mobiles.length >= 1) {
              // v1.33.4.6 — No chooser: auto-bind the MOST-RECENTLY-seen phone.
              // Agent swaps by unlinking + opening the app on the phone they
              // want (it becomes most-recent). Worker SIP routing already falls
              // back to most-recent, so the UI matches where audio goes.
              const pick = mobiles.slice().sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0))[0];
              const onlyId = pick.id;
              if (!pdRefreshModeRow._autoPickedFor || pdRefreshModeRow._autoPickedFor !== onlyId) {
                pdRefreshModeRow._autoPickedFor = onlyId;
                console.log(`[pd-mobile-autopick] auto-pairing most-recent mobile ${onlyId} (${pick.name || "no name"})`);
                state.activeMobilePairDeviceId = onlyId;
                activeId = onlyId;
                try { pdSavePairChoice(onlyId, "Auto-pairing your phone"); } catch (_e) {}
              }
            }

            if (mobiles.length === 0) {
              // First-run state — show the setup callout, hide chooser.
              if (chooserWrap) chooserWrap.style.display = "none";
              if (setupPopup)  setupPopup.style.display  = "";
              if (setupPhone) {
                // Format phone identifier as +1 (XXX) XXX-XXXX when 10
                // digits, else show what we have. Empty = "(none set —
                // sign back in here with a phone)".
                if (myPhone && myPhone.length >= 10) {
                  const d = myPhone.slice(-10);
                  setupPhone.textContent = `+1 (${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
                } else if (myPhone) {
                  setupPhone.textContent = `+${myPhone}`;
                } else {
                  setupPhone.textContent = "(not set — sign back in with a phone)";
                }
              }
              const lbl = $("pd-mode-mobile-lbl");
              // v1.33.2.2 — Clearer empty-state messaging. Was "no mobile
              // devices yet — see steps below" which sounded ambiguous.
              // Now explicit about what does/doesn't work without a paired
              // phone (commands flow, audio doesn't).
              if (lbl) lbl.textContent = "no phone paired — commands ok, audio routing needs a phone";
            } else {
              if (setupPopup)  setupPopup.style.display  = "none";
              // v1.33.4.6 — "Send audio to" picker removed from markup. Keep the
              // wrap visible for the Unlink / Get-app buttons + status line.
              // The most-recent phone auto-binds above.
              if (chooserWrap) chooserWrap.style.display = "";
              const lbl = $("pd-mode-mobile-lbl");
              if (lbl && activeId) {
                const paired = mobiles.find((d) => d.id === activeId);
                lbl.textContent = paired
                  ? `paired with ${paired.name || paired.id.slice(0, 8)}`
                  : "paired (device missing)";
              } else if (lbl) {
                lbl.textContent = "connecting your phone…";
              }
            }
          }
        } catch (_e) { /* defensive — chooser is an enhancement, never block paint */ }
        // Hide the Enable Microphone button once mic was granted at least once
        const micBtn = $("pd-mode-mic-btn");
        if (micBtn) {
          micBtn.style.display = state.micGranted ? "none" : "";
        }
        const deskHint = $("pd-mode-desk-hint");
        if (deskHint && state.micGranted) {
          deskHint.innerHTML = "✓ Microphone ready. Dialer routes audio through this browser — no phone needed.";
        }
      } catch (e) { /* best effort */ }
    }
    // Expose for the WS handler so it can refresh after session events
    window.__pdRefreshPark = pdRefreshModeRow;
    pdRefreshModeRow();

    // v1.29.7 — On mount, immediately align the offscreen Desktop WebRTC
    // client with the current park source. If the user was last in
    // Mobile App mode, this tears down a stale offscreen client that
    // would otherwise race the mobile site for SIP registration.
    applyOffscreenWebRtcForMode(state.activeParkSource || "desktop");

    // v1.29.31 — Wire the mic + speaker pickers. Change handlers persist
    // to chrome.storage and push the new deviceIds to the offscreen. The
    // populate call is deferred slightly so the offscreen has time to
    // register on first boot before we ask for enumerateDevices.
    $("pd-mic-picker")?.addEventListener("change", onMicPickerChange);
    $("pd-spk-picker")?.addEventListener("change", onSpeakerPickerChange);
    setTimeout(() => { refreshMicPicker(); refreshSpeakerPicker(); }, 800);
    // Re-refresh after a successful mic grant — labels become readable.
    $("pd-mode-mic-btn")?.addEventListener("click", () => {
      setTimeout(() => { refreshMicPicker(); refreshSpeakerPicker(); }, 1500);
    });

    // v1.29.32 — Audio devices drawer open/close. The pickers live
    // inside the drawer; we re-refresh on open so labels are fresh in
    // case devices were plugged/unplugged since the panel last loaded.
    const audioDrawer = $("pd-audio-drawer");
    // v1.31.80 — Move drawer to be a direct child of #pd-root so its
    // position:absolute is relative to the whole panel (not the small
    // Devices-button container). Makes it slide cleanly from the
    // right edge of the panel on phone + goal skins.
    if (audioDrawer && audioDrawer.parentElement !== root) {
      try { root.appendChild(audioDrawer); } catch (_e) {}
    }
    function openAudioDrawer() {
      if (audioDrawer) audioDrawer.classList.add("open");
      // Best-effort refresh so the dropdowns show current device state.
      try { refreshMicPicker(); } catch (_e) {}
      try { refreshSpeakerPicker(); } catch (_e) {}
    }
    // v1.31.80 — Belt-and-suspenders drawer close handling:
    //   1) Inject a BIG visible "× Close" button at the top of the
    //      drawer panel so the user can always find it.
    //   2) Bind close handlers DIRECTLY on the existing × + backdrop.
    //   3) Document-level capture-phase listener: any click outside
    //      the panel closes the drawer.
    if (audioDrawer) {
      // Inject big close button if not already present
      const panel = audioDrawer.querySelector(".pd-audio-drawer-panel");
      if (panel && !panel.querySelector(".pd-audio-drawer-bigclose")) {
        const bigClose = document.createElement("button");
        bigClose.type = "button";
        bigClose.className = "pd-audio-drawer-bigclose";
        bigClose.textContent = "× Close";
        bigClose.setAttribute("aria-label", "Close audio devices");
        bigClose.addEventListener("click", function (ev) {
          ev.preventDefault(); ev.stopPropagation();
          audioDrawer.classList.remove("open");
        });
        panel.insertBefore(bigClose, panel.firstChild);
      }
      const closeBtn = audioDrawer.querySelector(".pd-audio-drawer-close");
      const backdrop = audioDrawer.querySelector(".pd-audio-drawer-backdrop");
      if (closeBtn) {
        closeBtn.addEventListener("click", function (ev) {
          ev.preventDefault(); ev.stopPropagation();
          audioDrawer.classList.remove("open");
        });
      }
      if (backdrop) {
        backdrop.addEventListener("click", function (ev) {
          ev.preventDefault(); ev.stopPropagation();
          audioDrawer.classList.remove("open");
        });
      }
      // Document-level outside-click fallback.
      if (!window.__pdAudioOutsideClickInit) {
        window.__pdAudioOutsideClickInit = true;
        document.addEventListener("click", function (ev) {
          const drawer = document.getElementById("pd-audio-drawer");
          if (!drawer || !drawer.classList.contains("open")) return;
          if (ev.target.closest("#pd-audio-settings-btn")) return;
          if (ev.target.closest(".pd-options-popup-devices")) return;
          if (ev.target.closest(".pd-audio-drawer-panel")) return;
          drawer.classList.remove("open");
        }, true);
      }
    }
    function closeAudioDrawer() {
      if (audioDrawer) audioDrawer.classList.remove("open");
    }
    $("pd-audio-settings-btn")?.addEventListener("click", openAudioDrawer);
    $("pd-audio-drawer-close")?.addEventListener("click", closeAudioDrawer);
    // v1.33.3 — Audio test button. Sends pd_off_audio_test to offscreen,
    // animates the VU meter from pd_audio_test_level broadcasts, then
    // asks "Did you hear yourself?" on completion. Auto-submits a diag
    // on "No" so we can correlate to Bluetooth-profile / mic-out reports
    // (Cloeo Munroe 2026-06-23 class).
    // v1.33.3 — Orange "Having earphone issues?" button toggles the
    // help panel inline. Designed to sit next to the audio test so the
    // troubleshooting guidance is one click away when a test fails.
    (function wireEarphoneIssuesButton() {
      const btn = $("pd-earphone-issues-btn");
      const panel = $("pd-earphone-issues-panel");
      if (!btn || !panel) return;
      btn.addEventListener("click", () => {
        const open = panel.style.display === "block";
        panel.style.display = open ? "none" : "block";
        btn.textContent = open ? "🎧 Having earphone issues?" : "🎧 Hide earphone help";
      });
    })();
    (function wireAudioTestButton() {
      const btn = $("pd-audio-test-btn");
      if (!btn) return;
      const meter  = $("pd-audio-test-meter");
      const fill   = $("pd-audio-test-fill");
      const status = $("pd-audio-test-status");
      // Map dB to a 0-100% width on the meter. -60 dB (silence) → 0%,
      // -6 dB (loud) → 100%. Clamp to that range so soft speech still
      // shows a visible bar without bouncing the meter wildly.
      const dbToWidth = (db) => {
        if (!isFinite(db)) return 0;
        const min = -60, max = -6;
        const clamped = Math.max(min, Math.min(max, db));
        return ((clamped - min) / (max - min)) * 100;
      };
      // Live VU listener (only active while a test is running).
      let testInFlight = false;
      function onLevelMsg(msg) {
        if (!testInFlight) return;
        if (!msg || msg.type !== "pd_audio_test_level") return;
        if (fill) fill.style.width = dbToWidth(msg.peakDb).toFixed(0) + "%";
      }
      chrome.runtime.onMessage.addListener(onLevelMsg);

      btn.addEventListener("click", async () => {
        if (testInFlight) return;
        testInFlight = true;
        btn.disabled = true;
        btn.style.opacity = "0.6";
        if (meter)  meter.style.display = "block";
        if (fill)   fill.style.width = "0%";
        if (status) {
          status.textContent = "🎙 Listening for 5 seconds — speak into your mic now...";
          status.style.color = "rgba(255,255,255,0.7)";
        }
        try {
          const r = await new Promise((resolve) => {
            try {
              chrome.runtime.sendMessage(
                { type: "pd_off_audio_test", opts: { durationMs: 5000 } },
                (resp) => {
                  const _ = chrome.runtime.lastError;
                  resolve(resp || { ok: false, error: "no response from offscreen" });
                }
              );
            } catch (e) {
              resolve({ ok: false, error: e?.message || String(e) });
            }
          });
          if (!r || !r.ok) {
            if (status) {
              status.textContent = "✗ Test couldn't run: " + (r?.error || "unknown error") +
                ". Make sure Desktop mode is enabled + mic is granted.";
              status.style.color = "#ff8a93";
            }
          } else {
            const peakStr = Number.isFinite(r.peakDb) ? r.peakDb.toFixed(1) + " dB" : "no level captured";
            if (r.micLikelySilent) {
              if (status) {
                status.innerHTML =
                  `<span style="color:#ff8a93;">⚠ Mic returned silence (${peakStr} peak).</span> ` +
                  `Likely Bluetooth profile issue (A2DP instead of HSP). See troubleshooting below. ` +
                  `<a href="#" id="pd-audio-test-submit-diag" style="color:#aaf3c3;">Submit diagnostic</a>`;
              }
              try {
                _pdAutoSubmitWithReason("auto:audio-test-silent");
              } catch {}
            } else {
              if (status) {
                status.innerHTML =
                  `<span style="color:#aaf3c3;">✓ Mic captured audio (peak ${peakStr}).</span> ` +
                  `Did you hear yourself through your speaker? ` +
                  `<button type="button" id="pd-audio-test-yes" style="margin-left:6px;background:rgba(45,171,95,0.3);border:1px solid rgba(45,171,95,0.6);color:#aaf3c3;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;">Yes ✓</button> ` +
                  `<button type="button" id="pd-audio-test-no" style="margin-left:4px;background:rgba(255,138,147,0.2);border:1px solid #ff8a93;color:#ff8a93;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;">No ✗</button>`;
              }
              setTimeout(() => {
                $("pd-audio-test-yes")?.addEventListener("click", () => {
                  if (status) {
                    status.innerHTML = `<span style="color:#aaf3c3;">✓ Audio test passed — you're good to go.</span>`;
                  }
                });
                $("pd-audio-test-no")?.addEventListener("click", () => {
                  if (status) {
                    status.innerHTML =
                      `<span style="color:#ff8a93;">✗ Speaker playback didn't reach you.</span> ` +
                      `Try selecting a different Output device above, or check that your speaker volume isn't muted.`;
                  }
                  try { _pdAutoSubmitWithReason("auto:audio-test-no-playback"); } catch {}
                });
              }, 50);
            }
          }
        } finally {
          testInFlight = false;
          btn.disabled = false;
          btn.style.opacity = "";
          if (fill) fill.style.width = "0%";
          // Leave the meter visible so the agent can see the bar settled at 0
          // — confirms the test finished cleanly.
        }
      });
    })();
    // Tap on the dimmed backdrop (NOT the panel itself) closes the drawer.
    $("pd-audio-drawer-backdrop")?.addEventListener("click", closeAudioDrawer);
    // Esc closes the drawer when it's open.
    document.addEventListener("keydown", (ev) => {
      if (ev.key === "Escape" && audioDrawer?.classList.contains("open")) {
        closeAudioDrawer();
      }
    });

    // Click handlers — switching is end-of-session, so warn if a real
    // session is in flight.
    document.querySelectorAll(".pd-mode-btn").forEach((b) => {
      b.addEventListener("click", () => {
        const newMode = b.dataset.mode;
        if (newMode === state.activeParkSource) return;
        const inSession = state.session && state.session !== "idle";
        if (inSession) {
          toast(`Mode change saved — end this session and start a new one to use ${pdLabelForMode(newMode)}.`);
        }
        state.activeParkSource = newMode;
        state.dialMode = "desktop"; // always WebRTC in v1.29.0
        // Persist locally
        try { chrome.storage.local.set({ pd_dial_mode: state.dialMode, pd_active_park_source: newMode }); } catch {}
        // v1.29.31 — Refresh both pickers' visibility for the new mode
        // (only show in Desktop WebRTC). Slight delay so the offscreen
        // (re-)enabled by applyOffscreenWebRtcForMode is ready to enumerate.
        setTimeout(() => {
          try { refreshMicPicker(); } catch (_e) {}
          try { refreshSpeakerPicker(); } catch (_e) {}
        }, 1000);
        // Push to server so /mobile + /desktop surfaces see it.
        // v1.29.14 — Use the canonical token path (profiles[activeProfile])
        // instead of state.account which doesn't exist on this code path.
        // Previously the fetch silently dropped because tok was undefined,
        // so the server never learned about the mode switch. Then Start
        // Session's /api/me preflight would read the stale server value
        // and overwrite the local Mobile App pick back to Desktop.
        try {
          const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
          const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
          if (tok) {
            fetch(`${base}/api/me/update`, {
              method: "POST",
              headers: { "Content-Type": "application/json", Authorization: `Bearer ${tok}` },
              body: JSON.stringify({ activeParkSource: newMode }),
            }).catch(() => {});
          }
        } catch {}
        // v1.29.7 — CRITICAL: spin down the offscreen Desktop WebRTC
        // client when switching to Mobile App mode. Otherwise it stays
        // registered with the same SIP credential as the mobile site,
        // and Telnyx routes calls unpredictably — calls silently die
        // because the offscreen client steals the registration but has
        // no mic permission or active audio. In Desktop mode we spin
        // it BACK up so it's ready to receive calls.
        applyOffscreenWebRtcForMode(newMode);
        pdRefreshModeRow();

        // v1.33 Phase 7e — When switching INTO Mobile App mode, fire a
        // /api/me fetch so the chooser shows mobiles registered since
        // the last sync. Without this, agents who signed into the
        // mobile PWA AFTER opening the extension wouldn't see their
        // mobile in the chooser until they hit the Refresh button.
        if (newMode === "mobile_web") {
          try {
            const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
            const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
            if (tok) {
              fetch(`${base}/api/me?token=${encodeURIComponent(tok)}`, { cache: "no-store" })
                .then((r) => r.json())
                .then((j) => {
                  if (j && Array.isArray(j.devices)) {
                    state.userDevices = j.devices;
                    state.userPhones = Array.isArray(j.phones) ? j.phones : (state.userPhones || []);
                    state.activeMobilePairDeviceId = j.activeMobilePairDeviceId || state.activeMobilePairDeviceId || "";
                    try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
                  }
                })
                .catch(() => { /* best effort */ });
            }
          } catch (_e) {}
        }
      });
    });

    // v1.33 Phase 4 — Mobile App chooser dropdown + unlink handler.
    // On change: POST /api/devices/pair with chosen deviceId. On unlink:
    // POST with empty deviceId. Both update state.activeMobilePairDeviceId
    // immediately for UI feedback, and the worker persists for routing.
    const pdMobileChooser = $("pd-mobile-chooser");
    const pdMobileUnlink  = $("pd-mobile-unlink");
    const pdMobileMsg     = $("pd-mobile-chooser-msg");
    async function pdSavePairChoice(deviceId, friendlyAction) {
      const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      if (!tok) {
        if (pdMobileMsg) pdMobileMsg.textContent = "Sign in first.";
        return;
      }
      if (pdMobileMsg) pdMobileMsg.textContent = friendlyAction + "…";
      try {
        const res = await fetch(`${base}/api/devices/pair`, {
          method:  "POST",
          headers: { "content-type": "application/json" },
          body:    JSON.stringify({ token: tok, deviceId }),
        });
        const j = await res.json().catch(() => ({}));
        if (!res.ok || !j.ok) {
          if (pdMobileMsg) pdMobileMsg.textContent = "✗ " + (j.error || ("HTTP " + res.status));
          return;
        }
        state.activeMobilePairDeviceId = j.activeMobilePairDeviceId || "";
        if (pdMobileMsg) {
          pdMobileMsg.textContent = j.activeMobilePairDeviceId
            ? `✓ Paired with ${j.pairedDevice?.name || "device"}`
            : "✓ Unpaired";
        }
        try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
      } catch (err) {
        if (pdMobileMsg) pdMobileMsg.textContent = "✗ Network error: " + (err.message || err);
      }
    }
    pdMobileChooser?.addEventListener("change", () => {
      const id = (pdMobileChooser.value || "").trim();
      pdSavePairChoice(id, id ? "Pairing" : "Unpairing");
    });
    pdMobileUnlink?.addEventListener("click", (e) => {
      e.preventDefault();
      if (pdMobileChooser) pdMobileChooser.value = "";
      pdSavePairChoice("", "Unpairing");
    });

    // v1.33 Phase 6 — First-run popup buttons. Refresh re-fetches
    // /api/me so newly-registered mobile devices appear without making
    // the agent close + reopen the panel. Copy puts the mobile URL on
    // the clipboard so the agent can SMS / AirDrop / email it to their
    // phone.
    const pdMobileSetupRefresh = $("pd-mobile-setup-refresh");
    const pdMobileSetupCopy    = $("pd-mobile-setup-copy");
    const pdMobileSetupMsg     = $("pd-mobile-setup-msg");
    pdMobileSetupRefresh?.addEventListener("click", async (e) => {
      e.preventDefault();
      const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
      const base = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");
      if (!tok) {
        if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = "Sign in first.";
        return;
      }
      if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = "Checking…";
      try {
        const r = await fetch(`${base}/api/me?token=${encodeURIComponent(tok)}`, { cache: "no-store" });
        const j = await r.json();
        if (j && Array.isArray(j.devices)) {
          state.userDevices = j.devices;
          state.activeMobilePairDeviceId = j.activeMobilePairDeviceId || "";
          try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
          const myPhone = (activeProfile().phone || "").replace(/[^\d]/g, "");
          const mobiles = (j.devices || []).filter((d) => {
            if (!d || d.type !== "mobile_pwa") return false;
            if (!myPhone) return true;
            const dPhone = String(d.phone || "").replace(/[^\d]/g, "");
            return !dPhone || dPhone === myPhone;
          });
          if (pdMobileSetupMsg) {
            pdMobileSetupMsg.textContent = mobiles.length
              ? `✓ Found ${mobiles.length} mobile device${mobiles.length === 1 ? "" : "s"}`
              : "Still no mobile devices — finish signing in on your phone first";
          }
        } else {
          if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = "✗ Couldn't refresh — try again";
        }
      } catch (err) {
        if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = "✗ Network error: " + (err.message || err);
      }
    });
    pdMobileSetupCopy?.addEventListener("click", async (e) => {
      e.preventDefault();
      // v1.33.7.8 — Copy the DIVISION mobile URL (Liberty → lnl.goalpowerdialer.com/mobile).
      const url = (state.workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "") + "/mobile";
      try {
        await navigator.clipboard.writeText(url);
        if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = "📋 Copied — paste it on your phone";
      } catch {
        if (pdMobileSetupMsg) pdMobileSetupMsg.textContent = url;
      }
    });

    // v1.33.4 — "Get mobile app" button. Replaces the prior plain "Open
    // /mobile" link (which looked like text, not a button, and didn't
    // help agents who didn't already understand the pairing flow). Now:
    // 1. Toggle the setup wizard visible (#pd-mobile-setup-popup) even
    //    when devices are already paired — useful for adding ANOTHER
    //    phone or showing the instructions to a tester.
    // 2. Auto-fill the phone identifier in the wizard so agents see what
    //    to enter on the mobile site.
    // 3. Scroll the popup into view so the agent can read it.
    const pdMobileGetApp = $("pd-mobile-getapp");
    const pdMobileSetupPopup = $("pd-mobile-setup-popup");
    pdMobileGetApp?.addEventListener("click", (e) => {
      e.preventDefault();
      if (!pdMobileSetupPopup) return;
      // Fill in the agent's phone identifier so step 2 shows what to type.
      try {
        const phoneEl = $("pd-mobile-setup-phone");
        const myPhone = (activeProfile().phone || "").trim();
        if (phoneEl) phoneEl.textContent = myPhone || "(set your phone identifier in Setup)";
      } catch {}
      const isShown = pdMobileSetupPopup.style.display !== "none";
      pdMobileSetupPopup.style.display = isShown ? "none" : "block";
      if (!isShown) {
        try { pdMobileSetupPopup.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch {}
      }
    });

    // ── Plug-and-play mic permission for Desktop mode ──
    // Click sends a message to background.js which opens a small popup
    // window (chrome.windows.create) hosting mic-grant.html in the
    // extension origin. That page runs getUserMedia, gets the prompt,
    // stores micGranted=true, and closes itself.
    $("pd-mode-mic-btn")?.addEventListener("click", () => {
      const btn = $("pd-mode-mic-btn");
      if (btn) btn.disabled = true;
      try {
        chrome.runtime.sendMessage({ type: "open-mic-grant" }, (resp) => {
          if (btn) btn.disabled = false;
          // v1.34 — resp only confirms the tab opened; grant outcome + the
          // success toast arrive via pd_mic_granted_broadcast. Only surface
          // an error if the tab itself failed to open.
          if (resp && resp.ok === false) {
            toast("Couldn't open the mic window: " + (resp.error || "unknown error"), { error: true });
          } else {
            toast("Mic window opened — click Allow in it to finish", { ms: 6000 });
          }
        });
      } catch (e) {
        if (btn) btn.disabled = false;
        toast("Mic permission unavailable: " + e.message);
      }
    });
    // Mini logo doubles as a restore button on both skins. Phone theme
    // hides the dedicated ⤢ icon button (it's redundant with the logo);
    // Soccer theme shows both for clarity.
    $("pd-mini-logo")?.addEventListener("click", () => {
      // v1.31.80 — Solo ⚽ on the ball skin routes to JERSEY skin per Dustin
      // (was setSizeMode("full") which expanded to Goal).
      try { setSkin("jersey"); setSizeMode("full"); } catch {}
    });
    // Set initial icon based on current skin (both buttons)
    {
      const labelFor = (skin) => skin === "goal"
        ? { txt: "⚽", title: "Currently Soccer — click to switch to Phone" }
        : { txt: "📱", title: "Currently Phone — click to switch to Soccer" };
      const lbl = labelFor(state.skin);
      const btn = document.getElementById("pd-skin-toggle");
      const mbtn = document.getElementById("pd-mini-skin-toggle");
      if (btn)  { btn.textContent  = lbl.txt; btn.title  = lbl.title; }
      if (mbtn) { mbtn.textContent = lbl.txt; mbtn.title = lbl.title; }
    }

    enableDrag(root, $("pd-drag"));
    // v1.31.80 — Phone skin hides .pd-statusbar (#pd-drag) because the
    // phone background image already shows "12:00 / battery". That
    // killed drag. Wire .pd-header as a secondary drag handle.
    enableDrag(root, document.querySelector("#pd-root .pd-header"));
    // v1.31.72 — Restored from commit 5638310: drag from mini-head and
    // ball-bubble too. Without this, when the panel is in collapsed/ball
    // mode (no statusbar visible), agents have no draggable surface.
    enableDrag(root, document.querySelector("#pd-root .pd-mini-head"));
    enableDrag(root, $("pd-ball-bubble"));
    // v1.31.80 fix-up — Wire pd-mini-card as a drag surface. In the
    // ball-skin compact UI, .pd-mini-card IS the visible panel — drag
    // from anywhere on it (the 4px threshold in enableDrag still lets
    // button clicks through unaffected).
    enableDrag(root, document.querySelector("#pd-root .pd-mini-card"));
    enableScale(root, $("pd-resize"));
    applyScale();
    // v1.31.73 — Apply saved per-skin position on every paint so the
    // panel keeps its drag position across Impact navigations.
    applyPos();
    // v1.31.80 — Persist jersey watermark overlay across nav.
    if (state.jerseyBg) {
      root.classList.add("pd-jersey-bg");
    } else {
      root.classList.remove("pd-jersey-bg");
    }
    // v1.31.74 — Wire the universal skin-switcher icon bar (idempotent;
    // attaches a single delegated click listener on pd-root the first
    // time only — subsequent paints are no-ops).
    wireSkinBar();
    // v1.31.80 fix-up — Force skin bar to escape any ancestor with
    // overflow:hidden. Re-parents it to pd-root and pins it
    // absolutely. Runs idempotently on every paint.
    (function forceTopBarVisible() {
      const r = document.getElementById("pd-root");
      if (!r) return;
      const existing = r.querySelector(".pd-skin-bar:not(.pd-skin-bar-settings)");
      if (existing && existing.parentElement !== r) {
        r.appendChild(existing);
      }
      if (existing) {
        existing.style.position = "absolute";
        existing.style.top = "8px";
        existing.style.left = "50%";
        existing.style.transform = "translateX(-50%)";
        existing.style.zIndex = "99999";
        existing.style.display = "flex";
        existing.style.visibility = "visible";
        existing.style.opacity = "1";
        existing.style.background = "rgba(11, 18, 32, 0.92)";
        existing.style.border = "1px solid rgba(255, 255, 255, 0.12)";
        existing.style.borderRadius = "14px";
        existing.style.padding = "4px 8px";
        existing.style.boxShadow = "0 4px 14px rgba(0, 0, 0, 0.5)";
      }
    })();
    // v1.31.80 fix-up — Document-level click delegation for the
    // Settings-screen skin bar. Existing wireSkinBar's delegation is
    // scoped to pd-root via root.contains(btn) — fails if the
    // Settings panel mounts elsewhere. Catch clicks on
    // .pd-skin-bar-settings .pd-skin-bar-btn at document level too.
    if (!document.__pdSettingsBarWired) {
      document.__pdSettingsBarWired = true;
      document.addEventListener("click", (e) => {
        const btn = e.target.closest(".pd-skin-bar-settings .pd-skin-bar-btn");
        if (!btn) return;
        const action = btn.dataset.action;
        if (!action) return;
        e.preventDefault();
        e.stopPropagation();
        const root = document.getElementById("pd-root");
        if (!root) return;
        switch (action) {
          case "minimize":
            if (typeof setMinimized === "function") setMinimized(true);
            break;
          case "phone":
            root.classList.remove("pd-jersey-bg");
            if (typeof setSkin === "function") setSkin("phone");
            if (typeof setSizeMode === "function") setSizeMode("full");
            break;
          case "classic":
            // v1.31.80 — Classic phone skin (None-branded). Same overlay
            // and behaviour as phone, different background image.
            root.classList.remove("pd-jersey-bg");
            if (typeof setSkin === "function") setSkin("classic");
            if (typeof setSizeMode === "function") setSizeMode("full");
            break;
          case "goal":
            root.classList.remove("pd-jersey-bg");
            if (typeof setSkin === "function") setSkin("goal");
            if (typeof setSizeMode === "function") setSizeMode("full");
            break;
          case "jersey":
            if (typeof setSkin === "function") setSkin("goal");
            if (typeof setSizeMode === "function") setSizeMode("mini");
            root.classList.add("pd-jersey-bg");
            state.jerseyBg = true;
            try { chrome.storage.local.set({ pd_jersey_bg: true }); } catch {}
            break;
          case "ball":
            root.classList.remove("pd-jersey-bg");
            state.jerseyBg = false;
            try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
            if (typeof setSkin === "function") setSkin("goal");
            if (typeof setSizeMode === "function") setSizeMode("mini");
            break;
        }
      }, true);
    }
    // v1.31.80 fix-up — Bottom drawer open/close + toggle sync.
    (function wirePbd() {
      const r = document.getElementById("pd-root");
      if (!r || r.dataset.pbdWired === "1") return;
      r.dataset.pbdWired = "1";
      const fab = document.getElementById("pd-pbd-fab");
      const handle = document.getElementById("pd-pbd-handle");
      fab?.addEventListener("click", (e) => {
        e.preventDefault(); e.stopPropagation();
        // v1.31.80 fix-up — Toggle the bottom drawer; also stash
      // current state to be safe.
      r.classList.toggle("pd-pbd-open");
      // Auto-close on outside-click (one-shot listener).
      if (r.classList.contains("pd-pbd-open")) {
        setTimeout(() => {
          const outside = (ev) => {
            if (!ev.target.closest(".pd-phone-bottom-drawer") &&
                !ev.target.closest(".pd-pbd-fab")) {
              r.classList.remove("pd-pbd-open");
              document.removeEventListener("click", outside, true);
            }
          };
          document.addEventListener("click", outside, true);
        }, 50);
      }
      });
      handle?.addEventListener("click", (e) => {
        e.preventDefault(); e.stopPropagation();
        r.classList.remove("pd-pbd-open");
      });
      const syncPair = (drawerId, realId, stateKey) => {
        const d = document.getElementById(drawerId);
        const real = document.getElementById(realId);
        if (!d) return;
        if (real) {
          d.checked = !!real.checked;
          d.addEventListener("change", () => {
            real.checked = d.checked;
            real.dispatchEvent(new Event("change", { bubbles: true }));
          });
          real.addEventListener("change", () => { d.checked = real.checked; });
        } else if (stateKey) {
          d.checked = !!state[stateKey];
          d.addEventListener("change", () => {
            state[stateKey] = d.checked;
            try { chrome.storage.local.set({ ["pd_" + stateKey.toLowerCase()]: d.checked }); } catch {}
          });
        }
      };
      syncPair("pd-pbd-doubletap", "pd-double-tap", "doubleTap");
      syncPair("pd-pbd-bottomtotop", "pd-bottom-to-top", "bottomToTop");
      syncPair("pd-pbd-callcomments", "pd-call-from-comments", "callFromComments");
      syncPair("pd-pbd-autoattend", "pd-auto-attendant-hangup", "autoAttendantHangup");
      syncPair("pd-pbd-away", "pd-away-mode", "awayMode");
      syncPair("pd-pbd-manualdisp", "pd-manual-disposition", "manualDisposition");
    })();
    // v1.31.79 — Push jersey background image URL into a CSS custom
    // property so the stylesheet can apply it without knowing the
    // extension ID. chrome.runtime.getURL() is the only portable way
    // to address a packed-extension asset from injected CSS.
    try {
      if (isExtensionAlive() && root) {
        const jerseyImg = chrome.runtime.getURL("icons/jersey-large.png");
        root.style.setProperty("--pd-jersey-img", `url("${jerseyImg}")`);
        // v1.31.80 — Push soccerball image URL too (used by the ball
        // skin's panel-shape CSS, symmetric to jersey).
        const ballImg = chrome.runtime.getURL("icons/soccerball-large.png");
        root.style.setProperty("--pd-soccerball-img", `url("${ballImg}")`);
        // v1.31.80 fix-up — Push phone-skin background image URL too,
        // so the branded "Goal Power Dialer" phone graphic can render
        // as the panel background (symmetric to ball + jersey).
        // v1.31.80 — When classic variant is active, point --pd-phone-img
        // at the classic image so phone-skin's CSS uses the classic
        // photo. Branded otherwise.
        const isClassic = root.dataset.skinVariant === "classic" || state.skin === "classic";
        const phoneImg = chrome.runtime.getURL(
          isClassic ? "icons/classic-bg-large.png" : "icons/phone-bg-large.png"
        );
        root.style.setProperty("--pd-phone-img", `url("${phoneImg}")`);
        // v1.34 redesign — CSS-drawn phone frame uses the Goal Power logo as
        // its header band (replaces the baked-in logo of the old PNG frame).
        const logoImg = chrome.runtime.getURL("icons/gp-logo.png");
        root.style.setProperty("--pd-logo-img", `url("${logoImg}")`);
        // Keep --pd-classic-img around for any other use.
        const classicImg = chrome.runtime.getURL("icons/classic-bg-large.png");
        root.style.setProperty("--pd-classic-img", `url("${classicImg}")`);
        // v1.31.80 fix-up — Push goal-skin background image URL —
        // branded stadium + goal + ball-in-net + grass-painted
        // "Goal Power Dialer · Reach Your Goals!" branding.
        const goalImg = chrome.runtime.getURL("icons/goal-bg-large.png");
        root.style.setProperty("--pd-goal-img", `url("${goalImg}")`);
      }
    } catch {}
    // v1.31.75 — wireSkinDrawer DISABLED in v1.31.77 (the drawer DOM
    // it wires lives inside hidden elements; calling is harmless but
    // a waste). v1.31.80 replaces it with wireMiniButtons which targets
    // the actual compact UI.
    // wireSkinDrawer();
    // v1.31.80 — Wire Start Session + Auto Attend + drawer buttons
    // in the compact UI. Idempotent via dataset flag.
    wireMiniButtons();
    // v1.31.76 — Update banner: render once per paint, attach storage
    // watcher on first paint only (idempotent via root.dataset flag).
    wireUpdateStorageWatcher();
    renderUpdateBanner();
    // v1.31.78 — Apply minimized state on every paint so a restored
    // panel persists hidden across Impact navigations.
    applyMinimized();
    startStatusBarClock();
  }

  // ── Three-state minimize: full → mini → ball ──────────────────
  // v1.31.74 — Universal skin-switcher helpers.
  //
  // setSkin: programmatic skin change (used by the icon bar). Mirrors what
  // the Settings radio handler already does inline at the radio change site
  // — updates state.skin, writes storage, swaps pd-skin-* class on the
  // root, then reloads that skin's saved scale/pos via v1.31.73's
  // reloadGeometryForCurrentSkin if present.
  // v1.34 — Clear every inline geometry style that skins mutate. The
  // classic branch of setSkin sets screen top/bottom/mask + root
  // width/height/padding/bg/shadow inline with !important, and applyScale
  // sets phone.transform — but nothing cleared these when switching BACK
  // to another skin, so the previous skin's geometry stuck until a page
  // reload. Call at the start of every skin switch so the destination skin
  // renders clean — "come back to a refreshed skin" (Dustin 2026-08-04).
  function _pdResetSkinGeometry(root) {
    if (!root) return;
    try {
      const screen = root.querySelector(".pd-screen");
      if (screen) {
        ["top","bottom","left","right","width","height","overflow-y","overflow-x",
         "mask-image","-webkit-mask-image","padding","transform","background","box-shadow","position"]
          .forEach((p) => screen.style.removeProperty(p));
      }
      const phone = root.querySelector(".pd-phone");
      if (phone) {
        ["transform","transform-origin","width","height"].forEach((p) => phone.style.removeProperty(p));
      }
      ["width","height","padding","background","box-shadow"].forEach((p) => root.style.removeProperty(p));
    } catch (_e) {}
  }

  function setSkin(skin) {
    // v1.31.79 — Re-accept jersey now that Dustin provided a real jersey
    // image (icons/jersey-large.png, recolored to GPD grass-green). Ball
    // still routes through setSizeMode in the icon bar handler — it uses
    // the existing pd-size-ball compact UI Dustin confirmed looks good.
    // v1.31.80 — "classic" added (6th skin): None-branded clean phone
    // graphic with the same overlay as phone. Same large-skin slot as
    // phone for maximize-return purposes.
    if (!["phone", "goal", "jersey", "classic"].includes(skin)) return;
    // Only phone+goal+classic qualify as "large" for the maximize button.
    if (skin === "phone" || skin === "goal" || skin === "classic") {
      state.lastLargeSkin = skin;
    }
    state.skin = skin;
    if (isExtensionAlive()) {
      try { chrome.storage.local.set({ pd_skin: skin, pd_last_large_skin: skin }); } catch {}
    }
    const root = document.getElementById("pd-root");
    if (root) {
      _pdResetSkinGeometry(root);
      root.classList.remove("pd-skin-phone", "pd-skin-goal", "pd-skin-ball", "pd-skin-jersey", "pd-skin-medium", "pd-skin-soccer-medium", "pd-skin-classic");
      // v1.31.80 — Classic IS phone-skin with a different background.
      // Apply pd-skin-phone (NOT pd-skin-classic) so EVERY phone-skin
      // rule applies identically. Mark variant via data attribute for
      // the statusbar hide. Set --pd-phone-img DIRECTLY here so the
      // swap is immediate (don't wait for next paint()).
      // v1.31.80 — Belt-and-suspenders: set BOTH a class marker and a
      // data attribute so CSS can match via either path (some renders
      // might wipe one but not the other).
      root.classList.remove("pd-classic-mode");
      if (skin === "classic") {
        root.classList.add("pd-skin-phone");
        root.classList.add("pd-classic-mode");
        root.dataset.skinVariant = "classic";
        try {
          const classicImg = chrome.runtime.getURL("icons/classic-bg-large.png");
          root.style.setProperty("--pd-phone-img", "url(\"" + classicImg + "\")");
        } catch (_e) {}
        // v1.34 — Classic now uses the same CSS-drawn frame as branded
        // (just without the logo). The old inline geometry that cropped to
        // the classic PNG frame's screen window is removed — it fought the
        // CSS frame and left stale positioning when switching skins.
        // _pdResetSkinGeometry() clears any residue on switch-in.
      } else {
        root.classList.add("pd-skin-" + skin);
        delete root.dataset.skinVariant;
        // Reset --pd-phone-img to the branded photo for non-classic phone.
        if (skin === "phone") {
          try {
            const phoneImg = chrome.runtime.getURL("icons/phone-bg-large.png");
            root.style.setProperty("--pd-phone-img", "url(\"" + phoneImg + "\")");
          } catch (_e) {}
        }
        // Clear any classic inline styles when switching away.
        try {
          const screen = root.querySelector(".pd-screen");
          if (screen) {
            screen.style.removeProperty("top");
            screen.style.removeProperty("bottom");
            screen.style.removeProperty("overflow-y");
            screen.style.removeProperty("mask-image");
            screen.style.removeProperty("-webkit-mask-image");
          }
          root.style.removeProperty("width");
          root.style.removeProperty("height");
          root.style.removeProperty("padding");
          root.style.removeProperty("background");
          root.style.removeProperty("box-shadow");
        } catch (_e) {}
      }
    }
    if (typeof reloadGeometryForCurrentSkin === "function") {
      try { reloadGeometryForCurrentSkin(); } catch {}
    }
  }

  // Wire the icon bar via delegation on pd-root. paint() rebuilds the
  // inner HTML on every render, but pd-root itself is stable — listener
  // attached to root survives. Idempotency flag prevents stacking.
  function wireSkinBar() {
    const root = document.getElementById("pd-root");
    if (!root || root.dataset.gpdSkinBarWired === "1") return;
    root.dataset.gpdSkinBarWired = "1";
    root.addEventListener("click", (e) => {
      const btn = e.target.closest(".pd-skin-bar-btn");
      if (!btn || !root.contains(btn)) return;
      const action = btn.dataset.action;
      if (!action) return;
      e.preventDefault();
      e.stopPropagation();
      switch (action) {
        case "refresh":
          // v1.34 — Refresh relocated into the skin bar (first button).
          // Delegates to the existing header refresh (which is hidden on
          // the phone skin now that it lives here).
          document.getElementById("pd-refresh")?.click();
          break;
        case "minimize":
          // v1.31.78 — Real minimize. Hides the panel entirely and
          // surfaces a floating restore dot on document.body. The
          // soccer-ball UI is reachable via the ball icon instead.
          setMinimized(true);
          break;
        case "phone":
          // v1.31.80 — Large phone skin. Clear jersey watermark.
          root.classList.remove("pd-jersey-bg");
          state.jerseyBg = false;
          try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
          setSkin("phone");
          setSizeMode("full");
          break;
        case "classic":
          // v1.31.80 — Classic phone skin (None-branded). Same overlay,
          // different background image.
          root.classList.remove("pd-jersey-bg");
          state.jerseyBg = false;
          try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
          setSkin("classic");
          setSizeMode("full");
          break;
        // v1.32.9 — branded-top case removed; that design IS now the
        // default phone skin (top:116 baked into pd-skin-phone CSS).
        case "goal":
          // v1.31.80 — Large goal skin. Clear jersey watermark.
          root.classList.remove("pd-jersey-bg");
          state.jerseyBg = false;
          try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
          setSkin("goal");
          setSizeMode("full");
          break;
        case "jersey":
          // v1.31.80 — Jersey = existing compact UI w/ jersey watermark.
          // Layout/buttons identical to ball; only the watermark image
          // behind the controls changes from soccer ball to jersey.
          setSkin("goal");
          setSizeMode("mini");
          root.classList.add("pd-jersey-bg");
          state.jerseyBg = true;
          try { chrome.storage.local.set({ pd_jersey_bg: true }); } catch {}
          break;
        case "ball":
          // v1.31.80 — Ball = "large Active Soccer Ball" per Dustin.
          // pd-skin-goal + pd-size-mini gives the existing .pd-mini-card
          // with .pd-mini-ball-bg soccer ball watermark.
          root.classList.remove("pd-jersey-bg");
          state.jerseyBg = false;
          try { chrome.storage.local.set({ pd_jersey_bg: false }); } catch {}
          setSkin("goal");
          setSizeMode("mini");
          break;
      }
    }, true);
  }

  // ─── v1.31.76 — Update detection banner + modal ─────────────────
  //
  // Banner is mounted on document.body (NOT pd-root) so it survives:
  //   - paint() rebuilds (pd-root.innerHTML reset)
  //   - Skin changes (ball/jersey collapse pd-root visuals)
  //   - Panel hide states (we still want to see update notices)
  //
  // The banner renders only when state.updateAvailable is set AND the
  // agent hasn't dismissed it for this exact version. A newer version
  // arrival supersedes the dismissal.
  function renderUpdateBanner() {
    const existing = document.getElementById("pd-update-banner");
    const data = state.updateAvailable;
    const dismissedFor = state.updateDismissedFor;
    const shouldShow = !!(data && data.latest && data.current
      && data.latest !== dismissedFor);
    if (!shouldShow) {
      if (existing) existing.remove();
      return;
    }
    if (existing) {
      // Already rendered — just update the text in case the version moved
      const txt = existing.querySelector(".pd-update-banner-text");
      if (txt) txt.textContent =
        `Update available: v${data.current} → v${data.latest}`;
      return;
    }
    const banner = document.createElement("div");
    banner.id = "pd-update-banner";
    banner.className = "pd-update-banner";
    banner.innerHTML = `
      <span class="pd-update-banner-icon" aria-hidden="true">🔔</span>
      <span class="pd-update-banner-text">Update available: v${data.current} → v${data.latest}</span>
      <button type="button" class="pd-update-banner-btn" id="pd-update-banner-btn">Update</button>
      <button type="button" class="pd-update-banner-dismiss" id="pd-update-banner-dismiss" title="Dismiss" aria-label="Dismiss">×</button>
    `;
    document.body.appendChild(banner);
    banner.querySelector("#pd-update-banner-btn")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      showUpdateModal();
    });
    banner.querySelector("#pd-update-banner-dismiss")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      state.updateDismissedFor = data.latest;
      try { chrome.storage.local.set({ pd_update_dismissed_for: data.latest }); } catch {}
      banner.remove();
    });
  }

  function showUpdateModal() {
    const data = state.updateAvailable;
    if (!data) return;
    const existing = document.getElementById("pd-update-modal");
    if (existing) { existing.classList.add("pd-open"); return; }
    const zipPath = data.zipUrl || "/static/extension.zip";
    const zipUrl = zipPath.startsWith("http")
      ? zipPath
      : "https://app.goalpowerdialer.com" + zipPath;
    const wrap = document.createElement("div");
    wrap.id = "pd-update-modal";
    wrap.className = "pd-update-modal pd-open";
    wrap.innerHTML = `
      <div class="pd-update-modal-backdrop" id="pd-update-modal-backdrop"></div>
      <div class="pd-update-modal-body">
        <div class="pd-update-modal-head">
          <h2>Update Goal Power Dialer</h2>
          <button type="button" class="pd-update-modal-x" id="pd-update-modal-x" title="Close" aria-label="Close">×</button>
        </div>
        <p class="pd-update-versions">v${data.current} → <strong>v${data.latest}</strong></p>
        <ol class="pd-update-steps">
          <li>Click the download button below — saves <code>extension.zip</code> to your Downloads folder.</li>
          <li>Click the second button to open <code>chrome://extensions</code> in a new tab.</li>
          <li>On the extensions page, find Goal Power Dialer and click <strong>Remove</strong>. (You may need to log in again after — your account settings are saved on our servers.)</li>
          <li>Unzip the downloaded file (right-click → Extract All on Windows, or double-click on Mac).</li>
          <li>Back on the extensions page, click <strong>Load unpacked</strong> and select the unzipped folder.</li>
          <li>Refresh your Impact tab — you're on v${data.latest}.</li>
        </ol>
        <div class="pd-update-modal-actions">
          <a class="pd-update-download" href="${zipUrl}" target="_blank" download>
            <span>📥</span> Download v${data.latest}
          </a>
          <button type="button" class="pd-update-extensions-btn" id="pd-update-extensions-btn">
            <span>🔧</span> Open chrome://extensions
          </button>
        </div>
        <div class="pd-update-modal-foot">
          <button type="button" class="pd-update-remind" id="pd-update-remind">Remind me later</button>
        </div>
      </div>
    `;
    document.body.appendChild(wrap);
    const close = () => wrap.remove();
    wrap.querySelector("#pd-update-modal-x")?.addEventListener("click", close);
    wrap.querySelector("#pd-update-modal-backdrop")?.addEventListener("click", close);
    wrap.querySelector("#pd-update-remind")?.addEventListener("click", close);
    wrap.querySelector("#pd-update-extensions-btn")?.addEventListener("click", () => {
      // Content scripts can't navigate to chrome:// URLs. Ask the
      // background service worker to open the tab for us.
      try {
        chrome.runtime.sendMessage({ type: "pd_open_extensions_page" }, (resp) => {
          if (!resp || !resp.ok) {
            // Fallback: copy to clipboard so the agent can paste.
            try { navigator.clipboard.writeText("chrome://extensions"); } catch {}
            alert("Open a new tab and paste: chrome://extensions");
          }
        });
      } catch (e) {
        alert("Open a new tab and paste: chrome://extensions");
      }
    });
  }

  // chrome.storage.onChanged → live update of state.updateAvailable.
  // Wired once at boot via wireUpdateStorageWatcher; idempotent.
  function wireUpdateStorageWatcher() {
    const root = document.getElementById("pd-root");
    if (root && root.dataset.gpdUpdateWatcherWired === "1") return;
    if (root) root.dataset.gpdUpdateWatcherWired = "1";
    if (!isExtensionAlive()) return;
    try {
      chrome.storage.onChanged.addListener((changes, area) => {
        if (area !== "local") return;
        if ("pd_update_available" in changes) {
          state.updateAvailable = changes.pd_update_available.newValue || null;
          renderUpdateBanner();
        }
        if ("pd_update_dismissed_for" in changes) {
          state.updateDismissedFor = changes.pd_update_dismissed_for.newValue || null;
          renderUpdateBanner();
        }
      });
    } catch {}
  }

  // v1.31.75 — Wire the ball/jersey skin drawer + face buttons.
  // Idempotent — first paint() call wires; subsequent paints skip.
  // The drawer holds all action buttons; clicks delegate to the existing
  // main-panel button IDs so behavior stays single-source-of-truth.
  function wireSkinDrawer() {
    const root = document.getElementById("pd-root");
    if (!root || root.dataset.gpdSkinDrawerWired === "1") return;
    root.dataset.gpdSkinDrawerWired = "1";

    const drawer = root.querySelector("#pd-skin-drawer");
    const openBtn = root.querySelector("#pd-skin-face-drawer-toggle");
    const closeBtn = root.querySelector("#pd-skin-drawer-close");
    const maxBtn = root.querySelector("#pd-skin-face-max");

    // Open / close drawer
    openBtn?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      drawer?.classList.add("open");
    });
    closeBtn?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      drawer?.classList.remove("open");
    });

    // Maximize → return to last large skin (phone or goal)
    maxBtn?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      const target = state.lastLargeSkin === "goal" ? "goal" : "phone";
      setSkin(target);
      setSizeMode("full");
    });

    // v1.31.80 — Blue shoulder ball on jersey skin routes to active ball
    // skin (setSkin goal + mini, no jersey-bg class so the ball graphic shows).
    root.querySelector("#pd-jersey-shoulder-ball")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      try {
        root.classList.remove("pd-jersey-bg");
        setSkin("goal");
        setSizeMode("mini");
      } catch {}
    });

    // Action button delegation. Each drawer button finds the matching
    // real button in the main panel and clicks it. If the real button
    // is disabled or not in the DOM (state-dependent rendering), the
    // click is a no-op.
    const proxy = (selector, getReal) => {
      const btn = root.querySelector(selector);
      if (!btn) return;
      btn.addEventListener("click", (e) => {
        e.preventDefault(); e.stopPropagation();
        const real = getReal();
        if (real && !real.disabled) real.click();
      });
    };
    proxy(".pd-drawer-start",      () => document.getElementById("pd-start"));
    proxy(".pd-drawer-autopilot",  () => document.getElementById("pd-autopilot") || document.getElementById("pd-mini-pause"));
    proxy(".pd-drawer-endsess",    () => document.getElementById("pd-mini-endsess") || document.getElementById("pd-end"));
    proxy(".pd-drawer-endcall",    () => document.getElementById("pd-mini-endcall"));

    // Auto-attend toggle action button — flip the real checkbox + dispatch change
    const autoAttBtn = root.querySelector(".pd-drawer-autoattend");
    autoAttBtn?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      const cb = document.getElementById("pd-auto-attendant-hangup");
      if (cb) {
        cb.checked = !cb.checked;
        cb.dispatchEvent(new Event("change", { bubbles: true }));
      }
    });

    // Toggle sync: drawer checkbox ↔ real checkbox (bidirectional).
    // We read initial state from real, push changes both ways.
    const syncToggle = (drawerId, realId) => {
      const d = document.getElementById(drawerId);
      const r = document.getElementById(realId);
      if (!d || !r) return;
      d.checked = !!r.checked;
      d.addEventListener("change", () => {
        if (r.checked !== d.checked) {
          r.checked = d.checked;
          r.dispatchEvent(new Event("change", { bubbles: true }));
        }
      });
      // Listen on the real one so external flips reflect in the drawer.
      r.addEventListener("change", () => {
        if (d.checked !== r.checked) d.checked = r.checked;
      });
    };
    syncToggle("pd-drawer-auto-attend-cb", "pd-auto-attendant-hangup");
    syncToggle("pd-drawer-call-from-comments-cb", "pd-call-from-comments");

    // Dial stats — re-render from state.counters whenever applySizeMode
    // runs (which is already called on counter changes) and also on a
    // light 2-second interval as a safety net.
    const updateStats = () => {
      const el = document.getElementById("pd-drawer-stats");
      if (!el) return;
      const c = state.counters || {};
      el.textContent = (c.dials || 0) + " dials · " + (c.contacts || 0) + " contacts";
    };
    updateStats();
    setInterval(updateStats, 2000);

    // Microphone permission request
    root.querySelector("#pd-drawer-perm-mic")?.addEventListener("click", async (e) => {
      e.preventDefault(); e.stopPropagation();
      const btn = e.currentTarget;
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        stream.getTracks().forEach(t => t.stop());
        btn.textContent = "🎤 Microphone granted ✓";
        btn.classList.add("granted");
      } catch (err) {
        btn.textContent = "🎤 Microphone denied — check browser settings";
        btn.classList.add("denied");
      }
    });

    // Sync autopilot label with state
    const aLabel = root.querySelector(".pd-drawer-autopilot-label");
    if (aLabel) {
      const updateAutopilotLabel = () => {
        const real = document.getElementById("pd-mini-pause");
        if (real) {
          aLabel.textContent = state.autoDialMode ? "Pause Autopilot" : "Start Autopilot";
        }
      };
      updateAutopilotLabel();
      setInterval(updateAutopilotLabel, 1000);
    }
  }

  // v1.31.78 — True minimize: hide pd-root entirely + show a small
  // floating restore dot anchored at top:80px right:24px on the page.
  // Distinct from sizeMode (which controls panel layout WHILE visible)
  // and from collapsed (which hides header/body/footer but keeps
  // mini-head visible inside pd-root).
  function setMinimized(v) {
    state.minimized = !!v;
    if (isExtensionAlive()) {
      try { chrome.storage.local.set({ pd_minimized: state.minimized }); } catch {}
    }
    applyMinimized();
  }

  function applyMinimized() {
    const root = document.getElementById("pd-root");
    if (root) {
      root.classList.toggle("pd-minimized", !!state.minimized);
    }
    let restore = document.getElementById("pd-restore-btn");
    if (state.minimized) {
      if (!restore) {
        restore = document.createElement("button");
        restore.id = "pd-restore-btn";
        restore.className = "pd-restore-btn";
        restore.type = "button";
        restore.title = "Restore Goal Power Dialer";
        restore.setAttribute("aria-label", "Restore Goal Power Dialer");
        // v1.31.80 styling fix — Use soccerball-large.png as a CSS
        // background so the ball fills ~88% of the button interior.
        try {
          const ballImg = chrome.runtime.getURL("icons/soccerball-large.png");
          restore.style.backgroundImage = `url("${ballImg}")`;
        } catch {
          restore.textContent = "⚽";
        }

        // v1.31.80 fix-up — Apply persisted position + size BEFORE
        // wiring drag/resize so they read the correct starting state.
        if (state.restorePos && typeof state.restorePos.right === "number") {
          restore.style.right = state.restorePos.right + "px";
          restore.style.top = state.restorePos.top + "px";
        }
        if (state.restoreScale && state.restoreScale !== 1) {
          const sz = Math.max(20, Math.min(400, 60 * state.restoreScale));
          restore.style.width = sz + "px";
          restore.style.height = sz + "px";
        }

        // v1.31.80 fix-up — Invisible resize handle in bottom-right
        // corner. Transparent — agent discovers it by hover (cursor
        // changes) or by trying to drag near the corner.
        const resizeHandle = document.createElement("div");
        resizeHandle.className = "pd-restore-resize";
        restore.appendChild(resizeHandle);

        // v1.31.80 fix-up — Drag the button itself. Mousedown anywhere
        // OUTSIDE the resize handle arms the drag; 4px threshold
        // distinguishes click from drag. On mouseup after a real drag,
        // we set gpdJustDragged so the click handler can suppress
        // un-minimize for tail-of-drag clicks.
        let restoreArmed = false, restoreDragging = false;
        let rStartX = 0, rStartY = 0, rStartRight = 0, rStartTop = 0;
        const R_DRAG_THRESHOLD = 4;
        restore.addEventListener("mousedown", (e) => {
          if (e.target && e.target.classList && e.target.classList.contains("pd-restore-resize")) return;
          restoreArmed = true;
          rStartX = e.clientX;
          rStartY = e.clientY;
          const rect = restore.getBoundingClientRect();
          rStartRight = window.innerWidth - rect.right;
          rStartTop = rect.top;
        });
        document.addEventListener("mousemove", (e) => {
          if (!restoreArmed) return;
          const dx = e.clientX - rStartX, dy = e.clientY - rStartY;
          if (!restoreDragging) {
            if (Math.abs(dx) < R_DRAG_THRESHOLD && Math.abs(dy) < R_DRAG_THRESHOLD) return;
            restoreDragging = true;
          }
          e.preventDefault();
          const right = Math.max(0, rStartRight - dx);
          const top = Math.max(0, rStartTop + dy);
          restore.style.right = right + "px";
          restore.style.top = top + "px";
        });
        document.addEventListener("mouseup", () => {
          if (!restoreArmed) return;
          restoreArmed = false;
          if (!restoreDragging) return;
          restoreDragging = false;
          try { restore.dataset.gpdJustDragged = String(Date.now()); } catch {}
          const rect = restore.getBoundingClientRect();
          const pos = { right: window.innerWidth - rect.right, top: rect.top };
          state.restorePos = pos;
          if (isExtensionAlive()) {
            try { chrome.storage.local.set({ pd_restore_pos: pos }); } catch {}
          }
        });

        // v1.31.80 fix-up — Invisible resize handle drag. Clamps
        // 30–120 px (scale 0.5–2.0 on the 60 px base).
        let restoreResizing = false, rzStartX = 0, rzStartY = 0, rzStartSize = 60;
        resizeHandle.addEventListener("mousedown", (e) => {
          e.preventDefault(); e.stopPropagation();
          restoreResizing = true;
          rzStartX = e.clientX; rzStartY = e.clientY;
          rzStartSize = restore.offsetWidth;
        });
        document.addEventListener("mousemove", (e) => {
          if (!restoreResizing) return;
          e.preventDefault();
          const delta = ((e.clientX - rzStartX) + (e.clientY - rzStartY)) / 2;
          const next = Math.max(20, Math.min(400, rzStartSize + delta));
          restore.style.width = next + "px";
          restore.style.height = next + "px";
        });
        document.addEventListener("mouseup", () => {
          if (!restoreResizing) return;
          restoreResizing = false;
          const scale = restore.offsetWidth / 60;
          state.restoreScale = scale;
          if (isExtensionAlive()) {
            try { chrome.storage.local.set({ pd_restore_scale: scale }); } catch {}
          }
        });

        // v1.31.80 fix-up — Click handler gates on gpdJustDragged so
        // the tail-of-drag mouseup doesn't trigger un-minimize.
        restore.addEventListener("click", (e) => {
          e.preventDefault(); e.stopPropagation();
          const ts = parseInt(restore.dataset.gpdJustDragged || "0", 10);
          if (ts && Date.now() - ts < 200) return;
          setMinimized(false);
        });
        document.body.appendChild(restore);
      }
    } else {
      restore?.remove();
    }
  }

  // v1.31.80 — Wire the NEW buttons added to the compact (mini) UI:
  // Start Session, Auto Attend, drawer toggle + drawer contents.
  // Idempotent via root.dataset.gpdMiniWired flag.
  function wireMiniButtons() {
    const root = document.getElementById("pd-root");
    if (!root || root.dataset.gpdMiniWired === "1") return;
    root.dataset.gpdMiniWired = "1";

    // v1.31.80 fix-up — Start Session / End Session TOGGLE.
    // Click routes to pd-start when idle, pd-end (or pd-mini-endsess)
    // when active. Runtime sync below swaps label + color.
    document.getElementById("pd-mini-startsess")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      if (state.session === "idle") {
        const real = document.getElementById("pd-start");
        if (real && !real.disabled) {
          real.click();
        } else {
          try { window.dispatchEvent(new CustomEvent("pd_start_session_request")); } catch {}
        }
      } else {
        // End session — proxy to whichever real End button exists.
        const real = document.getElementById("pd-end") || document.getElementById("pd-mini-endsess");
        if (real) real.click();
      }
    });

    // v1.31.80 fix-up — Call / Hang Up TOGGLE on pd-mini-call.
    // When state.prospect is active, click forwards to pd-mini-endcall.
    // Otherwise normal call behavior (existing handler on pd-mini-call).
    const callBtnEl = document.getElementById("pd-mini-call");
    if (callBtnEl && callBtnEl.dataset.gpdToggleWired !== "1") {
      callBtnEl.dataset.gpdToggleWired = "1";
      // Capture-phase listener runs BEFORE the existing call dispatcher,
      // so when on-call we can swallow + redirect to hang up.
      callBtnEl.addEventListener("click", (e) => {
        if (!state.prospect) return; // not on call → let normal handler proceed
        e.preventDefault();
        e.stopPropagation();
        document.getElementById("pd-mini-endcall")?.click();
      }, true);
    }

    // v1.31.80 fix-up — Unified runtime sync. Runs every 1 second.
    // Updates Start/End Session label+class, Call/Hang Up icon+class,
    // Auto-Click pause+class, and the Auto Attend label (preserves the
    // earlier aaSync behavior).
    const aaBtn = document.getElementById("pd-mini-autoattend");
    const aaLbl = document.getElementById("pd-mini-autoattend-lbl");
    const PHONE_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92V20a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3.09a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.36a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2z"/></svg>';
    const HANGUP_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M3 11 Q12 3 21 11" /><line x1="6" y1="14" x2="9" y2="17" /><line x1="18" y1="14" x2="15" y2="17" /></svg>';
    const sync = () => {
      // Start Session ⇄ End Session
      const ssBtn = document.getElementById("pd-mini-startsess");
      if (ssBtn) {
        const active = state.session !== "idle";
        ssBtn.innerHTML = active
          ? '<span>⏹</span><span>End Session</span>'
          : '<span>▶</span><span>Start Session</span>';
        ssBtn.classList.toggle("is-active", active);
      }
      // Call ⇄ Hang Up
      const cBtn = document.getElementById("pd-mini-call");
      if (cBtn) {
        const onCall = !!state.prospect;
        if (cBtn.dataset.gpdLastState !== (onCall ? "1" : "0")) {
          cBtn.innerHTML = onCall ? HANGUP_SVG : PHONE_SVG;
          cBtn.dataset.gpdLastState = onCall ? "1" : "0";
        }
        cBtn.classList.toggle("is-active", onCall);
        cBtn.title = onCall ? "Hang up" : "Place call";
      }
      // Auto-Click pause indicator (orange when autodialing)
      const pBtn = document.getElementById("pd-mini-pause");
      if (pBtn) {
        pBtn.classList.toggle("is-active", !!state.autoDialMode);
      }
      // Auto Attend label
      const cb = document.getElementById("pd-auto-attendant-hangup");
      if (aaLbl) aaLbl.textContent = cb && cb.checked ? "Auto Attend ON" : "Auto Attend OFF";
      if (aaBtn) aaBtn.classList.toggle("active", !!(cb && cb.checked));
    };
    aaBtn?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      const cb = document.getElementById("pd-auto-attendant-hangup");
      if (cb) {
        cb.checked = !cb.checked;
        cb.dispatchEvent(new Event("change", { bubbles: true }));
        sync();
      }
    });
    sync();
    setInterval(sync, 1000);

    // Drawer open/close
    const drawer = document.getElementById("pd-mini-drawer");
    document.getElementById("pd-mini-drawer-toggle")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      drawer?.classList.add("open");
    });
    document.getElementById("pd-mini-drawer-close")?.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      drawer?.classList.remove("open");
    });

    // Drawer: mic permission
    document.getElementById("pd-mini-drawer-perm")?.addEventListener("click", async (e) => {
      e.preventDefault(); e.stopPropagation();
      const btn = e.currentTarget;
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        stream.getTracks().forEach(t => t.stop());
        btn.textContent = "🎤 Microphone granted ✓";
        btn.classList.add("granted");
      } catch (err) {
        btn.textContent = "🎤 Microphone denied — check browser settings";
        btn.classList.add("denied");
      }
    });

    // Drawer: toggle sync. Bidirectional — drawer ↔ real checkbox.
    const syncToggle = (drawerId, realId) => {
      const d = document.getElementById(drawerId);
      const r = document.getElementById(realId);
      if (!d || !r) return;
      d.checked = !!r.checked;
      d.addEventListener("change", () => {
        if (r.checked !== d.checked) {
          r.checked = d.checked;
          r.dispatchEvent(new Event("change", { bubbles: true }));
        }
      });
      r.addEventListener("change", () => {
        if (d.checked !== r.checked) d.checked = r.checked;
      });
    };
    syncToggle("pd-mini-toggle-aa", "pd-auto-attendant-hangup");
    syncToggle("pd-mini-toggle-callcomments", "pd-call-from-comments");
    // Double-tap End + Away mode toggles store directly in state since
    // they don't always have a real checkbox in the large skin.
    document.getElementById("pd-mini-toggle-doubletap")?.addEventListener("change", (e) => {
      const v = !!e.target.checked;
      state.doubleTapEnd = v;
      try { chrome.storage.local.set({ pd_double_tap: v }); } catch {}
    });
    document.getElementById("pd-mini-toggle-away")?.addEventListener("change", (e) => {
      const v = !!e.target.checked;
      state.awayMode = v;
      try { chrome.storage.local.set({ pd_away_mode: v }); } catch {}
    });
    // Initialize from state.
    const dt = document.getElementById("pd-mini-toggle-doubletap");
    if (dt) dt.checked = !!state.doubleTapEnd;
    const aw = document.getElementById("pd-mini-toggle-away");
    if (aw) aw.checked = !!state.awayMode;
  }

  function setSizeMode(mode) {
    state.sizeMode = mode;
    if (isExtensionAlive()) {
      try { chrome.storage.local.set({ pd_size_mode: mode }); } catch {}
    }
    applySizeMode();
  }

  function applySizeMode() {
    const root = document.getElementById("pd-root");
    if (!root) return;
    root.classList.remove("pd-size-full", "pd-size-mini", "pd-size-ball");
    root.classList.add("pd-size-" + (state.sizeMode || "full"));
    // Update mini-mode summary line (live counters)
    const mini = root.querySelector(".pd-mini-stats");
    if (mini) mini.textContent = `${state.counters.dials} dials · ${state.counters.contacts} contacts`;
    // Update ball badge count
    const badge = root.querySelector(".pd-ball-badge");
    if (badge) badge.textContent = state.counters.dials || "";
    // Mini-mode action buttons enable/disable based on session state
    const pause   = root.querySelector("#pd-mini-pause");
    const endCall = root.querySelector("#pd-mini-endcall");
    const endSess = root.querySelector("#pd-mini-endsess");
    if (pause) {
      pause.disabled = state.session !== "parked";
      pause.textContent = state.autoDialMode ? "⏸ Pause" : "▶ Resume";
    }
    if (endCall) endCall.disabled = !state.prospect;
    if (endSess) endSess.disabled = state.session === "idle";
  }

  // Apply the persisted scale to the panel. Runs after panel build and
  // again whenever the user drags the corner handle.
  function applyScale() {
    const phone = document.querySelector("#pd-root .pd-phone");
    if (!phone) return;
    const s = state.scale || 1;
    phone.style.transform = s === 1 ? "" : `scale(${s})`;
  }

  // v1.31.73 — Apply saved pos to pd-root's inline style. Previously
  // state.pos was loaded from storage but never applied on page nav,
  // so the panel reverted to CSS default (top:80 right:24) every time.
  // Call from paint() and from skin-change paths.
  function applyPos() {
    const root = document.getElementById("pd-root");
    if (!root) return;
    const pos = state.pos;
    if (pos && typeof pos.right === "number" && typeof pos.top === "number") {
      root.style.right = pos.right + "px";
      root.style.top = pos.top + "px";
    } else {
      root.style.right = "";
      root.style.top = "";
    }
  }

  // v1.31.73 — Reload state.scale + state.pos from the per-skin maps
  // for the current state.skin. Called by skin-change paths after they
  // update state.skin. Applies both visually.
  function reloadGeometryForCurrentSkin() {
    const sk = state.skin || "phone";
    const sv = state.scaleBySkin && state.scaleBySkin[sk];
    state.scale = (sv >= 0.35 && sv <= 2.5) ? sv : 1;
    state.pos = (state.posBySkin && state.posBySkin[sk]) || null;
    applyScale();
    applyPos();
  }

  function enableScale(root, handle) {
    if (!handle) return;
    let scaling = false, startX = 0, startY = 0, startScale = 1, baseDim = 600;
    handle.addEventListener("mousedown", (e) => {
      e.preventDefault();
      e.stopPropagation();          // don't trigger drag on the status bar
      scaling = true;
      root.classList.add("pd-dragging");
      startX = e.clientX;
      startY = e.clientY;
      startScale = state.scale || 1;
      // Use the panel width as the "feel" reference for sensitivity.
      const phone = root.querySelector(".pd-phone");
      baseDim = (phone && phone.offsetWidth) || 600;
    });
    document.addEventListener("mousemove", (e) => {
      if (!scaling) return;
      // Handle is at TOP-LEFT. Panel anchors top-right, so dragging the
      // handle UP-and-LEFT pulls the panel's left edge further from the
      // anchor → grow. Dragging DOWN-and-RIGHT (toward the anchor) shrinks.
      // dx<0 (leftward) and dy<0 (upward) both contribute positively.
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      const delta = (-dx - dy) / 2;
      // Wider clamp range — mini cards (240/288 px base) scale from ~1″
      // (96 px) to ~6″ (576 px) within these bounds. Full panel can also
      // get smaller / bigger than before; resize handle limits it.
      const next = clamp(startScale + delta / baseDim, 0.35, 2.5);
      state.scale = next;
      applyScale();
    });
    document.addEventListener("mouseup", () => {
      if (!scaling) return;
      scaling = false;
      root.classList.remove("pd-dragging");
      // v1.31.73 — Save under per-skin map. Keep legacy single value in
      // sync for any external readers.
      const sk = state.skin || "phone";
      state.scaleBySkin = state.scaleBySkin || {};
      state.scaleBySkin[sk] = state.scale;
      try {
        chrome.storage.local.set({
          pd_panel_scale: state.scale,
          pd_panel_scale_by_skin: state.scaleBySkin,
        });
      } catch {}
    });
  }

  function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }

  // Tick the phone-frame status-bar clock every 30s. Cheap; no-op if the
  // panel was hidden/destroyed.
  function startStatusBarClock() {
    const paint = () => {
      const el = document.getElementById("pd-sb-time");
      if (!el) return;
      const d = new Date();
      let h = d.getHours();
      const m = String(d.getMinutes()).padStart(2, "0");
      // 12-hour, no AM/PM (matches Galaxy lock-screen style when 24h is off)
      h = h % 12; if (h === 0) h = 12;
      el.textContent = `${h}:${m}`;
    };
    paint();
    // Fast paint heartbeat (3 s) keeps the UI aligned with chrome.storage
    // changes, WS state, and desktop-client status without forcing the
    // agent to reload Impact. Cheap — paint() is a single-tree DOM diff
    // and bails out early when nothing changed.
    setInterval(paint, 3000);
    // WS watchdog — every 5 s, if we have a valid agentId but the socket
    // is closed, auto-reconnect. Catches the case where Cloudflare's edge
    // dropped the connection silently or Impact navigation closed it.
    setInterval(() => {
      try {
        if (state.session === "ending") return;
        // v1.33.5.1 — Watchdog ⇄ supersede cooperation. When another tab
        // took over, don't blindly reconnect (that would steal the session
        // back and ping-pong forever) — but don't sit dead either. Check
        // the owner's cross-tab heartbeat: fresh means a real owner is
        // working, so stay passive; stale means that tab was closed /
        // crashed / slept, so reclaim automatically. This is what makes the
        // 5s watchdog reconnect the RIGHT extension.
        if (state.wsSuperseded) {
          pdOwnerIsAlive().then((alive) => {
            if (alive || !state.wsSuperseded) return;
            // Jitter so two passive tabs don't race to reclaim in lockstep;
            // if they do collide, the loser is simply superseded again and
            // will see a fresh heartbeat on its next tick.
            setTimeout(() => {
              if (!state.wsSuperseded) return;
              log("info", "session owner heartbeat went stale — reclaiming the dialer in this tab");
              try { document.getElementById("pd-superseded-banner")?.remove(); } catch (_e) {}
              connectWs();
            }, Math.floor(Math.random() * 1500));
          }).catch(() => {});
          return;
        }
        const id = agentId();
        if (!id) return;
        const wantWs = state.wsStatus !== "connected" &&
                       (!state.ws || state.ws.readyState === WebSocket.CLOSED || state.ws.readyState === WebSocket.CLOSING);
        if (wantWs) connectWs();
      } catch {}
    }, 5000);
  }

  // v1.31.72 — Restored from June 1 commits 5638310 + e2ad98a.
  //
  // Three changes from the prior version that never made it to prod root
  // content.js:
  //   1. 4px movement threshold distinguishes click from drag, so callers
  //      that wire enableDrag to elements containing buttons (mini-head,
  //      ball-bubble) don't break the click-to-expand interaction. A
  //      quick click stays a click; movement past the threshold becomes
  //      a drag.
  //   2. BUTTON exclusion removed — the 4px threshold safely distinguishes
  //      drag from button click. Without removing the exclusion, the
  //      goal-skin mini-head (which is mostly buttons) had no draggable
  //      surface.
  //   3. root.dataset.gpdJustDragged set after a real drag so the ball-
  //      bubble's click-to-expand handler can suppress its action when
  //      the click is the tail of a drag.
  function enableDrag(root, handle) {
    if (!handle) return;
    let dragging = false, startX = 0, startY = 0, startRight = 0, startTop = 0;
    let armed = false; // mousedown happened — waiting to see if it's a drag
    const DRAG_THRESHOLD = 4;
    handle.addEventListener("mousedown", (e) => {
      armed = true;
      startX = e.clientX; startY = e.clientY;
      const rect = root.getBoundingClientRect();
      startRight = window.innerWidth - rect.right;
      startTop = rect.top;
      // Don't preventDefault yet — let normal clicks fire on buttons.
    });
    document.addEventListener("mousemove", (e) => {
      if (!armed) return;
      const dx = e.clientX - startX, dy = e.clientY - startY;
      if (!dragging) {
        if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
        dragging = true;
        root.classList.add("pd-dragging");
      }
      e.preventDefault();
      const right = Math.max(0, startRight - dx);
      const top = Math.max(0, startTop + dy);
      root.style.right = right + "px";
      root.style.top = top + "px";
    });
    document.addEventListener("mouseup", () => {
      if (!armed) return;
      armed = false;
      if (!dragging) return;
      dragging = false;
      root.classList.remove("pd-dragging");
      // Mark recently-dragged so click-to-expand handlers can ignore the
      // tail-end mouseup that fires after a drag.
      try { root.dataset.gpdJustDragged = String(Date.now()); } catch {}
      const rect = root.getBoundingClientRect();
      const pos = { right: window.innerWidth - rect.right, top: rect.top };
      state.pos = pos;
      // v1.31.73 — Save under per-skin map. Keep the legacy single-value
      // key in sync so external code paths reading the old key still see
      // a reasonable value.
      const sk = state.skin || "phone";
      state.posBySkin = state.posBySkin || {};
      state.posBySkin[sk] = pos;
      chrome.storage.local.set({
        pd_panel_pos: pos,
        pd_panel_pos_by_skin: state.posBySkin,
      });
    });
  }

  // v1.31.72 — Orphan overlay reaper.
  //
  // Background: `.pd-tour-backdrop`, `.pd-continue-modal` (without
  // .pd-open), and any audio drawer or other pd-* overlay can in rare
  // edge cases be left mounted to document.body — either because the
  // owning code path didn't finish its teardown (page nav before
  // setTimeout fires) or because the tour was triggered and never
  // dismissed. Most have CSS rules that set pointer-events:none in
  // these states (so they shouldn't block clicks), but defense-in-depth
  // forcefully removes the element so we can't trip even if a future
  // CSS regression breaks the pointer-events rule.
  function orphanOverlayReaper() {
    try {
      // Tour root without active tour
      const tour = document.getElementById("pd-tour-root");
      if (tour && !tour.classList.contains("pd-tour-active")) {
        console.log("[pd] reaper: removing orphan #pd-tour-root");
        tour.remove();
      }
      // Continue modal without pd-open
      const cm = document.getElementById("pd-continue-modal");
      if (cm && !cm.classList.contains("pd-open")) {
        // Allow 1 sec for the close animation to finish
        const ts = parseInt(cm.dataset.pdReaperSeenAt || "0", 10);
        if (ts && Date.now() - ts > 1000) {
          console.log("[pd] reaper: removing orphan .pd-continue-modal (>1s without pd-open)");
          cm.remove();
        } else if (!ts) {
          cm.dataset.pdReaperSeenAt = String(Date.now());
        }
      }
      // Stranded audio drawer attached to document.body (should be
      // inside pd-root, not on body)
      document.querySelectorAll("body > .pd-audio-drawer").forEach(el => {
        console.log("[pd] reaper: removing stray .pd-audio-drawer on body");
        el.remove();
      });
    } catch (err) {
      // Reaper must never throw — it runs every 3s forever
      console.warn("[pd] reaper threw:", err && err.message);
    }
  }
  // Run reaper every 3 seconds. Cheap (a few getElementById + class checks).
  setInterval(orphanOverlayReaper, 3000);

  // v1.31.72 — Hit-test debug overlay.
  //
  // Toggle via chrome.storage.local.pd_debug_hit_test = true (or via the
  // Settings panel — wired separately). When on, paints a red translucent
  // outline around pd-root AND every pd-* element directly under
  // document.body. Lets us SEE invisible click traps instantly.
  function refreshDebugOutlines() {
    // v1.31.80 — Guard against extension-context invalidation. After
    // a Chrome extension reload, the OLD content script still running
    // on existing tabs has a dead chrome.runtime. Calling
    // chrome.storage.local.get(...) throws SYNCHRONOUSLY in that
    // state — the existing .catch() only catches promise rejections,
    // not sync throws. Result: every setInterval fire spammed the
    // Issues panel every 5s. Bail early and stop the loop.
    if (!isExtensionAlive()) {
      if (refreshDebugOutlinesInterval) {
        try { clearInterval(refreshDebugOutlinesInterval); } catch {}
        refreshDebugOutlinesInterval = null;
      }
      return;
    }
    try {
    chrome.storage.local.get("pd_debug_hit_test").then(({ pd_debug_hit_test }) => {
      const on = !!pd_debug_hit_test;
      // pd-root
      const root = document.getElementById("pd-root");
      if (root) {
        root.classList.toggle("pd-debug-outline", on);
        if (on) root.dataset.pdDebugLabel = "pd-root";
      }
      // pd-* siblings of pd-root on body
      document.querySelectorAll('body > [id^="pd-"], body > [class*="pd-"]').forEach(el => {
        if (el.id === "pd-root") return;
        el.classList.toggle("pd-debug-outline", on);
        if (on) el.dataset.pdDebugLabel = el.id || el.className.split(" ").find(c => c.startsWith("pd-")) || "pd-*";
      });
    }).catch(() => {});
    } catch {
      // Extension context died mid-call — clear our setInterval so
      // we stop hammering on a dead chrome.* API every 5 seconds.
      if (refreshDebugOutlinesInterval) {
        try { clearInterval(refreshDebugOutlinesInterval); } catch {}
        refreshDebugOutlinesInterval = null;
      }
    }
  }
  // v1.31.80 — Track the interval handle so refreshDebugOutlines can
  // clear itself when it detects the extension context is dead.
  let refreshDebugOutlinesInterval = null;
  // Refresh outlines whenever the toggle changes
  try {
    chrome.storage.onChanged.addListener((changes) => {
      if (changes.pd_debug_hit_test) refreshDebugOutlines();
    });
  } catch (_e) {}
  // Initial paint + periodic refresh (in case new overlays appear)
  setTimeout(refreshDebugOutlines, 1500);
  refreshDebugOutlinesInterval = setInterval(refreshDebugOutlines, 5000);

  // ─────────────────────────────────────────────────────────────────
  // Render
  // ─────────────────────────────────────────────────────────────────
  function paint() {
    // v1.32.16 — Respect the agent's per-tab "close dialer" choice.
    // Skip all paint work if the agent closed the dialer on this tab.
    try {
      if (sessionStorage.getItem("pd_dialer_closed") === "1") return;
    } catch {}
    // v1.32.17 — DEDUP. The Start Session / Pause Autopilot / End Session
    // buttons flicker when paint() is called repeatedly with the same
    // visual state (Dustin's complaint 2026-06-17 post v1.32.16 deploy).
    // Skip the paint cycle if nothing visually has changed since last
    // render. Built from the inputs that drive which buttons render
    // and what they say.
    try {
      const sessionHash = [
        state.session || "",
        state.autoDialMode ? "1" : "0",
        state.prospect ? (state.prospect.status || "x") : "n",
        state.prospect ? (state.prospect.leadId || "x") : "n",
        state.skin || "",
        state.collapsed ? "1" : "0",
        state.maximized ? "1" : "0",
        state.sizeMode || "",
        state.dialMode || "",
        state.activeParkSource || "",
        state.wsStatus || "",
        state.micGranted ? "1" : "0",
        state.counters ? state.counters.dials + "/" + state.counters.noAnswers + "/" + state.counters.completed : "",
        state.doubleTap ? "1" : "0",
        state.manualDisposition ? "1" : "0",
        state.lastError || "",
      ].join("|");
      if (paint._lastHash === sessionHash) {
        // Same visual state as last render — skip the full repaint.
        // We still re-inject the close button (idempotent + cheap) in
        // case external code dropped it.
        try { pdInjectCloseButton(); } catch {}
        return;
      }
      paint._lastHash = sessionHash;
    } catch {}
    if (!document.getElementById("pd-root")) buildPanel();
    // v1.32.16 — Ensure the close X button is present on every paint
    // (idempotent). Some skin changes / re-mounts can drop it.
    try { pdInjectCloseButton(); } catch {}

    // Sync skin class — popup may have changed the choice while panel was open.
    const root = document.getElementById("pd-root");
    if (root) {
      root.classList.remove("pd-skin-phone", "pd-skin-goal", "pd-skin-ball", "pd-skin-jersey", "pd-skin-medium", "pd-skin-soccer-medium", "pd-classic-mode");
      // v1.31.80 — Same dual-class for classic as setSkin uses.
      // v1.32.9 — Skin consolidation removed the branded-top variant —
      // phone IS now the branded-top design (CSS top:116 baked into
      // pd-skin-phone rule). Only classic still uses a variant marker
      // because its inline-style approach is structurally different.
      if (state.skin === "classic") {
        root.classList.add("pd-skin-phone");
        root.classList.add("pd-classic-mode");
        root.dataset.skinVariant = "classic";
      } else {
        root.classList.add("pd-skin-" + (state.skin || "phone"));
        delete root.dataset.skinVariant;
      }
      // v1.33.2 — In-call layout flip. When state.prospect != null (an
      // active call leg exists), .pd-incall on #pd-root triggers the
      // CSS flex-order swap in content.css "In-call panel reorder" so
      // Autopilot / lead identity + Hangup-VM-Auto-Attd / Manual Dial /
      // End Session jump to the top of pd-body. Scoped to non-grid skins;
      // goal/medium/soccer-medium already render prospect prominently in
      // the middle grid column and ignore this class.
      root.classList.toggle("pd-incall", !!state.prospect);
      // v1.33.3 — Pre-session bump. When session is idle/ended (Start
      // Session button visible), .pd-session-idle on #pd-root bumps
      // pd-session-area (which contains just that button) to the top of
      // pd-body so it sits right below the Devices area / mode-config
      // card instead of below profile/status/stats. Once a session moves
      // to "parked" or "starting", the class drops off and the normal
      // (or v1.33.2 in-call) order applies.
      const isPreSession = state.session !== "parked" && state.session !== "starting" && state.session !== "dialing-agent";
      root.classList.toggle("pd-session-idle", isPreSession);
    }

    // Resolve relay-mode for the current lead. Two paths:
    //   1. Manual override saved per-lead (state.relayLeads[leadId])
    //   2. Auto-detect from Impact's comments / page text
    // Either turns relay mode on. Computed on every paint so toggle UI
    // and dial-time decisions both see the same answer.
    try {
      const lid = (typeof getLeadId === "function") ? getLeadId() : null;
      const manual = !!(lid && state.relayLeads && state.relayLeads[lid]);
      const auto   = (typeof detectRelayLead === "function") ? detectRelayLead() : false;
      state.relayDetected = auto;
      state.relayMode     = manual || auto;
    } catch {}

    const p = activeProfile();
    $("pd-profile-name").textContent = p.name || state.activeProfile;
    $("pd-cell").textContent = p.agentCell || "(set in settings)";

    const pip = $("pd-ws-pip");
    const wsText = $("pd-ws-text");
    pip.classList.remove("pd-pip-ok", "pd-pip-warn", "pd-pip-err");
    if (state.wsStatus === "connected") {
      pip.classList.add("pd-pip-ok");
      wsText.textContent = state.audioStatus === "listening"
        ? "Worker connected · 🔊 listening"
        : "Worker connected";
    }
    else if (state.wsStatus === "connecting") { pip.classList.add("pd-pip-warn"); wsText.textContent = "Connecting…"; }
    else { pip.classList.add("pd-pip-err"); wsText.textContent = "Disconnected"; }

    const sess = $("pd-session-pill");
    sess.textContent = state.session;
    sess.className = "pd-session-pill pd-sess-" + state.session;

    // Test-mode banner — high-contrast yellow stripe so the agent
    // can't miss that they're in test mode.
    const tbanner = $("pd-test-banner");
    if (tbanner) tbanner.style.display = state.testMode ? "block" : "none";

    // Desktop status pip — only visible in desktop dial-mode
    const dpRow = $("pd-desk-pip-row");
    if (dpRow) {
      // v1.29.11 — Read activeParkSource (the real mode) so the pip
      // stays HIDDEN in Mobile App mode. Previously read state.dialMode
      // which is hardcoded "desktop", so the Desktop pip rendered in
      // every mode and showed "Desktop: connecting…" even when the
      // offscreen status was irrelevant to the active mode.
      const isDesk = (state.activeParkSource || "desktop") === "desktop";
      dpRow.style.display = isDesk ? "flex" : "none";
      if (isDesk) {
        const dPip = $("pd-desk-mini-pip");
        const dLbl = $("pd-desk-mini-lbl");
        const ds   = state.desktopStatus || { state: "absent" };
        let txt = "Desktop: …", kind = "";
        switch (ds.state) {
          case "absent":     txt = "Desktop: off"; break;
          case "idle":       txt = "Desktop: starting…"; break;
          case "connecting": txt = "Desktop: connecting…"; break;
          case "ready":      txt = "Desktop: ready"; kind = "ok"; break;
          case "ringing":    txt = "Desktop: incoming"; kind = "ok"; break;
          case "active":     txt = "Desktop: on call"; kind = "ok"; break;
          case "error":      txt = "Desktop: " + (ds.error || "error"); kind = "err"; break;
        }
        if (dLbl) dLbl.textContent = txt;
        if (dPip) {
          dPip.classList.remove("ok", "err", "warn");
          if (kind) dPip.classList.add(kind);
        }
      }
    }

    // v1.29.70 — `pd-c-done` is the renamed "Answered" tile. Read
    // counters.answered (real humans who picked up). The old field
    // counters.completed counted every hangup (including no-answers)
    // which made the tile equal Dials and gave NO ANS as the visually
    // largest number — confusing for agents.
    $("pd-c-dials").textContent = state.counters.dials;
    $("pd-c-noans").textContent = state.counters.noAnswers;
    $("pd-c-done").textContent  = state.counters.answered | 0;
    // Keep ball-mode badge + mini-card stats in sync with counters
    if (typeof applySizeMode === "function") {
      const root = document.getElementById("pd-root");
      const badge = root?.querySelector(".pd-ball-badge");
      if (badge) badge.textContent = state.counters.dials || "";
      // Mini-card live values
      const dEl = root?.querySelector("#pd-mini-dials");
      const sEl = root?.querySelector("#pd-mini-skipped");
      const tEl = root?.querySelector("#pd-mini-timer");
      const stEl = root?.querySelector("#pd-mini-status");
      const pEl  = root?.querySelector("#pd-mini-pause");
      const ecEl = root?.querySelector("#pd-mini-endcall");
      if (dEl) dEl.textContent = state.counters.dials || 0;
      if (sEl) sEl.textContent = state.counters.badNumbers || 0;
      if (tEl) tEl.textContent = formatSessionElapsed(state.sessionStartedAt);
      if (stEl) {
        const isParked = state.session === "parked";
        const onCall   = !!state.prospect;
        let txt = "Idle — open Setup and Start Session";
        if (state.session === "starting" || state.session === "dialing-agent") {
          // v1.29.6 — mode-aware mini status. No more cell calling.
          txt = (state.activeParkSource === "mobile_web") ? "Connecting your phone app…" : "Connecting headset…";
        }
        else if (state.session === "parked" && onCall) txt = "On a call · " + (state.prospect?.status || "ringing");
        else if (state.session === "parked") txt = state.autoDialMode ? "Autopilot ON — ready to dial" : "Parked — ready";
        else if (state.session === "ending") txt = "Ending session…";
        if (state.wsStatus !== "connected") txt = "Reconnecting to worker…";
        stEl.textContent = txt;
      }
      if (pEl) {
        if (state.session === "parked") {
          pEl.textContent = state.autoDialMode ? "⏸ Pause Auto-Click" : "▶ Start Auto-Click";
          pEl.disabled = false;
        } else {
          pEl.textContent = "▶ Start Auto-Click";
          pEl.disabled = state.session === "ending";
        }
      }
      if (ecEl) ecEl.disabled = !state.prospect;
    }

    const sa = $("pd-session-area");
    if (state.session === "idle") {
      // Compute observable readiness so the button is bright only when a
      // tap will actually work. We don't probe the worker here — preflight
      // does that — but we DO check the things the agent controls: are they
      // signed in and is the WS open?
      //
      // v1.29.6 — Gate the Desktop-client-ready check on activeParkSource
      // (the mode-row pill the user actually picked) instead of state.dialMode
      // (which is hardcoded "desktop" since v1.29.0). Previously, Mobile App
      // mode would refuse to start because the desktop offscreen WebRTC client
      // wasn't running — which doesn't matter in Mobile App mode because the
      // worker routes the call to the mobile site, not the offscreen doc.
      const profile = activeProfile();
      const mode = state.activeParkSource || "desktop";
      const isMobileApp = mode === "mobile_web";
      const isDesktop   = mode === "desktop";
      const reasons = [];
      if (!(profile.authToken || "").trim()) reasons.push("Sign in via the popup");
      if (!state.workerBase) reasons.push("Set the Worker URL in the popup");
      if (state.wsStatus !== "connected" && reasons.length === 0) {
        reasons.push("Connecting to worker…");
      }
      // Desktop mode needs the embedded WebRTC client to be ready (mic
      // permission granted + Telnyx SIP registered) — otherwise session.start
      // reaches the worker but the agent leg has nowhere to bridge to.
      // Mobile App mode does NOT require the desktop client; the agent leg
      // is routed to the /mobile site instead, so we skip this check entirely
      // when isMobileApp is true.
      const dsState = (state.desktopStatus && state.desktopStatus.state) || "absent";
      const dsReady = dsState === "ready" || dsState === "ringing" || dsState === "active";
      if (isDesktop && reasons.length === 0 && !dsReady) {
        if (dsState === "absent" || dsState === "idle") {
          reasons.push("Desktop client off — open the extension popup → Grant microphone access");
        } else if (dsState === "connecting") {
          reasons.push("Desktop client connecting to Telnyx…");
        } else if (dsState === "error") {
          reasons.push("Desktop error: " + (state.desktopStatus.error || "unknown"));
        } else {
          reasons.push("Desktop client not ready");
        }
      }
      const ready = reasons.length === 0;
      const dis = ready ? "" : "disabled";
      const titleReady = isMobileApp
        ? "Start a session — audio routes to your phone via the mobile companion site"
        : "Start a session — uses your browser headset (Desktop mode)";
      const hint = ready
        ? ""
        : `<div class="pd-disabled-hint">${escapeHtml(reasons[0])}</div>`;
      sa.innerHTML = `
        <button class="pd-btn pd-btn-start" id="pd-start" ${dis}
          title="${ready ? titleReady : escapeHtml(reasons[0])}">
          Start Session
        </button>
        ${hint}`;
      if (ready) $("pd-start").onclick = startSession;
    } else if (state.session === "starting" || state.session === "dialing-agent") {
      // v1.29.6 — Mode-aware connecting message. v1.29.0 removed PSTN cell
      // parking; the agent leg is always WebRTC routed to either the mobile
      // companion site or the desktop browser, depending on activeParkSource.
      const mode = state.activeParkSource || "desktop";
      // v1.33.7.8 — mobile host follows the division base (Liberty → lnl.goalpowerdialer.com).
      const _mobHost = (state.workerBase || "https://app.goalpowerdialer.com")
        .replace(/^https?:\/\//, "").replace(/\/+$/, "") + "/mobile";
      const msg = mode === "mobile_web"
        ? `📱 Connecting to your mobile companion — keep ${_mobHost} open on your phone`
        : "🎧 Connecting to your browser headset — WebRTC parking in progress";
      sa.innerHTML = `<div class="pd-info-soft">${msg}</div>
        <button class="pd-btn pd-btn-ghost" id="pd-end">Cancel</button>`;
      $("pd-end").onclick = endSessionFromButton;
    } else if (state.session === "parked") {
      const onCall = !!state.prospect;
      const dis = onCall ? "disabled" : "";
      const callBtnLabel = onCall ? "End Call" : "Call";
      const callBtnClass = onCall ? "pd-btn pd-btn-end" : "pd-btn pd-btn-call";
      const autoLabel = state.autoDialMode ? "⏸ Pause Autopilot" : "▶ Start Autopilot";
      const autoClass = state.autoDialMode ? "pd-btn pd-btn-autopause" : "pd-btn pd-btn-autostart";

      // v1.33.2 — Mobile + Home manual-dial buttons removed (vestigial in
      // the Autopilot + Double-Tap workflow — Autopilot already cycles
      // through both numbers in order, so dedicated per-number buttons
      // were eating vertical space for an edge case that's covered by
      // "dial any number" below). Skip-on-no-number affordance survives:
      // when the agent navigates to a lead Impact has flagged as "Bad
      // Number" (no dialable tel: links rendered), we still surface a
      // one-click Skip so the agent doesn't have to disposition manually.
      const hasMobile = !!CRM.numberFromLink(CRM.phoneLink("Mobile"));
      const hasHome   = !!CRM.numberFromLink(CRM.phoneLink("Home"));
      const hasAnyNum = hasMobile || hasHome;

      const dialRowHtml = (!onCall && !hasAnyNum)
        ? `<button class="pd-btn pd-btn-skiplead pd-full" id="pd-skip-bad" title="This lead has no dialable phone number on file — click to skip and advance">⏭ Skip — no number on this lead</button>`
        : ``;

      // v1.33.3 — 3-last-dial strip. Renders the most-recent up to 3
      // dials this session with a Redial button on each. Per-session
      // (wiped in endSession). Result icon: ✓ human, ✗ no_answer,
      // 📞 voicemail, • ended, ⏳ in-flight. Sits ABOVE the manual-
      // dial row so the agent's eye picks up "recent context" first.
      function _relTimeShort(ts) {
        if (!ts) return "";
        const diff = Math.max(0, Date.now() - ts);
        const s = Math.floor(diff / 1000);
        if (s < 60) return s + "s ago";
        const m = Math.floor(s / 60);
        if (m < 60) return m + "m ago";
        const h = Math.floor(m / 60);
        return h + "h ago";
      }
      function _lastDialIcon(r) {
        if (r === "human")     return `<span style="color:#aaf3c3;">✓</span>`;
        if (r === "no_answer") return `<span style="color:#ff8a93;">✗</span>`;
        if (r === "voicemail") return `<span style="color:#F2C94C;">📞</span>`;
        if (r === "ended")     return `<span style="color:rgba(255,255,255,0.5);">•</span>`;
        return `<span style="color:rgba(255,255,255,0.4);">⏳</span>`;
      }
      const lastDialsHtml = (function buildLastDialsStrip() {
        const dials = (state.lastDials || []).slice(0, 3);
        if (!dials.length) return "";
        const rows = dials.map((d) => {
          const ago   = _relTimeShort(d.dialedAt);
          const icon  = _lastDialIcon(d.result);
          const label = pdEscapeHtml(d.leadName || d.number || "(unknown)");
          const num   = pdEscapeHtml(d.number || "");
          return `<div class="pd-lastdial-row" style="display:flex;align-items:center;gap:8px;padding:4px 8px;font-size:11px;border-radius:4px;background:rgba(255,255,255,0.04);margin-bottom:3px;">
            <span style="flex:0 0 16px;text-align:center;">${icon}</span>
            <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:rgba(255,255,255,0.85);">${label}</span>
            <span style="flex:0 0 auto;color:rgba(255,255,255,0.5);font-size:10px;">${ago}</span>
            <button type="button" class="pd-lastdial-redial" data-num="${num}" style="flex:0 0 auto;background:rgba(45,171,95,0.25);border:1px solid rgba(45,171,95,0.5);color:#aaf3c3;border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer;font-family:inherit;" ${onCall ? "disabled" : ""}>Redial</button>
          </div>`;
        }).join("");
        return `<div class="pd-lastdials-strip" style="margin-bottom:8px;padding:6px 8px;background:rgba(0,0,0,0.18);border:1px solid rgba(255,255,255,0.06);border-radius:6px;">
          <div class="pd-section-label" style="margin-bottom:4px;font-size:10px;opacity:0.7;text-transform:uppercase;letter-spacing:0.5px;">Last ${dials.length} dial${dials.length === 1 ? "" : "s"} this session</div>
          ${rows}
        </div>`;
      })();

      // v1.29.21 — Split the previous combined session-grid into TWO
      // sections: dial controls (here, in pd-session-area) and toggles
      // (in pd-toggles-area below the in-call view). End Session stays
      // here at the bottom of the dial controls.
      // v1.33.2 — Autopilot + Manual Dial wrapped in named divs so the
      // in-call panel-reorder CSS (see content.css "In-call panel reorder")
      // can give each its own flex `order:` value. Required for the locked
      // in-call layout: Autopilot → Lead+Hangup/VM/Auto-Attd → Manual Dial
      // → End Session → Stats / Status / Profile → Toggles.
      // Outer .pd-call-stack stays so goal/medium/soccer-medium grid skins
      // continue to anchor it via grid-area: calls (CSS unchanged for them).
      sa.innerHTML = `
        <div class="pd-call-stack">
        <div class="pd-priority-autopilot">
        <button class="${autoClass} pd-full" id="pd-autopilot" title="Independent of End Session — pause keeps your cell parked.">
          ${autoLabel}
        </button>
        </div>
        <div class="pd-manual-dial-block">
        ${dialRowHtml}
        ${lastDialsHtml}
        <div class="pd-manual-head">
          <div class="pd-section-label">Dial any number</div>
          ${(state.myNumbers && state.myNumbers.length) ? `<select id="pd-manual-from" class="pd-manual-from" title="Dial FROM this number for this manual call — handy for checking if a number shows as spam. Auto = best local-presence match." ${dis}><option value="">From: Auto</option>${state.myNumbers.map((n) => { const e = (n.e164 || ""); const d = e.replace(/^\+1/, ""); const f = d.length === 10 ? "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6) : e; return `<option value="${e}">${f}</option>`; }).join("")}</select>` : ""}
        </div>
        <div class="pd-manual-row">
          <input type="tel" id="pd-manual-input" class="pd-manual-input" placeholder="+1 555 555 0100" ${dis} />
          <button class="${callBtnClass}" id="pd-manual-call">${callBtnLabel}</button>
        </div>
        </div>
        </div>
        <button class="pd-btn pd-btn-ghost pd-full" id="pd-end" title="End Session (Alt+S) · Alt+P pause autopilot · Alt+C end call">End Session <span style="opacity:.55;font-size:11px;">Alt+S</span></button>`;

      // Render toggles into the new pd-toggles-area (positioned below
      // the in-call view in the panel HTML).
      const togglesArea = $("pd-toggles-area");
      if (togglesArea) {
        // v1.32.2 — Double-Tap dual gate. Toggle renders ONLY when:
        //   1. runtimeConfig.doubleTapEnabled === true (global flag,
        //      hot-swappable via /api/admin/runtime-config), AND
        //   2. state.userIsTester === true (per-user role from /api/me).
        // If either is false, the toggle is hidden AND state.doubleTap is
        // force-disabled so the chain-queue defaults to single-dial. This
        // is the AIL vendor-approval gate: flip runtimeConfig.doubleTapEnabled
        // to false to globally disable; leave it true and grant
        // user.isTester to specific testers for granular control.
        // v1.32.7 — Gate is GLOBAL flag only. Earlier "v1.32.2 cleanup"
        // commit was supposed to drop the && state.userIsTester per-user
        // requirement but missed this line — result: toggle hidden from
        // every prod user because state.userIsTester is never set true
        // anymore. Restored the simpler single-flag check.
        // v1.33.2.4 — Boot-race fix. The previous test was:
        //   if (!_dtVisibleToUser && state.doubleTap) → force off
        // _dtVisibleToUser was just `!!(state.runtimeConfig?.doubleTapEnabled)`.
        // If state.runtimeConfig hadn't loaded yet (the GET /api/runtime-config
        // round-trip races content.js's first paint after boot), this test
        // evaluated TRUE — and the user's previously-saved doubleTap=true
        // got silently force-disabled and persisted as false in chrome.storage.
        // Since paint() fires on every prospect.dialing / .connected / .hangup
        // and many other events, this would re-fire mid-call too. Agent
        // reports: "Double Tap unchecks itself every call" (Brandon Keesler /
        // others 2026-06-23). The intentional gate (admin disables Double-Tap
        // globally) still works — we just gate the force-off on the config
        // being EXPLICITLY loaded AND explicitly false.
        const _dtConfigLoaded    = state.runtimeConfig != null && typeof state.runtimeConfig === "object";
        const _dtGloballyEnabled = _dtConfigLoaded && !!state.runtimeConfig.doubleTapEnabled;
        const _dtVisibleToUser   = _dtGloballyEnabled;
        if (_dtConfigLoaded && !_dtGloballyEnabled && state.doubleTap) {
          // Config is LOADED and explicitly says false → admin-gate is closed.
          // Safe to force off (this is the intended security path).
          try { log("info", "[v1.33.2.4] Double-Tap force-off: runtimeConfig.doubleTapEnabled=false (admin gate)"); } catch {}
          state.doubleTap = false;
          try { chrome.storage.local.set({ pd_double_tap: false }); } catch (_e) {}
        }
        togglesArea.innerHTML = `
          <div class="pd-toggle-stack">
          ${_dtVisibleToUser ? `
          <label class="pd-toggle-row${state.doubleTap ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-double-tap" ${state.doubleTap ? "checked" : ""}>
            <span class="pd-toggle-label">Double-tap dial</span>
            <button type="button" class="pd-toggle-info" data-help-title="Double-tap dial" data-help="When on, after a no-answer or voicemail the dialer waits ~2.5 s and calls the same number a second time before moving on. Each number gets two full ring cycles. Higher pickup rate but takes longer per lead. (Tester feature — gated per user.)">ⓘ</button>
          </label>` : ""}
          <label class="pd-toggle-row${state.leaveVoicemail ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-leave-voicemail" ${state.leaveVoicemail ? "checked" : ""}>
            <span class="pd-toggle-label">📩 Leave Voicemail</span>
            <button type="button" class="pd-toggle-info" data-help-title="📩 Leave Voicemail" data-help="When ON, the dialer automatically leaves your pre-recorded voicemail (from the Voicemail Studio on your dashboard, matched to the lead's type — or your Default recording) whenever it detects a voicemail, then dispositions 'Voicemail Left' and keeps dialing. At most one voicemail per number every 7 days. OFF (default): voicemails hang up as No Answer with no message. This toggle turns itself OFF at the start of every session so it can't be left on by accident. (The VM Drop button is separate — it never leaves a message; it just tells the dialer 'this is a voicemail' so the dialer doesn't pause on it.)">ⓘ</button>
          </label>
          <label class="pd-toggle-row${state.manualDisposition ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-manual-disposition" ${state.manualDisposition ? "checked" : ""}>
            <span class="pd-toggle-label">✋ Manual disposition</span>
            <button type="button" class="pd-toggle-info" data-help-title="✋ Manual disposition" data-help="When OFF (default): the dialer auto-marks every no-answer hangup and immediately advances to the next lead. When ON: after every call ends, autopilot pauses; YOU pick the disposition in Impact (Bad Number, Voicemail Left, Set Appointment, etc.), and when Impact returns to the main lead-list page, the dialer detects it and resumes by advancing + dialing the next lead.">ⓘ</button>
          </label>
          <!-- v1.33.3 — Wake Lock toggle. Default ON. Holds a screen
               wake lock during the session so the desktop doesn't go
               idle / sleep / lock mid-shift. -->
          <!-- v1.33.4.5 — "Keep computer awake" toggle removed; wake lock is now always on during an active session (auto-acquired on session start, released on End Session). -->
          <!-- v1.34 redesign — collapse the 6 session-behavior toggles behind a
               "More options" expander so the start screen shows only the 3
               per-call toggles by default. State persists in pd_more_open. -->
          <div class="pd-more-toggle${state._moreOpen ? " pd-more-open" : ""}" id="pd-more-toggle" role="button" tabindex="0" aria-expanded="${state._moreOpen ? "true" : "false"}">
            <span class="pd-more-chev">▾</span><span>More options</span>
          </div>
          <div class="pd-more-body${state._moreOpen ? " pd-more-open" : ""}" id="pd-more-body">
          <label class="pd-toggle-row${state.bottomToTop ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-bottom-to-top" ${state.bottomToTop ? "checked" : ""}>
            <span class="pd-toggle-label">⬆️ Bottom to Top${(state._autoFlippedAt && (Date.now() - state._autoFlippedAt < 60000)) ? ` <span title="Autopilot reversed direction automatically because it reached the end of the lead list — this was not a glitch." style="display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;background:#F2C94C;color:#1B4332;font-size:10px;font-weight:700;vertical-align:middle;white-space:nowrap;">🔄 auto-flipped</span>` : ""}</span>
            <button type="button" class="pd-toggle-info" data-help-title="⬆️ Bottom to Top" data-help="When on, autopilot's advance arrow walks UP the lead list instead of down. Useful when two agents split a list — one top→bottom, the other bottom→top — without dialing the same prospect twice.">ⓘ</button>
          </label>
          <label class="pd-toggle-row${state.callFromComments ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-call-from-comments" ${state.callFromComments ? "checked" : ""}>
            <span class="pd-toggle-label">💬 Call only from comments</span>
            <button type="button" class="pd-toggle-info" data-help-title="💬 Call only from comments" data-help="For bad-number leads. When on, the dialer ignores Mobile/Home and instead scrapes phone numbers from the lead's comments section, dialing each one in sequence. When all comment numbers are exhausted for a lead, autopilot advances to the next lead.">ⓘ</button>
          </label>
          <label class="pd-toggle-row${state.autoAttendantHangup ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-auto-attendant-hangup" ${state.autoAttendantHangup ? "checked" : ""}>
            <span class="pd-toggle-label">🤖 Auto-hangup on attendants</span>
            <button type="button" class="pd-toggle-info" data-help-title="🤖 Auto-hangup on attendants" data-help="When ON: dialer auto-hangs-up when it hears an IVR/auto-attendant ('press 1 for sales', 'main menu', 'estimated wait time') and dispositions the lead as No Answer — same protocol as voicemail. When OFF (default): IVRs keep the call alive so you can navigate the menu — use the in-call 🛡️ Auto-Attd button if you want to. v1.31.68 replaces the old Relay-user toggle (the 🛡️ button covers that use case).">ⓘ</button>
          </label>
          <!-- v1.34 — Skip future callbacks (persistent per-agent). When ON,
               autopilot also skips any lead whose notes show a future
               "Call Back appointment on <date>". -->
          <label class="pd-toggle-row${state.skipFutureCallbacks ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-skip-callbacks" ${state.skipFutureCallbacks ? "checked" : ""}>
            <span class="pd-toggle-label">⏭ Skip future callbacks</span>
            <button type="button" class="pd-toggle-info" data-help-title="⏭ Skip future callbacks" data-help="When ON, autopilot also skips any lead whose notes show a future &quot;Call Back appointment on &lt;date&gt;&quot; — so you don't dial leads who already have a callback scheduled ahead. Only FUTURE-dated callbacks are skipped (past ones stay dialable). Separate from the always-on skip of future Virtual / In-Home appointments. Persists across sessions.">ⓘ</button>
          </label>
          <!-- v1.32.2 — REMOVED 🛡️ Sticky IVR override row. Was a confused
               duplicate of "Auto-hangup on attendants" with inverted
               semantics, also disabled voicemail detection as a side
               effect which surprised agents. Session-long IVR mode is
               now exclusively the "🤖 Auto-hangup on attendants" toggle
               above. The per-call 🛡️ Auto-Attd button stays a per-call
               choice (always resets per prospect, by design). -->
          <!-- v1.33.4.5 — Away Mode toggle removed for now (feature disabled; flag forced off at boot). -->
          <label class="pd-toggle-row${state.ringTimeoutEnabled ? " pd-toggle-on" : ""}">
            <input type="checkbox" id="pd-ring-timeout-enabled" ${state.ringTimeoutEnabled ? "checked" : ""}>
            <span class="pd-toggle-label">⏱ Custom connection timeout${state.ringTimeoutEnabled ? ` (${state.ringTimeoutSecs}s)` : ""}</span>
            <button type="button" class="pd-toggle-info" data-help-title="⏱ Custom connection timeout" data-help="ONLY applies to calls that NEVER CONNECT to the carrier (network failure / SIP routing problem). When OFF (default), Telnyx waits ~24s before giving up. When ON, you set the seconds (5-180). NOTE: this does NOT control how long the dialer waits for the prospect to answer once their phone is ringing — that's a SEPARATE protocol (hard-coded at ~21 seconds = 3.5 ring cycles, NOT configurable). v1.32.0 split.">ⓘ</button>
          </label>
          ${state.ringTimeoutEnabled ? `
          <div class="pd-toggle-subrow" style="display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding:4px 12px 8px;">
            <div style="display:flex;align-items:center;gap:8px;">
              <span style="font-size:11px;opacity:0.8;">Connection wait (5-180s):</span>
              <input type="number" id="pd-ring-timeout-secs" min="5" max="180" step="1" value="${state.ringTimeoutSecs}" style="width:64px;padding:3px 6px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);border-radius:4px;color:#fff;font-size:12px;">
              <span style="font-size:10px;opacity:0.55;">applies next Start Session</span>
            </div>
            <span style="font-size:10px;opacity:0.55;line-height:1.3;">Ring wait (carrier ringing → answer): hard-coded ~21s = 3.5 rings, not configurable</span>
          </div>
          ` : ""}
          <label class="pd-toggle-row pd-toggle-test${state.testMode ? " pd-toggle-warn-on" : ""}">
            <input type="checkbox" id="pd-test-mode" ${state.testMode ? "checked" : ""}>
            <span class="pd-toggle-label">🧪 Test mode</span>
            <button type="button" class="pd-toggle-info" data-help-title="🧪 Test mode" data-help="When on, prospect dials are simulated server-side — no real customer is called. Cell parking is still real (your cell rings). Use for after-hours testing or training. Activation requires a passcode set by your admin (rotatable via the /admin page). Default is 4015 unless your admin has changed it.">ⓘ</button>
          </label>

          <!-- v1.31.93 — Lead-type skip is now a POP-OUT modal instead of
               inline. The toggles drawer was getting too long. Click the
               header link to open. Modal has the same 10 type checkboxes
               and the same per-session state (cleared on session end). -->
          <div class="pd-toggle-section-divider" style="margin:10px 0 4px;padding:6px 4px 4px;border-top:1px solid rgba(255,255,255,0.07);">
            <a href="#" id="pd-skip-lead-types-open" style="color:#000 !important;text-decoration:none !important;font-size:13px !important;font-weight:700 !important;letter-spacing:0.4px !important;cursor:pointer !important;display:inline-flex !important;align-items:center !important;gap:6px !important;background:#5DADE2 !important;background-color:#5DADE2 !important;padding:6px 12px !important;border-radius:6px !important;border:1px solid rgba(0,0,0,0.3) !important;box-shadow:0 1px 3px rgba(0,0,0,0.3) !important;width:max-content !important;max-width:max-content !important;white-space:nowrap !important;flex-wrap:nowrap !important;">
              <span style="color:#000 !important;flex:0 0 auto !important;">⏭</span><span style="color:#000 !important;white-space:nowrap !important;flex:0 0 auto !important;">SKIP LEAD TYPES</span>
            </a>
            <span style="font-weight:400;opacity:0.75;font-size:11px;margin-left:8px;color:#cfe0fa;vertical-align:middle;">${(() => {
              const n = state.skipLeadTypes ? Object.keys(state.skipLeadTypes).length : 0;
              return n > 0 ? `(${n} active · this session)` : "(this session only)";
            })()}</span>
          </div>
          <!-- v1.32.1 — SEND DIAGNOSTICS button moved OUT of toggles area
               and INTO the Settings drawer's SELF-DIAGNOSTIC section. The
               toggles area is on the main dialer card body and was making
               every agent see the button without context. Settings drawer
               is where the agent already goes for support actions like
               "Clear extension cache + reload" — diagnostics belongs next
               to that. See renderSettingsDrawer() for the new location +
               click wiring. -->
          </div>
          </div>`;
        // v1.33.6.15 — Flag-driven toggle visibility. The active CRM adapter
        // declares CRM.hiddenToggles (logical names); we map each to its DOM id
        // and remove that row so it never shows on that CRM. Impact leaves the
        // list empty → everything renders in its normal spot. Removal is
        // synchronous (same paint frame, before the browser lays out) so there's
        // no visible flash, and it sidesteps the host-page CSP that was blocking
        // a runtime <style> on the Salesforce page. Re-runs every paint.
        const _hiddenToggles = Array.isArray(CRM.hiddenToggles) ? CRM.hiddenToggles : [];
        if (_hiddenToggles.length) {
          const _toggleIdByName = {
            doubleTap:           "pd-double-tap",
            leaveVoicemail:      "pd-leave-voicemail",
            manualDisposition:   "pd-manual-disposition",
            bottomToTop:         "pd-bottom-to-top",
            callFromComments:    "pd-call-from-comments",
            autoAttendantHangup: "pd-auto-attendant-hangup",
            skipFutureCallbacks: "pd-skip-callbacks",
            ringTimeout:         "pd-ring-timeout-enabled",
            testMode:            "pd-test-mode",
          };
          _hiddenToggles.forEach((name) => {
            try {
              const _id  = _toggleIdByName[name] || name;
              const _el  = togglesArea.querySelector("#" + _id);
              const _row = _el && _el.closest("label");
              if (_row) _row.remove();
            } catch (_e) {}
          });
        }
      }

      // v1.33.2 — Mobile/Home button wireup removed alongside their DOM
      // (vestigial; Autopilot handles per-lead dialing). Skip-bad warning
      // still wires up the same way when applicable.
      if (!onCall && !hasAnyNum) {
        $("pd-skip-bad").onclick = () => {
          toast("Skipping — no number on this lead");
          state.counters.badNumbers++;
          persistCounters();
          // Capture for the end-of-session report
          state.skippedLeads.push({
            leadId:   getLeadId() || null,
            leadName: getLeadName() || "",
            reason:   "no number on file",
            time:     Date.now(),
          });
          // Mark as "dialed" so autopilot's same-lead loop guard doesn't
          // accidentally re-trigger on whatever Impact does after the skip.
          const lid = getLeadId();
          if (lid) {
            try {
              chrome.storage.local.set({
                [pdGuardKey("pd_last_dialed_lead")]: lid,
                [pdGuardKey("pd_last_dialed_at")]:   Date.now(),
              });
            } catch {}
          }
          advanceToNextLead();
        };
      }
      $("pd-manual-call").onclick = onCall ? skipProspect : manualDial;
      // v1.33.3 — Wire the 3-last-dial Redial buttons. Each carries
      // data-num="<phone>". Click loads the number into the manual-
      // dial input and fires manualDial(). Disabled while in a call
      // (matches the input's existing onCall guard).
      try {
        document.querySelectorAll(".pd-lastdial-redial").forEach((btn) => {
          if (btn._pdWired) return;
          btn._pdWired = true;
          btn.addEventListener("click", (ev) => {
            ev.preventDefault();
            if (state.prospect) return;
            const num = btn.getAttribute("data-num") || "";
            if (!num) return;
            const inp = $("pd-manual-input");
            if (inp) inp.value = num;
            try { manualDial(); } catch (e) {
              try { log("warn", "lastdial redial threw: " + (e?.message || e)); } catch {}
            }
          });
        });
      } catch {}
      const inp = $("pd-manual-input");
      if (inp && !onCall) {
        inp.addEventListener("keydown", (e) => {
          if (e.key === "Enter") { e.preventDefault(); manualDial(); }
        });
      }
      $("pd-autopilot").addEventListener("click", () => {
        state.autoDialMode = !state.autoDialMode;
        try { chrome.storage.local.set({ pd_autodial: state.autoDialMode }); } catch {}
        if (state.autoDialMode) {
          toast("Autopilot started");
          // v1.32.18 — clear the explicit-pause stamp when agent
          // resumes autopilot explicitly.
          state.userPausedAt = 0;
          // v1.33.5.2 — Explicit (re)start = fresh pass. Wipe the rolling
          // already-dialed memory so re-enabling recovers from an "it skipped
          // everything" state instead of instantly re-skipping (Casey).
          clearSessionDialedMemory("pd-autopilot button");
          if (!state.prospect) maybeAutoDial();
        } else {
          toast("Autopilot paused — cell stays parked");
          // v1.32.18 — Stamp explicit pause so the End-Human-Call
          // popup's "Continue" button doesn't override the agent's
          // intent (Dustin's report 2026-06-18: "keeps calling when
          // I've hit pause"). 30-min window in the Continue handler.
          state.userPausedAt = Date.now();
          if (state.autoDialTimer) { clearTimeout(state.autoDialTimer); state.autoDialTimer = null; }
        }
        broadcastToggleState();
        paint();
      });
      const dt = $("pd-double-tap");
      if (dt) {
        dt.addEventListener("change", () => {
          state.doubleTap = dt.checked;
          try { chrome.storage.local.set({ pd_double_tap: state.doubleTap }); } catch {}
          toast(state.doubleTap
            ? "Double-tap on — each # will get a second try before moving on"
            : "Double-tap off — single attempt per number");
          broadcastToggleState();
        });
      }
      // v1.34 — Leave Voicemail toggle (verified write + broadcast → worker
      // decides play/skip at drop time). Auto-offs at Start Session.
      const lv = $("pd-leave-voicemail");
      if (lv) {
        lv.addEventListener("change", () => {
          state.leaveVoicemail = lv.checked;
          try { _pdWriteToggleVerified("pd_leave_voicemail", state.leaveVoicemail); } catch {}
          toast(state.leaveVoicemail
            ? "📩 Leave Voicemail ON — detected voicemails get your recording (1 per number / 7 days). Turns off at next Start Session."
            : "Leave Voicemail OFF — voicemails hang up as No Answer, no message", { ms: 5000 });
          broadcastToggleState();
          paint();
        });
      }
      // v1.31.2 — Manual disposition toggle
      const md = $("pd-manual-disposition");
      if (md) {
        md.addEventListener("change", () => {
          state.manualDisposition = md.checked;
          try { _pdWriteToggleVerified("pd_manual_disposition", state.manualDisposition); } catch {}
          // Clear any pending-disposition wait when turning the mode off
          if (!state.manualDisposition) state.waitingForManualDisposition = false;
          toast(state.manualDisposition
            ? "✋ Manual disposition ON — autopilot pauses after each call; advance when you return to the lead list"
            : "Manual disposition OFF — autopilot auto-marks no-answer and advances", { ms: 5000 });
          broadcastToggleState();
          paint();
        });
      }
      // v1.34 redesign — "More options" expander (collapses the 6 session
      // toggles + Skip Lead Types so the start screen shows only the 3
      // per-call toggles by default). Toggles classes in place (no re-render).
      const moreBtn = $("pd-more-toggle");
      if (moreBtn && !moreBtn._pdWired) {
        moreBtn._pdWired = true;
        const _toggleMore = () => {
          state._moreOpen = !state._moreOpen;
          try { chrome.storage.local.set({ pd_more_open: state._moreOpen }); } catch {}
          moreBtn.classList.toggle("pd-more-open", state._moreOpen);
          const body = $("pd-more-body");
          if (body) body.classList.toggle("pd-more-open", state._moreOpen);
          moreBtn.setAttribute("aria-expanded", state._moreOpen ? "true" : "false");
        };
        moreBtn.addEventListener("click", _toggleMore);
        moreBtn.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); _toggleMore(); } });
      }
      // v1.33.4.5 — Wake Lock toggle removed; wake lock is always on during sessions.
      const tm = $("pd-test-mode");
      if (tm) {
        tm.addEventListener("change", () => {
          // Activation requires the admin-configured passcode. Disabling
          // is free. Default "4015" applies if the admin never set one or
          // if /api/me hasn't synced yet.
          if (tm.checked) {
            const expected = String(state.testerPasscode || "4015").trim();
            const pw = (typeof prompt === "function") ? prompt("Enter passcode to enable Test mode:") : null;
            if (pw == null || String(pw).trim() !== expected) {
              tm.checked = false;
              if (pw !== null) {
                toast("Wrong passcode — Test mode stays off.", { ms: 4500, level: "warn" });
              }
              return;
            }
          }
          state.testMode = tm.checked;
          try { chrome.storage.local.set({ pd_test_mode: state.testMode }); } catch {}
          toast(state.testMode
            ? "🧪 Test mode ON — no real prospect calls will be placed. Cell parking still real."
            : "Test mode OFF — real dialing is back.",
            { ms: state.testMode ? 7000 : 3500, level: state.testMode ? "warn" : null });
          broadcastToggleState();
          // Re-render so the panel banner + label update immediately
          paint();
        });
      }

      // v1.31.90 — Calling hours toggle + time inputs.
      const chEnabled = $("pd-calling-hours-enabled");
      if (chEnabled) {
        chEnabled.addEventListener("change", () => {
          state.callingHoursEnabled = chEnabled.checked;
          try { chrome.storage.local.set({ pd_calling_hours_enabled: state.callingHoursEnabled }); } catch {}
          toast(state.callingHoursEnabled
            ? "⏰ Calling hours ENFORCED — out-of-window leads will skip"
            : "⏰ Calling hours OFF — no time gating",
            { ms: 3000, level: state.callingHoursEnabled ? "ok" : null });
          paint();
        });
      }
      // v1.32.10 — Per-state mandate reference dropdown handler.
      const statePicker = $("pd-state-mandate-picker");
      const stateDisplay = $("pd-state-mandate-display");
      if (statePicker && stateDisplay) {
        statePicker.addEventListener("change", () => {
          const st = statePicker.value;
          if (!st) { stateDisplay.style.display = "none"; return; }
          const mandate = getStateTcpaHours(st);
          const tzInfo = STATE_TZ[st] || {};
          const tzName = tzInfo.key || "?";
          const stateName = tzInfo.name || st;
          const isFederal = !STATE_TCPA_HOURS_OVERRIDE[st];
          const sourceLabel = isFederal
            ? `<span style="color:#7fd99c;">✓ Federal TCPA default</span> (no stricter state law)`
            : `<span style="color:#F2C94C;">⚠ State-specific override</span> (stricter than federal)`;
          stateDisplay.innerHTML = `
            <div style="font-weight:700; color:#fff; margin-bottom:4px;">${st} — ${stateName} <span style="opacity:0.7;font-weight:400;">(${tzName})</span></div>
            <div>Mon-Fri: <strong>${mandate.wdStart}–${mandate.wdEnd}</strong></div>
            <div>Sat-Sun: <strong>${mandate.weStart}–${mandate.weEnd}</strong></div>
            <div style="margin-top:5px; font-size:10px;">${sourceLabel}</div>
          `;
          stateDisplay.style.display = "block";
        });
      }
      // Wire all 5 zones × 4 fields = 20 time inputs.
      document.querySelectorAll("#pd-calling-hours-grid input[type='time']").forEach((inp) => {
        inp.addEventListener("change", () => {
          const zone = inp.getAttribute("data-zone");
          const field = inp.getAttribute("data-field");
          if (!zone || !field) return;
          // v1.31.92 — Clamp the value to the state mandate. Agent can
          // TIGHTEN (later start / earlier end) but never go LOOSER than
          // the mandate. If they manually type a value outside the
          // mandate, snap it back and warn.
          const mandateMin = inp.getAttribute("data-mandate-min");
          const mandateMax = inp.getAttribute("data-mandate-max");
          let v = inp.value;
          if (mandateMin && v && v < mandateMin) {
            inp.value = mandateMin;
            v = mandateMin;
            toast(`⚠ ${zone} ${field} can't be earlier than mandate ${mandateMin} — snapped back`, { ms: 3500 });
          }
          if (mandateMax && v && v > mandateMax) {
            inp.value = mandateMax;
            v = mandateMax;
            toast(`⚠ ${zone} ${field} can't be later than mandate ${mandateMax} — snapped back`, { ms: 3500 });
          }
          state.callingHours = state.callingHours || defaultCallingHours();
          state.callingHours[zone] = state.callingHours[zone] || { ...defaultCallingHours()[zone] };
          state.callingHours[zone][field] = v;
          try { chrome.storage.local.set({ pd_calling_hours: state.callingHours }); } catch {}
        });
      });

      // v1.31.93 — Lead-type skip POP-OUT (was inline). Click the header
      // link to open a modal with the 10 type checkboxes. State is still
      // per-session (state.skipLeadTypes, cleared in endSession()).
      const openBtn = $("pd-skip-lead-types-open");
      if (openBtn) {
        openBtn.addEventListener("click", (e) => {
          e.preventDefault();
          openSkipLeadTypesModal();
        });
      }

      // v1.32.1 — Send Diagnostics click handler MOVED to renderSettingsDrawer()
      // (the button now lives inside the Settings drawer's SELF-DIAGNOSTIC
      // section, not in the toggles area).

      const rtEnabled = $("pd-ring-timeout-enabled");
      if (rtEnabled) {
        rtEnabled.onchange = () => {
          state.ringTimeoutEnabled = rtEnabled.checked;
          try { chrome.storage.local.set({ pd_ring_timeout_enabled: state.ringTimeoutEnabled }); } catch (_e) {}
          if (state.ringTimeoutEnabled) {
            // v1.32.0 — Popup-on-toggle. When the agent CHECKS the box,
            // prompt for seconds with a clear explanation of what this
            // timeout actually controls. Locked spec: only applies to
            // calls that don't reach carrier ringing; separate hard-coded
            // 21s ring-wait covers the "phone is ringing, waiting on pickup"
            // case. See memory: project_ring_timeout_redesign_2026-06-12.
            const cur = state.ringTimeoutSecs || 24;
            const answer = window.prompt(
              "How many seconds should the dialer wait if a call is NOT connecting?\n\n" +
              "(This is the CONNECTION timeout — only triggers when the call can't reach the carrier. " +
              "Wait-for-pickup once their phone is ringing is hard-coded at ~21 seconds = 3.5 ring cycles.)\n\n" +
              "Allowed range: 5-180 seconds. Default if blank: 24.",
              String(cur)
            );
            if (answer == null) {
              // Cancelled — un-check
              rtEnabled.checked = false;
              state.ringTimeoutEnabled = false;
              try { chrome.storage.local.set({ pd_ring_timeout_enabled: false }); } catch (_e) {}
              paint();
              return;
            }
            const n = parseInt(String(answer).trim(), 10);
            if (!Number.isFinite(n) || n < 5 || n > 180) {
              toast("⚠ Please enter a number between 5 and 180 — keeping default 24s");
              state.ringTimeoutSecs = 24;
            } else {
              state.ringTimeoutSecs = n;
            }
            try { chrome.storage.local.set({ pd_ring_timeout_secs: state.ringTimeoutSecs }); } catch {}
            toast(`⏱ Connection timeout set to ${state.ringTimeoutSecs}s — applies on next Start Session`);
          } else {
            toast("⏱ Connection timeout OFF — using default 24s");
          }
          paint();
        };
      }
      const rtSecs = $("pd-ring-timeout-secs");
      if (rtSecs) {
        const _commitRtSecs = () => {
          const n = parseInt(rtSecs.value, 10);
          if (!Number.isFinite(n) || n < 5 || n > 180) {
            rtSecs.value = state.ringTimeoutSecs;
            return;
          }
          if (n !== state.ringTimeoutSecs) {
            state.ringTimeoutSecs = n;
            try { chrome.storage.local.set({ pd_ring_timeout_secs: n }); } catch (_e) {}
            paint();
          }
        };
        rtSecs.onchange = _commitRtSecs;
        rtSecs.onblur   = _commitRtSecs;
      }
      // v1.31.68 — Auto-attendant hangup toggle wiring (replaces relay
      // toggle wiring; relay UI row was removed because the 🛡️ Auto-Attd
      // in-call button supersedes its use case).
      const aah = $("pd-auto-attendant-hangup");
      if (aah) {
        aah.addEventListener("change", () => {
          state.autoAttendantHangup = aah.checked;
          try { _pdWriteToggleVerified("pd_auto_attendant_hangup", state.autoAttendantHangup); } catch {}
          toast(aah.checked
            ? "🤖 Auto-hangup on attendants: ON — dialer will drop IVRs like voicemail"
            : "🤖 Auto-hangup on attendants: OFF — IVRs keep the call alive (use 🛡️ button as needed)", { ms: 3000 });
          broadcastToggleState();
          paint();
        });
      }
      // v1.32.2 — REMOVED Sticky IVR override toggle wiring. The HTML row
      // is gone (toggle removed entirely), and the per-call 🛡️ override
      // always resets per prospect now. Session-long IVR mode lives on
      // the "🤖 Auto-hangup on attendants" toggle wired above.
      // v1.31.69 — Away Mode toggle wiring.
      const aw = $("pd-away-mode");
      if (aw) {
        aw.addEventListener("change", () => {
          state.awayMode = aw.checked;
          try { _pdWriteToggleVerified("pd_away_mode", state.awayMode); } catch {}
          toast(aw.checked
            ? "🚶 Away Mode: ON — human calls auto-disposition + screenshot to Downloads"
            : "🚶 Away Mode: OFF — back to normal Continue-modal flow on human calls", { ms: 3500, kind: aw.checked ? "warn" : "info" });
          broadcastToggleState();
          paint();
        });
      }
      // Legacy relay handler — UI row removed in v1.31.68 but worker-side
      // relayMode logic still functions if anything sets state.relayMode
      // through other paths (auto-detection from comments).
      const relay = $("pd-relay-mode");
      if (relay) {
        relay.addEventListener("change", () => {
          state.relayMode = relay.checked;
          // Persist per-lead so it sticks across Impact's page navigations
          const leadId = getLeadId();
          if (leadId) {
            const map = state.relayLeads || {};
            if (relay.checked) map[leadId] = Date.now();
            else delete map[leadId];
            state.relayLeads = map;
            try { chrome.storage.local.set({ pd_relay_leads: map }); } catch {}
          }
          toast(relay.checked
            ? "📟 Relay mode ON for this lead — extended timeout, AMD disabled, no voicemail triggers."
            : "Relay mode OFF — back to standard dial settings.",
            { ms: 5000 });
          broadcastToggleState();
          paint();
        });
      }
      const btt = $("pd-bottom-to-top");
      if (btt) {
        btt.addEventListener("change", () => {
          state.bottomToTop = btt.checked;
          try { chrome.storage.local.set({ pd_bottom_to_top: state.bottomToTop }); } catch {}
          toast(state.bottomToTop
            ? "⬆️ Bottom-to-Top ON — autopilot will walk the list backward."
            : "Bottom-to-Top OFF — back to normal top-to-bottom advance.");
          broadcastToggleState();
        });
      }
      const cfc = $("pd-call-from-comments");
      if (cfc) {
        cfc.addEventListener("change", () => {
          state.callFromComments = cfc.checked;
          try { chrome.storage.local.set({ pd_call_from_comments: state.callFromComments }); } catch {}
          toast(state.callFromComments
            ? "💬 Call-from-comments ON — autopilot will dial numbers found in the comments instead of Mobile/Home."
            : "Call-from-comments OFF — back to dialing Mobile/Home.",
            { ms: 6000 });
          broadcastToggleState();
        });
      }
      // v1.34 — Skip future callbacks toggle (persistent per-agent preference).
      const skcb = $("pd-skip-callbacks");
      if (skcb) {
        skcb.addEventListener("change", () => {
          state.skipFutureCallbacks = skcb.checked;
          try { chrome.storage.local.set({ pd_skip_future_callbacks: state.skipFutureCallbacks }); } catch {}
          toast(state.skipFutureCallbacks
            ? "⏭ Skip future callbacks ON — autopilot will skip leads with a future \"Call Back appointment on\" date."
            : "Skip future callbacks OFF — callbacks are no longer auto-skipped.",
            { ms: 5000 });
          broadcastToggleState();
        });
      }
      $("pd-end").onclick = endSessionFromButton;
    } else if (state.session === "ending") {
      sa.innerHTML = `<div class="pd-info-soft">Hanging up agent leg…</div>`;
    }

    const pa = $("pd-prospect-area");
    // Detect idle→active transition so we only flash the call buttons once
    // per call (not on every status update / transcript line that re-renders
    // the prospect-area).
    const wasActive = !!state._wasInCall;
    const isActive  = !!state.prospect;
    state._wasInCall = isActive;
    const justActivated = isActive && !wasActive;
    if (!state.prospect) {
      // Idle state — render a greyed-out preview of the in-call view so the
      // agent sees where the live transcript and call buttons will appear.
      // Buttons are disabled; the whole block is dimmed via .pd-incall-idle.
      pa.innerHTML = `
        <div class="pd-incall pd-incall-idle" aria-disabled="true">
          <div class="pd-incall-label">In-call view</div>
          <div class="pd-incall-avatar">—</div>
          <div class="pd-incall-num">No active call</div>
          <div class="pd-incall-sub">Buttons activate when a prospect connects</div>
          <div class="pd-incall-status pd-incall-status-idle">
            <span class="pd-incall-pulse"></span>
            <span>Idle · 00:00</span>
          </div>
          <div class="pd-transcript pd-incall-tr pd-incall-tr-idle">
            <div class="pd-tr-line pd-tr-placeholder">Live transcription will appear here during a call.</div>
          </div>
          <div class="pd-incall-btnrow">
            <button class="pd-callbtn pd-callbtn-end" disabled title="Skip — available during a call">
              ${gpBallIcon()}
              <span class="pd-callbtn-lbl">Skip</span>
            </button>
            <button class="pd-callbtn pd-callbtn-vm" disabled title="VM Drop — available during a call">
              <span class="pd-callbtn-icon">📨</span>
              <span class="pd-callbtn-lbl">VM Drop</span>
            </button>
            <button class="pd-callbtn pd-callbtn-ivr" disabled title="Auto-Attendant: when you reach an IVR / phone-tree, click during the call to prevent the dialer from auto-hanging-up on inconclusive AMD">
              <span class="pd-callbtn-icon">🛡️</span>
              <span class="pd-callbtn-lbl">Auto-Attd</span>
            </button>
          </div>
        </div>`;
      stopCallTimer();
    } else {
      const pr = state.prospect;
      const transcript =
        pr.transcript.map(t => `<div class="pd-tr-line">${escapeHtml(t)}</div>`).join("") +
        (pr.interim ? `<div class="pd-tr-line pd-tr-interim">${escapeHtml(pr.interim)}…</div>` : "");
      const amdLabel = pr.amd ? `<span class="pd-amd pd-amd-${pr.amd}">${pr.amd}</span>` : "";
      const initials = leadInitials(pr);
      const statusLabel = friendlyStatus(pr.status);
      const elapsed = formatElapsed(state.callStartedAt);

      // Big in-call view — avatar + number + timer + three round buttons.
      // The Skip and Bridge buttons render as red/green soccer balls; VM Drop
      // stays an orange disc. .pd-incall-flash plays a one-time light-up
      // animation only on the transition from idle to active.
      const flashClass = justActivated ? "pd-incall-flash" : "";
      pa.innerHTML = `
        <div class="pd-incall ${flashClass}">
          <div class="pd-incall-label">Outgoing call</div>
          <div class="pd-incall-avatar">${escapeHtml(initials)}</div>
          <div class="pd-incall-num">${escapeHtml(pr.number)}</div>
          <div class="pd-incall-sub">${escapeHtml(pr.leadId ? "Lead #" + pr.leadId : "")}</div>
          ${pr.fromNumber ? `<div class="pd-incall-from" style="font-size:11px;opacity:.8;margin-top:2px;letter-spacing:.2px;">📞 Calling from ${escapeHtml(pr.fromNumber)}</div>` : ""}
          <div class="pd-incall-status pd-incall-status-${escapeHtml(pr.status || 'dialing')}">
            <span class="pd-incall-pulse"></span>
            <span>${escapeHtml(statusLabel)} · <span id="pd-call-timer">${elapsed}</span></span>
            ${amdLabel}
          </div>
          ${transcript ? `<div class="pd-transcript pd-incall-tr">${transcript}</div>` : ""}
          <div class="pd-incall-btnrow">
            ${(pr && pr._wasLive)
              ? `<button class="pd-callbtn pd-callbtn-endhuman" id="pd-end-human-call" title="End the human conversation — pauses dialer and asks what to do next">
                  ${gpBallIcon()}
                  <span class="pd-callbtn-lbl">End Call</span>
                </button>`
              : `<button class="pd-callbtn pd-callbtn-end" id="pd-hangup" title="Hang up the current call — auto-dispositions as No Answer and advances">
                  ${gpBallIcon()}
                  <span class="pd-callbtn-lbl">Hangup</span>
                </button>`}
            <button class="pd-callbtn pd-callbtn-vm" id="pd-vm" title="Drop voicemail">
              <span class="pd-callbtn-icon">📨</span>
              <span class="pd-callbtn-lbl">VM Drop</span>
            </button>
            <button class="pd-callbtn pd-callbtn-ivr ${state.overrideActive ? "active" : ""}" id="pd-override" title="Auto-Attendant: I hit an IVR — keep the call connected, don't auto-hang-up on VM detection">
              <span class="pd-callbtn-icon">🛡️</span>
              <span class="pd-callbtn-lbl">${state.overrideActive ? "IVR ✓" : "Auto-Attd"}</span>
            </button>
            ${((state.activeParkSource || "desktop") === "desktop") ? `
            <button class="pd-callbtn pd-callbtn-mute ${state.deskMicMuted ? "active" : ""}" id="pd-mute" title="Mute the computer's microphone — prospect won't hear you.">
              <span class="pd-callbtn-icon">${state.deskMicMuted ? "🔇" : "🎙️"}</span>
              <span class="pd-callbtn-lbl">${state.deskMicMuted ? "Unmute" : "Mute"}</span>
            </button>
            ` : ""}
          </div>
        </div>`;
      // v1.31.97 — Wire whichever variant of the end button is rendered
      // (depends on pr._wasLive). Only one is in the DOM at a time.
      const endHumanBtn = $("pd-end-human-call");
      if (endHumanBtn) endHumanBtn.onclick = endHumanCall;
      const hangupBtn   = $("pd-hangup");
      if (hangupBtn)   hangupBtn.onclick   = skipProspect;
      $("pd-vm").onclick = dropVmNow;
      $("pd-override").onclick = autoAttendantOverride;
      // v1.29.30 — Mute button only renders in Desktop WebRTC mode (the
      // computer mic is only in the call path in that mode). In Mobile
      // App mode the agent uses the Mute button on /mobile instead.
      const muteBtn = $("pd-mute");
      if (muteBtn) muteBtn.onclick = toggleDesktopMicMute;
      startCallTimer();
    }

    const info = $("pd-info");
    if (state.lastError) { info.style.display = "block"; info.textContent = state.lastError; }
    else { info.style.display = "none"; }
  }

  // ─────────────────────────────────────────────────────────────────
  // Disposition tracking — watch clicks on Impact's disposition buttons
  // and accumulate counts into state.dispositions for the end-of-session
  // report. Counts persist across Impact's page navigations via
  // pd_dispositions in chrome.storage.local, keyed to sessionStartedAt.
  // ─────────────────────────────────────────────────────────────────
  const KNOWN_DISPOSITIONS = [
    "Pres Done", "Set Appointment", "Reschedule", "No Show", "Refused",
    "Voicemail Left", "Voicemail", "Bad Number", "Bad Number — Set Dropby Appointment",
    "DropBy", "Drop By", "No Answer", "Hung Up", "Wrong Number", "Do Not Call",
  ];
  function matchDisposition(el) {
    if (!el) return null;
    // Walk up to a reasonable click target — anchor, button, or labeled cell
    const target = el.closest("a, button, [role='button'], .btn, .disposition, td, th, div");
    if (!target) return null;
    const txt = (target.textContent || "").trim().replace(/\s+/g, " ");
    if (!txt || txt.length > 60) return null;
    for (const d of KNOWN_DISPOSITIONS) {
      // Match exact (case-insensitive) or whole-word containment
      const re = new RegExp(`(^|\\s|—\\s)${d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "i");
      if (re.test(txt) || txt.toLowerCase() === d.toLowerCase()) return d;
    }
    return null;
  }

  function bumpDisposition(name) {
    if (!name) return;
    state.dispositions = state.dispositions || {};
    state.dispositions[name] = (state.dispositions[name] | 0) + 1;
    if (isExtensionAlive()) {
      try {
        chrome.storage.local.set({
          pd_dispositions: state.dispositions,
          pd_dispositions_session: state.sessionStartedAt | 0,
        });
      } catch {}
    }
  }

  function loadDispositionsForCurrentSession() {
    if (!isExtensionAlive()) return;
    chrome.storage.local.get(["pd_dispositions", "pd_dispositions_session"]).then(d => {
      if ((d.pd_dispositions_session | 0) === (state.sessionStartedAt | 0)) {
        state.dispositions = d.pd_dispositions || {};
      } else {
        state.dispositions = {};
      }
    }).catch(() => {});
  }

  // Single delegated listener — captures clicks anywhere in the document
  // and matches against the disposition vocabulary above.
  // v1.29.93 — Gate on isExtensionLive() so disposition tracking only
  // happens during a live session+autopilot run. When dormant, we don't
  // even walk the DOM looking for matches — zero overhead.
  document.addEventListener("click", (e) => {
    if (!isExtensionLive()) return;
    if (!state.sessionStartedAt) return; // only count during an active session
    const name = matchDisposition(e.target);
    if (name) bumpDisposition(name);
  }, true);

  // v1.32.21 — Delegated change listener for the per-state TCPA
  // mandate dropdown in the Setup drawer. Previously wired inside
  // paint() which races the user's click: the drawer renders the
  // <select> in renderSettingsDrawer(), and if the user opens it
  // and immediately picks a state BEFORE paint() runs, no listener
  // exists and the display stays hidden ("nothing happens when state
  // is selected" — Dustin 2026-06-18). Document delegation eliminates
  // the race entirely.
  document.addEventListener("change", (e) => {
    const tgt = e.target;
    if (!tgt || tgt.id !== "pd-state-mandate-picker") return;
    const stateDisplay = document.getElementById("pd-state-mandate-display");
    if (!stateDisplay) return;
    const st = tgt.value;
    if (!st) { stateDisplay.style.display = "none"; return; }
    try {
      const mandate = getStateTcpaHours(st);
      const tzInfo = (typeof STATE_TZ !== "undefined" ? STATE_TZ[st] : null) || {};
      const tzName = tzInfo.key || "?";
      const stateName = tzInfo.name || st;
      const isFederal = typeof STATE_TCPA_HOURS_OVERRIDE !== "undefined"
        ? !STATE_TCPA_HOURS_OVERRIDE[st]
        : true;
      const sourceLabel = isFederal
        ? `<span style="color:#7fd99c;">✓ Federal TCPA default</span> (no stricter state law)`
        : `<span style="color:#F2C94C;">⚠ State-specific override</span> (stricter than federal)`;
      stateDisplay.innerHTML = `
        <div style="font-weight:700; color:#fff; margin-bottom:4px;">${st} — ${stateName} <span style="opacity:0.7;font-weight:400;">(${tzName})</span></div>
        <div>Mon-Fri: <strong>${mandate.wdStart}–${mandate.wdEnd}</strong></div>
        <div>Sat-Sun: <strong>${mandate.weStart}–${mandate.weEnd}</strong></div>
        <div style="margin-top:5px; font-size:10px;">${sourceLabel}</div>
      `;
      stateDisplay.style.display = "block";
    } catch (err) {
      console.warn("[pd][tcpa-picker] render failed:", err && err.message);
    }
  }, true);

  // ─── ⓘ info-button popover (compact toggle help) ────────────────
  // Single shared popover. Click any .pd-toggle-info → fills with the
  // button's data-help-title + data-help text and positions next to it.
  // Click outside or the same button again → dismisses.
  function ensureTogglePopover() {
    let pop = document.getElementById("pd-toggle-popover");
    if (pop) return pop;
    pop = document.createElement("div");
    pop.id = "pd-toggle-popover";
    pop.className = "pd-toggle-popover";
    document.body.appendChild(pop);
    return pop;
  }
  document.addEventListener("click", (e) => {
    const btn = e.target && e.target.closest && e.target.closest(".pd-toggle-info");
    const pop = document.getElementById("pd-toggle-popover");
    if (btn) {
      e.preventDefault();
      e.stopPropagation();
      const popover = ensureTogglePopover();
      // Same button = toggle off
      if (popover.classList.contains("open") && popover._anchor === btn) {
        popover.classList.remove("open");
        popover._anchor = null;
        return;
      }
      const title = btn.getAttribute("data-help-title") || "";
      const text  = btn.getAttribute("data-help") || "";
      popover.innerHTML = "";
      if (title) {
        const t = document.createElement("div");
        t.className = "pd-pop-title";
        t.textContent = title;
        popover.appendChild(t);
      }
      const body = document.createElement("div");
      body.textContent = text;
      popover.appendChild(body);
      // Position relative to the button. Try below; if it would clip the
      // viewport bottom, flip above.
      const r = btn.getBoundingClientRect();
      const VW = window.innerWidth;
      const VH = window.innerHeight;
      // Make sure popover is rendered before measuring
      popover.style.visibility = "hidden";
      popover.classList.add("open");
      const pr = popover.getBoundingClientRect();
      let top  = r.bottom + 6;
      let left = Math.max(8, Math.min(VW - pr.width - 8, r.right - pr.width));
      if (top + pr.height > VH - 8) {
        top = Math.max(8, r.top - pr.height - 6);
      }
      popover.style.top  = top + "px";
      popover.style.left = left + "px";
      popover.style.visibility = "visible";
      popover._anchor = btn;
      return;
    }
    // Click outside any info button → dismiss
    if (pop && pop.classList.contains("open") && !e.target.closest("#pd-toggle-popover")) {
      pop.classList.remove("open");
      pop._anchor = null;
    }
  }, true);

  // ─────────────────────────────────────────────────────────────────
  // tel:/callto: click interceptor.
  //
  // Impact's native "Call Mobile" / "Call Home" / "Send Text" icons are
  // <a href="tel:..."> anchors. Without this handler, every click on
  // them shows Chrome's "Open Pick an app?" dialog, which the agent has
  // to dismiss with Cancel before anything useful happens.
  //
  // Capture-phase listener so we run BEFORE the browser's default
  // navigation. preventDefault() blocks the system handler / popup.
  // If a dialer session is parked and ready, we also route the call
  // through the worker — clicking Impact's button just dials.
  // ─────────────────────────────────────────────────────────────────
  document.addEventListener("click", (e) => {
    const a = e.target.closest && e.target.closest('a[href^="tel:"], a[href^="callto:"]');
    if (!a) return;
    // Don't intercept clicks inside our own panel.
    if (a.closest && a.closest("#pd-root")) return;
    // v1.29.93 — Dormant mode gate. If the extension isn't active
    // (no session OR session parked but autopilot paused), pass the
    // click through untouched — Impact's native click-to-dial handles
    // it. Only intercept when autopilot is actively running.
    if (!isExtensionLive()) return;
    if (state.prospect) {
      // Mid-call — intercept to prevent accidental re-dial, but warn.
      e.preventDefault();
      toast("Already on a call — hit Skip first");
      return;
    }
    // Session live + autopilot running. Suppress Chrome's "Pick an app"
    // dialog (preventDefault) and route through the worker. Don't call
    // stopPropagation — Impact's own onclick handlers (which navigate
    // to the lead call screen) need to run alongside.
    e.preventDefault();
    if (state._programmaticTelClick) return;
    const num = (typeof CRM.numberFromLink === "function") ? CRM.numberFromLink(a) : null;
    if (num) {
      const leadId = (typeof getLeadId === "function") ? getLeadId() : null;
      try { captureAndSendLeadInfo(leadId).catch(() => {}); } catch {}
      send("session.dial", {
        // v1.31.44 — User explicitly clicked a tel: link → bypass guard.
        force: true,
        prospect: { number: num, leadId, testMode: !!state.testMode, relayMode: !!state.relayMode },
      });
      state.lastAutoDialAt = Date.now();
      toast("Dialing " + num + " through the dialer");
    }
  }, true);

  // ── In-call view helpers ─────────────────────────────────────────
  function leadInitials(pr) {
    const name = pr && pr.leadName;
    if (name) {
      const parts = String(name).trim().split(/\s+/).slice(0, 2);
      return parts.map(p => p[0]).join("").toUpperCase() || "📞";
    }
    // Fallback: last 2 digits of phone number, or generic icon.
    const digits = String(pr && pr.number || "").replace(/\D/g, "");
    return digits ? digits.slice(-2) : "📞";
  }

  function friendlyStatus(s) {
    switch (s) {
      case "dialing":  return "Dialing";
      case "ringing":  return "Ringing";
      case "answered": return "Connected";
      default: return s ? s[0].toUpperCase() + s.slice(1) : "Calling";
    }
  }

  function formatElapsed(startedAt) {
    if (!startedAt) return "00:00";
    const sec = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
    const m = String(Math.floor(sec / 60)).padStart(2, "0");
    const s = String(sec % 60).padStart(2, "0");
    return `${m}:${s}`;
  }

  // Same as formatElapsed but emits hh:mm:ss for the mini-card session
  // timer (sessions can run hours; mm:ss isn't enough).
  function formatSessionElapsed(startedAt) {
    if (!startedAt) return "00:00:00";
    const sec = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
    const h = String(Math.floor(sec / 3600)).padStart(2, "0");
    const m = String(Math.floor((sec % 3600) / 60)).padStart(2, "0");
    const s = String(sec % 60).padStart(2, "0");
    return `${h}:${m}:${s}`;
  }

  function startCallTimer() {
    if (state.callTimerInterval) return;
    state.callTimerInterval = setInterval(() => {
      const el = document.getElementById("pd-call-timer");
      if (!el) return;
      el.textContent = formatElapsed(state.callStartedAt);
    }, 1000);
  }

  function stopCallTimer() {
    if (state.callTimerInterval) {
      clearInterval(state.callTimerInterval);
      state.callTimerInterval = null;
    }
  }

  // Returns the inline SVG markup for a soccer ball with a colored panel
  // overlay — green/white for "answer/bridge", red/white for "end/skip".
  // The classic pentagon stitching is preserved so it still reads as a ball.
  // Realistic-looking soccer ball: spherical shading via radial gradient,
  // specular highlight, tight pentagon panels (1 center + 5 perimeter),
  // and connecting hex-seam lines so the panels visually link into the
  // truncated-icosahedron pattern. `idSuffix` MUST be unique per insertion
  // (gradient IDs collide otherwise). `panelColor` is the fill of the
  // pentagon panels — black for a normal ball, green/red for call buttons.
  function realisticBallSvg({ idSuffix, panelColor, size }) {
    const gid = `pd-bg-${idSuffix}`;
    return `
      <svg viewBox="0 0 32 32" width="${size}" height="${size}" aria-hidden="true">
        <defs>
          <radialGradient id="${gid}" cx="32%" cy="28%" r="78%">
            <stop offset="0%"  stop-color="#ffffff"/>
            <stop offset="55%" stop-color="#e0e0e0"/>
            <stop offset="100%" stop-color="#5a5a5a"/>
          </radialGradient>
        </defs>
        <circle cx="16" cy="16" r="14.8" fill="url(#${gid})" stroke="#1a1a1a" stroke-width="0.5"/>
        <ellipse cx="11" cy="10" rx="3.6" ry="2.1" fill="#ffffff" opacity="0.45"/>
        <g fill="${panelColor}" stroke="#0a0a0a" stroke-width="0.5" stroke-linejoin="round">
          <polygon points="16,11.6 19.95,14.45 18.5,19.05 13.5,19.05 12.05,14.45"/>
          <polygon points="16,3.6 18.3,5.3 17.45,7.85 14.55,7.85 13.7,5.3"/>
          <polygon points="27.6,11.0 28.45,13.7 26.05,15.2 23.55,13.55 24.55,10.85"/>
          <polygon points="4.4,11.0 7.45,10.85 8.45,13.55 5.95,15.2 3.55,13.7"/>
          <polygon points="23.95,25.6 21.45,27.5 19.0,25.75 19.85,22.8 22.8,22.6"/>
          <polygon points="8.05,25.6 9.2,22.6 12.15,22.8 13.0,25.75 10.55,27.5"/>
        </g>
        <g stroke="#0a0a0a" stroke-width="0.5" fill="none" stroke-linejoin="round">
          <line x1="16"    y1="11.6" x2="16"    y2="7.85"/>
          <line x1="19.95" y1="14.45" x2="23.55" y2="13.55"/>
          <line x1="12.05" y1="14.45" x2="8.45"  y2="13.55"/>
          <line x1="18.5"  y1="19.05" x2="19.85" y2="22.8"/>
          <line x1="13.5"  y1="19.05" x2="12.15" y2="22.8"/>
          <line x1="14.55" y1="7.85" x2="11.5" y2="5.5"/>
          <line x1="17.45" y1="7.85" x2="20.5" y2="5.5"/>
          <line x1="24.55" y1="10.85" x2="26"  y2="7.5"/>
          <line x1="26.05" y1="15.2" x2="29.6" y2="16.2"/>
          <line x1="7.45"  y1="10.85" x2="6"   y2="7.5"/>
          <line x1="5.95"  y1="15.2" x2="2.4"  y2="16.2"/>
          <line x1="22.8"  y1="22.6" x2="26"   y2="24.5"/>
          <line x1="19.0"  y1="25.75" x2="20.0" y2="29.2"/>
          <line x1="9.2"   y1="22.6" x2="6"    y2="24.5"/>
          <line x1="13.0"  y1="25.75" x2="12.0" y2="29.2"/>
        </g>
        <circle cx="16" cy="16" r="14.8" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="1.0"/>
      </svg>`;
  }

  // In-call action ball: green panels for Bridge ("answer"), red for Skip ("end").
  // v1.34 redesign — branded soccer ball (Dustin's Goal Power Dialer ball)
  // for the in-call Hangup / Skip / End Call button. Transparent PNG shipped
  // in the extension icons folder.
  function gpBallIcon() {
    const url = (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getURL)
      ? chrome.runtime.getURL("icons/gp-ball.png") : "icons/gp-ball.png";
    return `<img class="pd-gpball" src="${url}" alt="" aria-hidden="true" draggable="false"/>`;
  }
  function ballSvg(kind) {
    const tint = kind === "answer" ? "#2dab5f" : "#e63946";
    const id   = kind === "answer" ? "answer"  : "end";
    return realisticBallSvg({ idSuffix: id, panelColor: tint, size: 44 });
  }

  // Realistic soccer-cleat icon — side profile with gradient upper, lacing,
  // brand stripe, conical studs (with stud-tip highlights), and a ground
  // shadow. `side` = "right" (toe to right) or "left" (mirrored). Each side
  // gets its own gradient ID so two cleats on the same page don't collide.
  function cleatSvg(side) {
    const flip = side === "left" ? ' transform="scale(-1,1) translate(-32,0)"' : "";
    const uid  = `pd-cleat-${side}`;
    return `
      <svg class="pd-cleat" viewBox="0 0 32 18" width="26" height="15" aria-hidden="true">
        <defs>
          <linearGradient id="${uid}-up" x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%"  stop-color="#ffffff"/>
            <stop offset="55%" stop-color="#eaeaea"/>
            <stop offset="100%" stop-color="#b8b8b8"/>
          </linearGradient>
          <linearGradient id="${uid}-sole" x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%" stop-color="#2a2a2a"/>
            <stop offset="100%" stop-color="#0a0a0a"/>
          </linearGradient>
        </defs>
        <g${flip}>
          <ellipse cx="16" cy="16.4" rx="11.5" ry="0.9" fill="#000" opacity="0.28"/>
          <path d="M3.4,11.7 C3.4,8.3 5.2,5.6 11,4.4 C16,3.9 21.2,4.0 25.2,4.9 C28.2,5.6 29.8,7.4 29.8,10.0 L29.8,11.8 C29.8,12.3 29.4,12.6 28.6,12.6 L4.2,12.6 C3.7,12.6 3.4,12.3 3.4,11.7 Z"
                fill="url(#${uid}-up)" stroke="#1a1a1a" stroke-width="0.55" stroke-linejoin="round"/>
          <path d="M9.5,10.7 Q15,6.8 22,6.2" stroke="#1a1a1a" stroke-width="0.85" fill="none" stroke-linecap="round"/>
          <line x1="13.6" y1="5.4" x2="13.2" y2="11.6" stroke="#1a1a1a" stroke-width="0.4"/>
          <line x1="14.8" y1="5.0" x2="14.4" y2="11.6" stroke="#1a1a1a" stroke-width="0.3" opacity="0.6"/>
          <circle cx="13.6" cy="6.4" r="0.42" fill="#1a1a1a"/>
          <circle cx="13.5" cy="7.7" r="0.42" fill="#1a1a1a"/>
          <circle cx="13.4" cy="9.0" r="0.42" fill="#1a1a1a"/>
          <circle cx="13.3" cy="10.3" r="0.42" fill="#1a1a1a"/>
          <path d="M5,12.6 L5,14.0 L27.6,14.0 L27.6,12.6 Z" fill="url(#${uid}-sole)"/>
          <ellipse cx="6.5"  cy="14.9" rx="1.3" ry="0.55" fill="#1a1a1a"/>
          <ellipse cx="11"   cy="15.05" rx="1.4" ry="0.6" fill="#1a1a1a"/>
          <ellipse cx="16"   cy="15.05" rx="1.4" ry="0.6" fill="#1a1a1a"/>
          <ellipse cx="21"   cy="15.05" rx="1.4" ry="0.6" fill="#1a1a1a"/>
          <ellipse cx="25.5" cy="14.9" rx="1.3" ry="0.55" fill="#1a1a1a"/>
          <ellipse cx="6.5"  cy="14.65" rx="0.45" ry="0.18" fill="#888"/>
          <ellipse cx="11"   cy="14.8" rx="0.5"  ry="0.2"  fill="#888"/>
          <ellipse cx="16"   cy="14.8" rx="0.5"  ry="0.2"  fill="#888"/>
          <ellipse cx="21"   cy="14.8" rx="0.5"  ry="0.2"  fill="#888"/>
          <ellipse cx="25.5" cy="14.65" rx="0.45" ry="0.18" fill="#888"/>
          <path d="M3.4,8.5 Q9,7.0 18,7.2" stroke="#fff" stroke-width="0.4" fill="none" opacity="0.55"/>
        </g>
      </svg>`;
  }

  function escapeHtml(s) {
    return String(s ?? "").replace(/[&<>"']/g, c => ({
      "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
    }[c]));
  }

  // ─────────────────────────────────────────────────────────────────
  // First-run onboarding tour
  //
  // 5-step spotlight overlay over the dialer. Auto-shows on first install
  // (gated by pd_onboarding_seen_v1). The "?" footer link re-launches it
  // anytime. Each step has an anchor selector — if the anchor isn't on the
  // page (e.g. Start button only renders before a session starts), the
  // tooltip centers itself near the panel and the spotlight falls back to
  // the whole panel. That makes the tour resilient to UI state changes.
  // ─────────────────────────────────────────────────────────────────

  const ONBOARDING_VERSION = "v1";
  const ONBOARDING_FLAG_KEY = `pd_onboarding_seen_${ONBOARDING_VERSION}`;

  const TOUR_STEPS = [
    {
      title: "Welcome to Goal Power Dialer",
      body: "This is your floating dialer. Drag the top status bar to move it, drag the bottom-left corner to resize. Let's walk through the controls.",
      anchor: "#pd-root",
      placement: "left",
    },
    {
      title: "Switch themes anytime",
      body: "The 📱 / ⚽ icon next to the title flips the dialer between Phone skin (compact, like a Galaxy) and Goal skin (wider, soccer-themed with perspective lines). Pick whichever feels right for your monitor.",
      anchor: "#pd-skin-toggle",
      placement: "left",
    },
    {
      title: "Skin chooser bar (top toolbar)",
      body: "The little toolbar at the very top of the panel lets you switch between 5 skins fast: minimize (−), Phone, Classic, Jersey, and Soccer Ball. Each one reshapes the panel — pick what fits your workflow today.",
      anchor: "#pd-skin-bar",
      altAnchor: "#pd-root",
      placement: "top",
    },
    {
      title: "Setup (sign in once)",
      body: "Click the red Setup pill to open the settings drawer. Sign in with your email — we'll text you a 6-digit code. Once signed in, your name, cell, and balance show in the drawer and your settings sync to your account so they follow you across machines.",
      anchor: "#pd-setup-btn",
      placement: "left",
    },
    {
      title: "Mobile App vs Desktop",
      body: "Two big buttons let you choose where audio goes. Desktop routes calls through your laptop's headset via WebRTC — no phone needed. Mobile App routes audio through your phone or tablet (tap 'Get the mobile app' for your /mobile link and add it to home screen). Pick once, the choice sticks.",
      anchor: "#pd-mode-row",
      altAnchor: "#pd-root",
      placement: "left",
    },
    {
      title: "Pick your skin in Setup",
      body: "Inside the Setup drawer there's a Skin selector — Phone or Goal. Same toggle as the icon next to the title, but with a preview tile. Use whichever way feels easier.",
      anchor: "#pd-setup-btn",
      placement: "left",
    },
    {
      title: "Start a session",
      body: "Click Start Session. Your cell rings once — answer and stay on the line. The dialer parks you with hold music, then bridges prospects in instantly when they pick up. Your cell stays connected for the whole session.",
      anchor: "#pd-session-area",
      placement: "left",
    },
    {
      title: "Auto-Pilot + Double-Tap",
      body: "After parking, you'll see ▶ Start Autopilot — that auto-advances to the next lead after each disposition. Double-Tap dial calls each number twice (one full ring cycle each) before moving on, for higher pickup rates.",
      anchor: "#pd-autopilot",
      altAnchor: "#pd-session-area",
      placement: "left",
    },
    {
      title: "Power-dialing toggles",
      body: "Below the call controls is the toggles area — Manual disposition (pauses on every hangup), Bottom-to-Top (walk the list upward), Call from comments (for bad-number leads), Auto-hangup on attendants (drops IVRs as no-answer), Away Mode (auto-disposition while you're away), and Custom connection timeout. Flip on what fits your workflow.",
      anchor: "#pd-toggles-area",
      altAnchor: "#pd-root",
      placement: "left",
    },
    {
      title: "Skip lead types",
      body: "The light-blue ⏭ SKIP LEAD TYPES button at the bottom of the toggles opens a checklist of lead types to skip this session — DNC, bad number, voicemail-only, no-pickup history, etc. Useful when you want to focus only on warm leads.",
      anchor: "#pd-skip-lead-types-open",
      altAnchor: "#pd-toggles-area",
      placement: "left",
    },
    {
      title: "In-call view (always visible)",
      body: "The bottom of the panel shows the in-call view at all times — greyed out when idle, lit up the instant a prospect connects. Skip hangs up the call, VM Drop plays your voicemail message, Bridge re-bridges if AMD got it wrong.",
      anchor: "#pd-prospect-area",
      placement: "left",
    },
    {
      title: "≡ History — what just happened",
      body: "Click History anytime mid-session for a slide-out list of every call this session, color-coded by outcome (Human / Voicemail / No Answer / Skipped). Useful when you want to remember whether the last lead picked up.",
      anchor: "#pd-history-btn",
      placement: "left",
    },
    {
      title: "▼ Stats jersey",
      body: "Drops down a soccer-jersey scoreboard showing today's totals — dials, contacts, contact rate. Tap to peek at your numbers without ending the session.",
      anchor: "#pd-jersey-btn",
      placement: "left",
    },
    {
      title: "Three minimize modes",
      body: "Top-right corner: − shrinks the panel to a tiny soccer-ball icon (~0.5 inch). O shows a compact summary strip with Pause / End Call / End Session buttons. ⛶ maximizes for big monitors. × hides the panel entirely.",
      anchor: "#pd-collapse",
      placement: "top",
    },
    {
      title: "End Session for the recap",
      body: "When you're done, click End Session. A slide-out shows your match summary — Monthly Usage progress bar, Results % (contacts/voicemails/no-answer), Dispositions you picked, and the names of any leads you skipped. We'll also email you a copy.",
      anchor: "#pd-session-area",
      placement: "left",
    },
  ];

  function startOnboarding(opts = {}) {
    const force = !!opts.force;
    if (!force && document.getElementById("pd-tour-root")) return;
    document.getElementById("pd-tour-root")?.remove();

    const root = document.createElement("div");
    root.id = "pd-tour-root";
    // v1.32.5 — Mark tour as ACTIVE so the orphan-overlay reaper at
    // orphanOverlayReaper (runs every 3s) doesn't remove it mid-tour.
    // Bug: reaper checked for !pd-tour-active, but startOnboarding
    // never SET the class, so tour was reaped after 3s and the agent's
    // Next click hit a detached element with no listener.
    root.classList.add("pd-tour-active");
    root.innerHTML = `
      <div class="pd-tour-backdrop" id="pd-tour-backdrop"></div>
      <div class="pd-tour-spotlight" id="pd-tour-spot"></div>
      <div class="pd-tour-card" id="pd-tour-card" role="dialog" aria-modal="true" aria-labelledby="pd-tour-title">
        <div class="pd-tour-step" id="pd-tour-step">1 / ${TOUR_STEPS.length}</div>
        <h3 class="pd-tour-title" id="pd-tour-title"></h3>
        <p class="pd-tour-body" id="pd-tour-body"></p>
        <div class="pd-tour-actions">
          <button class="pd-tour-btn pd-tour-btn-skip" id="pd-tour-skip">Skip</button>
          <div class="pd-tour-nav">
            <button class="pd-tour-btn pd-tour-btn-back" id="pd-tour-back">Back</button>
            <button class="pd-tour-btn pd-tour-btn-next" id="pd-tour-next">Next</button>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    let i = 0;
    function render() {
      const step = TOUR_STEPS[i];
      document.getElementById("pd-tour-step").textContent = `${i + 1} / ${TOUR_STEPS.length}`;
      document.getElementById("pd-tour-title").textContent = step.title;
      document.getElementById("pd-tour-body").textContent = step.body;
      const back = document.getElementById("pd-tour-back");
      const next = document.getElementById("pd-tour-next");
      back.disabled = i === 0;
      next.textContent = i === TOUR_STEPS.length - 1 ? "Got it" : "Next";
      positionStep(step);
    }

    function positionStep(step) {
      const spot = document.getElementById("pd-tour-spot");
      const card = document.getElementById("pd-tour-card");
      // v1.31.87 — Hard null-guard. If the tour DOM has been torn down
      // (close() ran, a re-open dropped the prior root, or our resize
      // listener was orphaned), bail silently instead of crashing on
      // spot.style. Live-demo froze on exactly this path: opening a
      // popup window fires a resize on the main tab → orphaned onResize
      // → positionStep → null spot → "Cannot read properties of null".
      if (!spot || !card) {
        try { window.removeEventListener("resize", onResize); } catch (_e) {}
        return;
      }
      // Make sure the panel is visible and uncollapsed for the duration of
      // the tour — otherwise the step anchors won't be in the layout.
      const panel = document.getElementById("pd-root");
      if (panel) {
        panel.style.display = "block";
        panel.classList.remove("pd-collapsed");
      }
      let target = step.anchor ? document.querySelector(step.anchor) : null;
      if ((!target || !isVisible(target)) && step.altAnchor) {
        target = document.querySelector(step.altAnchor);
      }
      if (!target || !isVisible(target)) target = panel;

      if (!target) {
        spot.style.display = "none";
        card.style.top = "50%";
        card.style.left = "50%";
        card.style.transform = "translate(-50%, -50%)";
        return;
      }

      const r = target.getBoundingClientRect();
      const pad = 8;
      spot.style.display = "block";
      spot.style.top = (r.top - pad) + "px";
      spot.style.left = (r.left - pad) + "px";
      spot.style.width = (r.width + pad * 2) + "px";
      spot.style.height = (r.height + pad * 2) + "px";

      // Position the tooltip card. Default: float to the LEFT of the panel
      // (which lives on the right side of the screen). If the target is
      // small or near the top, anchor relative to the target itself.
      const cardW = 340;
      const cardH = card.getBoundingClientRect().height || 180;
      const placement = step.placement || "left";
      let top, left;
      if (placement === "top") {
        top = Math.max(16, r.top - cardH - 16);
        left = Math.min(window.innerWidth - cardW - 16,
          Math.max(16, r.left + r.width / 2 - cardW / 2));
      } else {
        // left
        left = Math.max(16, r.left - cardW - 24);
        top = Math.max(16, Math.min(window.innerHeight - cardH - 16,
          r.top + r.height / 2 - cardH / 2));
      }
      card.style.top = top + "px";
      card.style.left = left + "px";
      card.style.transform = "none";
    }

    function isVisible(el) {
      if (!el) return false;
      const r = el.getBoundingClientRect();
      return r.width > 0 && r.height > 0;
    }

    function close(seen) {
      root.remove();
      window.removeEventListener("resize", onResize);
      document.removeEventListener("keydown", onKey);
      if (seen && isExtensionAlive()) {
        try { chrome.storage.local.set({ [ONBOARDING_FLAG_KEY]: Date.now() }); } catch {}
      }
    }
    const onResize = () => positionStep(TOUR_STEPS[i]);
    const onKey = (e) => {
      if (e.key === "Escape") close(true);
      else if (e.key === "ArrowRight") { if (i < TOUR_STEPS.length - 1) { i++; render(); } else close(true); }
      else if (e.key === "ArrowLeft" && i > 0) { i--; render(); }
    };
    window.addEventListener("resize", onResize);
    document.addEventListener("keydown", onKey);

    document.getElementById("pd-tour-skip").addEventListener("click", () => close(true));
    document.getElementById("pd-tour-backdrop").addEventListener("click", () => close(true));
    document.getElementById("pd-tour-back").addEventListener("click", () => {
      if (i > 0) { i--; render(); }
    });
    document.getElementById("pd-tour-next").addEventListener("click", () => {
      if (i < TOUR_STEPS.length - 1) { i++; render(); }
      else close(true);
    });

    render();
  }

  async function maybeAutoStartOnboarding() {
    if (!isExtensionAlive()) return;
    try {
      const data = await chrome.storage.local.get(ONBOARDING_FLAG_KEY);
      if (data && data[ONBOARDING_FLAG_KEY]) return;
    } catch { return; }
    // Give the panel one frame to mount + paint so anchors resolve.
    setTimeout(() => startOnboarding({ force: true }), 600);
  }

  // ─────────────────────────────────────────────────────────────────
  // Popup-message bridge
  // ─────────────────────────────────────────────────────────────────
  try { chrome.runtime.onMessage.addListener((msg) => {
    if (pdInvalidated || !isExtensionAlive()) { markInvalidated(); return; }
    if (!msg || !msg.type) return;
    // v1.28.0: mic-grant popup reported back its outcome via background.js.
    // Update local state and re-render the mode-info banner so the green
    // "Microphone ready" line replaces the orange "Enable" button.
    if (msg.type === "pd_mic_granted_broadcast") {
      state.micGranted = !!msg.granted;
      try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
      try { chrome.storage.local.set({ pd_mic_granted: !!msg.granted }); } catch {}
      // v1.34 — This broadcast is the ONLY reliable grant-outcome signal.
      // The open-mic-grant response just confirms the tab opened (no grant
      // result), so the modals must react HERE. Previously they didn't, so a
      // successful grant left the "Microphone permission needed" modal stuck
      // on screen with only "Not now" — agents thought it failed and mashed
      // Send-Diagnostics. Now: dismiss both mic modals on success; on failure
      // re-enable the grant buttons and surface the real reason.
      if (msg.granted) {
        try { document.getElementById("pd-mic-permission-modal")?.remove(); } catch {}
        try { document.getElementById("pd-mic-firstlogin-modal")?.remove(); } catch {}
        try { toast("✓ Microphone enabled — dialer ready"); } catch {}
      } else {
        for (const [id, label] of [["pd-mic-permission-grant-btn","Grant microphone"],["pd-mic-firstlogin-grant","Enable microphone"]]) {
          const b = document.getElementById(id);
          if (b) { b.disabled = false; b.textContent = label; }
        }
        try { toast("Mic not enabled: " + (msg.error || "Chrome blocked it. Click Grant again, or reset mic access via the 🔒 icon in the address bar."), { error: true, ms: 8000 }); } catch {}
      }
      return;
    }
    if (msg.type === "pd_show_tour") {
      startOnboarding({ force: true });
      return;
    }
    if (msg.type === "pd_show") {
      buildPanel();
      const root = document.getElementById("pd-root");
      if (root) {
        root.style.display = "block";
        root.classList.remove("pd-collapsed");
        state.collapsed = false;
        chrome.storage.local.set({ pd_collapsed: false });
      }
    } else if (msg.type === "pd_reset_position") {
      const root = document.getElementById("pd-root");
      if (root) {
        root.style.right = "24px";
        root.style.top = "120px";
        state.pos = { right: 24, top: 120 };
        try { chrome.storage.local.set({ pd_panel_pos: state.pos }); } catch { markInvalidated(); }
      }
    }
  }); } catch { markInvalidated(); }

  // React to settings/profile changes from the popup so the panel updates live
  try {
    chrome.storage.onChanged.addListener((changes) => {
      if (pdInvalidated || !isExtensionAlive()) { markInvalidated(); return; }
      let needConnect = false;
      // v1.31.15-stable — DO NOT reconnect on pd_profiles changes.
      // /api/me writes incremental user updates (balance, lastSeenAt,
      // devices[]) to pd_profiles on every poll. Reconnecting on each
      // save created an infinite WS reconnect loop on fresh installs.
      // pd_active_profile change is what fires on sign-in/sign-out,
      // which is sufficient for catching auth identity changes.
      if (changes.pd_worker_base || changes.pd_active_profile) {
        needConnect = true;
      }
      loadConfig().then(() => {
        paint();
        if (needConnect) {
          if (state.ws) try { state.ws.close(); } catch {}
          state.ws = null;
          disconnectAudioWs();
          connectWs();
        }
      }).catch(() => {});
    });
  } catch { markInvalidated(); }

  // v1.31.61 — Worker-config Layer 2: fetch + cache runtime config.
  // Hydrates state.runtimeConfig from chrome.storage.local synchronously
  // (so any code reading from it during boot sees the last known value),
  // then kicks off a background fetch to refresh.
  async function loadCachedRuntimeConfig() {
    try {
      const data = await chrome.storage.local.get(["pd_runtime_config_v1"]);
      const cached = data?.pd_runtime_config_v1;
      if (cached && typeof cached === "object" && cached.version) {
        state.runtimeConfig = { ...RUNTIME_CONFIG_DEFAULTS, ...cached };
      }
    } catch (_e) {}
  }

  async function fetchRuntimeConfig() {
    try {
      if (!isExtensionAlive()) return;
      const workerBase = state.workerBase || "https://app.goalpowerdialer.com";
      const resp = await chrome.runtime.sendMessage({
        type: "pd_fetch_runtime_config",
        workerBase,
      });
      if (!resp || !resp.ok || !resp.data || !resp.data.config) {
        // Transient — quietly skip. Next poll retries. Cache stays valid.
        if (resp?.error) console.debug("[pd] runtime config fetch skipped:", resp.error);
        return;
      }
      const fresh = resp.data.config;
      // Deep-merge defaults → fresh so any missing fields fall through.
      const merged = { ...RUNTIME_CONFIG_DEFAULTS, ...fresh };
      const versionChanged = !state.runtimeConfig || state.runtimeConfig.version !== merged.version;
      state.runtimeConfig = merged;
      try { await chrome.storage.local.set({ pd_runtime_config_v1: merged }); } catch {}
      if (versionChanged) {
        try { log("info", `[runtime-config] v${merged.version} loaded`); } catch {}
      }
    } catch (err) {
      console.debug("[pd] runtime config fetch threw:", err?.message || err);
    }
  }

  // v1.29.97 — Update check. Polls the worker's extension-version.json
  // periodically and surfaces a banner when the installed extension is
  // older than what's available. Detects install type (Web Store vs
  // sideload) and shows different copy + actions for each:
  //   • Web Store users → informational ("auto-update within a few hours")
  //   • Sideload users  → actionable (opens chrome://extensions for reload)
  //
  // The banner sits at the top of the panel above the mode selector.
  // Dismissible per-session; reappears on next boot if still outdated.
  async function checkForExtensionUpdate() {
    try {
      // Get installed version + install type
      const installedVersion = chrome.runtime.getManifest().version;
      let installType = "normal";  // "normal" = Web Store, "development" = sideload
      try {
        const info = await new Promise((r) => chrome.management.getSelf((i) => r(i)));
        installType = info?.installType || "normal";
      } catch (_e) { /* chrome.management not available in some contexts */ }
      // v1.30.2 — Route the fetch through background.js. Content-script
      // fetches were occasionally being cancelled mid-flight when Impact
      // navigated, producing a console-warn "Failed to fetch" noise that
      // Dustin spotted. Background does the same fetch with one silent
      // retry, isolated from the page lifecycle.
      if (!isExtensionAlive()) return;
      const workerBase = state.workerBase || "https://app.goalpowerdialer.com";
      const resp = await chrome.runtime.sendMessage({
        type: "pd_fetch_version",
        workerBase,
      });
      if (!resp || !resp.ok) {
        // Transient — quietly skip. Next 30-minute poll will retry.
        // Only log at debug level for troubleshooting; no console-warn spam.
        if (resp?.error) console.debug("[pd] update check skipped:", resp.error);
        return;
      }
      const latestVersion = resp.data?.version;
      if (!latestVersion) return;
      if (!isNewerVersion(latestVersion, installedVersion)) return;
      // Update available — show the banner (persistent reminder) AND
      // the modal (in-your-face nudge, once per browser session per
      // version). Banner stays even after modal dismiss so the user has
      // a permanent visual reminder.
      showUpdateBanner({ installed: installedVersion, latest: latestVersion, installType });
      showUpdateModal({ installed: installedVersion, latest: latestVersion, installType });
      // v1.31.50 — Inline update prompt in pd-footer of dialer panel.
      // Always-visible update CTA even after banner/modal dismissed.
      updatePdFooterWithVersion(installedVersion, latestVersion);
    } catch (err) {
      // chrome.runtime.sendMessage throws if the bg worker is asleep
      // mid-call. Harmless — next poll retries.
      console.debug("[pd] update check skipped:", err?.message || err);
    }
  }
  // Compare semver-ish version strings — true if `a` is newer than `b`.
  // v1.33.2 — 4-segment silent-update scheme. Mirrors the comparator in
  // background.js pdCompareVersions: only the first 3 segments matter for
  // banner/modal display. A 4th-segment bump (e.g., 1.33.2 → 1.33.2.1)
  // ships new code silently — any agent who reloads picks it up, but the
  // update banner/modal stays suppressed so managers don't get hit with
  // "please update" prompts for trivial mid-day fixes. Bump segment 1, 2,
  // or 3 when you actually want to notify the fleet.
  function isNewerVersion(a, b) {
    const parse = (s) => String(s || "").split(".").map((n) => parseInt(n, 10) || 0).slice(0, 3);
    const A = parse(a), B = parse(b);
    const len = Math.max(A.length, B.length);
    for (let i = 0; i < len; i++) {
      const av = A[i] || 0, bv = B[i] || 0;
      if (av > bv) return true;
      if (av < bv) return false;
    }
    return false;
  }
  function showUpdateBanner(args) {
    // v1.33.3.2 — Default-tolerant signature. Karla Ireta / Sam Jaizan
    // 2026-06-25 crash: "Cannot destructure property 'installed' of
    // 'undefined'" — there's a legacy showUpdateModal() call at line
    // 9792 with no args that, due to JS function-hoisting, resolves to
    // the v1.33.x signature here. The throw kills the entire content.js
    // boot so the dialer panel never builds — Karla saw the extension
    // "disappear" until she reloaded. Defensive: accept either a real
    // args object or fall back to state.updateAvailable (the legacy
    // shape) or skip silently.
    const _src = (args && typeof args === "object") ? args
              : (state && state.updateAvailable) || {};
    const installed   = _src.installed   || _src.current || null;
    const latest      = _src.latest      || null;
    const installType = _src.installType || null;
    if (!latest) return; // Nothing to show; bail rather than crash.
    if (document.getElementById("pd-update-banner")) return; // idempotent
    // Dismissed for this session? Bail.
    try {
      const dismissed = sessionStorage.getItem("pd_update_dismissed_for_" + latest);
      if (dismissed === "1") return;
    } catch (_e) {}
    // v1.31.45 — ALWAYS show the install-guide link regardless of install
    // type. Previously, "Web Store" detected users (installType=="normal")
    // got an info-only message "Auto-update arrives in 6-24h" with no
    // clickable action — they had no way to force the update. Also dropped
    // the confusing "(Web Store)" terminology from the subtitle. The
    // install page itself explains both Web-Store-force-update and direct-
    // download paths, so one link covers both cases cleanly.
    let osPath = "/install"; // OS-picker fallback
    try {
      const platform = (navigator.userAgentData && navigator.userAgentData.platform) ||
                       navigator.platform || "";
      const lc = String(platform).toLowerCase();
      if (lc.includes("mac") || lc.includes("darwin"))   osPath = "/install-mac";
      else if (lc.includes("win"))                       osPath = "/install-windows";
    } catch (_e) {}
    const workerBase = state.workerBase || "https://app.goalpowerdialer.com";
    const installUrl = workerBase + osPath;

    const wrap = document.createElement("div");
    wrap.id = "pd-update-banner";
    wrap.style.cssText = "position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483646;max-width:560px;width:calc(100vw - 32px);padding:10px 14px;background:linear-gradient(135deg,#1d6a3d,#2dab5f);color:#fff;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.4);font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:13px;line-height:1.4;display:flex;align-items:center;gap:10px;";
    wrap.innerHTML = `
      <span style="font-size:18px;line-height:1;">🔄</span>
      <div style="flex:1;">
        <div style="font-weight:700;">Update available: v${pdEscapeHtml(latest)}</div>
        <div style="font-size:11px;opacity:0.88;">You're on v${pdEscapeHtml(installed)} — install latest for newest fixes (takes ~3 min).</div>
      </div>
      <a id="pd-update-link" href="${pdEscapeHtml(installUrl)}" target="_blank" rel="noopener" style="background:#F2C94C;border:1px solid #C49B12;color:#1B4332;padding:7px 14px;border-radius:6px;font-size:12px;cursor:pointer;font-weight:700;font-family:inherit;text-decoration:none;white-space:nowrap;">Update now ↗</a>
      <button id="pd-update-dismiss-btn" title="Dismiss for this session" style="background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;opacity:0.7;line-height:1;">×</button>
    `;
    document.body.appendChild(wrap);
    document.getElementById("pd-update-dismiss-btn")?.addEventListener("click", () => {
      try { sessionStorage.setItem("pd_update_dismissed_for_" + latest, "1"); } catch (_e) {}
      wrap.remove();
    });
  }

  // v1.31.47 — Update MODAL (in addition to the banner). Fires once per
  // browser session per version: bigger, more attention-grabbing nudge
  // for users who scroll past the banner. Same OS-aware install URL +
  // session-scoped snooze keyed to the target version (snooze key
  // mismatches the moment a NEW version ships, so they get a fresh
  // modal then). The banner remains visible after modal dismiss so the
  // user has a persistent reminder.
  function showUpdateModal(args) {
    // v1.33.3.2 — Default-tolerant signature; see showUpdateBanner above
    // for the full Karla Ireta crash narrative. Same defensive shape.
    const _src = (args && typeof args === "object") ? args
              : (state && state.updateAvailable) || {};
    const installed   = _src.installed   || _src.current || null;
    const latest      = _src.latest      || null;
    const installType = _src.installType || null;
    if (!latest) return;
    if (document.getElementById("pd-update-modal")) return; // idempotent
    // Session-scoped snooze. Key includes the target version so a new
    // release auto-busts the snooze.
    try {
      const dismissed = sessionStorage.getItem("pd_update_modal_dismissed_for_" + latest);
      if (dismissed === "1") return;
    } catch (_e) {}
    // Mid-call safety: skip if an active dial session is in progress.
    // The state.session check matches how other content.js modals (like
    // the dialing UI overlay) gate themselves. Modal will fire on the
    // next 30-min poll once the session ends.
    try {
      if (state && state.session && state.session.active) return;
    } catch (_e) {}
    // OS-aware install URL — same logic as banner.
    let osPath = "/install";
    try {
      const platform = (navigator.userAgentData && navigator.userAgentData.platform) ||
                       navigator.platform || "";
      const lc = String(platform).toLowerCase();
      if (lc.includes("mac") || lc.includes("darwin"))   osPath = "/install-mac";
      else if (lc.includes("win"))                       osPath = "/install-windows";
    } catch (_e) {}
    const workerBase = state.workerBase || "https://app.goalpowerdialer.com";
    const installUrl = workerBase + osPath;

    const wrap = document.createElement("div");
    wrap.id = "pd-update-modal";
    wrap.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);font-family:-apple-system,BlinkMacSystemFont,sans-serif;";
    wrap.innerHTML = `
      <div style="width:380px;max-width:calc(100vw - 32px);background:linear-gradient(180deg,#1B4332,#16263d);color:#fff;border-radius:14px;border:2px solid #F2C94C;box-shadow:0 20px 50px rgba(0,0,0,0.6);overflow:hidden;">
        <div style="background:#F2C94C;color:#1B4332;padding:10px 20px;font-weight:800;letter-spacing:1.5px;font-size:12px;text-transform:uppercase;text-align:center;">
          🔄 Update available
        </div>
        <div style="padding:22px 24px;">
          <h2 style="margin:0 0 10px;font-size:20px;color:#fff;">Goal Power Dialer v${pdEscapeHtml(latest)} is out</h2>
          <p style="margin:0 0 14px;font-size:14px;line-height:1.5;color:#cfe0fa;">
            You're on <strong style="color:#F2C94C;">v${pdEscapeHtml(installed)}</strong>. The latest version includes bug fixes and improvements. Reinstall takes about 3 minutes.
          </p>
          <div style="background:rgba(242,201,76,0.08);border:1px solid rgba(242,201,76,0.25);border-radius:8px;padding:10px 12px;margin:14px 0 18px;font-size:12.5px;line-height:1.5;color:#d8e7dd;">
            <strong style="color:#F2C94C;">Why update?</strong> Each release fixes bugs and ships improvements based on agent feedback. Older versions may have known issues already solved.
          </div>
          <div style="display:flex;gap:10px;">
            <a id="pd-update-modal-link" href="${pdEscapeHtml(installUrl)}" target="_blank" rel="noopener" style="flex:1;text-align:center;background:#F2C94C;color:#1B4332;border:0;padding:11px 16px;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;text-decoration:none;font-family:inherit;">
              Update now ↗
            </a>
            <button id="pd-update-modal-later" type="button" style="background:transparent;color:#cfe0fa;border:1px solid rgba(255,255,255,0.25);padding:11px 16px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">
              Remind me later
            </button>
          </div>
          <div style="margin-top:12px;text-align:center;font-size:11px;color:#9fb1c9;">
            "Remind me later" hides this for the current browser session. Banner above stays as a reminder.
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(wrap);

    // "Remind me later" → snooze for the rest of this browser session.
    // sessionStorage clears on browser-close, so the next morning fresh
    // modal fires again if they haven't updated yet.
    document.getElementById("pd-update-modal-later")?.addEventListener("click", () => {
      try { sessionStorage.setItem("pd_update_modal_dismissed_for_" + latest, "1"); } catch (_e) {}
      wrap.remove();
    });
    // Clicking the actual update link ALSO snoozes — no point re-showing
    // them the modal in the same session if they've already opened the
    // install guide in a new tab.
    document.getElementById("pd-update-modal-link")?.addEventListener("click", () => {
      try { sessionStorage.setItem("pd_update_modal_dismissed_for_" + latest, "1"); } catch (_e) {}
      // Don't preventDefault — let the link open in a new tab as designed.
      setTimeout(() => { try { wrap.remove(); } catch (_e) {} }, 300);
    });
    // Click on the overlay backdrop (NOT the modal card itself) also
    // dismisses — common user expectation for modals.
    wrap.addEventListener("click", (e) => {
      if (e.target === wrap) {
        try { sessionStorage.setItem("pd_update_modal_dismissed_for_" + latest, "1"); } catch (_e) {}
        wrap.remove();
      }
    });
  }


  // v1.31.50 — Update pd-footer with inline version-mismatch CTA.
  // Replaces the panel's footer "v1.31.49 · drag top bar to move" with
  // "v1.31.49 → v1.31.50 🔄 Update now ↗" when a newer version is
  // detected. Click opens /install (same target as banner/modal).
  // Always visible while dialer panel is open — survives banner+modal
  // dismissal so agents see the upgrade prompt every session.
  function updatePdFooterWithVersion(installed, latest) {
    try {
      const el = document.getElementById("pd-footer-msg");
      if (!el) return;
      let osPath = "/install";
      try {
        const platform = (navigator.userAgentData && navigator.userAgentData.platform) ||
                         navigator.platform || "";
        const lc = String(platform).toLowerCase();
        if (lc.includes("mac") || lc.includes("darwin"))   osPath = "/install-mac";
        else if (lc.includes("win"))                       osPath = "/install-windows";
      } catch (_e) {}
      const workerBase = (state && state.workerBase) || "https://app.goalpowerdialer.com";
      const installUrl = workerBase + osPath;
      const safeInstalled = pdEscapeHtml(installed);
      const safeLatest    = pdEscapeHtml(latest);
      const safeUrl       = pdEscapeHtml(installUrl);
      el.innerHTML = `<span id="pd-footer-version">v${safeInstalled}</span> <span style="color:#F2C94C;">→</span> <strong style="color:#F2C94C;">v${safeLatest}</strong> <a href="${safeUrl}" target="_blank" rel="noopener" id="pd-footer-update" style="background:#F2C94C;color:#1B4332;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:700;text-decoration:none;margin-left:4px;font-family:inherit;">🔄 Update now ↗</a>`;
    } catch (err) {
      try { log("warn", "[v1.31.50] updatePdFooterWithVersion threw:", err.message); } catch {}
    }
  }

  // ─────────────────────────────────────────────────────────────────
  // v1.31.2 — URL watcher for Manual disposition mode resume
  //
  // When state.manualDisposition is ON and we hung up a call, we set
  // state.waitingForManualDisposition = true. The agent then picks a
  // disposition in Impact (Voicemail Left / Bad Number / Set Appt /
  // etc.), which causes Impact to navigate from /Lead/InboxDetail or
  // /Lead/WhatHappend back to a top-level "lead list" URL like
  // /Lead/Inbox or /Lead/InboxList. THAT's our signal to resume:
  // advance to next lead + dial it.
  //
  // Impact is a SPA — no popstate events fire on internal nav — so we
  // poll location.pathname every 500ms. Cheap and reliable.
  // ─────────────────────────────────────────────────────────────────
  (function setupManualDispositionUrlWatcher() {
    let lastPath = location.pathname;
    setInterval(() => {
      const cur = location.pathname;
      if (cur === lastPath) return;
      lastPath = cur;
      if (!state.waitingForManualDisposition) return;
      // Detect "main lead list" pages — not InboxDetail / WhatHappend.
      // Impact's list URLs match /Lead/Inbox (without "Detail"), /Lead
      // (root), /Lead/InboxList, etc. A pragmatic regex matches paths
      // that end in "/Lead" or "/Lead/Inbox" or "/Lead/InboxList" but
      // explicitly excludes "InboxDetail" and "WhatHappend".
      const isDetail = /\/Lead\/(InboxDetail|WhatHappend)/i.test(cur);
      const isList = !isDetail && /\/Lead(\/(Inbox(List)?)?)?(\?|$)/i.test(cur);
      if (!isList) return;
      log("info", "[manual-dispo] returned to lead list — advancing to next lead");
      state.waitingForManualDisposition = false;
      toast("▶ Advancing to next lead (you returned from disposition)", { ms: 4000, kind: "ok" });
      // Resume autopilot + tee up the advance + dial via the same
      // pending-advance flag the no-answer path uses.
      state.autoDialMode = true;
      try { chrome.storage.local.set({ pd_autodial: true }); } catch {}
      try { send("autodial.set", { running: true }); } catch (_e) {}
      try { state.ws && state.ws.send(JSON.stringify({ type: "autopilot", running: true })); } catch (_e) {}
      try {
        chrome.storage.local.set({
          pd_pending_advance: { reason: "manual-dispo-resume", at: Date.now() },
        });
      } catch {}
      paint();
    }, 500);
  })();

  // v1.32.16 — DRASTIC redesign per Dustin 2026-06-17. The blocking
  // popup from v1.32.14/15 was locking agents OUT entirely (Rabecca,
  // Katherine, more). Replaced with the simplest possible model:
  //
  //   - Dialer auto-appears on every Impact tab. NO popup.
  //   - Agent can CLOSE the dialer with the X button on the panel.
  //   - Closed tabs remember it via sessionStorage. The dialer never
  //     returns on that tab — they open a fresh tab to get it back.
  //   - Multiple tabs can coexist. The worker (v1.32.16 session.js)
  //     no longer murders existing WSes when a new one connects.
  //   - Conflict resolution happens at Start Session (active-session
  //     lock), not at boot. If another tab tries Start Session while
  //     one is active, that tab gets a "switch?" prompt.
  //
  // This function is now trivial — just a sessionStorage check.
  function pdMultiTabGate() {
    // Skip iframes — only top window runs the dialer.
    if (window !== window.top) return false;
    // Agent explicitly closed dialer on this tab — stay quiet.
    let closed = null;
    try { closed = sessionStorage.getItem("pd_dialer_closed"); } catch {}
    if (closed === "1") return false;
    return true;
  }

  // v1.32.16 — Injects a "Close dialer on this tab" X button into the top-
  // right of #pd-root. Click → confirm dialog ("Click yes to close"). On
  // confirm: sets sessionStorage so the dialer won't return for this tab,
  // tears down the panel + WS. No hover dot — once closed, the tab is done
  // until the agent opens a fresh Impact tab (per Dustin's spec 2026-06-17).
  function pdInjectCloseButton() {
    if (document.getElementById("pd-close-tab-btn")) return; // idempotent
    const root = document.getElementById("pd-root");
    if (!root) return;

    const btn = document.createElement("button");
    btn.id = "pd-close-tab-btn";
    btn.type = "button";
    btn.title = "Close dialer on this tab (won't return until you open a new tab)";
    btn.setAttribute("aria-label", "Close dialer on this tab");
    btn.innerHTML = "✕";
    btn.style.cssText = [
      "position:absolute",
      "top:6px",
      "right:6px",
      "width:26px",
      "height:26px",
      "padding:0",
      "border:none",
      "border-radius:50%",
      "background:rgba(220,53,69,0.95)",
      "color:#fff",
      "font:600 15px/26px system-ui,sans-serif",
      "cursor:pointer",
      "z-index:2147483646",
      "box-shadow:0 2px 6px rgba(0,0,0,0.25)",
    ].join(";");
    btn.onmouseenter = () => { btn.style.background = "rgba(220,53,69,1)"; btn.style.transform = "scale(1.08)"; };
    btn.onmouseleave = () => { btn.style.background = "rgba(220,53,69,0.95)"; btn.style.transform = "scale(1)"; };

    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      e.preventDefault();
      pdShowCloseConfirm();
    });
    root.appendChild(btn);
  }

  // Confirmation modal before closing. Two buttons: Yes, close / Cancel.
  function pdShowCloseConfirm() {
    if (document.getElementById("pd-close-confirm-overlay")) return; // already showing

    const overlay = document.createElement("div");
    overlay.id = "pd-close-confirm-overlay";
    overlay.style.cssText = [
      "position:fixed","top:0","left:0","right:0","bottom:0",
      "background:rgba(0,0,0,0.55)","z-index:2147483647",
      "display:flex","align-items:center","justify-content:center",
      "font:14px system-ui,sans-serif",
    ].join(";");

    const card = document.createElement("div");
    card.style.cssText = [
      "background:linear-gradient(135deg,#1B4332,#2D6A4F)",
      "color:#fff",
      "border:2px solid #F2C94C",
      "border-radius:16px",
      "padding:24px 28px",
      "max-width:420px","width:90%",
      "box-shadow:0 20px 60px rgba(0,0,0,0.45)",
      "text-align:center",
    ].join(";");

    card.innerHTML = `
      <div style="font-size:28px;margin-bottom:4px;">⚠️</div>
      <h3 style="margin:0 0 10px;font-size:18px;font-weight:700;letter-spacing:0.3px;">Close dialer on this tab?</h3>
      <p style="margin:0 0 6px;line-height:1.5;color:#E8F4EC;">The dialer will stay closed on this tab for the rest of the session.</p>
      <p style="margin:0 0 22px;line-height:1.5;color:#E8F4EC;font-size:13px;">To get it back, just open a new Impact tab.</p>
      <div style="display:flex;gap:10px;justify-content:center;">
        <button id="pd-close-confirm-yes" style="flex:1;max-width:170px;padding:11px 16px;border:0;border-radius:8px;background:#F2C94C;color:#1B4332;font:600 14px system-ui;cursor:pointer;">Yes, close</button>
        <button id="pd-close-confirm-cancel" style="flex:1;max-width:170px;padding:11px 16px;border:0;border-radius:8px;background:rgba(255,255,255,0.18);color:#fff;font:600 14px system-ui;cursor:pointer;">Cancel</button>
      </div>
    `;
    overlay.appendChild(card);
    document.body.appendChild(overlay);

    function close(confirmed) {
      try { overlay.remove(); } catch {}
      if (confirmed) pdCloseDialerOnThisTab();
    }
    card.querySelector("#pd-close-confirm-yes").onclick = () => close(true);
    card.querySelector("#pd-close-confirm-cancel").onclick = () => close(false);
    overlay.addEventListener("click", (e) => { if (e.target === overlay) close(false); });
    const escHandler = (e) => {
      if (e.key === "Escape") {
        document.removeEventListener("keydown", escHandler);
        close(false);
      }
    };
    document.addEventListener("keydown", escHandler);
  }

  // Performs the actual close: persists choice + tears down panel + WS.
  function pdCloseDialerOnThisTab() {
    try { sessionStorage.setItem("pd_dialer_closed", "1"); } catch {}
    try {
      if (state.ws) {
        try { state.ws.close(1000, "agent-closed-tab"); } catch {}
        state.ws = null;
      }
    } catch {}
    try {
      const root = document.getElementById("pd-root");
      if (root) root.remove();
    } catch {}
    // Also remove the close button itself (it lived inside #pd-root but
    // belt-and-suspenders if anything reparented it).
    try { document.getElementById("pd-close-tab-btn")?.remove(); } catch {}
    // Show a brief confirmation toast so user knows the close registered.
    try {
      const notice = document.createElement("div");
      notice.style.cssText = "position:fixed;bottom:20px;right:20px;background:#1B4332;color:#fff;padding:10px 14px;border-radius:8px;font:13px system-ui;z-index:2147483647;box-shadow:0 4px 12px rgba(0,0,0,0.2)";
      notice.textContent = "Dialer closed on this tab. Open a new tab to bring it back.";
      document.body.appendChild(notice);
      setTimeout(() => { try { notice.remove(); } catch {} }, 5000);
    } catch {}
  }

  // ─────────────────────────────────────────────────────────────────
  // Boot
  // ─────────────────────────────────────────────────────────────────
  loadConfig().then(() => loadDeviceId()).then(async () => {
    // v1.31.89 — Fire-and-forget device registration on staging/lab so
    // the admin / picker list sees this device immediately after first
    // boot. No-op on prod (env gate inside registerThisDevice).
    registerThisDevice().catch(() => {});

    // v1.33.2 — Periodic /api/me refresh so newly-registered mobile
    // devices appear in the Mobile App chooser without manual reload.
    // Pre-fix, /api/me was only fetched on boot/signin and via the
    // Refresh button in the PAIR A PHONE callout. Agents who walked
    // over to their phone, signed into /mobile, then came back, saw
    // the empty PAIR A PHONE callout indefinitely because state.user
    // Devices was frozen at boot. Same problem hid legacy devices
    // whose type=extension got patched server-side by Phase 7c dedupe
    // on re-register — the ext just wasn't checking. Polls every 90s
    // (gentle on KV reads; agent activity is minutes-scale not seconds).
    // Skip when no profile token (not signed in) or workerBase unset.
    setInterval(async () => {
      try {
        const tok  = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
        const base = (state.workerBase || "").replace(/\/+$/, "");
        if (!tok || !base) return;
        const r = await fetch(`${base}/api/me?token=${encodeURIComponent(tok)}`, { cache: "no-store" });
        if (!r.ok) return;
        const j = await r.json();
        const u = j?.user;
        if (!u) return;
        // Only the fields the chooser + mode pane care about. Avoid
        // overwriting the broader profile state that the panel relies
        // on (paint/skin/calling-hours etc.); those are refreshed by
        // the dedicated paths.
        state.userDevices              = Array.isArray(u.devices) ? u.devices : [];
        state.userPhones               = Array.isArray(u.phones)  ? u.phones  : state.userPhones || [];
        state.activeMobilePairDeviceId = u.activeMobilePairDeviceId || state.activeMobilePairDeviceId || "";
        // Re-render the mode pane so a newly-paired mobile shows in
        // the chooser without an agent click. Cheap — only mutates
        // DOM if the Mobile App pane is currently visible.
        try { window.__pdRefreshPark && window.__pdRefreshPark(); } catch {}
      } catch (_e) { /* best effort */ }
    }, 90 * 1000);
    // v1.32.2 — Persist the multi-device-env flag + workerBase so the
    // offscreen sandbox can decide which JWT endpoint to call. This is
    // load-bearing for per-device SIP credentials — without it, every
    // desktop install on the same account would still register at the
    // SAME legacy SIP user and collide on Telnyx REGISTER.
    persistMultiDeviceFlagForOffscreen().catch(() => {});

    // v1.32.16 — Trivial gate. Just checks sessionStorage for the agent's
    // explicit close-on-this-tab decision. No popup, no leader election at
    // boot — multiple tabs can coexist (worker tolerates per v1.32.16 storm
    // fix). Conflict resolution is at Start Session time, not boot time.
    const shouldBoot = pdMultiTabGate();
    if (!shouldBoot) return;

    buildPanel();
    pdInjectCloseButton(); // v1.32.16 — red X top-right for explicit close
    paint();
    connectWs();
    // Open audio WS too — playback is gated on a real user click later, so
    // having the socket open early just means we're ready immediately when
    // the first prospect dial happens.
    connectAudioWs();
    // v1.29.98 — REVERTED the v1.29.93 isExtensionLive() gate on these
    // boot-time consumers. That gate broke auto-advance between leads:
    // on a fresh content-script boot (after Impact navigates from
    // /Lead/WhatHappend back to /Lead/InboxDetail), state.session is
    // "idle" by default until the WebSocket reconnects and the worker
    // reports the existing session. The gate refused to fire the
    // consumer, the pending flag rotted in storage, autopilot got stuck
    // on the first lead. These consumers already have age-based TTLs
    // (15s for advance, 60s for double-tap, 10min for human-advance)
    // that prevent stale flags from firing — that's the right gate.
    // Always run them on boot; the TTLs make them safe.
    maybeConsumePendingAdvance();
    maybeConsumePendingNoAnswerClick();
    // v1.29.97 — Check for extension updates 5s after boot and then
    // every 30 min while the tab stays open. Always-on regardless of
    // session state — we want to nudge users to update even when out
    // of session.
    setTimeout(() => { checkForExtensionUpdate().catch(() => {}); }, 5000);
    setInterval(() => { checkForExtensionUpdate().catch(() => {}); }, 30 * 60 * 1000);
    // v1.31.61 — Runtime config (Layer 2). Load from cache synchronously,
    // refresh from worker 3s after boot, poll every 60 min thereafter.
    loadCachedRuntimeConfig().then(() => fetchRuntimeConfig()).catch(() => {});
    setInterval(() => { fetchRuntimeConfig().catch(() => {}); }, 60 * 60 * 1000);
    // First-run tour — only auto-shows if the user has never closed it before.
    maybeAutoStartOnboarding();
    // Restore disposition counts if we're still in the same session that
    // started before Impact navigated this page.
    loadDispositionsForCurrentSession();
    // v1.31.97 — Install the WhatHappend → InboxDetail URL watcher that
    // fires the End-Human-Call popup. Idempotent — safe to call on every
    // boot (only installs interval once).
    try { watchForBackToImpact(); } catch (_e) {}
    // If we're configured for desktop dial-mode and signed in, ask
    // background to ensure the offscreen WebRTC sandbox is up. Background
    // will be a no-op if it's already running. Mic grant must already
    // exist for this to actually succeed; otherwise the offscreen doc
    // surfaces an error which the panel pip will reflect.
    try {
      const tok = (state.profiles?.[state.activeProfile]?.authToken || "").trim();
      // v1.29.28 — Boot code was checking state.dialMode === "desktop"
      // which is HARDCODED to "desktop" by the v1.29.0 migration — so
      // this branch always re-enabled the offscreen, even in Mobile App
      // mode. That re-opened the computer-side WebRTC audio path on every
      // page nav and was the source of the "ringing on computer" and the
      // computer mic answering INVITEs. The correct check is on
      // state.activeParkSource (the real park-source toggle).
      const parkSrc = state.activeParkSource || "desktop";
      if (parkSrc === "desktop" && tok) {
        chrome.runtime.sendMessage({
          type:       "pd_desktop_enable",
          token:      tok,
          workerBase: state.workerBase || "https://app.goalpowerdialer.com",
        }, () => { void chrome.runtime.lastError; });
        // Pull current status so the header pip shows real state on first paint
        chrome.runtime.sendMessage({ type: "pd_desktop_status" }, (resp) => {
          void chrome.runtime.lastError;
          if (resp?.status) {
            state.desktopStatus = resp.status;
            paint();
          }
        });
        // v1.33.6 — Proactive first-login mic prompt. Fires ~2s after the
        // panel paints so it isn't racing the initial render. Once-ever,
        // desktop-mode + signed-in + mic-not-granted only (all gated inside).
        setTimeout(() => { maybeShowFirstLoginMicPrompt(); }, 2000);
      } else if (parkSrc === "mobile_web") {
        // Mobile App mode — actively tear down any leftover offscreen
        // from a previous Desktop-mode session, so the computer audio
        // path is fully closed when the phone is the park line.
        chrome.runtime.sendMessage({ type: "pd_desktop_disable" }, () => {
          void chrome.runtime.lastError;
        });
      }
    } catch {}
  });
})();


// ─── v1.31.80 moveable-ball runtime ──────────────────────────────────
// Press-and-drag the minimized ball to reposition. Click without
// dragging still fires the panel-restore handler. Position persists.
(function pdRestoreMoveable() {
  if (window.__pdRestoreMoveableInit) return;
  window.__pdRestoreMoveableInit = true;

  const POS_KEY = "pdRestorePos";       // { left, top }
  const CLICK_PX = 5;                    // < this many px moved = click, not drag
  const EDGE_KEEP = 24;                  // px of ball that must stay on-screen

  function getSavedPos() {
    try {
      const v = JSON.parse(localStorage.getItem(POS_KEY) || "null");
      if (v && Number.isFinite(v.left) && Number.isFinite(v.top)) return v;
    } catch (_) {}
    return null;
  }
  function savePos(left, top) {
    try { localStorage.setItem(POS_KEY, JSON.stringify({ left, top })); } catch (_) {}
  }

  function clamp(btn, left, top) {
    const w = btn.offsetWidth || 64;
    const h = btn.offsetHeight || 64;
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    // Allow some of the ball off-screen but keep EDGE_KEEP visible.
    const minL = EDGE_KEEP - w;
    const maxL = vw - EDGE_KEEP;
    const minT = EDGE_KEEP - h;
    const maxT = vh - EDGE_KEEP;
    return {
      left: Math.max(minL, Math.min(maxL, left)),
      top:  Math.max(minT, Math.min(maxT, top)),
    };
  }

  function applyPos(btn, left, top) {
    const c = clamp(btn, left, top);
    // Switch from right/bottom anchoring to left/top so we control
    // absolute placement. Force fixed so it follows the viewport.
    btn.style.position = "fixed";
    btn.style.left = c.left + "px";
    btn.style.top  = c.top + "px";
    btn.style.right = "auto";
    btn.style.bottom = "auto";
  }

  function attach(btn) {
    if (btn.__pdMoveWired) return;
    btn.__pdMoveWired = true;

    // Restore saved position if any.
    const saved = getSavedPos();
    if (saved) {
      // Wait a tick so the layout has the button's size resolved.
      requestAnimationFrame(function () {
        applyPos(btn, saved.left, saved.top);
      });
    }

    let dragging = false;
    let startX = 0, startY = 0;
    let startLeft = 0, startTop = 0;
    let moved = 0;

    function onDown(e) {
      // Don't hijack drags that started on the resize grip.
      if (e.target && e.target.classList &&
          e.target.classList.contains("pd-restore-resize")) {
        return;
      }
      const p = e.touches ? e.touches[0] : e;
      dragging = true;
      moved = 0;
      startX = p.clientX;
      startY = p.clientY;
      const rect = btn.getBoundingClientRect();
      startLeft = rect.left;
      startTop  = rect.top;
      // Pre-pin to current rect so the first move doesn't snap.
      applyPos(btn, startLeft, startTop);
      document.addEventListener("pointermove", onMove, { passive: false });
      document.addEventListener("pointerup", onUp, { passive: false });
      document.addEventListener("pointercancel", onUp, { passive: false });
      // Suppress text-selection while dragging.
      document.body.style.userSelect = "none";
    }
    function onMove(e) {
      if (!dragging) return;
      const p = e.touches ? e.touches[0] : e;
      const dx = p.clientX - startX;
      const dy = p.clientY - startY;
      moved = Math.max(moved, Math.hypot(dx, dy));
      if (moved < CLICK_PX) return; // tiny wiggle — treat as click intent
      e.preventDefault();
      applyPos(btn, startLeft + dx, startTop + dy);
    }
    function onUp(e) {
      if (!dragging) return;
      dragging = false;
      document.removeEventListener("pointermove", onMove);
      document.removeEventListener("pointerup", onUp);
      document.removeEventListener("pointercancel", onUp);
      document.body.style.userSelect = "";

      const rect = btn.getBoundingClientRect();
      // If we actually moved, save + swallow the upcoming click so
      // the panel doesn't restore from a drag. If barely moved, let
      // the click through to the existing restore handler.
      if (moved >= CLICK_PX) {
        savePos(rect.left, rect.top);
        // One-shot click swallow.
        const swallow = function (ev) {
          ev.preventDefault();
          ev.stopPropagation();
          ev.stopImmediatePropagation();
          btn.removeEventListener("click", swallow, true);
        };
        btn.addEventListener("click", swallow, true);
        // Belt-and-suspenders: remove it on a microtask in case no
        // click event fires (e.g. on touch end).
        setTimeout(function () {
          btn.removeEventListener("click", swallow, true);
        }, 0);
      }
    }

    btn.addEventListener("pointerdown", onDown, { passive: false });
  }

  // Watch for the restore button to appear / re-appear.
  const obs = new MutationObserver(function (muts) {
    for (const m of muts) {
      for (const node of m.addedNodes) {
        if (node.nodeType !== 1) continue;
        if (node.classList && node.classList.contains("pd-restore-btn")) {
          attach(node);
        } else if (node.querySelector) {
          const inner = node.querySelector(".pd-restore-btn");
          if (inner) attach(inner);
        }
      }
    }
  });
  obs.observe(document.documentElement, { childList: true, subtree: true });

  // Catch the one already mounted, if any.
  const existing = document.querySelector(".pd-restore-btn");
  if (existing) attach(existing);
})();

// ─── v1.31.80 restore-bg-fix ─────────────────────────────────────────
// Force the branded soccer-ball URL inline on .pd-restore-btn so it
// beats the CSS rule that uses var(--pd-soccerball-img) (which is
// out-of-scope because the button lives on document.body).
(function pdRestoreBgFix() {
  if (window.__pdRestoreBgFixInit) return;
  window.__pdRestoreBgFixInit = true;
  var URL = chrome.runtime.getURL("icons/soccerball-large.png");
  function paint(btn) {
    btn.style.setProperty("background-image", "url(\"" + URL + "\")", "important");
    btn.style.setProperty("background-color", "transparent", "important");
    btn.style.setProperty("background-size", "100% 100%", "important");
    btn.style.setProperty("background-position", "center", "important");
    btn.style.setProperty("background-repeat", "no-repeat", "important");
  }
  var ex = document.querySelector(".pd-restore-btn");
  if (ex) paint(ex);
  var obs = new MutationObserver(function (muts) {
    for (var i = 0; i < muts.length; i++) {
      var m = muts[i];
      for (var j = 0; j < m.addedNodes.length; j++) {
        var n = m.addedNodes[j];
        if (n.nodeType !== 1) continue;
        if (n.classList && n.classList.contains("pd-restore-btn")) paint(n);
        else if (n.querySelector) {
          var inner = n.querySelector(".pd-restore-btn");
          if (inner) paint(inner);
        }
      }
    }
  });
  obs.observe(document.documentElement, { childList: true, subtree: true });
})();

// ─── v1.31.80 — Classic skin inline-style enforcer ───────────────────
// paint() does pd-root.innerHTML = ... which destroys pd-screen and
// recreates it without inline styles. This observer re-applies the
// classic skin's positioning every time the DOM changes, as long as
// pd-root has the .pd-classic-mode class.
(function pdClassicEnforcer() {
  if (window.__pdClassicEnforcerInit) return;
  window.__pdClassicEnforcerInit = true;

  function applyClassicStyles() {
    const root = document.getElementById("pd-root");
    if (!root) return;
    // v1.34 — Classic now uses the same CSS-drawn frame as branded (minus
    // the logo). This enforcer used to brute-force the old cramped PNG-frame
    // geometry every 250 ms, which fought the CSS frame. Forced false so the
    // classic branch never runs; the else branch just keeps screen inline
    // geometry cleared (a no-op once nothing sets it).
    const isClassic = false;
    const screen = root.querySelector(".pd-screen");
    if (isClassic) {
      // pd-screen: full visible band between top + bottom overlays.
      // overflow-y: scroll (NOT auto) forces the scrollbar to always
      // be visible so the user can see they're scrollable.
      if (screen) {
        // v1.32.9 — REVERTED to 80/100; 56/76 broke layout.
        screen.style.setProperty("top", "80px", "important");
        screen.style.setProperty("bottom", "100px", "important");
        // v1.31.80 — Shrink sides by 3 mm (~12 px) on each side per Dustin.
        // Phone-skin default is left/right: 22 px; classic gets 34 px.
        screen.style.setProperty("left", "34px", "important");
        screen.style.setProperty("right", "34px", "important");
        // CRITICAL: explicit height:500px in base CSS was forcing
        // pd-screen to render BELOW pd-phone, clipping Start Session.
        // Forcing height:auto lets top+bottom inset compute height
        // correctly so the screen fits inside the phone.
        screen.style.setProperty("height", "auto", "important");
        screen.style.setProperty("min-height", "0", "important");
        screen.style.setProperty("max-height", "none", "important");
        screen.style.setProperty("overflow-y", "auto", "important");
        screen.style.setProperty("scrollbar-width", "thin", "important");
        screen.style.setProperty("scrollbar-color", "rgba(255,255,255,0.5) rgba(255,255,255,0.05)", "important");
        screen.style.setProperty("mask-image", "none", "important");
        screen.style.setProperty("-webkit-mask-image", "none", "important");
      }
      // v1.31.89 → v1.31.91 — Classic enforcer.
      // v1.31.89 used clip-path + overflow:hidden + border-radius 42px.
      // Per Dustin's 2026-06-11 screenshot, that clipped the skin chooser
      // bar (top:-36px is OUTSIDE pd-root's box) AND clipped the phone's
      // right edge. Fix: pd-root stays transparent and hugs the phone
      // with padding 0; no clipping. The white card behind the phone is
      // provided by .pd-phone::before (see content.css).
      root.style.setProperty("width", "auto", "important");
      root.style.setProperty("height", "auto", "important");
      root.style.setProperty("padding", "0", "important");
      root.style.setProperty("background", "transparent", "important");
      root.style.setProperty("background-color", "transparent", "important");
      root.style.setProperty("border", "0", "important");
      root.style.setProperty("border-radius", "0", "important");
      root.style.setProperty("box-shadow", "none", "important");
      // ACTIVELY REMOVE the v1.31.89 clip-path / overflow inline values
      // in case the MutationObserver caught them being applied somewhere.
      root.style.removeProperty("clip-path");
      root.style.removeProperty("-webkit-clip-path");
      root.style.removeProperty("overflow");
    } else {
      // Clear when not classic
      if (screen) {
        screen.style.removeProperty("top");
        screen.style.removeProperty("bottom");
        screen.style.removeProperty("overflow-y");
        screen.style.removeProperty("mask-image");
        screen.style.removeProperty("-webkit-mask-image");
      }
    }
  }

  // Run once on init
  applyClassicStyles();

  // Observe pd-root for any child changes (paint() will trigger this)
  // OR pd-root class changes (setSkin will trigger this).
  const obs = new MutationObserver(function () {
    applyClassicStyles();
  });
  // Also watch the body for pd-root being added/replaced.
  obs.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class", "data-skin-variant"],
  });
  // v1.31.80 — Final defensive layer: setInterval brute-forces the
  // inline styles every 250 ms. Even if paint() somehow runs faster
  // than the MutationObserver, this WILL re-apply within a quarter
  // second. Cheap (single function call every 250 ms).
  setInterval(applyClassicStyles, 250);
})();

// ─── v1.31.80 — Jersey LAYOUT enforcer (mirrors pdClassicEnforcer) ───
// CSS-only approach lost the cascade. Same trick that fixed classic:
// brute-force inline styles every 250 ms + on every paint mutation.
// Two jobs: (1) make pd-root OPAQUE so Impact CRM page doesn't bleed
// through transparent areas around the jersey image; (2) constrain
// .pd-screen to the jersey TORSO so Client Number / Auto-Cuts /
// Setup / Options don't run off the green shoulders.
(function pdJerseyLayoutEnforcer() {
  if (window.__pdJerseyLayoutEnforcerInit) return;
  window.__pdJerseyLayoutEnforcerInit = true;

  function applyJerseyLayout() {
    const root = document.getElementById("pd-root");
    if (!root) return;
    // v1.31.80 FIX: real jersey marker is .pd-jersey-bg (not .pd-skin-jersey).
    // Jersey = goal skin + mini size + jersey watermark. Controls live
    // inside .pd-mini-fg (NOT .pd-screen, which is hidden in mini mode).
    const isJersey = root.classList.contains("pd-jersey-bg");
    const card = root.querySelector(".pd-mini-card");
    const fg   = root.querySelector(".pd-mini-fg");
    if (isJersey) {
      // v1.31.80 step 10 — Dustin: "I resized past the window size."
      // The 360×460 max-width/max-height + overflow:hidden combo I
      // shipped in step 7 was the "view window" — it locked the panel
      // to a fixed visible size while the underlying pd-root could
      // still grow via the resize handle, breaking the resize UX.
      //
      // New approach: act like every OTHER skin. No size locks, no
      // overflow:hidden, no aspect-ratio enforcement. Just paint the
      // jersey as a background and let pd-root be whatever size the
      // user drags it to.
      root.style.removeProperty("aspect-ratio");
      root.style.removeProperty("width");
      root.style.removeProperty("height");
      root.style.removeProperty("min-width");
      root.style.removeProperty("min-height");
      root.style.removeProperty("max-width");
      root.style.removeProperty("max-height");
      root.style.removeProperty("overflow");
      root.style.removeProperty("transform");
      // v1.31.80 step 12 — Diagnostic confirmed #pd-root itself has
      // rgb(255,255,255) background, 360×460, and the jersey image
      // renders on a LARGER element extending past pd-root bounds.
      // setProperty with !important wins over CSS !important rules.
      root.style.setProperty("background-color", "transparent", "important");
      root.style.setProperty("background", "transparent", "important");
      root.style.setProperty("box-shadow", "none", "important");
      root.style.setProperty("border", "0", "important");
      // v1.31.80 step 11 — Dustin: "make the viewer window invisible".
      // Jersey PNG now has TRANSPARENT background (flood-filled by
      // Python/PIL). So pd-mini-card no longer needs an opaque white
      // background — transparent card + transparent-bg image = only
      // the jersey SHAPE is visible. The "viewer window" (rectangular
      // panel around the jersey) becomes invisible.
      if (card) {
        card.style.removeProperty("transform");
        card.style.removeProperty("width");
        card.style.removeProperty("height");
        card.style.removeProperty("min-width");
        card.style.removeProperty("min-height");
        card.style.removeProperty("max-width");
        card.style.removeProperty("max-height");
        card.style.removeProperty("overflow");
        card.style.setProperty("position", "relative", "important");
        // Transparent card — page shows through where jersey isn't.
        card.style.setProperty("background-color", "transparent", "important");
        const jerseyVar = getComputedStyle(root).getPropertyValue("--pd-jersey-img");
        if (jerseyVar) {
          card.style.setProperty("background-image", jerseyVar.trim(), "important");
          card.style.setProperty("background-size", "cover", "important");
          card.style.setProperty("background-position", "center center", "important");
          card.style.setProperty("background-repeat", "no-repeat", "important");
        }
        card.style.setProperty("box-sizing", "border-box", "important");
      }
      // pd-mini-ball-bg: same cover treatment, no size locks.
      const ballBg = root.querySelector(".pd-mini-ball-bg");
      if (ballBg) {
        ballBg.style.removeProperty("transform");
        ballBg.style.setProperty("background-size", "cover", "important");
        ballBg.style.setProperty("background-position", "center center", "important");
        ballBg.style.setProperty("background-repeat", "no-repeat", "important");
        ballBg.style.setProperty("opacity", "1", "important");
      }
      // v1.31.80 step 10 — Dustin: "white space is a VIEW WINDOW that
      // blocks the resize button — other skins don't have this".
      // The absolute-positioned overflow:auto mini-fg I added in step
      // 6 was the culprit; it covered pd-root and intercepted clicks
      // including the resize handle in the bottom-right corner.
      //
      // Step 10 reverts mini-fg to its NORMAL flow positioning (like
      // every other skin uses). Buttons still stack as a single column
      // via display:flex flex-direction:column, but no overflow auto,
      // no absolute fill, no "view window." Anything that won't fit
      // just overflows pd-root — same as other skins behave.
      if (fg) {
        fg.style.removeProperty("position");
        fg.style.removeProperty("top");
        fg.style.removeProperty("left");
        fg.style.removeProperty("right");
        fg.style.removeProperty("bottom");
        fg.style.removeProperty("height");
        fg.style.removeProperty("overflow-y");
        fg.style.removeProperty("overflow-x");
        fg.style.setProperty("padding", "75px 35px 60px 55px", "important");
        fg.style.setProperty("box-sizing", "border-box", "important");
        fg.style.setProperty("z-index", "2", "important");
        fg.style.setProperty("display", "flex", "important");
        fg.style.setProperty("flex-direction", "column", "important");
        fg.style.setProperty("gap", "6px", "important");
        // EVERY direct child stacks full-width — kills side-by-side pairs.
        const kids = fg.children;
        for (let i = 0; i < kids.length; i++) {
          const el = kids[i];
          el.style.setProperty("max-width", "100%", "important");
          el.style.setProperty("width", "100%", "important");
          el.style.setProperty("flex-shrink", "0", "important");
          el.style.setProperty("box-sizing", "border-box", "important");
          el.style.setProperty("display", "block", "important");
        }
        // Inner-grid containers also switch to column.
        const innerGrids = fg.querySelectorAll(
          ".pd-mini-startsess, .pd-mini-autoclick, .pd-mini-autoattend, " +
          ".pd-mini-settings, .pd-mini-stats3, .pd-mini-status, " +
          ".pd-manual-input"
        );
        innerGrids.forEach(function(el){
          el.style.setProperty("display", "block", "important");
          el.style.setProperty("width", "100%", "important");
          el.style.setProperty("max-width", "100%", "important");
          el.style.setProperty("box-sizing", "border-box", "important");
        });
      }
      // CRITICAL: ensure resize handle is ALWAYS clickable.
      const resize = root.querySelector(".pd-resize-handle");
      if (resize) {
        resize.style.setProperty("z-index", "9999", "important");
        resize.style.setProperty("pointer-events", "auto", "important");
        resize.style.setProperty("position", "absolute", "important");
        resize.style.setProperty("bottom", "0", "important");
        resize.style.setProperty("right", "0", "important");
      }
    } else {
      // Clear when leaving jersey.
      root.style.removeProperty("background-color");
      root.style.removeProperty("aspect-ratio");
      root.style.removeProperty("width");
      root.style.removeProperty("height");
      root.style.removeProperty("min-width");
      root.style.removeProperty("min-height");
      root.style.removeProperty("max-width");
      root.style.removeProperty("max-height");
      root.style.removeProperty("overflow");
      if (card) {
        card.style.removeProperty("width");
        card.style.removeProperty("height");
        card.style.removeProperty("min-width");
        card.style.removeProperty("min-height");
        card.style.removeProperty("max-width");
        card.style.removeProperty("max-height");
        card.style.removeProperty("overflow");
        card.style.removeProperty("background-color");
        card.style.removeProperty("background-image");
        card.style.removeProperty("background-size");
      }
      if (fg) {
        fg.style.removeProperty("position");
        fg.style.removeProperty("top");
        fg.style.removeProperty("left");
        fg.style.removeProperty("right");
        fg.style.removeProperty("bottom");
        fg.style.removeProperty("width");
        fg.style.removeProperty("height");
        fg.style.removeProperty("padding");
        fg.style.removeProperty("display");
        fg.style.removeProperty("flex-direction");
        fg.style.removeProperty("gap");
        fg.style.removeProperty("overflow-y");
        fg.style.removeProperty("overflow-x");
      }
    }
  }

  applyJerseyLayout();

  const obs = new MutationObserver(applyJerseyLayout);
  obs.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class"],
  });
  setInterval(applyJerseyLayout, 250);
})();

// ─── v1.31.80 — Jersey opacity enforcer ──────────────────────────────
// Same brute-force trick: any element matching the jersey watermark
// gets opacity:1 forced via inline style. CSS rule at line ~1079 sets
// 0.95 and was winning the cascade. Inline style with !important
// beats every external CSS rule (inline + !important = highest).
(function pdJerseyOpaqueEnforcer() {
  if (window.__pdJerseyOpaqueInit) return;
  window.__pdJerseyOpaqueInit = true;

  function applyJerseyOpacity() {
    const root = document.getElementById("pd-root");
    if (!root) return;
    const isJersey = root.classList.contains("pd-jersey-bg");
    if (!isJersey) return;
    // v1.31.80 — Force opacity:1 on EVERY possible jersey-bg carrier.
    // The original .pd-mini-ball-bg + .pd-mini-card + .pd-mini-fg + .pd-phone
    // all might be involved in the jersey rendering.
    const candidates = root.querySelectorAll(
      ".pd-mini-ball-bg, .pd-mini-card, .pd-mini-fg, .pd-phone, " +
      ".pd-jersey-bg, .pd-skin-face, .pd-skin-face-graphic"
    );
    candidates.forEach(function (el) {
      el.style.setProperty("opacity", "1", "important");
      el.style.setProperty("filter", "none", "important");
      el.style.setProperty("mix-blend-mode", "normal", "important");
    });
  }

  applyJerseyOpacity();

  const obs = new MutationObserver(function () {
    applyJerseyOpacity();
  });
  obs.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class"],
  });
  setInterval(applyJerseyOpacity, 250);
})();

