Pixel Source Code

Last updated: March 29, 2026

This is the complete, unminified source code of the Harvv behavioral analytics pixel. Every site running Harvv loads a minified version of exactly this code.

Size: 192,659 bytes unminified · ~3,363 bytes gzipped · 18 behavioral signals · Zero PII

Download raw file

/**
 * TheReallyFastPixel — Behavioral analytics in under 2KB gzipped.
 * Drop-in, zero-config, first-party behavioral analytics.
 * Captures the behavioral story of every session as a first-class data structure.
 *
 * Configure: optionally set window.__px = "https://yourdomain.com/px" before this script loads.
 * Or just load the script — it auto-detects its own origin and POSTs back to /px on it.
 */
(function () {
  // 2026-05-13 — Double-install guard. A site can have the Harvv pixel
  // installed via TWO independent paths: the canonical <script> tag AND
  // the WP plugin's enqueue (or future Shopify app, Laravel package, etc).
  // If both fire, every event doubles. The first instance wins; subsequent
  // ones bail. We stamp window.__harvvLoaded__ with the source string so
  // /internal/site-snapshot can later report which path got the pixel
  // running. The plugin's bundled tracker uses the same sentinel.
  if (window.__harvvLoaded__) {
    try {
      (window.__harvvDuplicates__ = window.__harvvDuplicates__ || []).push({
        first: window.__harvvLoaded__,
        second: 'pixel.js',
        at: Date.now(),
      });
    } catch (_) {}
    return;
  }
  window.__harvvLoaded__ = 'pixel.js';

  // 2026-05-26 — Shopify theme-editor / theme-preview guard.
  // Tiger Friday reported that BOOST theme previews would not render
  // when our pixel was in the theme code. Confirmed root cause: the
  // Shopify theme-preview screenshotter is a headless browser that
  // waits for the page to be "idle" before snapshotting. Our pixel
  // attaches three MutationObservers on document.body with subtree:true
  // plus continuous beacon traffic to harvv.com, which keeps the
  // network/main-thread busy enough that the screenshotter times out
  // and renders the placeholder thumbnail. The CUSTOMER's actual
  // visitors are unaffected — this only matters in the editor.
  //
  // The detection covers three Shopify editor surfaces:
  //   1. Theme customizer:   window.Shopify.designMode === true
  //   2. Theme preview:      ?preview_theme_id=... in the URL
  //   3. Staff/ATC preview:  ?_ab=0&_ab_test_id=... (deny-staff preview)
  //
  // Other platforms with similar editor modes can extend this list.
  // Override: append ?harvv_force=1 to test pixel behavior in preview.
  try {
    var _href = (location && location.href) || '';
    var _isShopifyDesign = !!(window.Shopify && window.Shopify.designMode);
    var _isShopifyPreview = _href.indexOf('preview_theme_id=') > -1;
    var _isShopifyStaff = _href.indexOf('_ab=0&_ab_test_id=') > -1;
    var _isForced = _href.indexOf('harvv_force=1') > -1;
    if (!_isForced && (_isShopifyDesign || _isShopifyPreview || _isShopifyStaff)) {
      // Stamp a global so dashboard install-health can show "pixel
      // detected but bailed (editor mode)". No events emitted.
      window.__harvvEditorBail__ = _isShopifyDesign ? 'shopify_designmode'
        : _isShopifyPreview ? 'shopify_preview' : 'shopify_staff_ab';
      return;
    }
  } catch (_) { /* never block the pixel on the editor-guard check */ }

  // --- Consent gate (Harvv-properties only) ---
  // 2026-05-06 — when running on harvv.com properties (harvv.com, *.harvv.com,
  // including admin.harvv.com / flow.harvv.com / pk2 etc.) the pixel must
  // honor the cookie banner's analytics choice. Customer sites are exempt:
  // their consent banner is THEIR responsibility, our pixel just runs as the
  // customer configured. We listen for the harvv:consent event so the pixel
  // can wake up if the user later flips analytics back on without a reload.
  try {
    var hostIsHarvv = /(?:^|\.)harvv\.com$/i.test(location.hostname);
    if (hostIsHarvv) {
      var consent = (typeof window.__harvvConsent === 'function') ? window.__harvvConsent() : null;
      if (consent && consent.choices && consent.choices.analytics === false) {
        // User explicitly declined analytics on this harvv property. Bail.
        // If they later accept, the cookie banner fires the harvv:consent event;
        // we re-bootstrap the pixel by reloading itself.
        window.addEventListener('harvv:consent', function (ev) {
          if (ev && ev.detail && ev.detail.choices && ev.detail.choices.analytics === true) {
            try {
              var s = document.createElement('script');
              s.async = true;
              // 2026-05-26 — Replaced `?.src` optional chaining with explicit
              // ternary because PK Android 7-8 Webviews (Chrome 60-70 era) lack
              // ES2020 support and throw "Uncaught SyntaxError: Unexpected token '.'"
              // when parsing the whole pixel. Found via internal_errors row from
              // a real visitor on /signup?ref=wordpress. Every customer site
              // embedding the pixel was breaking on old Webviews.
              var __q = document.querySelector('script[src*="/pixel.js"]');
              s.src = (document.currentScript && document.currentScript.src) ||
                      (__q && __q.src) ||
                      '/px/5abaa759db95fcf4/pixel.js';
              document.head.appendChild(s);
            } catch (_) {}
          }
        });
        return;
      }
    }
  } catch (_) { /* never block the pixel on consent-check errors */ }

  // --- Configuration ---
  // Auto-detect endpoint from script src so a single <script src="..."> just works.
  // document.currentScript is null for defer scripts, so find ourselves by src attribute.
  var scripts = document.querySelectorAll('script[src*="/pixel.js"]');
  var scriptSrc = scripts.length && scripts[scripts.length - 1].src;
  var ENDPOINT = window.__px || (scriptSrc ? scriptSrc.replace(/\/[^/]*$/, "/px") : "/px");
  // 2026-05-19 — edge spillover. When the primary endpoint can't be reached
  // (Railway-wide outage = the very situation we just hit), the pixel falls
  // over to this Cloudflare-hosted backup ingest. The receiver injects
  // `__pxFallback` into the preamble only when INGEST_FALLBACK_URL is set
  // on the receiver side; absent → no fallback try → no behaviour change.
  // The fallback receives the SAME POST body the primary would have, stores
  // it in Cloudflare KV, and the receiver drains it back into PG when
  // primary is healthy. See docs/runbooks/edge-spillover.md.
  var BACKUP_ENDPOINT = (typeof window !== 'undefined' && typeof window.__pxFallback === 'string' && window.__pxFallback) ? window.__pxFallback : null;

  // 1.0.7 — GA4 coexistence (research-recommended pattern).
  // We detect GA4 presence at boot so the server can:
  //   • Dedupe between our DOM-detected events and GA4 dataLayer events
  //   • Show "vs GA4" comparison in the dashboard
  //   • Annotate sessions with ga4_present
  // We also honor GA4 Consent Mode v2: if analytics_storage is 'denied',
  // we downgrade to anonymous-only mode (no persistent visitor_id).
  // We do NOT subscribe to dataLayer events by default (separate-namespace
  // is the FullStory/Mouseflow/Clarity default). We DO push Harvv-detected
  // signals to dataLayer under the harvv_signal namespace so customers
  // can route them to their own GTM-managed downstream tools.
  var GA4_PRESENT = !!(window.gtag || window.google_tag_manager || (Array.isArray(window.dataLayer) && window.dataLayer.length));
  var CONSENT_DENIED = false;
  try {
    // Probe GA4 Consent Mode v2 state. Inline (synchronous) read first;
    // for the async gtag('get', ...) path we'd need to wait, which we
    // skip for byte budget.
    var dl = window.dataLayer || [];
    for (var ci = 0; ci < dl.length; ci++) {
      var dle = dl[ci];
      if (dle && dle[0] === 'consent' && dle[2] && dle[2].analytics_storage === 'denied') {
        CONSENT_DENIED = true; break;
      }
    }
  } catch (_) {}
  // Map our `cm` kinds to GA4 recommended event names so the dashboard
  // can label both.
  var GA4_NAMES = { ca: 'add_to_cart', co: 'begin_checkout' };
  // Inverse map for the dataLayer subscriber — when GA4 emits one of
  // these recommended event names, ingest as a Harvv `cm` event with
  // src:'dl' (more precise than DOM detection because the customer's
  // code chose to fire it).
  // 2026-05-23 — Extended map. Standard GA4 recommended event names
  // (Shopify, GTM defaults, etc.) plus the legacy/custom names we've seen
  // in real customer data (Axel: shopping_cart, checkout, contact_us,
  // product, ads_conversion_*). Mapping multiple GA4 names → same Harvv
  // cm kind is intentional: emitCm() de-dupes on (kind, ga4) so we never
  // double-count.
  var GA4_TO_KIND = {
    // GA4 recommended event names
    add_to_cart: 'ca', begin_checkout: 'co', view_cart: 'vc',
    purchase: 'pu', view_item: 'vi', view_item_list: 'vi',
    generate_lead: 'gl', sign_up: 'su', search: 'sr',
    // Common custom / legacy names from Shopify, WooCommerce, GTM templates
    shopping_cart: 'ca',     // Shopify default; same intent as add_to_cart
    checkout: 'co',          // some sites fire 'checkout' instead of 'begin_checkout'
    contact_us: 'gl',        // contact-form submit is a lead event
    product: 'vi',           // some templates fire 'product' for view_item
  };

  // 1.0.8 — dedupe-aware commerce emitter. The DOM click handler and
  // the dataLayer subscriber can both detect the same logical event
  // (customer's button click + their GTM tag firing on the same click).
  // We hold DOM emissions for 100ms; if a dataLayer-sourced event with
  // the same `ga4` name arrives in that window, we cancel the DOM emit
  // and keep only the (more precise) dataLayer version. The dashboard
  // then sees one event per real conversion regardless of how many
  // signal paths the customer's site has.
  var pendingDom = {};
  function emitCm(src, kind, data) {
    var ga4 = (data && data.ga4) || GA4_NAMES[kind] || null;
    var key = kind + ':' + (ga4 || '');
    var payload = Object.assign({ k: kind, src: src }, data || {});
    if (ga4) payload.ga4 = ga4;
    if (src === 'dom') {
      pendingDom[key] = setTimeout(function () {
        push('cm', payload);
        delete pendingDom[key];
      }, 100);
    } else {
      if (pendingDom[key]) { clearTimeout(pendingDom[key]); delete pendingDom[key]; }
      push('cm', payload);
    }
    // Push to customer's dataLayer under the harvv_signal namespace so
    // their GTM can route to other tools (Meta Pixel, Klaviyo, etc.)
    // WITHOUT double-counting GA4 — we never reuse a GA4 event name as
    // our dataLayer event.
    try {
      if (!window.dataLayer) window.dataLayer = [];
      window.dataLayer.push({ event: 'harvv_signal', harvv_kind: kind, harvv_src: src, harvv_ga4: ga4 || null });
    } catch (_) {}
  }

  // Subscribe to the customer's dataLayer for known GA4 commerce events.
  // Wraps `window.dataLayer.push` so future events flow through us; also
  // replays anything already in the array (events that fired before our
  // pixel booted — e.g. if a server-rendered `gtag('config', ...)` ran
  // before our async script). Subscribe-once guard via window.__harvvDL.
  try {
    if (!window.__harvvDL) {
      window.__harvvDL = 1;
      if (!Array.isArray(window.dataLayer)) window.dataLayer = [];
      var dlIngest = function (a) {
        if (!a || a[0] !== 'event') return;
        var name = a[1];
        var kind = GA4_TO_KIND[name];
        if (!kind) return;
        // Ignore re-firings of our own harvv_signal pushes.
        if (name === 'harvv_signal') return;
        emitCm('dl', kind, { ga4: name });
      };
      for (var di = 0; di < window.dataLayer.length; di++) dlIngest(window.dataLayer[di]);
      var dlOrig = window.dataLayer.push;
      window.dataLayer.push = function () {
        for (var ai = 0; ai < arguments.length; ai++) {
          try { dlIngest(arguments[ai]); } catch (_) {}
        }
        return dlOrig.apply(window.dataLayer, arguments);
      };
    }
  } catch (_) {}
  var SESSION_TIMEOUT = 1800000;    // 30 minutes in ms
  var FLUSH_INTERVAL = 10000;       // 10 seconds
  var RAGE_THRESHOLD = 3;           // clicks within window
  var RAGE_WINDOW = 800;            // ms
  var RAGE_RADIUS = 50;             // px
  var HOVER_DELAY = 500;            // ms to qualify as hover intent
  var SCROLL_MILESTONES = [10, 25, 50, 75, 100];
  var TEXT_LIMIT = 20;              // max chars of innerText in element ID

  // --- Utilities ---

  // Generate a random ID for session_id + visitor_id.
  //
  // T70 (2026-04-28): expanded from 8 → 16 hex chars after a real production
  // collision was observed: session_id `ce51b784` appeared on two distinct
  // sites (51 events on one, 14 on the other). 8 hex chars = 4B-ish space,
  // and at Tiger Friday's 35M-events/month scale the birthday-paradox math
  // makes inter-site collisions inevitable.
  //
  // 16 hex chars = 64-bit space (~1.8e19), making collisions effectively
  // impossible at any realistic scale. Two Math.random calls produce ~64
  // effective bits (Math.random itself caps at ~53 bits each but the
  // concatenation comfortably saturates the 64-bit hex output).
  //
  // Cost: +8 bytes per event payload, +~50 bytes to the pixel. Trivial
  // vs the data-integrity gain. Backwards-compatible — receiver treats
  // ID as a string, and existing visitors keep their old 8-char IDs from
  // cookies/localStorage until those expire naturally.
  function rid() {
    return ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0") +
           ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0");
  }

  // Read a cookie by name
  function getCookie(n) {
    var m = document.cookie.match(new RegExp("(?:^|; )" + n + "=([^;]*)"));
    return m ? m[1] : null;
  }

  // Set a first-party cookie, 2-year expiry for visitor, session for session.
  // Domain set to registrable domain so subdomains share the same visitor/session.
  //
  // [PC-019] The naive two-label extraction (`parts.slice(-2).join('.')`) produced
  // a public suffix for ccTLDs like .co.uk, .com.br, .com.au — cookies set on a
  // public suffix are rejected by browsers, silently killing visitor tracking.
  // This was a live bug affecting oxfordcityfalafel.co.uk (703 sessions, all
  // visitors undercounted as new). Fix: compressed Public Suffix List of common
  // two-label ccTLD suffixes. The list covers ~98% of real-world ccTLD traffic;
  // exotic suffixes fall back to the two-label heuristic, which is correct for
  // non-ccTLD domains.
  var PSL_2LABELS = "co.uk,org.uk,me.uk,ac.uk,gov.uk,net.uk,com.au,net.au,org.au,edu.au,co.nz,net.nz,org.nz,co.jp,or.jp,ne.jp,ac.jp,com.br,net.br,org.br,com.mx,com.ar,com.cn,com.sg,com.my,com.ph,com.hk,com.tw,com.tr,com.ua,com.co,com.pe,com.ve,co.in,net.in,org.in,co.za,org.za,co.kr,or.kr,co.il,org.il,co.th,or.th,com.pk,net.pk,org.pk,co.id,or.id,com.ng,com.eg,com.bd,co.ke,co.tz,com.do,com.gt,com.sv,com.hn,com.ni,com.py,com.bo,com.ec,com.uy";
  var PSL_SET = {};
  PSL_2LABELS.split(",").forEach(function(s) { PSL_SET[s] = 1; });

  var rootDomain = (function() {
    var parts = location.hostname.split(".");
    if (parts.length <= 2) return location.hostname; // example.com → example.com
    // Check if the last 2 labels form a known public suffix (ccTLD)
    var last2 = parts.slice(-2).join(".");
    if (PSL_SET[last2] && parts.length >= 3) {
      // ccTLD: www.example.co.uk → example.co.uk (take last 3 labels)
      return parts.slice(-3).join(".");
    }
    // Standard TLD: www.example.com → example.com (take last 2 labels)
    return last2;
  })();
  // [2026-04-21] Dropped the RFC 2109 leading dot from `domain=.example.com`. Modern
  // browsers normalize it, but some privacy tools + ad blockers strip cookies with
  // the leading-dot syntax, which was eating session continuity on same-origin
  // navigations (e.g. "clicked 30-day exchange button → new page, new session").
  // Skip the domain attribute entirely on localhost / single-label hosts (browser
  // defaults to the current host, which is what we want).
  function setCookie(n, v, days) {
    var e = days ? ";expires=" + new Date(Date.now() + days * 864e5).toUTCString() : "";
    var dom = (rootDomain && rootDomain.indexOf(".") !== -1) ? (";domain=" + rootDomain) : "";
    document.cookie = n + "=" + v + e + ";path=/;SameSite=Lax" + dom;
  }

  // LocalStorage backup for session continuity. Cookies can be stripped by
  // ad-blockers, strict privacy modes, or host policies, which would orphan
  // events across navigations. localStorage is more durable; we write BOTH
  // on every update and read from either on init (cookie wins if both present).
  function lsSet(k, v) { try { localStorage.setItem(k, String(v)); } catch (_) {} }
  function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }

  // --- PII Scrubbing ---
  // An element is "private" (text-scrubbed) if it OR any ancestor up to the
  // document root:
  //   • has data-harvv-private="true"
  //   • is contenteditable (a chat composer, comment editor, etc.)
  //   • is a TEXTAREA
  //   • matches a role that typically contains user-generated content
  //     (role=log, role=textbox, role=feed, role=listitem inside role=log)
  // Private elements still get identified by tag/id/class (for detection) but
  // their textContent is NEVER captured — the `:"..."` suffix is dropped.
  //
  // Sites can opt IN more aggressively by setting data-harvv-private="true" on
  // any container (e.g. wrap your chat bubbles in <div data-harvv-private>...)
  var PRIVATE_ROLE = /^(log|textbox|feed)$/;
  function isPrivateElement(el) {
    var p = el, depth = 20;
    while (p && depth--) {
      if (p.getAttribute) {
        var priv = p.getAttribute("data-harvv-private");
        if (priv === "true" || priv === "" || priv === "1") return true;
        if (p.isContentEditable) return true;
        var tag = p.tagName;
        if (tag === "TEXTAREA") return true;
        if (tag === "INPUT") {
          // Password, credit card, hidden, email inputs — never capture surrounding text
          var t = (p.type || "").toLowerCase();
          if (t === "password" || t === "hidden" || t === "email" || t === "tel" || t === "number") return true;
        }
        var role = p.getAttribute("role");
        if (role && PRIVATE_ROLE.test(role)) return true;
      }
      p = p.parentElement;
    }
    return false;
  }

  // Walk up (max 5) to the nearest ancestor that identifies the control a bare
  // glyph belongs to. Prefers a semantic control (button / a / role=button);
  // otherwise the first ancestor carrying an id, class, or aria-label. Reads
  // STRUCTURAL attributes only (id/class/aria-label/role/tag), never
  // textContent, so the PII guard is preserved. Returns a short selector
  // fragment (e.g. button.cart-close, a[aria-label="Close"]) or null. Used by
  // eid() for the bare-tag rescue (ICX-020, 2026-06-18).
  function ancestorHandle(el) {
    var p = el.parentElement, d = 5, fb = null;
    while (p && d--) {
      if (p.getAttribute) {
        var pt = (p.tagName || "").toLowerCase();
        var pid = p.id ? "#" + p.id : "";
        var pcRaw = typeof p.className === "string" ? p.className
                  : (p.className && p.className.baseVal) || "";
        var pc = (!pid && pcRaw.trim()) ? "." + pcRaw.trim().split(/\s+/)[0] : "";
        var pa = p.getAttribute("aria-label");
        pa = pa ? '[aria-label="' + String(pa).replace(/[\t\n\r\s]+/g, " ").trim().slice(0, TEXT_LIMIT) + '"]' : "";
        var pr = p.getAttribute("role");
        if (pt === "button" || pt === "a" || (pr && /^(button|link|menuitem|tab|switch)$/.test(pr))) {
          return pt + pid + pc + pa;
        }
        if (!fb && (pid || pc || pa)) fb = pt + pid + pc + pa;
      }
      p = p.parentElement;
    }
    return fb;
  }

  // Build a minimal element identifier.
  // Format (public):  tag#id:"text" or tag.class:"text"
  //                   e.g. btn#add-cart:"Add to C" — a human reads the session story.
  // Format (private): tag#id or tag.class (no text — PII guard)
  //                   e.g. div.chat-bubble, div#response-34
  // For INPUT elements: input[type]#id or input[type].name (never value)
  function eid(el) {
    if (!el || !el.tagName) return null;
    var tag = el.tagName.toLowerCase();
    // For inputs, include type attribute and prefer name for the class slot
    if (tag === "input") {
      var type = el.type || "text";
      var id = el.id ? "#" + el.id : "";
      var name = !id && el.name ? "." + el.name : "";
      return "input[" + type + "]" + id + name;
    }
    var id2 = el.id ? "#" + el.id : "";
    // className is a plain string on HTML nodes but an SVGAnimatedString on
    // SVG/MathML nodes (icons!). Read .baseVal in that case so <svg class="x">
    // resolves to svg.x instead of a useless bare "svg" (ICX-020).
    var rawCls = typeof el.className === "string" ? el.className
               : (el.className && el.className.baseVal) || "";
    var cls = !id2 && rawCls.trim() ? "." + rawCls.trim().split(/\s+/)[0] : "";
    // PII guard — if this element is inside a private zone, skip text content
    // entirely. We still report the tag/id/class so detection (dead clicks,
    // blind spots, etc.) works, but the user-typed/generated text never leaks.
    if (isPrivateElement(el)) {
      return tag + id2 + cls;
    }
    // Use textContent (fast) instead of innerText (triggers layout/style calc).
    // Sanitize: strip control chars (tab/newline/CR) and collapse whitespace so
    // titles like `Engaging New Mexico\t` don't surface the escape char to users.
    // Also reject text that contains "<" — if textContent starts with an HTML-like
    // fragment, something rendered raw markup as a text child; capturing that as
    // a "title" produces broken strings like `"<img width="150" hei`.
    var rawTxt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
    var txt = rawTxt.slice(0, TEXT_LIMIT);
    if (txt.indexOf("<") !== -1) txt = "";
    // 2026-05-13 — reject CSS-like content. Inline <style> blocks inside SVGs
    // surface in textContent as `.cls-1 { fill: #fff }` which was getting
    // captured as the "label" for icon elements (issue 6270 on Prime MD:
    // `a.elementor-icon:".cls-1 { fill: #fff;"` instead of an empty label).
    // Also catches `@media`, `@keyframes`, `:root {`, etc.
    if (/^[.#:@][\w-]+\s*\{|@(media|keyframes|font-face|supports|import|charset)/i.test(txt)) txt = "";
    // 2026-05-13 — prefer ARIA / title attributes for icon elements that have
    // no real text content. SVG icons + <i> tags often have aria-label or
    // title set (e.g. Elementor sets aria-label="Instagram" on social icons).
    // That's the real semantic label, not whatever the SVG's textContent leaked.
    if (!txt && (tag === "svg" || tag === "i" || /icon/i.test(cls))) {
      var aria = el.getAttribute && (el.getAttribute("aria-label") || el.getAttribute("title"));
      if (aria) {
        aria = String(aria).replace(/[\t\n\r\s]+/g, " ").trim().slice(0, TEXT_LIMIT);
        if (aria) txt = aria;
      }
    }
    txt = txt ? ':"' + txt + '"' : "";
    // Bare-tag rescue (ICX-020, 2026-06-18): an icon/glyph with no id, class,
    // or label of its own resolves to just "svg"/"path"/"use" — useless to a
    // customer ("svg icons"). Anchor it to the nearest ancestor that names the
    // control it belongs to: button.cart-close svg, a[aria-label="Close"] use.
    // Structural attributes only (id/class/aria-label/role/tag), never text —
    // the PII guard above is unaffected.
    if (!id2 && !cls && !txt) {
      var anc = ancestorHandle(el);
      if (anc) return anc + " " + tag;
    }
    return tag + id2 + cls + txt;
  }

  // Check if an element is interactive (clickable by nature)
  function isInteractive(el) {
    if (!el || !el.tagName) return false;
    var tag = el.tagName;
    // Interactive tags
    if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|DETAILS|SUMMARY)$/.test(tag)) return true;
    // ARIA roles (button, radio, checkbox, option, menuitem, tab, switch)
    var role = el.getAttribute && el.getAttribute("role");
    if (role && /^(button|radio|checkbox|option|menuitem|tab|switch)$/.test(role)) return true;
    // Tabindex = explicitly interactive
    if (el.getAttribute && el.getAttribute("tabindex")) return true;
    // Label-for-input (clicks on labels natively toggle their associated input)
    if (tag === "LABEL") {
      var f = el.htmlFor || (el.getAttribute && el.getAttribute("for"));
      if (f && document.getElementById(f)) return true;
    }
    // Data attributes indicating JS-handled interactive elements (swatches, variant selectors)
    if (el.hasAttribute && (el.hasAttribute("data-value") || el.hasAttribute("data-option-value") || el.hasAttribute("data-variant-id") || el.hasAttribute("data-action"))) return true;
    // Walk up to see if inside an anchor, button, label[for], ARIA button,
    // tabindex-enabled element, or custom element (max 8 levels). Shopify themes
    // nest deeply: div.product__info-wrapper > div > div > span > a.product-link.
    // 5 levels was too shallow. Elementor/React/Vue use role="button" tabindex="0"
    // on divs instead of <button> — previously we fired false-positive dead clicks
    // on child icons inside those (e.g. <i class="menu-toggle__icon--open"> inside
    // <div role="button" tabindex="0">). Covers M-Diamond's mobile menu icon
    // (68 sessions of false positives verified against actual ix events).
    var p = el.parentElement, i = 8;
    while (p && i--) {
      if (/^(A|BUTTON)$/.test(p.tagName)) return true;
      if (p.tagName === "LABEL" && p.htmlFor && document.getElementById(p.htmlFor)) return true;
      // ARIA button / interactive role pattern (Elementor, React, Tailwind, etc.)
      if (p.getAttribute) {
        var pRole = p.getAttribute("role");
        if (pRole && /^(button|radio|checkbox|option|menuitem|tab|switch|link)$/.test(pRole)) return true;
        // tabindex=0/1/... explicitly makes the element keyboard-interactive
        var pTab = p.getAttribute("tabindex");
        if (pTab !== null && pTab !== "-1") return true;
        // Event-handler hints from common frameworks
        if (p.hasAttribute("onclick") || p.hasAttribute("@click") || p.hasAttribute("v-on:click") || p.hasAttribute("ng-click")) return true;
      }
      // Shopify custom elements for variant selectors + product cards
      if (/^(VARIANT-SELECTS|VARIANT-RADIOS|PRODUCT-FORM)$/.test(p.tagName)) return true;
      // Shopify product cards: clicking product title text inside a card-wrapper that
      // contains a link should NOT be a dead click — the click navigates via the link.
      if (p.hasAttribute && p.hasAttribute("data-product-id")) return true;
      p = p.parentElement;
    }
    return false;
  }

  // 2026-05-14 — Host-shielded element detection. True when the element
  // (or any ancestor up to 5 levels) is something the host-page pixel
  // cannot see inside of:
  //   - <iframe>: cross-origin iframes hide their DOM from us
  //   - <video> / <canvas>: native renderers, no DOM mutations to observe
  //   - Custom elements (tag name contains `-`): may have closed shadow
  //     roots; even open shadow DOM hides mutations from our MutationObserver
  //   - element.shadowRoot present: open shadow DOM
  // dc/rc events on host-shielded elements carry `bs:1` so server-side
  // detection can filter them out (the canonical case is Shopify Inbox —
  // `inbox-online-store-chat` is a custom-element wrapper for a cross-
  // origin iframe; every chat interaction looked like a dead click).
  function isHostShielded(el) {
    for (var d = 5; el && d--; el = el.parentElement) {
      var t = el.tagName;
      if (!t) continue;
      if (t === 'IFRAME' || t === 'VIDEO' || t === 'CANVAS') return 1;
      if (t.indexOf('-') > 0) return 1;
      if (el.shadowRoot) return 1;
    }
    return 0;
  }

  // --- Third-party widget / editor-preview detection ---
  // Clicks inside embedded widgets (Setmore, Calendly, Acuity, Intercom, etc.)
  // or inside CMS-editor previews are not the host site's UX — agencies can't
  // fix them. Suppress dc events for elements whose class matches hashed
  // styled-components (`Foo-sc-a1b2c3d4-0`) or known widget containers.
  var WIDGET_CLASS_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|livechat|fc-|__intercom|hubspot)-|^sc-[a-z0-9]{6,}|^[A-Z][\w]*__[A-Z][\w]*-sc-[a-z0-9]{6,}/;
  var WIDGET_ATTR_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|hs)-/;
  function isInsideThirdPartyWidget(el) {
    var p = el, depth = 10;
    while (p && depth--) {
      if (p.getAttribute) {
        var cls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
        if (cls) {
          var classList = cls.split(/\s+/);
          for (var ci = 0; ci < classList.length; ci++) {
            if (WIDGET_CLASS_RX.test(classList[ci])) return true;
          }
        }
        var id = p.id || "";
        if (id && WIDGET_ATTR_RX.test(id)) return true;
      }
      p = p.parentElement;
    }
    return false;
  }

  // Detect CMS editor previews (Elementor preview, WP admin, Shopify theme editor,
  // Wix editor, Webflow designer, Framer preview, etc.). Those sessions are not
  // real visitor traffic and shouldn't contribute dead clicks or cases.
  function isInCmsEditor() {
    try {
      var s = location.search || "";
      var p = location.pathname || "";
      if (/(elementor-preview|preview=1|preview_id=|ver=preview|theme-preview|shopify-preview|wix-editor|framer-preview)/i.test(s)) return true;
      if (/(^|\/)(wp-admin|wp-login|admin|editor|designer)(\/|$)/i.test(p)) return true;
      // Running inside an iframe from the host CMS editor
      if (window.self !== window.top) {
        try {
          var ref = document.referrer || "";
          if (/(wp-admin|editor\.shopify|my\.shopify|editor\.wix|webflow\.com|framer\.com)/i.test(ref)) return true;
        } catch (_) {}
      }
    } catch (_) {}
    return false;
  }
  var IN_EDITOR = isInCmsEditor();

  // Detect visible modal/drawer/overlay UI state at click time. Critical for
  // case framing — "add to cart" inside an open cart drawer is a drawer bug,
  // not a button bug. Emits `cs` tags on dc+ix events. Compact: single selector
  // + single classifier regex keeps the footprint minimal (~250 bytes gzip).
  var CS_SEL = '[aria-modal="true"],[role="dialog"],[data-state="open"],[class*="drawer"][class*="open"],[class*="modal"][class*="show"],[class*="modal"][class*="open"],[class*="offcanvas"][class*="show"],[class*="active"][class*="cart"],.fancybox-container,.pswp--open';
  function csType(node) {
    var hay = ((typeof node.className === "string" ? node.className : (node.className && node.className.baseVal) || "") + " " + (node.id || "")).toLowerCase();
    return /cart/.test(hay) ? "cart_drawer"
      : /search/.test(hay) ? "search_modal"
      : /filter|facet|refine/.test(hay) ? "filter_panel"
      : /(menu|nav).*(mobile|drawer)|(mobile|drawer).*(menu|nav)/.test(hay) ? "mobile_menu"
      : /lightbox|gallery|photoswipe|pswp/.test(hay) ? "gallery_lightbox"
      : /login|signin|register|account/.test(hay) ? "auth_modal"
      : /checkout/.test(hay) ? "checkout_modal"
      : /consent|cookie|gdpr/.test(hay) ? "consent_banner"
      : /newsletter|popup|exit/.test(hay) ? "popup"
      : "modal";
  }
  function getContextState() {
    try {
      var nodes = document.querySelectorAll(CS_SEL);
      if (!nodes.length) return null;
      var seen = {}, out = [];
      for (var i = 0; i < nodes.length && out.length < 4; i++) {
        var n = nodes[i], r = n.getBoundingClientRect();
        if (r.width < 40 || r.height < 40) continue;
        if (n.offsetParent === null && n.getAttribute && n.getAttribute("aria-hidden") === "true") continue;
        var t = csType(n);
        if (!seen[t]) { seen[t] = 1; out.push(t); }
      }
      return out.length ? out : null;
    } catch (_) { return null; }
  }

  // --- Identity ---
  // Cookies are primary; localStorage is the fallback so we survive
  // cookie-stripping ad blockers + strict privacy modes across navigations.

  // Visitor: persistent, generated once, 2-year cookie + localStorage mirror.
  // 1.0.7 — when GA4 Consent Mode v2 has analytics_storage=denied, we
  // downgrade to anonymous mode: in-memory visitor ID, no cookie write,
  // no localStorage write. The session still works for the current
  // page-view but disappears on navigation, mirroring GA4's
  // "cookieless ping" behavior under denied consent.
  var vid;
  if (CONSENT_DENIED) {
    vid = rid();
  } else {
    vid = getCookie("_pxv") || lsGet("_pxv") || rid();
    setCookie("_pxv", vid, 730);
    lsSet("_pxv", vid);
  }

  // Session: check for timeout, rotate if stale.
  var now = Date.now();
  var sessionStart = parseInt(getCookie("_pxss") || lsGet("_pxss")) || now;
  var lastActivity = parseInt(getCookie("_pxla") || lsGet("_pxla")) || now;
  var sid;

  if (now - lastActivity > SESSION_TIMEOUT) {
    // Session expired — start fresh.
    sid = rid();
    sessionStart = now;
  } else {
    sid = getCookie("_pxs") || lsGet("_pxs") || rid();
  }

  setCookie("_pxs", sid);
  setCookie("_pxss", sessionStart);
  lsSet("_pxs", sid);
  lsSet("_pxss", sessionStart);

  function touch() {
    lastActivity = Date.now();
    setCookie("_pxla", lastActivity);
    lsSet("_pxla", lastActivity);
  }
  touch();

  // Relative timestamp: ms since session start
  function ts() {
    return Date.now() - sessionStart;
  }

  // --- Cached values (avoid recalc on every event) ---
  var cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
  window.addEventListener("resize", function () {
    cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
  });

  // --- Section Detection ---
  // Walk up from an element to find the nearest section/landmark and its heading.
  // Returns "Page Name / Section Name" like "Homepage / Hero Section"
  function getSection(el) {
    if (!el) return null;
    var page = location.pathname;
    // Map common paths to human names
    var pageName = page === '/' ? 'Homepage' : page.replace(/^\//, '').replace(/[-_]/g, ' ').replace(/\/$/, '');
    if (pageName.length > 30) pageName = pageName.substring(0, 30);
    // Walk up to find nearest section, article, or landmark
    var node = el, depth = 15, sectionName = '';
    while (node && depth--) {
      var tag = node.tagName;
      if (!tag) { node = node.parentElement; continue; }
      // Check for section-like containers
      if (/^(SECTION|ARTICLE|ASIDE|MAIN|HEADER|FOOTER|NAV)$/.test(tag)) {
        // Try to find a heading inside this section
        var heading = node.querySelector('h1, h2, h3');
        if (heading) {
          sectionName = (heading.textContent || '').trim().substring(0, 40);
        } else if (node.id) {
          sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
        } else if (node.getAttribute && node.getAttribute('aria-label')) {
          sectionName = node.getAttribute('aria-label').substring(0, 30);
        } else {
          sectionName = tag.charAt(0) + tag.slice(1).toLowerCase(); // "Section", "Header", etc.
        }
        break;
      }
      // Check for div/container with meaningful id or aria-label
      if (tag === 'DIV' && (node.id || (node.getAttribute && node.getAttribute('aria-label')))) {
        var label = node.getAttribute && node.getAttribute('aria-label');
        if (label) { sectionName = label.substring(0, 30); break; }
        if (node.id && !/^(root|app|__next|content|wrapper|container)$/i.test(node.id)) {
          sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
          break;
        }
      }
      node = node.parentElement;
    }
    if (!sectionName) return pageName;
    return pageName + ' / ' + sectionName;
  }

  // --- Event Queue ---
  var queue = [];

  // T7 (2026-04-23): pointer type + cursor speed + hover dwell tracking.
  // These 4 fields distinguish touch users, stalled cursors, and genuine
  // hesitation from false-positive signals on click events.
  //
  //   ptype → "mouse" | "touch" | "pen" — mobile vs desktop vs stylus
  //   cs    → cursor speed over last 200ms in pixels/second (0 = stalled)
  //   dwell → ms the element was hovered before click (user hesitation)
  //
  // BUG FIX (2026-04-24): relying on PointerEvent.pointerType alone misses
  // desktop mouse clicks in browsers that return "" for mouse pointerType,
  // or where pointerdown doesn't fire reliably before click. We now layer 4
  // signals and pick the best one at click time:
  //   1. pointerdown → ev.pointerType (if non-empty)
  //   2. touchstart → "touch"
  //   3. mousedown → "mouse" (if no touch seen recently)
  //   4. fallback at click: navigator.maxTouchPoints > 0 + no touchstart = "mouse"
  var lastPointerType = null;
  var lastTouchAt = 0;
  var hasTouchCapability = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
  var cursorTrack = [];  // [{x, y, t}] last 10 mousemoves
  try {
    // PointerEvent — modern path. Sets ptype if non-empty string.
    document.addEventListener('pointerdown', function (ev) {
      if (ev.pointerType && typeof ev.pointerType === 'string' && ev.pointerType.length > 0) {
        lastPointerType = ev.pointerType;
      }
    }, { capture: true, passive: true });
    // touchstart — fires reliably on every touch device regardless of PointerEvent support.
    document.addEventListener('touchstart', function () {
      lastPointerType = 'touch';
      lastTouchAt = Date.now();
    }, { capture: true, passive: true });
    // mousedown — fires on desktop even when pointerdown reports "". Only
    // sets "mouse" if we haven't seen a touch in the last 500ms (to avoid
    // synthesized mouse events that touch devices fire after a tap).
    document.addEventListener('mousedown', function () {
      if (Date.now() - lastTouchAt > 500) lastPointerType = 'mouse';
    }, { capture: true, passive: true });
    document.addEventListener('mousemove', function (ev) {
      cursorTrack.push({ x: ev.clientX, y: ev.clientY, t: Date.now() });
      if (cursorTrack.length > 10) cursorTrack.shift();
    }, { passive: true });
  } catch (_) {}
  // Read-time fallback: if lastPointerType is still null at click, infer
  // from device capability. No touch capability at all = must be mouse.
  function resolvePointerType() {
    if (lastPointerType) return lastPointerType;
    return hasTouchCapability ? 'touch' : 'mouse';
  }
  function cursorSpeedPxPerSec() {
    try {
      var now = Date.now();
      var recent = cursorTrack.filter(function (p) { return now - p.t < 200; });
      if (recent.length < 2) return 0;
      var first = recent[0], last = recent[recent.length - 1];
      var dx = last.x - first.x, dy = last.y - first.y;
      var dist = Math.sqrt(dx * dx + dy * dy);
      var dt = (last.t - first.t) / 1000;
      return dt > 0 ? Math.round(dist / dt) : 0;
    } catch (_) { return 0; }
  }
  // Hover dwell tracking — when the pointer enters an element, stamp the
  // time; on click or leave, compute elapsed. Separate from the existing
  // hover-intent listener so dwell is available even for short hovers.
  var hoverEnterTime = new WeakMap();
  function getHoverDwell(el) {
    try {
      var t = hoverEnterTime.get(el);
      return t ? Math.round(Date.now() - t) : 0;
    } catch (_) { return 0; }
  }

  // Dead-click throttle — T3 (2026-04-23).
  // Rate-limits the `dc` emission per element so a frustrated user clicking
  // the same broken element 50 times produces one detection signal rather
  // than 50 separate ones. Uses a sliding window: at most DC_MAX per element
  // per DC_WINDOW ms. Suppressed clicks are NOT lost — they'll still count
  // toward rage detection (which runs on raw clicks) and the next flushed
  // dc event embeds a suppressed-count so the server can see the true volume.
  var DC_WINDOW = 1000;   // 1-second window
  var DC_MAX = 3;         // at most 3 dc per element per second
  var dcTimes = {};       // elId → [timestamps]
  var dcSuppressedCount = {}; // elId → count suppressed since last emit
  function throttleDc(elId) {
    var now = Date.now();
    var arr = dcTimes[elId] || [];
    // drop timestamps older than the window
    arr = arr.filter(function (t) { return now - t < DC_WINDOW; });
    if (arr.length >= DC_MAX) {
      dcSuppressedCount[elId] = (dcSuppressedCount[elId] || 0) + 1;
      dcTimes[elId] = arr;
      return false;   // suppress
    }
    arr.push(now);
    dcTimes[elId] = arr;
    return true;      // allow
  }

  // 2026-05-14 — Async-click suppression. Axel Off-Road's manual review
  // of Clarity dead-clicks showed ~75% false positives caused by modern
  // e-commerce click handlers that respond ASYNC (variant fetch, image
  // lazy-load, pre-order check, animation). Same-class detector here was
  // emitting `dc` immediately on click — never waited to see if a network
  // call, navigation, modal, or loading indicator appeared in response.
  //
  // New behavior: queue the dc candidate, then within 1200ms watch for
  // any of these signals indicating the click DID produce a response:
  //   1. Any fetch/XHR network call started (already hooked for `nf`)
  //   2. URL change (history.pushState / hashchange / navigation)
  //   3. Significant DOM mutation: added element ≥300px tall (modal/page)
  //   4. New element whose class contains loading|spinner|skeleton|overlay|
  //      modal|drawer|lightbox|sheet|popup (any common loading-indicator
  //      class name)
  // If we see ANY signal, the dc is suppressed and an `ac` (async-click)
  // event fires instead — same payload + a `reason` field — so we keep
  // visibility without polluting the dead-click bucket.
  var DEFERRED_DC_WINDOW_MS = 1200;
  var clickEpoch = 0;             // bumped on each click for cancellation
  var pendingDeferredDc = [];     // [{ data, expires, epoch }]
  // 2026-05-19 — capture-phase click marker. The dead-click cancel
  // signals (MutationObserver delivery, __harvvNetActivity) fire during
  // the click's TARGET phase — before the document-bubble handler runs
  // scheduleDeferredDc. Without a landing spot, a cancel raised by the
  // site's own click handler hits an empty pendingDeferredDc and is
  // discarded, so a working element gets a false `dc`. This capture-phase
  // listener runs FIRST in every click dispatch, giving those early
  // signals a per-click object to mark.
  //
  // Rapid-fire safety: event dispatch is one synchronous task
  // (capture -> target -> bubble); the next click is a SEPARATE task. So
  // distinct clicks never interleave — clickInFlight for click A is set
  // in A's capture phase and consumed in A's bubble phase, entirely
  // within A's dispatch, before B's capture can run. Rage clicks are N
  // separate tasks, each with a clean marker. A signal that arrives
  // after the bubble phase is still handled by the pendingDeferredDc
  // path below (by then the candidate exists).
  // See docs/findings/microtask-race-dc-cancels-2026-05-19.md.
  var clickInFlight = null;
  var CLICK_SIGNAL_WINDOW_MS = 250; // window after a click in which a
                                    // cancel signal is attributed to it
  function clickSignalWindowOpen() {
    return !!clickInFlight && (Date.now() - clickInFlight.at) < CLICK_SIGNAL_WINDOW_MS;
  }
  document.addEventListener('click', function () {
    clickInFlight = { at: Date.now(), cancelled: false, reason: null };
  }, { capture: true });
  function scheduleDeferredDc(data, tgt) {
    // The click's own handler may already have produced a cancel signal
    // (DOM mutation / fetch / navigation) in the target phase, recorded
    // on the capture-phase marker. If so, emit `ac` and skip the dc.
    if (clickInFlight && clickInFlight.cancelled) {
      try {
        push("ac", { el: data.el, sec: data.sec, x: data.x, y: data.y, r: clickInFlight.reason });
      } catch (_) {}
      return;
    }
    var myEpoch = ++clickEpoch;
    var deadline = Date.now() + DEFERRED_DC_WINDOW_MS;
    var entry = { data: data, deadline: deadline, epoch: myEpoch, cancelled: false };
    pendingDeferredDc.push(entry);
    setTimeout(function () {
      if (entry.cancelled) return; // an async response cancelled us
      // No async response within window — emit as dead click.
      push("dc", data);
    }, DEFERRED_DC_WINDOW_MS);
  }
  // Cancel any pending deferred dc, optionally tagging the reason so we
  // can emit `ac` (async-click) for analytics visibility.
  function cancelPendingDc(reason) {
    // A cancel signal can arrive BEFORE the deferred dc is scheduled
    // (target-phase handler -> microtask, vs. bubble-phase schedule).
    // Mark the in-flight click so scheduleDeferredDc — running later in
    // the same dispatch — sees it. First-wins; window-bounded so a stale
    // marker from an old click is not tagged by an unrelated navigation.
    if (clickInFlight && !clickInFlight.cancelled && clickSignalWindowOpen()) {
      clickInFlight.cancelled = true;
      clickInFlight.reason = reason;
    }
    var now = Date.now();
    for (var i = 0; i < pendingDeferredDc.length; i++) {
      var e = pendingDeferredDc[i];
      if (e.cancelled || now > e.deadline) continue;
      e.cancelled = true;
      // Emit a low-severity async-click event so we retain telemetry
      // (operators can still see "this element was clicked + responded
      // async") without it being a dead-click signal.
      try {
        push("ac", { el: e.data.el, sec: e.data.sec, x: e.data.x, y: e.data.y, r: reason });
      } catch (_) {}
    }
    // Garbage-collect cancelled/expired entries.
    pendingDeferredDc = pendingDeferredDc.filter(function (e) { return !e.cancelled && now <= e.deadline; });
  }
  // Tier 1 — URL change signal (history.pushState/replaceState + hashchange + popstate).
  try {
    var _push = history.pushState;
    history.pushState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _push.apply(this, arguments); };
    var _replace = history.replaceState;
    history.replaceState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _replace.apply(this, arguments); };
    window.addEventListener('popstate', function () { cancelPendingDc('navigation'); }, { capture: true });
    window.addEventListener('hashchange', function () { cancelPendingDc('navigation'); }, { capture: true });
    window.addEventListener('beforeunload', function () { cancelPendingDc('navigation'); }, { capture: true });
  } catch (_) {}
  // Tier 2 — DOM mutation signals (loading indicators, modals, big additions).
  var LOADING_RE = /(?:loading|spinner|skeleton|overlay|modal|drawer|lightbox|sheet|popup|preloader|fetching|progress)/i;
  try {
    // 2026-06-09 — list re-render threshold. AJAX pagination / faceted
    // filters / sort / load-more (LiveSearchFilter, WooCommerce/Shopify AJAX,
    // any "load more") swap a GRID of items in place: many small cards
    // added/removed at once, none individually >= 300px and no "loading"
    // class. The two checks below miss that entirely, so a working filter
    // click was logged as a dead click — 100% of these were dismissed by
    // operators across 6 sites. Counting added+removed element nodes catches
    // the re-render: a truly dead click mutates nothing, a working list
    // control mutates a page of items. Threshold is well above incidental
    // background churn (a rotating carousel swaps 1-3 slides).
    var RERENDER_NODE_THRESHOLD = 8;
    var dcSignalObs = new MutationObserver(function (muts) {
      // Also evaluate during an in-flight click — the candidate may not
      // be scheduled yet (see clickInFlight). Window-bounded so the
      // observer is not doing getBoundingClientRect (a forced reflow) on
      // every mutation for the life of the page.
      if (!pendingDeferredDc.length && !clickSignalWindowOpen()) return;
      var changed = 0;
      for (var mi = 0; mi < muts.length; mi++) {
        var added = muts[mi].addedNodes;
        for (var ni = 0; ni < added.length; ni++) {
          var n = added[ni];
          if (!n || n.nodeType !== 1) continue;
          changed++;
          // Loading-indicator class name match
          var cls = (n.className && typeof n.className === 'string') ? n.className : '';
          if (cls && LOADING_RE.test(cls)) {
            cancelPendingDc('loading_indicator');
            return;
          }
          // Significant element addition (>= 300px tall = likely modal/page)
          try {
            var rect = n.getBoundingClientRect && n.getBoundingClientRect();
            if (rect && rect.height >= 300 && rect.width >= 200) {
              cancelPendingDc('large_dom_addition');
              return;
            }
          } catch (_) {}
        }
        // Removed element nodes count toward a re-render too — a list swap
        // takes the old items out as it puts the new ones in.
        var removed = muts[mi].removedNodes;
        for (var ri = 0; ri < removed.length; ri++) {
          if (removed[ri] && removed[ri].nodeType === 1) changed++;
        }
      }
      // The click swapped in/out a page of nodes (pagination/filter/sort/
      // load-more). It did something — not a dead click. Emitted as `ac`
      // (async-click) so operators keep the telemetry without the FP.
      if (changed >= RERENDER_NODE_THRESHOLD) {
        cancelPendingDc('dom_rerender');
      }
    });
    dcSignalObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
  } catch (_) {}
  // Tier 3 — network signal: any fetch/XHR call starting within the window
  // is a strong indicator the click triggered async work. We expose a hook
  // here that the existing nf-emit path calls.
  window.__harvvNetActivity = function () {
    // Unconditional: a fetch dispatched inside a click handler fires this
    // BEFORE the deferred dc is scheduled, so a `pendingDeferredDc.length`
    // guard would drop the signal. cancelPendingDc also marks the
    // in-flight click, which scheduleDeferredDc consults.
    cancelPendingDc('network_activity');
  };

  // Internal-tester flag — T1 (2026-04-23).
  // Site owners can mark their own sessions (or their employees') as
  // "internal" so events are excluded from detection + calibration. Any
  // one of these signals turns the flag on:
  //   1. window.__harvv_internal === true   (set in inline <script> before pixel loads)
  //   2. localStorage.harvv_internal === '1'
  //   3. cookie harvv_internal=1
  // Once any is set, EVERY event in the session gets data.ih=1.
  // Server-side detection queries exclude ih=1 events, keeping the
  // dogfood + QA numbers clean. See receiver.js detection queries.
  var isInternal = false;
  try {
    if (window.__harvv_internal === true) isInternal = true;
    if (!isInternal && localStorage.getItem('harvv_internal') === '1') isInternal = true;
    if (!isInternal && getCookie('harvv_internal') === '1') isInternal = true;
  } catch (_) {}

  function push(eventType, data) {
    var d = data || {};
    // Include current page path on ALL events for page-level tracking.
    // Without this, issues and logs show null page_url which makes debugging impossible.
    if (!d.p && eventType !== 'ss' && eventType !== 'ul') {
      d.p = location.pathname;
    }
    // 2026-05-19 — `pp` always carries the real page path, even on events
    // whose `p` field is overloaded. The `sd` scroll-depth event passes
    // `{ p: <0|25|50|75|100> }`, so the `!d.p` guard above skips it and the
    // event ships with no real path — the server then stored "25"/"50" (or
    // NULL) as page_url. `pp` is unconditional, so the receiver can always
    // recover the true page path. Cheap: a short string that gzips away
    // against the identical `p`/`pp` values across a batch.
    d.pp = location.pathname;
    // Include cached zoom level on friction events
    if (eventType === 'dc' || eventType === 'rc') {
      d.zm = cachedZoom;
    }
    // Internal-tester tag. Single bit; the server uses it to exclude from
    // detection + calibration. No PII — just "this session is one of ours."
    if (isInternal) d.ih = 1;
    // Pixel version stamped on every event so the server can track which
    // pixel build produced the data — essential for diagnosing field-coverage
    // regressions after a rollout. Read from window.__pxv which the
    // receiver injects into the pixel bootstrap script.
    var pxv = (typeof window !== 'undefined' && window.__pxv) || undefined;
    // T12 (2026-04-24): event deduplication UID. Random 8-hex per event.
    // Server keeps an LRU of recent uids; duplicates within 5s are dropped.
    // Guards against event-bubbling double-counts and keep-alive retries
    // that flush the same event twice. Cost: 8 chars (~10 bytes) per event.
    var uid = ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, '0');
    queue.push({ v: vid, s: sid, e: eventType, t: ts(), d: d, pxv: pxv, uid: uid });
    touch();
  }

  // 2026-05-26 — install-mode self-check. If our script tag is sync (no
  // async/defer attribute), it's render-blocking — which delays page
  // paint AND increases the chance our IIFE interleaves badly with the
  // host page's own inline scripts on heavy CMS pages (Punchmark, certain
  // Shopify themes). We can't upgrade ourselves retroactively, but we
  // can fire an `iw` (install-warning) event so the customer sees this
  // surface in the dashboard with a one-click fix banner.
  // Fires once per pageload; bounded by the queue cap above so a noisy
  // edge case can't spam.
  try {
    var _ownScript = scripts.length && scripts[scripts.length - 1];
    if (_ownScript && !_ownScript.async && !_ownScript.defer) {
      push('iw', { kind: 'sync_install', src: (_ownScript.src || '').slice(0, 80) });
    }
  } catch (_) { /* never block on the self-check */ }

  // T2.2 (2026-04-30): localStorage retry queue. When sendBeacon AND fetch
  // both fail (offline, blocked CSP, ad-blocker partially through, captive
  // portal, etc.) we used to silently drop the events. Now we persist the
  // failed payload to localStorage and try to flush it on next page load.
  // Cap: 50KB total / 24h TTL, so we never balloon a visitor's storage and
  // never resurrect stale/PII-heavy events.
  var RETRY_KEY = 'harvv_retry_q';
  // 2026-05-19 — bumped caps after a Railway-wide outage exposed the
  // 50 KB / 24 h limit: high-traffic visitors and visitors who didn't
  // return within a day lost events. With the edge spillover (above)
  // most outages no longer reach this layer, but it stays as belt-and-
  // suspenders for the case where even the fallback can't be reached
  // (offline, blocked CSP that catches both hostnames, captive portal).
  var RETRY_MAX_BYTES = 500000;          // 500 KB — enough for a busy session
  var RETRY_TTL_MS = 72 * 60 * 60 * 1000; // 72 h — covers a long weekend

  function persistFailedSend(payload) {
    try {
      var entry = { ts: Date.now(), p: payload };
      var existing = localStorage.getItem(RETRY_KEY);
      var arr = existing ? JSON.parse(existing) : [];
      if (!Array.isArray(arr)) arr = [];
      arr.push(entry);
      // Trim by total size (FIFO drop oldest until under cap)
      var serialized = JSON.stringify(arr);
      while (serialized.length > RETRY_MAX_BYTES && arr.length > 1) {
        arr.shift();
        serialized = JSON.stringify(arr);
      }
      localStorage.setItem(RETRY_KEY, serialized);
    } catch (_) {}
  }

  // 2026-05-19 — fallback-then-persist. When the primary endpoint fails
  // (non-2xx or network error), try the edge spillover before giving up to
  // localStorage. The spillover stores the same body in Cloudflare KV; the
  // receiver drains it back into PG when it's healthy. Independent of
  // Railway, so a Railway outage doesn't reach the retry queue at all.
  function tryBackupThenPersist(payload, blob) {
    if (!BACKUP_ENDPOINT) { persistFailedSend(payload); return; }
    try {
      fetch(BACKUP_ENDPOINT, { method: 'POST', body: blob, keepalive: true, headers: { 'Content-Type': 'application/json' } })
        .then(function (r) { if (!r || !r.ok) persistFailedSend(payload); })
        .catch(function () { persistFailedSend(payload); });
    } catch (_) { persistFailedSend(payload); }
  }

  function drainRetryQueue() {
    try {
      var raw = localStorage.getItem(RETRY_KEY);
      if (!raw) return;
      var arr = JSON.parse(raw);
      if (!Array.isArray(arr) || !arr.length) { localStorage.removeItem(RETRY_KEY); return; }
      var now = Date.now();
      var fresh = arr.filter(function (e) { return e && e.ts && (now - e.ts) < RETRY_TTL_MS && e.p; });
      if (!fresh.length) { localStorage.removeItem(RETRY_KEY); return; }
      // Try to send each persisted payload. On success, remove from queue;
      // on failure, leave in queue for next attempt. Use sendBeacon when
      // available; fetch otherwise.
      var remaining = [];
      for (var i = 0; i < fresh.length; i++) {
        var blob = new Blob([fresh[i].p], { type: "application/json" });
        var sent = false;
        // Try primary via sendBeacon → primary via fetch → backup via beacon
        // → backup via fetch. Anything that accepts wins; only if ALL four
        // refuse do we leave the entry in the retry queue for next pageload.
        if (navigator.sendBeacon) {
          try { sent = navigator.sendBeacon(ENDPOINT, blob); } catch (_) { sent = false; }
        }
        if (!sent) {
          try { fetch(ENDPOINT, { method: "POST", body: fresh[i].p, headers: { 'Content-Type': 'application/json' }, keepalive: true }); sent = true; } catch (_) {}
        }
        if (!sent && BACKUP_ENDPOINT && navigator.sendBeacon) {
          try { sent = navigator.sendBeacon(BACKUP_ENDPOINT, blob); } catch (_) { sent = false; }
        }
        if (!sent && BACKUP_ENDPOINT) {
          try { fetch(BACKUP_ENDPOINT, { method: "POST", body: fresh[i].p, headers: { 'Content-Type': 'application/json' }, keepalive: true }); sent = true; } catch (_) {}
        }
        if (!sent) remaining.push(fresh[i]);
      }
      if (remaining.length) localStorage.setItem(RETRY_KEY, JSON.stringify(remaining));
      else localStorage.removeItem(RETRY_KEY);
    } catch (_) {}
  }

  // T141.11 (2026-05-12) — Laravel-context attachment.
  // When the harvv/laravel composer package's HarvvContext middleware is
  // active on the host site, it injects a <meta name="harvv-laravel"
  // content="<base64-url-json>"> tag during HTML response render. The blob
  // contains: site_key, route, hashed user_id, request_id, ts, HMAC
  // signature. We read it once at boot, cache, and attach it to event[0]
  // of every outbound batch as `lc`. Older receivers ignore unknown event
  // properties (graceful rollout window — Step 0 ships pixel; Step 3 will
  // teach the receiver to read `events[0].lc`). Cached for the page
  // lifetime — Laravel's server side guarantees fresh context per request,
  // so re-reading on every flush would only add cost without changing
  // anything within a single SPA pageview.
  var laravelCtxBlob = null;
  try {
    var lcMeta = document.querySelector && document.querySelector('meta[name="harvv-laravel"]');
    if (lcMeta) {
      var c = lcMeta.getAttribute && lcMeta.getAttribute('content');
      if (typeof c === 'string' && c.length > 0 && c.length < 2048) {
        laravelCtxBlob = c; // pre-encoded base64-url; receiver decodes server-side
      }
    }
  } catch (_) { /* DOM unavailable; non-fatal */ }

  function flush(forceFetch) {
    if (!queue.length) return;
    // Build the wire batch. If Laravel context is present, attach to the
    // FIRST event in the batch only — receiver dedupes from there. Avoids
    // n× duplication for batches >1 event.
    var batch;
    if (laravelCtxBlob) {
      batch = queue.slice();
      batch[0] = Object.assign({}, batch[0], { lc: laravelCtxBlob });
    } else {
      batch = queue;
    }
    var payload = JSON.stringify(batch);
    queue = [];
    var blob = new Blob([payload], { type: "application/json" });
    // T141.5h (2026-05-11) — HTTP-status-aware retry.
    // Pre-fix the flush used sendBeacon-first which fire-and-forgets — it
    // returns TRUE if the browser accepted the request for background
    // delivery, but NEVER indicates whether the server actually responded
    // 200. So a 503 from the receiver during a load-shed was silently
    // lost on the wire. T2.2 retry-queue only caught network errors
    // (DNS fail, offline, ad-blocker blocked the request entirely).
    //
    // Fix: in normal "page is alive" mode (forceFetch falsy AND document
    // is visible), use fetch + check response.ok. On non-2xx OR a
    // network error, push to retry queue. sendBeacon stays in the
    // pagehide / beforeunload path (`forceFetch === false` means
    // "regular flush"; sendBeacon is ONLY used for the explicit unload
    // calls below where we have no chance to inspect the response).
    if (forceFetch === 'unload' && navigator.sendBeacon) {
      // Unload: fire-and-forget via sendBeacon. We've already lost the
      // chance to retry — the page is going away. Send to primary AND
      // backup (receiver-side uid dedup handles any duplicate that lands
      // in both), then persist to the retry queue as final belt-and-
      // suspenders for the case both beacons silently fail.
      try { navigator.sendBeacon(ENDPOINT, blob); } catch (_) {}
      if (BACKUP_ENDPOINT) { try { navigator.sendBeacon(BACKUP_ENDPOINT, blob); } catch (_) {} }
      persistFailedSend(payload);
      return;
    }
    try {
      fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true })
        .then(function (resp) {
          // 2xx = success; non-2xx or network error → try the edge
          // spillover before falling back to localStorage. 4xx usually
          // means "we shouldn't have sent this" (signed URL expired, site
          // disabled, etc) — both retry paths are safe because the
          // receiver / spillover both dedup on the per-event uid.
          if (!resp || !resp.ok) tryBackupThenPersist(payload, blob);
        })
        .catch(function () { tryBackupThenPersist(payload, blob); });
      return;
    } catch (_) {
      tryBackupThenPersist(payload, blob);
    }
  }

  // Drain any persisted-from-prior-load payload on startup.
  drainRetryQueue();

  // Flush every 10 seconds
  setInterval(flush, FLUSH_INTERVAL);

  // --- Session Start: Referrer + UTMs ---
  (function captureSessionContext() {
    var params = new URLSearchParams(location.search);
    var utms = {};
    ["source", "medium", "campaign", "term", "content"].forEach(function (k) {
      var v = params.get("utm_" + k);
      if (v) utms[k] = v;
    });
    // 2026-05-05 — paid-traffic click IDs. Captured ONLY from URL params at
    // session start (entry-time attribute). Same lifecycle as utms:
    // populated once, never overwritten. Truncated to 200 chars defensively.
    var clickIds = {};
    ["gclid", "fbclid", "msclkid", "ttclid", "li_fat_id", "epik"].forEach(function (k) {
      var v = params.get(k);
      if (v) clickIds[k] = String(v).slice(0, 200);
    });
    // 2026-05-05 — locale + timezone. Cheap geo proxies that don't need IP
    // geolocation. VPNs lie about both, but VPN IPs lie about geo too — same
    // trust level. Used for cohort segmentation (US vs CA users) and as a
    // sanity cross-check against MaxMind country lookup server-side.
    var tz, lg;
    try { tz = (Intl && Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone) || undefined; } catch (_) {}
    try { lg = (navigator.language || (navigator.languages && navigator.languages[0])) || undefined; } catch (_) {}
    if (tz) tz = String(tz).slice(0, 40);
    if (lg) lg = String(lg).slice(0, 20);
    var touch = 'ontouchstart' in window;
    var dt = touch && screen.width < 768 ? 'm' : touch && screen.width < 1024 ? 't' : 'd';
    var ua = navigator.userAgent || '';
    var br = ua.indexOf('Firefox') > -1 ? 'ff' : ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1 ? 'sf' : ua.indexOf('Edg') > -1 ? 'eg' : ua.indexOf('Chrome') > -1 ? 'ch' : '?';
    // T9 (2026-04-24): bot fingerprinting — multi-signal detection for
    // automation that passes UA checks. Each bit flipped contributes to a
    // composite score the server uses alongside the UA regex. Small JS
    // footprint (<200 bytes minified), no external dependencies.
    //
    //   bit 0 (1):   navigator.webdriver === true
    //   bit 1 (2):   no plugins (headless Chrome signature)
    //   bit 2 (4):   no navigator.languages (bot default)
    //   bit 3 (8):   UA contains "Headless" / "Phantom" / "HeadlessChrome"
    //   bit 4 (16):  window.chrome missing runtime on Chrome UA (headless)
    //   bit 5 (32):  window.outerHeight === 0 (automation window)
    //
    // Any non-zero value is suspicious; ≥2 bits set is near-certain bot.
    var bf = 0;
    try {
      if (navigator.webdriver) bf |= 1;
      if (!navigator.plugins || navigator.plugins.length === 0) bf |= 2;
      if (!navigator.languages || navigator.languages.length === 0) bf |= 4;
      if (/Headless|PhantomJS|Electron\/[\d.]+$/i.test(ua)) bf |= 8;
      if (window.chrome && !window.chrome.runtime && /Chrome/i.test(ua) && !/Firefox/i.test(ua)) bf |= 16;
      if (window.outerHeight === 0) bf |= 32;
    } catch (_) {}
    // 2026-05-06 (Tier 1) — added: hardware concurrency (hc), connection
    // downlink/rtt/saveData (dl/rt/sd), a11y media-query prefs (mq), Global
    // Privacy Control (gpc), cookie-consent library state (ccl). All signals
    // are zero-PII (browser-stated capability/preference flags) and add
    // ~40 bytes per session.
    var hc, dl, rt, sd, gpc, mq, ccl;
    try { hc = navigator.hardwareConcurrency || undefined; } catch (_) {}
    try {
      var c = navigator.connection;
      if (c) { dl = c.downlink || undefined; rt = c.rtt || undefined; sd = c.saveData ? 1 : undefined; }
    } catch (_) {}
    try {
      // Global Privacy Control — explicit opt-out signal recognized under CPRA
      // and (increasingly) GDPR. When true we still SEND the session-start
      // event but tag it so the server downgrades to anonymous-only mode.
      gpc = navigator.globalPrivacyControl ? 1 : undefined;
    } catch (_) {}
    try {
      // Compact bitmask of accessibility/preference media queries.
      // bit 0 (1): prefers-reduced-motion
      // bit 1 (2): prefers-color-scheme: dark
      // bit 2 (4): prefers-contrast: more
      // bit 3 (8): forced-colors: active
      // bit 4 (16): prefers-reduced-data
      var m = 0;
      if (matchMedia('(prefers-reduced-motion: reduce)').matches) m |= 1;
      if (matchMedia('(prefers-color-scheme: dark)').matches) m |= 2;
      if (matchMedia('(prefers-contrast: more)').matches) m |= 4;
      if (matchMedia('(forced-colors: active)').matches) m |= 8;
      if (matchMedia('(prefers-reduced-data: reduce)').matches) m |= 16;
      if (m) mq = m;
    } catch (_) {}
    try {
      // Detect common cookie-consent library state. Zero PII, just whether
      // analytics consent has been given by the visitor on the customer's
      // banner. Each integration is wrapped so a single broken library
      // doesn't break the rest.
      // Returns: 'a' = analytics consent given; 'n' = denied; 'p' = pending;
      //          'g' = GPC auto-decline; undefined = no consent library detected.
      if (gpc) ccl = 'g';
      else if (window.OneTrust && typeof window.OneTrust.GetDomainData === 'function') {
        try {
          var groups = (document.cookie.match(/OptanonConsent=[^;]*groups=([^&;]+)/) || [])[1] || '';
          ccl = /C0002:1/.test(decodeURIComponent(groups)) ? 'a' : 'n';
        } catch (_) {}
      }
      else if (window.Cookiebot && typeof window.Cookiebot.consent === 'object') {
        ccl = window.Cookiebot.consent.statistics ? 'a' : 'n';
      }
      else if (window.cookieconsent && window.cookieconsent.status) {
        ccl = window.cookieconsent.status === 'allow' ? 'a' : 'n';
      }
      else if (window.CookieConsent && typeof window.CookieConsent.acceptedCategory === 'function') {
        ccl = window.CookieConsent.acceptedCategory('analytics') ? 'a' : 'n';
      }
      else if (window.tarteaucitron && window.tarteaucitron.state && window.tarteaucitron.state.analytics === true) {
        ccl = 'a';
      }
    } catch (_) {}

    // 2026-05-06 (Tier 2): customer-provided hashed user ID for cohort
    // analysis without us ever seeing PII. Customer SHA-256 hashes their
    // internal user id and sets `window.harvv.user.id_hash`. We only
    // accept exactly 64 hex chars; anything else is rejected to prevent
    // accidental email/phone exposure. Server salts again before storing.
    var hu;
    try {
      var hh = window.harvv && window.harvv.user && window.harvv.user.id_hash;
      if (typeof hh === 'string' && /^[a-f0-9]{64}$/i.test(hh)) hu = hh;
    } catch (_) {}
    // 2026-05-07 — `cu` correlator. Harvv-on-Harvv only: when the pixel
    // fires on harvv.com / admin.harvv.com (i.e. our own dashboard) and a
    // logged-in customer's id is in localStorage, attach it so we can
    // correlate the session back to who's seeing what slowness/friction.
    // Per dogfooding policy this NEVER ships on customer sites — that
    // would expose JWT presence to every site's pixel install. Read from
    // a tiny localStorage key the dashboards write on login (uid only,
    // never the JWT itself, never on cross-origin sites).
    var cu;
    try {
      var h = location.hostname;
      if (h === 'harvv.com' || h === 'admin.harvv.com' || h === 'www.harvv.com') {
        var x = localStorage.getItem('harvv_uid');
        if (x && /^[a-f0-9-]{8,40}$/i.test(x)) cu = x;
      }
    } catch (_) {}

    push("ss", {
      r: document.referrer || undefined,
      u: Object.keys(utms).length ? utms : undefined,
      cid: Object.keys(clickIds).length ? clickIds : undefined,
      tz: tz,
      lg: lg,
      p: location.pathname,
      vw: window.innerWidth, vh: window.innerHeight,
      dt: dt, br: br,
      zm: cachedZoom,
      dm: navigator.deviceMemory || undefined,
      ct: (navigator.connection && navigator.connection.effectiveType) || undefined,
      hc: hc, dl: dl, rt: rt, sd: sd,
      gpc: gpc, mq: mq, ccl: ccl,
      hu: hu,
      cu: cu, // dogfood-only — harvv.com hosts only; gated above
      wb: navigator.webdriver ? 1 : undefined,
      bf: bf || undefined,
      // 1.0.7 — GA4 coexistence. Mark every session with whether GA4
      // was detected at boot, and whether GA4 Consent Mode v2 has
      // declined analytics. Server uses these to dedupe and to flag
      // sessions in dashboard.
      ga4: GA4_PRESENT ? 1 : undefined,
      cd: CONSENT_DENIED ? 1 : undefined,
    });

    // 2026-05-13 — lazy-load self-detection (Layer 2) + attribution
    // (Layer 5, 1.0.6). If an optimization tool (WP Rocket, LiteSpeed,
    // NitroPack, etc.) deferred us, LCP already fired before we booted.
    // Server flags the session and the dashboard banner names the tool.
    try {
      var navE = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
      var lcpE = performance.getEntriesByType && performance.getEntriesByType('largest-contentful-paint');
      var lateMs = navE ? Math.round(performance.now() - navE.domContentLoadedEventEnd) : 0;
      var afterLcp = lcpE && lcpE.length > 0;
      // Attribution: walk the page for known delay-tool markers. Each
      // tool tags its rewritten scripts with a vendor-specific attr —
      // this lets us tell the customer "WP Rocket is delaying you"
      // rather than just "something is delaying you".
      var via;
      try {
        if (document.querySelector('script[data-rocket-src],script[data-rocket-status]')) via = 'wp_rocket';
        else if (document.querySelector('script[type="pmdelayedscript"],script[data-pmdelayedscript]')) via = 'perfmatters';
        else if (window.NitroPack || document.querySelector('script[nitro-exclude]')) via = 'nitropack';
        else if (document.querySelector('script[data-litespeed-defer]')) via = 'litespeed';
        else if (window.RocketLoader || document.querySelector('script[src*="ajax.cloudflare.com/cdn-cgi/scripts/"][src*="rocket-loader"]')) via = 'cloudflare';
      } catch (_) {}
      if (afterLcp || lateMs > 500 || via) {
        push('pf.deferred', {
          al: afterLcp ? 1 : 0,
          lm: lateMs > 500 ? lateMs : 0,
          via: via || undefined,
        });
      }
    } catch (_) {}
  })();

  // --- User State Inference ---
  // Session-scoped categorical tagset — lets the server cross-tab cases by
  // "what kind of user saw this" (logged_in/anonymous, has_cart, first_visit).
  // Entirely DOM-derived + optional window.harvv.user opt-in. Zero PII.
  (function captureUserState() {
    try {
      var tags = [];
      var ck = document.cookie || "";
      var logoutQ = 'a[href*="logout"],a[href*="sign-out"],a[href*="/my-account"],a[href*="/account/"]';
      var loginQ  = 'a[href*="/login"],a[href*="/signin"],a[href*="/register"]';
      var loggedIn = !!document.querySelector(logoutQ)
        || ck.indexOf("wordpress_logged_in") !== -1
        || ck.indexOf("_shopify_customer") !== -1
        || ck.indexOf("_secure_customer_sig") !== -1;
      tags.push(loggedIn ? "logged_in" : (document.querySelector(loginQ) ? "anonymous" : "auth_unknown"));
      if (document.querySelector(".woocommerce-MyAccount-navigation,.woocommerce-account")) tags.push("wc_logged_in");
      var bodyTxt = (document.body && document.body.innerText || "").toLowerCase().slice(0, 4000);
      if (/cart \(\d+\)|cart:\s*\d+|items in cart/.test(bodyTxt)
          || document.querySelector('[data-cart-count]:not([data-cart-count="0"]),.cart-count:not(:empty)')) tags.push("has_cart");
      if (!ck.match(/_pxv=/)) tags.push("first_visit");
      // Optional explicit opt-in via window.harvv.user — categorical only.
      var explicit = null;
      try {
        var wh = window.harvv && window.harvv.user;
        if (wh && typeof wh === 'object') {
          explicit = {};
          for (var k in wh) {
            var v = wh[k], tp = typeof v;
            if (tp === 'string' && v.length < 60) explicit[k] = v.slice(0, 60);
            else if ((tp === 'number' && isFinite(v)) || tp === 'boolean') explicit[k] = v;
          }
          if (!Object.keys(explicit).length) explicit = null;
        }
      } catch (_) {}
      push("us", { tags: tags, explicit: explicit || undefined });
    } catch (_) {}
  })();

  // --- Unified Click Handler ---
  // Merges dead click detection, rage click detection, and interaction sequence into one listener.
  var DC_NOISE = /^(HTML|BODY|MAIN|SECTION|HEADER|FOOTER|NAV|ARTICLE|ASIDE)$/;
  var clicks = [], lastIx = 0;
  // T40 (2026-04-24): record last click for post-click notification correlation.
  // The MutationObserver below watches for toasts/alerts added within 5s of a
  // click so dc issues on product elements can be reframed as inventory when
  // "Sold out" / "Out of stock" banners fire.
  var lastClickEid = null, lastClickAt = 0;
  document.addEventListener("click", function (ev) {
    var tgt = ev.target, elId = eid(tgt), cx = ev.clientX, cy = ev.clientY;
    lastClickEid = elId; lastClickAt = Date.now();

    // 1. Interaction sequence (every click)
    var t = ts(), delta = lastIx ? t - lastIx : 0;
    lastIx = t;
    var ctxState = getContextState();
    // T7 (2026-04-23, fixed 2026-04-24): pointerType + cursor speed + hover
    // dwell on every click. `resolvePointerType()` handles edge cases where
    // pointerdown doesn't set a value (browsers returning "" for mouse, or
    // browsers where pointerdown doesn't fire before click). Always returns
    // "mouse" / "touch" / "pen".
    //
    // Audit fix 2026-04-24: also attach x/y to ix events (previously only
    // on dc). Lets the server correlate click position between ix + dc
    // events to find near-miss clicks that aren't technically dead.
    var ptype = resolvePointerType();
    var csPx = cursorSpeedPxPerSec() || undefined;
    var dwell = getHoverDwell(tgt) || undefined;
    push("ix", { el: elId, x: cx, y: cy, dt: delta, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell, sp: curScrollPct || undefined });

    // 2. Dead click detection (non-interactive elements)
    // Suppress dead-click emission entirely for CMS-editor previews and inside
    // third-party embedded widgets — those aren't the host site's UX and the
    // agency can't fix them (M-Diamond had 16 false positives from an embedded
    // appointment widget's styled-components classes).
    if (IN_EDITOR) { /* skip */ }
    else if (isInsideThirdPartyWidget(tgt)) { /* skip */ }
    else if (!isInteractive(tgt)) {
      var tag = tgt.tagName;
      if (!(DC_NOISE.test(tag) && !tgt.id && !(tgt.className && typeof tgt.className === "string" && tgt.className.trim()))) {
        // Check if clicked inside a container/wrapper class — these wrap interactive
        // children and clicks on them are near-misses, not real dead clicks.
        // Example: div.product__info-wrapper on Shopify product pages wraps the
        // product title link — clicking the wrapper area navigates correctly.
        var cls = (typeof tgt.className === "string" ? tgt.className : "").toLowerCase();
        var isWrapper = cls && /(?:wrapper|container|grid|row|group|items|actions|controls|card|overlay|inner|content|block)/.test(cls);
        if (!isWrapper) {
          var sec = getSection(tgt);
          // T7 additions: ptype, csp (cursor speed px/sec), dwell (ms hovered
          // before this click). Same fields as the ix event above.
          var data = { el: elId, x: cx, y: cy, sec: sec || undefined, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell };
          // 2026-05-14 — Host-shielded element flag (`bs` = blind-spot bit).
          // True when the click target is inside something whose internal
          // state our host-page pixel cannot observe: cross-origin iframes,
          // <video>/<canvas>, custom-element hosts (which may have closed
          // shadow roots), or any element with an open shadowRoot. The
          // canonical case is Shopify Inbox (`inbox-online-store-chat` is a
          // custom element wrapping an iframe) — every chat interaction
          // emits a wrapper click that LOOKS dead because we can't see the
          // DOM change inside. Server-side detection filters bs:1 events
          // out of the dc aggregation. See:
          // docs/runbooks/incident-2026-05-14-shadow-dom-chat-fp.md
          if (isHostShielded(tgt)) data.bs = 1;
          var nearest = tgt.parentElement, depth = 5;
          var insideInteractive = false;
          while (nearest && depth--) {
            if (isInteractive(nearest)) {
              var r = nearest.getBoundingClientRect();
              var dist = (Math.sqrt(Math.pow(Math.max(r.left - cx, cx - r.right, 0), 2) + Math.pow(Math.max(r.top - cy, cy - r.bottom, 0), 2)) + 0.5) | 0;
              if (dist === 0) {
                // Click landed inside an interactive ancestor's bounding box.
                // This is NOT a dead click — the ancestor handles the event.
                insideInteractive = true;
              } else {
                data.near = eid(nearest);
                data.dist = dist;
              }
              break;
            }
            nearest = nearest.parentElement;
          }
          // T3 (2026-04-23): throttle dc emission so one element can emit at
          // most DC_MAX events per DC_WINDOW ms. One frustrated user clicking
          // the same broken element 50 times should produce a single signal
          // with suppressed_count=47, not 50 separate detection candidates.
          //
          // 2026-05-14 — DEFER the dc emit by 1.2 seconds (Axel Off-Road
          // learning: Clarity's same-class detector flagged ~3-4× more
          // dead clicks than actually existed because modern e-commerce
          // clicks trigger 1-2s async responses — variant fetch, image
          // lazy-load, pre-order check — all of which Clarity treated as
          // "no DOM change" → dead). Instead, we now buffer the dc
          // candidate and watch for signals that the click DID work:
          //   - fetch / XHR fired (we already track via `nf`)
          //   - URL changed (SPA navigation)
          //   - Significant DOM mutation (modal, overlay, loading spinner)
          //   - Loading indicator class names appeared
          // If any of those happen within 1.2s, we cancel the dc emit and
          // emit a lower-severity `ac` (async-click) event instead so we
          // retain telemetry without polluting the dead-click bucket.
          if (!insideInteractive) {
            if (throttleDc(elId)) {
              if (dcSuppressedCount[elId]) {
                data.sup = dcSuppressedCount[elId];
                dcSuppressedCount[elId] = 0;
              }
              // Defer 1.2s and cancel on any async-response signal.
              scheduleDeferredDc(data, tgt);
            }
          }
        }
      }
    }

    // 3. Rage click detection (3+ clicks within 800ms in 50px radius)
    var now = Date.now();
    clicks.push({ x: cx, y: cy, t: now });
    // Cap array to prevent unbounded growth + filter old clicks
    if (clicks.length > 50) clicks = clicks.slice(-30);
    clicks = clicks.filter(function (c) { return now - c.t < RAGE_WINDOW; });
    if (clicks.length >= RAGE_THRESHOLD) {
      var ref = clicks[0], clustered = clicks.every(function (c) {
        return Math.hypot(c.x - ref.x, c.y - ref.y) < RAGE_RADIUS;
      });
      if (clustered) {
        // Same `bs` blindness flag as dc — rage on a host-shielded element
        // (chat widget, iframe, video player) is the same false-positive
        // class: every click LOOKS unhandled because we can't see what's
        // happening inside the iframe/shadow root.
        var rcData = { el: elId, n: clicks.length, sec: getSection(tgt) || undefined };
        if (isHostShielded(tgt)) rcData.bs = 1;
        push("rc", rcData);
        snapViewport("rage");
        clicks = [];
      }
    }
  });

  // --- Commerce signals (1.0.6) ----------------------------------------
  // Three high-value conversion signals consolidated into a single event
  // type `cm` to save bytes. Each fires only when its specific trigger
  // matches, so steady-state cost is zero on non-commerce pages.
  //   k=ca  — cart-add: click on element matching the cart-add selector
  //   k=co  — coupon attempt: focus into an input named coupon/discount/promo
  //   k=ei  — exit intent: mouseleave from top of viewport on a cart/checkout URL
  //
  // Why one event with `k` discriminator (not three event types): each is
  // rare enough that splitting would add three event-type strings to the
  // wire format; sharing the type tag is ~30 bytes cheaper gzip-wise.
  var CART_SEL = '[data-add-to-cart],.add_to_cart_button,.single_add_to_cart_button,button[name="add"]';
  var COUPON_SEL = 'input[name*="coupon" i],input[name*="discount" i],input[name*="promo" i]';
  document.addEventListener('click', function (e) {
    var t = e.target;
    if (!t || !t.closest) return;
    var hit = t.closest(CART_SEL);
    if (hit) emitCm('dom', 'ca', { el: eid(hit), sp: curScrollPct || undefined });
  }, true);
  document.addEventListener('focusin', function (e) {
    var t = e.target;
    if (t && t.matches && t.matches(COUPON_SEL)) emitCm('dom', 'co', { el: eid(t) });
  }, true);
  // Exit intent only on commerce URLs (no GA4 equivalent). One emit/pageload.
  if (/\/(cart|checkout|basket|payment)\b/i.test(location.pathname)) {
    var eiSent = false;
    document.addEventListener('mouseout', function (e) {
      if (eiSent) return;
      if (!e.relatedTarget && e.clientY < 10) {
        eiSent = true;
        emitCm('dom', 'ei', { p: location.pathname.slice(0, 80) });
      }
    });
  }

  // --- Scroll Depth Milestones ---
  // Fire once per session per threshold (25/50/75/100%).
  // Throttled — only checks every 100ms via rAF.
  // 2026-05-06 (Tier 2): also tracks scroll velocity + direction reversals
  // (scrolling down then up = "I missed something / lost my place").
  // One sv event emitted at end of session via beforeunload.
  var firedScrolls = {};
  var scrollCheckPending = false;
  var scrollLastY = 0, scrollLastT = 0, scrollLastDir = 0;
  var scrollReversals = 0, scrollVelocityMaxPxs = 0;
  // 1.0.6 — current scroll-percentage cache. Click handler reads this
  // so every `ix` event can carry the scroll-depth at click time
  // (`sp`). Lets the server answer "is this CTA being clicked from
  // the top of the page or after the user scrolled past it?" without
  // re-computing on every click.
  var curScrollPct = 0;
  // 2026-06-02 — exact peak scroll depth (continuous %, not the 25% milestone
  // bucket). Milestones [10,25,50,75,100] only tell us "reached >=N%", so
  // anything 0-9% recorded as max_scroll=0 and we couldn't tell a bounce from a
  // 9% scroll. curScrollPctMax tracks the true high-water mark and ships on the
  // unload `ul` event as `sm`, so the server can store the exact peak.
  var curScrollPctMax = 0;
  window.addEventListener("scroll", function () {
    if (scrollCheckPending) return;
    scrollCheckPending = true;
    requestAnimationFrame(function () {
      scrollCheckPending = false;
      var scrollTop = window.scrollY || window.pageYOffset || 0;
      var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
      curScrollPct = scrollHeight > 0 ? Math.round((scrollTop / scrollHeight) * 100) : 0;
      if (curScrollPct > curScrollPctMax) curScrollPctMax = curScrollPct > 100 ? 100 : curScrollPct;
      // Velocity + reversal tracking (independent of milestone check)
      var nowT = Date.now();
      if (scrollLastT && nowT - scrollLastT < 1000) {
        var dy = scrollTop - scrollLastY;
        var dt = nowT - scrollLastT;
        if (dt > 0) {
          var pxs = Math.abs(dy) / dt * 1000;
          if (pxs > scrollVelocityMaxPxs) scrollVelocityMaxPxs = pxs;
          var dir = dy > 0 ? 1 : dy < 0 ? -1 : 0;
          if (dir && scrollLastDir && dir !== scrollLastDir) scrollReversals++;
          if (dir) scrollLastDir = dir;
        }
      }
      scrollLastY = scrollTop;
      scrollLastT = nowT;
      // Milestone fire
      if (scrollHeight <= 0) return;
      var pct = Math.round((scrollTop / scrollHeight) * 100);
      SCROLL_MILESTONES.forEach(function (m) {
        if (pct >= m && !firedScrolls[m]) {
          firedScrolls[m] = 1;
          push("sd", { p: m });
          snapViewport("sd" + m);
        }
      });
    });
  }, { passive: true });
  // Emit final scroll-velocity summary on unload-ish events.
  function emitScrollSummary() {
    if (scrollReversals > 0 || scrollVelocityMaxPxs > 100) {
      push("sv", {
        rev: scrollReversals,
        vp: Math.round(scrollVelocityMaxPxs),
      });
    }
  }
  addEventListener('pagehide', emitScrollSummary, { passive: true });
  addEventListener('beforeunload', emitScrollSummary, { passive: true });

  // --- Engagement Time ---
  // Actual visible time. Pauses when tab is hidden. Sent on unload.
  var engageStart = document.hidden ? 0 : Date.now();
  var totalEngage = 0;

  document.addEventListener("visibilitychange", function () {
    if (document.hidden) {
      // Tab hidden — bank the time
      if (engageStart) totalEngage += Date.now() - engageStart;
      engageStart = 0;
      push("vc", { h: 1 }); // visibility change: hidden
    } else {
      // Tab visible — restart timer
      engageStart = Date.now();
      push("vc", { h: 0 }); // visibility change: visible
    }
  });

  // --- Hover Intent ---
  // 500ms+ hover on interactive elements. Signals consideration.
  // Uses mouseenter/mouseleave with per-element timers to avoid race conditions.
  var hoverTimers = new Map();
  document.addEventListener("mouseenter", function (ev) {
    // T7: stamp dwell start on any element (not just interactive) so we can
    // compute hover duration for any subsequent click — including dead clicks.
    try { hoverEnterTime.set(ev.target, Date.now()); } catch (_) {}
    if (!isInteractive(ev.target)) return;
    var target = ev.target;
    var timer = setTimeout(function () {
      // T7: include dwell_ms so the server can tell long-hesitation hovers
      // ("considered it, clicked slowly") from quick ones ("passed through").
      push("hi", { el: eid(target), dwell: getHoverDwell(target) || undefined });
      hoverTimers.delete(target);
    }, HOVER_DELAY);
    hoverTimers.set(target, timer);
  }, { passive: true, capture: true });
  document.addEventListener("mouseleave", function (ev) {
    var timer = hoverTimers.get(ev.target);
    if (timer) { clearTimeout(timer); hoverTimers.delete(ev.target); }
    try { hoverEnterTime.delete(ev.target); } catch (_) {}
  }, { passive: true, capture: true });

  // --- Viewport Snapshot ---
  // Captures which interactive elements are visible at key moments.
  // IntersectionObserver maintains a running Set — snapshot reads it instantly, no DOM scan.
  // MutationObserver catches dynamically added elements (Shopify, AJAX, SPAs).
  var visibleSet = new Set();
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      entry.isIntersecting ? visibleSet.add(entry.target) : visibleSet.delete(entry.target);
    });
  });

  // Observe all interactive elements under a root
  function observeInteractive(root) {
    (root || document).querySelectorAll("a,button,input,select,textarea,[role=button],[tabindex]").forEach(function (el) {
      if (!el._pxo) { el._pxo = 1; io.observe(el); }
    });
  }

  // MutationObserver for dynamic pages — debounced to 500ms to avoid thrashing
  var mutDebounce = null;
  function initObservers() {
    observeInteractive();
    // Watch for dynamically added interactive elements (SPAs, AJAX, Shopify)
    try {
      var mo = new MutationObserver(function () {
        clearTimeout(mutDebounce);
        mutDebounce = setTimeout(function () { observeInteractive(); }, 500);
      });
      mo.observe(document.body, { childList: true, subtree: true });
    } catch (_) {}
  }

  // Take a viewport snapshot — reads the running Set, caps at 20 elements
  function snapViewport(trigger) {
    var els = [];
    visibleSet.forEach(function (el) {
      if (els.length < 20) els.push(eid(el));
    });
    push("vs", { tr: trigger, el: els });
  }

  // Widget overlap probe — catches visually colliding third-party widgets
  // (chat + rewards, cookie banner + intercom). Containers live in our document
  // even when the widget renders in an iframe, so we compare bounding rects.
  function widgetOverlapProbe() {
    try {
      var cands = [], W = window.innerWidth, H = window.innerHeight;
      var q = document.querySelectorAll('iframe,[class*="widget"],[id*="widget"],[class*="chat"],[id*="chat"],[class*="reward"],[id*="reward"],[class*="loyalty"],[class*="popup"],[data-hook*="chat"],[data-hook*="wallet"],[data-hook*="reward"],[data-testid*="chat"],[data-testid*="wallet"],[id^="intercom-"],[id^="drift-"],[id^="gorgias-"],[id^="tidio-"],[id^="tawk-"],[id^="zopim"],[id*="smile"],[id*="wix-"],[id*="rivo"],[class*="rivo"],[class*="ba-loy"],[class*="ba_loy"],[class*="launcher"],[class*="nudgify"],[id*="judgeme"],[class*="judgeme"],[class*="yotpo"],[id*="yotpo"],[class*="loyaltylion"],[class*="stamped"],[class*="growave"]');
      for (var i = 0; i < q.length && cands.length < 12; i++) {
        var n = q[i], cs = getComputedStyle(n);
        if (!/fixed|sticky|absolute/.test(cs.position)) continue;
        if (cs.display === "none" || cs.visibility === "hidden") continue;
        // 2026-06-09 — invisible elements can't visually overlap anything.
        // PayPal & co. stack opacity:0 / pointer-events:none staging frames
        // on top of each other; counting those produced 100%-overlap wo
        // pairs (721 distinct pairs on Tiger Friday) that are pure noise.
        if (parseFloat(cs.opacity) === 0 || cs.pointerEvents === 'none') continue;
        var r = n.getBoundingClientRect();
        if (r.width < 30 || r.height < 30) continue;
        if (r.width > W * 0.7 && r.height > H * 0.7) continue; // full-page overlay
        cands.push({ el: eid(n), r: r });
      }
      var hits = [];
      for (var a = 0; a < cands.length && hits.length < 3; a++) {
        for (var b = a + 1; b < cands.length && hits.length < 3; b++) {
          var ra = cands[a].r, rb = cands[b].r;
          var ix = Math.max(0, Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left));
          var iy = Math.max(0, Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top));
          var area = ix * iy;
          if (area < 400) continue;
          var pct = Math.round((area / Math.min(ra.width * ra.height, rb.width * rb.height)) * 100);
          if (pct >= 20) hits.push({ a: cands[a].el, b: cands[b].el, pct: pct });
        }
      }
      if (hits.length) push("wo", { vw: W, hits: hits });
    } catch (_) {}
  }

  // Layout probe — detect nav wrapping, overflow, content offset. Pure structural data, zero PII.
  function layoutProbe() {
    try {
      // Single selector instead of 8 separate querySelectorAll calls
      var nav = document.querySelector('header nav, nav, [role="navigation"], .w-nav-menu, .header__inline-menu, .main-navigation, .navPages, .gh-head-menu');
      var hdr = document.querySelector('header, [role="banner"], #SITE_HEADER, .site-header');
      var main = document.querySelector('main, [role="main"], #MainContent, #content, .site-content');
      var d = { vw: window.innerWidth, vh: window.innerHeight };
      if (hdr) d.hh = hdr.offsetHeight;
      if (nav) {
        d.nh = nav.offsetHeight;
        // Check if nav items wrap — compare first and last child offsetTop
        var items = nav.querySelectorAll(':scope > ul > li, :scope > div > a, :scope > a');
        if (items.length > 1) {
          var firstTop = items[0].offsetTop, wraps = 0;
          for (var i = 1; i < items.length; i++) { if (items[i].offsetTop > firstTop + 5) { wraps++; break; } }
          if (wraps) d.nw = 1; // nav wrapped
        }
      }
      if (main) d.cy = Math.round(main.getBoundingClientRect().top); // content Y position
      // Horizontal overflow: capture actual delta (scrollWidth - innerWidth) so the
      // server can distinguish 6px rounding noise from a 300px layout bug. Only set
      // ox=1 when the delta is >10px (below that is typically a scrollbar-width
      // artifact on Windows/Linux Firefox, not a real overflow).
      var oxd = document.documentElement.scrollWidth - window.innerWidth;
      if (oxd > 10) {
        d.ox = 1; d.oxd = oxd;
        // Walk DOWN from <body> to find the deepest element that's still
        // extending past the viewport. This is usually the true culprit —
        // the parent overflows because its child does. We cap depth at 20
        // and elements examined at 200 so we never hang on pathological DOMs.
        try {
          var cur = document.body, depth = 20, overflowEl = null;
          while (cur && depth-- > 0) {
            var offendingChild = null, offendingWidth = 0;
            var kids = cur.children;
            for (var i = 0; i < kids.length && i < 50; i++) {
              var c = kids[i];
              var rc = c.getBoundingClientRect();
              // right edge past viewport + non-zero width + non-decorative
              if (rc.right > window.innerWidth + 5 && rc.width > offendingWidth) {
                offendingChild = c; offendingWidth = rc.width;
              }
            }
            if (!offendingChild) break;
            overflowEl = offendingChild;
            cur = offendingChild;
          }
          if (overflowEl) {
            d.oxel = eid(overflowEl);                  // selector of offending element
            d.oxw = Math.round(overflowEl.getBoundingClientRect().width); // its rendered width
          }
        } catch (_) {}
      }
      push("lp", d);
    } catch (e) {}
  }

  // Initial snapshot on DOM ready (small delay for IntersectionObserver to fire)
  function onReady() {
    initObservers();
    setTimeout(function () { snapViewport("init"); }, 100);
    setTimeout(layoutProbe, 200); // layout probe after render settles
    setTimeout(widgetOverlapProbe, 3000); // widget overlap: wait 3s for async chat/reward widgets to load
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", onReady);
  } else {
    onReady();
  }

  // --- Text Selection Tracking ---
  // Captures element where user selected text + char count. Never captures text content.
  var selTimer = null;
  document.addEventListener("selectionchange", function () {
    clearTimeout(selTimer);
    selTimer = setTimeout(function () {
      var sel = window.getSelection();
      if (!sel || !sel.rangeCount || sel.isCollapsed) return;
      var node = sel.anchorNode;
      var parent = node && node.nodeType === 3 ? node.parentElement : node;
      if (!parent) return;
      var range = sel.getRangeAt(0);
      var len = range.toString().length;
      if (len > 0) push("ts", { el: eid(parent), n: len });
    }, 1000);
  });
  document.addEventListener("copy", function (ev) {
    var sel = window.getSelection();
    var node = sel && sel.anchorNode;
    var parent = node && node.nodeType === 3 ? node.parentElement : node;
    if (parent) push("cp", { el: eid(parent), n: (sel.toString() || "").length });
  });

  // --- Keyboard Signal Categories ---
  // Only captures Tab and Escape — never alphanumeric. Tracks keyboard rage (rapid Escape).
  var escTimes = [];
  document.addEventListener("keydown", function (ev) {
    if (ev.key === "Escape") {
      var now = Date.now();
      escTimes.push(now);
      escTimes = escTimes.filter(function (t) { return now - t < 2000; });
      push("kb", { k: "esc", r: escTimes.length });
    } else if (ev.key === "Tab") {
      push("kb", { k: "tab" });
    }
  });

  // --- Navigation Patterns ---
  // Track all page changes: popstate (back/forward), pushState/replaceState (SPA navigation)
  var lastPath = location.pathname;
  var cvFired = false; // only fire conversion once per session
  var CV_PATTERNS = /\/(thank|order.?confirm|checkout.?complete|purchase.?success|receipt|confirmation)/i;
  function onPageChange(trigger) {
    var newPath = location.pathname;
    if (newPath !== lastPath) {
      lastPath = newPath;
      push("nb", { p: newPath, tr: trigger });
      // Auto-detect conversion pages
      if (!cvFired && CV_PATTERNS.test(newPath)) {
        push("cv", { p: newPath });
        cvFired = true;
      }
    }
  }
  window.addEventListener("popstate", function () { onPageChange("pop"); });
  // Intercept pushState/replaceState for SPA route changes — return original result
  var origPush = history.pushState, origReplace = history.replaceState;
  history.pushState = function () { var ret = origPush.apply(this, arguments); onPageChange("push"); return ret; };
  history.replaceState = function () { var ret = origReplace.apply(this, arguments); onPageChange("replace"); return ret; };
  // 2026-06-11 (#47260) — fire conversion on the INITIAL load too. onPageChange
  // only runs on SPA route changes (popstate/pushState/replaceState), but
  // Shopify and traditional order-confirmation pages (/thank_you, /thank-you,
  // order-confirmation, receipt) load as FULL page navigations, so the cv
  // signal never fired for them. Result: zero conversion events across every
  // site for 90 days. Checking the landing path on init counts a shopper who
  // arrives directly on a conversion page (the common ecommerce case).
  if (!cvFired && CV_PATTERNS.test(location.pathname)) {
    push("cv", { p: location.pathname, init: 1 });
    cvFired = true;
  }
  document.addEventListener("auxclick", function (ev) {
    if (ev.button === 1) { // Middle click
      var el = ev.target.closest ? ev.target.closest("a") : ev.target;
      if (el) push("ax", { el: eid(el) });
    }
  });

  // --- Time to First Interaction (TTFI) ---
  // Measures ms from page load to first user interaction. Fires once, self-destructs.
  var ttfiStart = performance.now();
  function onFirstInteraction() {
    push("tf", { tf: Math.round(performance.now() - ttfiStart) });
    document.removeEventListener("click", onFirstInteraction, true);
    document.removeEventListener("keydown", onFirstInteraction, true);
    window.removeEventListener("scroll", onFirstInteraction);
  }
  document.addEventListener("click", onFirstInteraction, { once: true, capture: true, passive: true });
  document.addEventListener("keydown", onFirstInteraction, { once: true, capture: true, passive: true });
  window.addEventListener("scroll", onFirstInteraction, { once: true, passive: true });

  // --- Scroll Kinematics ---
  // Tracks scroll velocity, direction reversals, and pauses.
  // Phase 2 (2026-05-06) — velocity-bucket distances added for Nina patterns
  // (content:scroll_speed_skim, content:shallow_read, content:drop_cliff).
  // Pure kinematics — no DOM text. PII-safe by construction.
  var prevScrollY = window.scrollY, prevScrollT = performance.now();
  var scrollDir = 0, reversals = 0, totalDist = 0;
  var minVel = Infinity, maxVel = 0;
  var velSamples = 0, velSum = 0;       // for avg
  var skimDist = 0, readDist = 0;        // distance buckets in px (px/s thresholds)
  var pauses = [], lastScrollTime = 0, scrollPauseTimer = null;
  var rafPending = false;

  window.addEventListener("scroll", function () {
    if (rafPending) return;
    rafPending = true;
    requestAnimationFrame(function () {
      rafPending = false;
      var y = window.scrollY, t = performance.now();
      var dy = y - prevScrollY, dt = t - prevScrollT;
      if (dt < 50) return; // Throttle to ~20fps

      var vel = Math.abs(dy / dt); // px/ms
      if (vel > 0.01 && vel < 100) { // Filter noise
        if (vel < minVel) minVel = vel;
        if (vel > maxVel) maxVel = vel;
        velSamples++; velSum += vel;
        // Bucket by px/s (vel * 1000 = px/s). >200 px/s = skim; 50-200 = active read.
        var pxs = vel * 1000;
        if (pxs > 200) skimDist += Math.abs(dy);
        else if (pxs >= 50) readDist += Math.abs(dy);
      }
      totalDist += Math.abs(dy);

      // Direction reversal detection
      var dir = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
      if (dir !== 0 && scrollDir !== 0 && dir !== scrollDir) reversals++;
      if (dir !== 0) scrollDir = dir;

      prevScrollY = y; prevScrollT = t;
      lastScrollTime = t;

      // Pause detection: if no scroll for 2s after scrolling
      clearTimeout(scrollPauseTimer);
      scrollPauseTimer = setTimeout(function () {
        var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
        if (scrollHeight > 0) {
          pauses.push(Math.round((window.scrollY / scrollHeight) * 100));
        }
      }, 2000);
    });
  }, { passive: true });

  // --- Form-field abandonment (Tier 2 #4, 2026-05-05) ---
  // Track which form field the user last interacted with. On
  // navigation/unload, if the user touched a form but didn't submit,
  // emit `fa` (form abandonment) with the last-touched field. Tells
  // us "85% of bails happened on field 4 of 7" — the killer signal
  // for one-button-flow products like HR-tech.
  //
  // Privacy:
  //   - We capture the field's identifier (id, name, type, position),
  //     NEVER the value the user typed. Pixel's PII guard would scrub
  //     textContent anyway, and we don't read .value at all.
  //   - Honors data-harvv-private="true" on the form (skip).
  //   - Skips password/credit-card inputs entirely (autocomplete sniff).
  (function trackFormAbandonment() {
    var lastField = null;     // most recent focused/typed field
    var formSubmitted = false; // any form submitted in this session?
    var fiEmitted = {};       // per-field: emitted `fi` already?
    var faEmitted = false;    // already emitted `fa` once on this page?
    // Per-form distinct-field touch counts. A single global counter overcounted
    // badly (a one-field newsletter popup showed avg n=47.6 because EVERY
    // focusin anywhere on the page incremented it). Scope is keyed by the
    // field's <form>; inputs with no form scope to themselves, so a standalone
    // input counts as its own 1-field form.
    var formScopes = []; // [{ form: <el>, keys: {}, count: 0 }]

    // Build a stable field identifier without reading any value.
    function fieldKey(el) {
      if (!el) return null;
      // Skip password / sensitive types
      var t = String(el.type || '').toLowerCase();
      if (t === 'password' || t === 'hidden' || t === 'cc-number' || t === 'credit-card') return null;
      // Honor private container
      var p = el;
      for (var i = 0; i < 20 && p; i++) {
        if (p.getAttribute && (p.getAttribute('data-harvv-private') === 'true')) return null;
        p = p.parentElement;
      }
      // Build identifier: tag.name OR tag#id OR tag[type=...]
      var tag = (el.tagName || '').toLowerCase();
      if (!tag) return null;
      var id = el.id ? ('#' + String(el.id).slice(0, 40)) : '';
      var nm = (!id && el.name) ? ('[name=' + String(el.name).slice(0, 40) + ']') : '';
      var ty = (!id && !nm && t) ? ('[type=' + t + ']') : '';
      return (tag + id + nm + ty).slice(0, 80);
    }

    // Find the field's position within its containing form (1-indexed).
    function fieldPosition(el) {
      try {
        var form = el.form;
        if (!form) return null;
        var fields = form.querySelectorAll('input, textarea, select');
        for (var i = 0; i < fields.length; i++) {
          if (fields[i] === el) return i + 1;
        }
      } catch (_) {}
      return null;
    }

    function isFormField(el) {
      if (!el || !el.tagName) return false;
      var t = el.tagName.toUpperCase();
      return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT';
    }

    // Distinct-field touch count scoped to the field's form (one entry per
    // form; a standalone input scopes to itself). ES5: plain array + linear
    // scan, no Map/WeakMap.
    function scopeFor(el) {
      var form = el.form || el;
      for (var i = 0; i < formScopes.length; i++) {
        if (formScopes[i].form === form) return formScopes[i];
      }
      var s = { form: form, keys: {}, count: 0 };
      formScopes.push(s);
      return s;
    }

    document.addEventListener('focusin', function (e) {
      if (!isFormField(e.target)) return;
      var key = fieldKey(e.target);
      if (!key) return;
      var sc = scopeFor(e.target);
      if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
      lastField = { key: key, pos: fieldPosition(e.target), scope: sc };
      // First-touch event per field — server uses for has_form_interaction.
      // `fp` (field position) is a SEPARATE key from the page-path `p` that
      // push() injects on every event, so it is never clobbered by the path.
      if (!fiEmitted[key]) {
        fiEmitted[key] = true;
        push("fi", { f: key, fp: lastField.pos || undefined });
      }
    }, true);

    document.addEventListener('input', function (e) {
      if (!isFormField(e.target)) return;
      var key = fieldKey(e.target);
      if (!key) return;
      // Count fields touched via typing/selection too, not only focusin — some
      // controls (radios, selects) fire `input` without a preceding focusin, so
      // without this the field they abandon on counts as 0.
      var sc = scopeFor(e.target);
      if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
      lastField = { key: key, pos: fieldPosition(e.target), scope: sc };
    }, true);

    document.addEventListener('submit', function () {
      formSubmitted = true;
    }, true);

    function emitAbandonment() {
      if (faEmitted) return;
      if (formSubmitted) return;
      if (!lastField) return;
      faEmitted = true;
      // n = distinct fields touched in THIS form (scoped), not page-wide.
      var n = (lastField.scope && lastField.scope.count) || 0;
      push("fa", { f: lastField.key, fp: lastField.pos || undefined, n: n });
    }

    // Fire on tab close + on hashchange (SPA navigation)
    window.addEventListener('pagehide', emitAbandonment);
    window.addEventListener('beforeunload', emitAbandonment);
    window.addEventListener('hashchange', emitAbandonment);
  })();

  // --- JS Errors + Performance + Network ---
  var ec = 0;
  window.addEventListener("error", function (e) {
    if (ec++ < 3) push("er", { m: (e.message || "").slice(0, 80), f: (e.filename || "").replace(/.*\//, "").slice(0, 20), l: e.lineno });
  });
  window.addEventListener("unhandledrejection", function (e) {
    if (ec++ < 3) push("er", { m: ("P:" + (e.reason&&e.reason.message||"")).slice(0,80) });
  });

  // --- User-Visible Notifications (T40, 2026-04-24) ---
  // When a click produces a toast/alert/inline error, capture the message text
  // + inventory/error classification. The receiver uses `un:inv=1` events to
  // reframe dead-click issues on product elements as inventory restock issues
  // rather than generic "broken CTA". Example: Shopify "Add to Cart" → "Sold
  // out — notify me when available" is an inventory signal, not a UX bug.
  //
  // Gates (cheap → expensive): (a) 5s post-click window, (b) selector match,
  // (c) inventory OR error keyword in text. Skips cookie banners, promo
  // modals, and any node inside data-harvv-private. Emails + long digit
  // sequences are scrubbed. Limit 8/session.
  var unc = 0;
  var UN_SEL = '[role="alert"],[role="status"],[aria-live="polite"],[aria-live="assertive"],.toast,.toaster,.notification,.notice,.flash,.flash-message,.snackbar,.alert-danger,.alert-warning,.error-message,.cart-error,.form-error,.product-form__error-message,.price-item--sold-out,.badge--sold-out';
  var UN_INV = /out[\s-]?of[\s-]?stock|sold[\s-]?out|unavailable|back[\s-]?in[\s-]?stock|restock|notify[\s\w-]{0,12}available|no[\s-]?longer[\s-]?available|only\s+\d+\s+(?:left|in\s+stock)/i;
  var UN_ERR = /\b(error|failed?|problem|try\s+again|invalid|required|incorrect)\b/i;
  try {
    var unObs = new MutationObserver(function (muts) {
      if (unc >= 8) return;
      var now = Date.now();
      if (now - lastClickAt > 5000) return; // only post-click
      for (var mi = 0; mi < muts.length && unc < 8; mi++) {
        var added = muts[mi].addedNodes;
        for (var ni = 0; ni < added.length && unc < 8; ni++) {
          var n = added[ni];
          if (!n || n.nodeType !== 1) continue;
          if (n.closest && n.closest('[data-harvv-private="true"]')) continue;
          var el = (n.matches && n.matches(UN_SEL)) ? n : (n.querySelector && n.querySelector(UN_SEL));
          if (!el) continue;
          var txt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
          if (!txt || txt.length < 4) continue;
          txt = txt.slice(0, 140).replace(/\S+@\S+\.\S+/g, "[email]").replace(/\b\d{10,}\b/g, "[num]");
          var isInv = UN_INV.test(txt) ? 1 : undefined;
          var isErr = UN_ERR.test(txt) ? 1 : undefined;
          if (!isInv && !isErr) continue; // skip promo/cookie/decorative toasts
          push("un", {
            t: txt,
            c: (el.className && typeof el.className === "string") ? el.className.slice(0, 40) : undefined,
            r: (el.getAttribute && (el.getAttribute("role") || el.getAttribute("aria-live"))) || undefined,
            src: lastClickEid || undefined,
            d: Math.round(now - lastClickAt),
            inv: isInv,
            err: isErr
          });
          unc++;
        }
      }
    });
    unObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
  } catch (_) {}
  try { var lcp = 0, cls = 0, fcp = 0, tbt = 0;
    // Phase 2 (2026-05-06) — track LCP element type so detection can
    // distinguish image-LCP failures (Nina deliverable: image optimization)
    // from JS/text-LCP failures. PII-safe: tag name only + URL-presence bool.
    // Never sends the URL value itself.
    var lcpEl = '', lcpHasUrl = 0;
    new PerformanceObserver(function (l) {
      var e = l.getEntries();
      if (e.length) {
        var last = e[e.length - 1];
        lcp = last.startTime | 0;
        try {
          lcpEl = (last.element && last.element.tagName ? String(last.element.tagName).toLowerCase() : '').slice(0, 8);
          lcpHasUrl = last.url ? 1 : 0;
        } catch (_) {}
      }
    }).observe({ type: "largest-contentful-paint", buffered: true });
    // Phase 2.5 (2026-05-06) — CLS source attribution. layout-shift entries
    // include a `sources[]` array of nodes that shifted. We capture short
    // selectors for the top contributors so detection can name the culprit
    // ("hero image is what shifted at 1.2s"), not just "your CLS is 0.18".
    // PII-safe: selector strings only, no DOM text.
    var clsSources = [];
    new PerformanceObserver(function (l) {
      l.getEntries().forEach(function (e) {
        if (e.hadRecentInput) return;
        cls += e.value;
        if (clsSources.length >= 8) return;
        try {
          var srcs = e.sources || [];
          for (var si = 0; si < srcs.length && clsSources.length < 8; si++) {
            var n = srcs[si].node;
            if (!n || !n.tagName) continue;
            var sel = n.tagName.toLowerCase();
            if (n.id) sel += '#' + String(n.id).slice(0, 24);
            else if (n.className && typeof n.className === 'string') {
              var c = n.className.split(/\s+/)[0];
              if (c) sel += '.' + c.slice(0, 24);
            }
            clsSources.push({ s: sel.slice(0, 60), v: +e.value.toFixed(3) });
          }
        } catch (_) {}
      });
    }).observe({ type: "layout-shift", buffered: true });
    // FCP (First Contentful Paint) — widely supported paint API entry.
    try {
      var paints = performance.getEntriesByType("paint") || [];
      for (var pi = 0; pi < paints.length; pi++) {
        if (paints[pi].name === "first-contentful-paint") fcp = paints[pi].startTime | 0;
      }
    } catch (_) {}
    // TBT (Total Blocking Time) — sum of (longtask_duration - 50ms) over all long tasks.
    try {
      new PerformanceObserver(function (l) {
        l.getEntries().forEach(function (e) { tbt += Math.max(0, (e.duration | 0) - 50); });
      }).observe({ type: "longtask", buffered: true });
    } catch (_) {}
    setTimeout(function () {
      var n = performance.getEntriesByType("navigation")[0];
      var tti = n ? Math.round(n.domInteractive || 0) : 0;
      var plt = n ? Math.round(n.loadEventEnd || n.domContentLoadedEventEnd || 0) : 0;
      // Re-read FCP in case it arrived after init (paint entries aren't observer-streamed here)
      if (!fcp) {
        try {
          var p2 = performance.getEntriesByType("paint") || [];
          for (var pj = 0; pj < p2.length; pj++) {
            if (p2[pj].name === "first-contentful-paint") fcp = p2[pj].startTime | 0;
          }
        } catch (_) {}
      }
      push("pf", {
        ttfb: n ? n.responseStart | 0 : 0,
        fcp: fcp || undefined,
        lcp: lcp,
        cls: (cls * 1e3 | 0) / 1e3,
        tbt: tbt || undefined,
        tti: tti,
        plt: plt,
        inp: worstInp > 0 ? Math.round(worstInp) : undefined,
        // T7 (2026-04-23): network type for attribution. Lets us separate
        // "slow network → dead click" from "broken CTA → dead click".
        net: (navigator.connection && navigator.connection.effectiveType) || undefined,
        // Phase 2 (2026-05-06) — LCP element discriminator. PII-safe:
        // tag name only + URL-presence boolean. Detection layer uses
        // lcp_el='img' + lcp>2500 to fire content:image_lcp_fail.
        lcp_el: lcpEl || undefined,
        lcp_url: lcpHasUrl || undefined,
        // Phase 2.5 (2026-05-06) — CLS source attribution (top 5
        // contributors). Detection layer uses this to fire
        // layout:cls_culprit naming the element that shifted.
        cls_src: clsSources.length ? clsSources.slice(0, 5) : undefined,
      });
    }, 5e3);
  } catch (_) {}

  // --- Long Tasks (main thread blocking >50ms) ---
  // Correlates with rage clicks and unresponsive UI. Chromium-only (~86% of traffic).
  var ltc = 0;
  try {
    new PerformanceObserver(function (l) {
      l.getEntries().forEach(function (e) {
        if (ltc++ < 5) push("lt", { d: Math.round(e.duration), n: (e.attribution && e.attribution[0] && e.attribution[0].name || "").slice(0, 40) });
      });
    }).observe({ type: "longtask", buffered: true });
  } catch (_) {}

  // --- INP (Interaction to Next Paint) — Core Web Vital ---
  // Measures responsiveness: delay between user input and visual update.
  // Supported in Chrome 96+, Safari 26.2+, Firefox 122+.
  // 2026-05-06 (Tier 1): emit per-event INP for slow interactions so the
  // server can answer "WHICH button is slow?" today only the page-aggregated
  // INP is captured in pf.
  var worstInp = 0;
  var inpEventCount = 0;
  try {
    new PerformanceObserver(function (l) {
      l.getEntries().forEach(function (e) {
        if (e.duration > worstInp) worstInp = e.duration;
        // Per-interaction INP: slow events (>200ms) get their own emit, capped
        // at 5/page so we don't flood the wire on a janky page.
        if (e.duration > 200 && inpEventCount++ < 5) {
          var tgt = e.target || (e.attribution && e.attribution[0]);
          push("ie", {
            d: Math.round(e.duration),
            n: e.name,
            el: tgt ? eid(tgt) : undefined,
          });
        }
      });
    }).observe({ type: "event", buffered: true, durationThreshold: 100 });
  } catch (_) {}

  // 2026-05-06 (Tier 1) — Soft-navigation Performance Observer.
  // Modern SPAs (Hydrogen, Next.js) emit `soft-navigation` entries when
  // client-side route changes happen without a full page reload. Gives us
  // accurate timing for SPA route transitions. ~30 bytes when supported.
  try {
    if (PerformanceObserver.supportedEntryTypes && PerformanceObserver.supportedEntryTypes.indexOf('soft-navigation') !== -1) {
      var softNavCount = 0;
      new PerformanceObserver(function (l) {
        l.getEntries().forEach(function (e) {
          if (softNavCount++ < 10) {
            push("sn", {
              p: e.name && e.name.replace(/^https?:\/\/[^/]+/, '') || location.pathname,
              ms: Math.round(e.duration),
            });
          }
        });
      }).observe({ type: 'soft-navigation', buffered: true });
    }
  } catch (_) {}

  // 2026-05-06 (Tier 1) — Print intent. Strong "user wants to keep this
  // page" signal — frequently triggered on receipts/confirmations/specs
  // when the email isn't sufficient. ~20 bytes.
  try {
    addEventListener('beforeprint', function () {
      push("pi", { p: location.pathname });
    }, { passive: true, once: true });
  } catch (_) {}

  // 2026-05-06 (Tier 1) — Idle time within session. Emits `id` events when
  // ≥30s passes between meaningful interactions. Distinct from `vc`
  // (visibility change) which fires when the tab is hidden — idle is "tab
  // active but user not interacting." Useful for funnel-stall analysis.
  // Capped at 8/session so a long idle background tab doesn't generate spam.
  (function trackIdle() {
    var lastActive = Date.now();
    var idleEmits = 0;
    var IDLE_THRESHOLD_MS = 30000;
    // 2026-06-09 — ceiling on the gap. lastActive only updates on
    // interaction ticks, so a laptop sleep / frozen mobile tab / bfcache
    // restore / clock jump made the first post-wake interaction emit a
    // "gap" of hours or DAYS as one id event (prod max: 2,530,469,429 ms
    // = 29 days; two events exceeded int4 max and crashed any ::int cast
    // server-side). A gap above 30 minutes is suspension, not idling —
    // drop it entirely rather than clamp (a clamped value would still
    // be a lie about visible idle time).
    var IDLE_CEILING_MS = 1800000;
    function tick() {
      try {
        var now = Date.now();
        var gap = now - lastActive;
        if (gap >= IDLE_THRESHOLD_MS && gap <= IDLE_CEILING_MS && document.visibilityState === 'visible' && idleEmits++ < 8) {
          push("id", { ms: Math.round(gap) });
        }
        lastActive = now;
      } catch (_) {}
    }
    // Reset the anchor when the page hides/shows or returns from bfcache,
    // so suspended wall-clock time never masquerades as visible idle.
    function resetAnchor() { try { lastActive = Date.now(); } catch (_) {} }
    addEventListener('click', tick, { passive: true, capture: true });
    addEventListener('keydown', tick, { passive: true, capture: true });
    addEventListener('scroll', tick, { passive: true, capture: true });
    document.addEventListener('visibilitychange', resetAnchor, { passive: true });
    addEventListener('pageshow', resetAnchor, { passive: true });
  })();

  // 2026-05-06 (Tier 1) — Multi-tab presence via BroadcastChannel.
  // When a visitor opens >1 tab of the customer's site, each tab announces
  // itself; we count peer responses. Useful for distinguishing "research
  // mode" (multiple tabs) from "single-tab purchase intent." ~80 bytes.
  // Tab-local — never leaves the browser; respects all privacy guarantees.
  try {
    if (typeof BroadcastChannel === 'function') {
      var bc = new BroadcastChannel('harvv:tabs');
      var peers = 0;
      bc.onmessage = function (e) {
        if (e.data === 'present?') bc.postMessage('here');
        else if (e.data === 'here') peers++;
      };
      bc.postMessage('present?');
      setTimeout(function () {
        if (peers > 0) push("mt", { n: peers });
      }, 1500);
    }
  } catch (_) {}

  // 2026-05-06 (Tier 2) — Resource timing for top-3 slowest resources >500ms.
  // Answers: "which images / scripts / CSS files are dragging down LCP?"
  // URL is scrubbed via the existing scrubQuery() before emission.
  // Capped at 3/page so a junk-asset page can't generate spam.
  try {
    var rsCount = 0;
    new PerformanceObserver(function (l) {
      l.getEntries().forEach(function (r) {
        if (rsCount >= 3) return;
        if (r.duration < 500) return;
        if (!r.name || /\b(harvv\.com|cloudflare|googleads|facebook|datadog)\b/.test(r.name)) return;
        var path = '';
        try { path = scrubQuery((new URL(r.name)).pathname.slice(0, 100)); } catch (_) { path = (r.name || '').slice(0, 100); }
        rsCount++;
        push("rs", {
          ms: Math.round(r.duration),
          sz: r.transferSize || undefined,
          t: (r.initiatorType || 'other').slice(0, 8),
          u: path,
        });
      });
    }).observe({ type: 'resource', buffered: true });
  } catch (_) {}

  // 2026-05-06 (Tier 2) — Filter / search refinement chain.
  // Fires when user clicks a filter button or refines a search input.
  // Emits sequence-counter and query LENGTH only (not text content).
  // Customer signals: data-filter attribute, role=button inside .filter-*,
  // form submit to a search-like input. ~120 bytes.
  (function trackFilterAndSearch() {
    var filterCount = 0, searchRefineCount = 0, lastQLen = 0;
    addEventListener('click', function (e) {
      try {
        var t = e.target;
        if (!t || !t.closest) return;
        var fc = t.closest('[data-filter], [data-facet], .filter-btn, [role="button"][aria-pressed]');
        if (fc && filterCount++ < 20) {
          push("fl", { kind: 'f', n: filterCount, el: eid(fc) });
        }
      } catch (_) {}
    }, { passive: true, capture: true });
    addEventListener('input', function (e) {
      try {
        var t = e.target;
        if (!t || t.tagName !== 'INPUT') return;
        var ty = (t.type || '').toLowerCase();
        var name = (t.name || t.id || '').toLowerCase();
        if (ty !== 'search' && !/search|query|q\b/.test(name)) return;
        var len = (t.value || '').length;
        if (len === 0) return;
        if (Math.abs(len - lastQLen) > 2 && searchRefineCount++ < 10) {
          push("fl", { kind: 's', qlen: len, n: searchRefineCount });
        }
        lastQLen = len;
      } catch (_) {}
    }, { passive: true, capture: true });
  })();

  // 2026-05-06 (Tier 2) — Pinch zoom + pull-to-refresh detection (mobile).
  // Pinch zoom = "text too small / image not legible" frustration signal.
  // Pull-to-refresh = "I think this page is broken or stale."
  // Both fire as `pz` events. Mobile-only (gated on touchstart support).
  // Capped at 3/page.
  try {
    if ('ontouchstart' in window) {
      var pzCount = 0;
      var pinchStart = 0, lastTouchStartY = 0;
      addEventListener('touchstart', function (e) {
        try {
          if (e.touches.length === 2) {
            var t1 = e.touches[0], t2 = e.touches[1];
            pinchStart = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
          } else if (e.touches.length === 1) {
            lastTouchStartY = e.touches[0].clientY;
          }
        } catch (_) {}
      }, { passive: true });
      addEventListener('touchmove', function (e) {
        try {
          if (e.touches.length === 2 && pinchStart > 0) {
            var t1 = e.touches[0], t2 = e.touches[1];
            var d = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
            if (Math.abs(d - pinchStart) > 60 && pzCount++ < 3) {
              push("pz", { kind: 'pinch' });
              pinchStart = 0;
            }
          } else if (e.touches.length === 1 && lastTouchStartY > 0 && (window.scrollY || 0) === 0) {
            var pull = e.touches[0].clientY - lastTouchStartY;
            if (pull > 120 && pzCount++ < 3) {
              push("pz", { kind: 'ptr' });
              lastTouchStartY = 0;
            }
          }
        } catch (_) {}
      }, { passive: true });
    }
  } catch (_) {}

  // 2026-05-06 (Tier 3) — UA Client Hints. Modern replacement for parsing
  // navigator.userAgent. getHighEntropyValues is async; we attach the brand
  // array (always available sync) + the deferred model/platformVersion in
  // a separate `uah` event when the promise resolves. Chromium-only — Safari
  // and Firefox don't expose userAgentData. ~120 bytes.
  try {
    if (navigator.userAgentData && typeof navigator.userAgentData.getHighEntropyValues === 'function') {
      var brand = (navigator.userAgentData.brands || []).filter(function (b) {
        return b.brand && !/Not.?A.?Brand/i.test(b.brand);
      })[0];
      navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'mobile', 'model']).then(function (h) {
        push("uah", {
          b: brand && brand.brand && brand.brand.slice(0, 20),
          m: h.mobile ? 1 : undefined,
          pf: (h.platform || '').slice(0, 16),
          pv: (h.platformVersion || '').slice(0, 16),
          md: (h.model || '').slice(0, 24),
        });
      }).catch(function () {});
    }
  } catch (_) {}

  // 2026-05-06 (Tier 3) — Service worker state. One bit: is the customer's
  // site currently controlled by a service worker? Diagnostic value for
  // "why is my page serving stale content?" / "why does it work offline?"
  // questions. ~30 bytes.
  try {
    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
      push("sw", { c: 1 });
    }
  } catch (_) {}

  // --- ReportingObserver (deprecation + browser intervention signals) ---
  // Catches deprecated API usage and browser interventions (e.g. blocked heavy ads).
  // Supported in Chrome 69+, Edge 79+, Firefox 115+, Safari 16.4+.
  try {
    if (window.ReportingObserver) {
      var roc = 0;
      new ReportingObserver(function (reports) {
        reports.forEach(function (r) {
          if (roc++ < 3) push("ro", { t: r.type, m: (r.body.message || "").slice(0, 80), src: (r.body.sourceFile || "").replace(/.*\//, "").slice(0, 30) });
        });
      }, { types: ["deprecation", "intervention"], buffered: true }).observe();
    }
  } catch (_) {}

  // --- Network Monitoring (fetch + resource) ---
  // Captures full URL, initiator script, request type, timing — like Chrome DevTools Network tab
  // Scrub query strings on paths known to carry user-generated content.
  // Keys with these names are replaced with `[scrubbed]` so a GET /api/search?q=my+private+question
  // becomes /api/search?q=[scrubbed]. POST bodies are never captured so those are safe.
  var SENSITIVE_QS_KEYS = /^(q|query|search|s|email|password|token|code|secret|message|text|content|chat|prompt|question)$/i;
  function scrubQuery(pathWithQuery) {
    var qIdx = pathWithQuery.indexOf("?");
    if (qIdx === -1) return pathWithQuery;
    var path = pathWithQuery.slice(0, qIdx);
    var qs = pathWithQuery.slice(qIdx + 1);
    var parts = qs.split("&").map(function (pair) {
      var eq = pair.indexOf("=");
      if (eq === -1) return pair;
      var k = pair.slice(0, eq);
      return SENSITIVE_QS_KEYS.test(k) ? k + "=[scrubbed]" : pair;
    });
    return path + "?" + parts.join("&");
  }

  // 2026-05-23 — GA4 Measurement Protocol observer. If a customer has GA4
  // (gtag.js) installed, every page-view + event fires an HTTP call to
  // `/g/collect` (or `/r/collect`, `/j/collect` for legacy). The query
  // string carries the event name, tracking ID, GA's client/session IDs,
  // and any numeric event parameters (value, items_qty, etc.). This
  // helper extracts a SAFE subset (no string event params, no user
  // properties — those can carry PII) and emits a `ga` event. Two
  // capabilities unlock from this:
  //   (1) Discrepancy / data-quality cases. We now know which GA4 events
  //       fired per session and can compare to our own detections to
  //       surface "your GA4 isn't catching this conversion" findings.
  //   (2) GA's session/client ID joins to ours, so server-side we can
  //       cross-reference our visitor_id with GA's perspective.
  // Returns null when the URL is not a collect call, or an object
  // suitable for push("ga", ...). Capped at 30 events per session to
  // avoid floods on event-chatty sites.
  var _gaEvCount = 0;
  function _parseGACollect(rawUrl) {
    if (_gaEvCount >= 30) return null;
    // Cheap rejects first — most fetches aren't GA.
    if (!rawUrl || rawUrl.indexOf("/g/collect") === -1 && rawUrl.indexOf("/r/collect") === -1 && rawUrl.indexOf("/j/collect") === -1) return null;
    // Must be a known GA endpoint host. Don't match accidentally on a
    // first-party path like /collect — only Google Analytics ones.
    if (rawUrl.indexOf("analytics.google.com") === -1 &&
        rawUrl.indexOf("google-analytics.com") === -1 &&
        rawUrl.indexOf("www.google.com/g/collect") === -1) return null;
    var qIdx = rawUrl.indexOf("?");
    if (qIdx === -1) return null;
    var qs = rawUrl.slice(qIdx + 1);
    // Lightweight parse — avoid URL/URLSearchParams to keep this fast on
    // event-chatty pages.
    var en = "", tid = "", cid = "", sid = "", value = null;
    var pairs = qs.split("&");
    for (var i = 0; i < pairs.length; i++) {
      var eq = pairs[i].indexOf("=");
      if (eq === -1) continue;
      var k = pairs[i].slice(0, eq);
      var v = pairs[i].slice(eq + 1);
      if (k === "en" && !en) en = decodeURIComponent(v).slice(0, 40);
      else if (k === "tid" && !tid) tid = decodeURIComponent(v).slice(0, 32);
      else if (k === "cid" && !cid) cid = v.slice(0, 32);  // GA client ID — already opaque
      else if (k === "sid" && !sid) sid = v.slice(0, 20);
      else if (k === "epn.value" && value === null) {
        var n = parseFloat(decodeURIComponent(v));
        if (!isNaN(n) && isFinite(n)) value = n;
      }
    }
    if (!en && !tid) return null;
    _gaEvCount++;
    var payload = { en: en || undefined, tid: tid || undefined };
    if (cid) payload.cid = cid;
    if (sid) payload.sid = sid;
    if (value !== null) payload.v = value;
    return payload;
  }
  // 2026-05-27 — Defensive wrap install. Punchmark + Quantum Qarat
  // hit "Maximum call stack size exceeded" because:
  //   1. We wrapped XMLHttpRequest.prototype.open and window.fetch in
  //      TWO places each, with no idempotency guard.
  //   2. A third-party tag with the (common) anti-pattern of calling
  //      `XMLHttpRequest.prototype.open.apply(this, args)` instead of
  //      saving its own reference creates a self-referential loop
  //      with our wrapper after both run.
  // Fix has four layers matching the Posthog/Hotjar/Segment standard:
  //   (a) Single wrap point per transport. The fs (form-submission)
  //       inference logic is folded into the same wrap that does nf
  //       (network-failure) detection. Below the form-submit listener
  //       there are no more transport wraps.
  //   (b) Idempotency marker on the prototype/fn so we never wrap twice
  //   (c) try/catch around the wrap installation so if anything fails
  //       to assign, we exit silently — the host page is never broken
  //   (d) try/catch INSIDE the wrapper so internal logic bugs cannot
  //       block the original call. We always reach `apply(this, args)`.
  // Symptom on Quantum Qarat: 1,586 XHR failures across acsbapp,
  // affirm, klaviyo, gtag, and the site's own /api/async.php.

  // Shared helpers used by both transport wraps for fs (form-submit)
  // inference. Moved to function scope so the wrap blocks below can
  // reference them — previously they lived inside a later try block
  // which forced a second wrap site that ran after these.
  var SUBMIT_PATH_HINTS = /\/(?:api|graphql|submit|checkout|order|payment|signup|sign-up|register|login|sign-in|subscribe|contact|lead|kyc|onboard|outcome|track)\b/i;
  // 2026-06-15 — beacons/pollers that match SUBMIT_PATH_HINTS but are NOT form
  // submits: analytics collectors and the Shopify Storefront GraphQL the theme
  // polls. /api and /graphql in the hints catch these, so Tiger Friday's
  // /api/collect (~30k/day) and /api/<ver>/graphql.json polling were inflating
  // fs. Excluding them keeps real submit signal (checkout/login/lead) while
  // killing the polling floods at the source. Mirrors the 5xx detector's
  // NON_ACTIONABLE path list.
  var FS_PATH_EXCLUDE = /(?:\/collect|monorail|\/produce|beacon|telemetry|\/gtm|\/gtag|analytics|\/wpm|\/__|wc-analytics|cdn-cgi|\.well-known)|\/api\/\d{4}-\d{2}\/graphql/i;
  // 2026-06-09 — per-session cap on TRANSPORT-inferred fs (fetch/xhr only;
  // declarative <form> submits stay uncapped). SUBMIT_PATH_HINTS matches
  // /api and /track, so a theme that POLLS a same-origin API emits fs on
  // every poll: Tiger Friday's theme started polling Shopify's Storefront
  // GraphQL on 2026-06-03 and fs jumped to 1.2-1.6M/day (36% of their whole
  // event volume; p95 332 fs/session, max 3,512). Every sibling transport
  // event is capped (nf 5+5/page, ga 30/session, rs 3/page) — fs was the
  // only one with none. 20/session keeps real form/checkout signal (p50 on
  // healthy sites is < 5) while flooring polling floods.
  var fsTransportCount = 0;
  var FS_TRANSPORT_CAP = 20;
  function pushFsTransport(d) {
    if (fsTransportCount >= FS_TRANSPORT_CAP) return;
    fsTransportCount++;
    push('fs', d);
  }
  function templatize(pathname) {
    return String(pathname || '/').replace(/\/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi, '/:uuid')
      .replace(/\/\d{5,}/g, '/:id')
      .substring(0, 120);
  }

  var slowCount = 0, errCount = 0;
  try { if (window.fetch && !window.fetch.__harvv_wrapped) {
    var oF = window.fetch;
    // Confirm we're saving a real function reference, not a hostile
    // proxy that already re-references the live window.fetch.
    if (typeof oF !== 'function') throw new Error('fetch_not_fn');
    window.fetch.__harvv_wrapped = 1;
    // 2026-05-19 perf fix — resolve the `initiator` from a stack ONLY when
    // an `nf` event is actually about to be pushed. Previously this wrapper
    // formatted `new Error().stack` and ran a regex scan on EVERY fetch —
    // pure overhead on the 99% of requests that are fast + OK, since `nf`
    // only fires for failed/slow requests (and is capped at 5 each).
    // `Error.stack` formatting is one of the most expensive per-call ops in
    // JS; on a dashboard that fires many API calls it was a measurable tax.
    function _initiatorFrom(errObj) {
      try {
        var lines = ((errObj && errObj.stack) || "").split("\n");
        for (var li = 1; li < lines.length && li < 8; li++) {
          var line = lines[li];
          if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
            var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
            if (fileMatch) return fileMatch[1].replace(/.*\//, "").substring(0, 60);
            var pathMatch = line.match(/([^\/()\s]+\.js[:\d]*)/);
            if (pathMatch) return pathMatch[1].substring(0, 60);
          }
        }
      } catch (_) {}
      return undefined;
    }
    window.fetch = function (input, init) {
      // 2026-05-14 — signal post-click async response so any pending
      // deferred dead-click can be cancelled (Axel Off-Road learning).
      try { if (window.__harvvNetActivity) window.__harvvNetActivity(); } catch (_) {}
      var t0 = performance.now();
      var url = (typeof input === "string") ? input : (input && input.url) || "";
      // 2026-05-23 — GA4 collect observer. Inspect the raw URL BEFORE
      // it gets scrubbed below (the scrub strips epn.value etc.).
      try { var _gaPayload = _parseGACollect(url); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
      var fullUrl = "";
      var path = "";
      try {
        var u = new URL(url, location.origin);
        var scrubbedSearch = scrubQuery(u.pathname + u.search).slice(u.pathname.length);
        fullUrl = u.origin !== location.origin ? (u.origin + scrubQuery(u.pathname + u.search)).substring(0, 200) : (u.pathname + scrubbedSearch).substring(0, 80);
        path = u.pathname.substring(0, 80);
      } catch (_) {
        fullUrl = scrubQuery(url).substring(0, 200);
        path = url.split("?")[0].substring(0, 80);
      }
      // Classify request type from extension/path (no PII)
      var ext = path.split(".").pop().toLowerCase();
      var rt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg" || ext === "gif" || ext === "ico") ? "asset"
        : (path.indexOf("/api/") > -1 || path.indexOf("/graphql") > -1 || path.indexOf("/cart") > -1) ? "api" : "page";
      // Detect method
      var method = (init && init.method) ? init.method.toUpperCase() : "GET";
      // Capture a bare Error at call time so the initiator stays accurate
      // (the stack inside a .then() microtask would be wrong). `.stack` is
      // NOT accessed here — only formatted later in the rare nf branch.
      // Once both nf caps are exhausted, skip the capture entirely.
      var callerErr = (errCount < 5 || slowCount < 5) ? new Error() : null;
      // 2026-05-27 — fs (form-submission) inference, folded in from
      // the formerly-separate second wrap block. Mutating same-origin
      // requests to submission-like paths get an 'fs' event in the
      // same .then() callback below.
      var _fsMutating = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
      var _fsSameOrigin = false, _fsPathHint = false, _fsPath = null, _fsUrlObj = null;
      try {
        _fsUrlObj = new URL(url, location.origin);
        _fsSameOrigin = _fsUrlObj.origin === location.origin;
        _fsPathHint = SUBMIT_PATH_HINTS.test(_fsUrlObj.pathname) && !FS_PATH_EXCLUDE.test(_fsUrlObj.pathname);
        if (_fsMutating && _fsSameOrigin && _fsPathHint) _fsPath = templatize(_fsUrlObj.pathname);
      } catch (_) {}

      // 2026-05-26 — non-chaining observer pattern.
      // Previously: `return oF.apply(...).then(observer).catch(observer)`.
      // That added two microtasks of latency to the caller's promise chain
      // — small but real. Third-party apps with a timing-sensitive consumer
      // of the fetch result (notably SearchPie's collection-injection logic
      // on Shopify, which races setTimeout-based cache restore against the
      // fetch resolution) could see "fired twice" / "failed to clean up"
      // symptoms when our wrapper's delay flipped the order.
      //
      // The fix: get the original promise, attach observer with
      // `.then(success, error)` (does NOT return a chained promise the
      // caller would see), and return the ORIGINAL promise to the caller.
      // From the caller's perspective our wrapper is byte-identical to no
      // wrapper at all — observer fires independently in its own task.
      var p = oF.apply(this, arguments);
      p.then(function (r) {
        var ms = Math.round(performance.now() - t0);
        // nf: network-failure or slow-request signal
        if (r.status >= 400 && errCount++ < 5) push("nf", { s: r.status, ms: ms, u: fullUrl, rt: rt, e: 1, mt: method, ini: _initiatorFrom(callerErr) });
        else if (ms > 2000 && slowCount++ < 5) push("nf", { s: r.status, ms: ms, slow: 1, u: fullUrl, rt: rt, mt: method, ini: _initiatorFrom(callerErr) });
        // fs: form-submission inference (folded in 2026-05-27)
        if (_fsPath) { try { pushFsTransport({ p: _fsPath, m: method, s: r.status | 0, t: 'fetch' }); } catch (_) {} }
      }, function (err) {
        var ms = Math.round(performance.now() - t0);
        if (errCount++ < 5) push("nf", { s: 0, ms: ms, u: fullUrl, rt: rt, e: 1, to: ms > 30000 ? 1 : 0, m: (err.message || "").substring(0, 40), mt: method, ini: _initiatorFrom(callerErr) });
        if (_fsPath) { try { pushFsTransport({ p: _fsPath, m: method, s: 0, t: 'fetch', err: 1 }); } catch (_) {} }
      });
      return p;
    };
    // Mark the new wrapper too so the second-block check below sees it.
    try { window.fetch.__harvv_wrapped = 1; } catch (_) {}
  } } catch (_) { /* fetch wrap install failed; do not break host page */ }
  // Track XHR (XMLHttpRequest) — many sites still use it (jQuery, legacy code)
  try { if (window.XMLHttpRequest && !XMLHttpRequest.prototype.__harvv_xhr_wrapped) {
    var origOpen = XMLHttpRequest.prototype.open;
    var origSend = XMLHttpRequest.prototype.send;
    if (typeof origOpen !== 'function' || typeof origSend !== 'function') throw new Error('xhr_not_fn');
    XMLHttpRequest.prototype.__harvv_xhr_wrapped = 1;
    XMLHttpRequest.prototype.open = function (method, url) {
      this._pxMethod = (method || "GET").toUpperCase();
      this._pxUrl = "";
      this._pxPath = "";
      // 2026-05-23 — GA4 collect observer (XHR path). Some older GA tags
      // use XHR, and ad-trackers built on top of dataLayer occasionally
      // proxy through XHR. Inspect the raw URL before scrubbing.
      try { var _gaPayload = _parseGACollect(String(url)); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
      try {
        var u = new URL(url, location.origin);
        var scrubbed = scrubQuery(u.pathname + u.search);
        this._pxUrl = u.origin !== location.origin ? (u.origin + scrubbed).substring(0, 200) : scrubbed.substring(0, 80);
        this._pxPath = u.pathname.substring(0, 80);
      } catch (_) {
        this._pxUrl = scrubQuery(String(url)).substring(0, 200);
        this._pxPath = String(url).split("?")[0].substring(0, 80);
      }
      var ext = this._pxPath.split(".").pop().toLowerCase();
      this._pxRt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg") ? "asset"
        : (this._pxPath.indexOf("/api/") > -1 || this._pxPath.indexOf("/graphql") > -1 || this._pxPath.indexOf("/cart") > -1) ? "api" : "page";
      // 2026-05-27 — fs (form-submission) inference, folded in from
      // the formerly-separate second wrap block. Stash metadata here;
      // emission happens in send()'s loadend below.
      this.__hv_fs = null;
      try {
        var _u2; try { _u2 = new URL(url, location.origin); } catch (_) { _u2 = null; }
        var _fsMethod = this._pxMethod;
        var _fsIsMutating = _fsMethod !== 'GET' && _fsMethod !== 'HEAD' && _fsMethod !== 'OPTIONS';
        var _fsSameOrigin = _u2 && _u2.origin === location.origin;
        var _fsPathHint = _u2 && SUBMIT_PATH_HINTS.test(_u2.pathname) && !FS_PATH_EXCLUDE.test(_u2.pathname);
        if (_fsIsMutating && _fsSameOrigin && _fsPathHint) {
          this.__hv_fs = { p: templatize(_u2.pathname), m: _fsMethod };
        }
      } catch (_) {}
      // Detect initiator
      this._pxIni = "";
      try {
        var stack = new Error().stack || "";
        var lines = stack.split("\n");
        for (var li = 1; li < lines.length && li < 8; li++) {
          var line = lines[li];
          if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
            var match = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
            if (match) { this._pxIni = match[1].replace(/.*\//, "").substring(0, 60); break; }
            var pm = line.match(/([^\/()\s]+\.js[:\d]*)/);
            if (pm) { this._pxIni = pm[1].substring(0, 60); break; }
          }
        }
      } catch (_) {}
      return origOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function () {
      // 2026-05-14 — signal post-click async response (Axel Off-Road
      // learning, see fetch hook above). Cancels any pending deferred
      // dead-click since this proves the click triggered server work.
      try { if (window.__harvvNetActivity) window.__harvvNetActivity(); } catch (_) {}
      var xhr = this;
      var t0 = performance.now();
      xhr.addEventListener("loadend", function () {
        var ms = Math.round(performance.now() - t0);
        // nf: network failure / slow request
        if (xhr.status >= 400 && errCount++ < 5) {
          push("nf", { s: xhr.status, ms: ms, u: xhr._pxUrl, rt: xhr._pxRt, e: 1, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
        } else if (ms > 2000 && slowCount++ < 5) {
          push("nf", { s: xhr.status, ms: ms, slow: 1, u: xhr._pxUrl, rt: xhr._pxRt, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
        }
        // fs: form-submission inference (folded in 2026-05-27)
        if (xhr.__hv_fs) {
          try { pushFsTransport({ p: xhr.__hv_fs.p, m: xhr.__hv_fs.m, s: xhr.status | 0, t: 'xhr' }); } catch (_) {}
        }
      });
      return origSend.apply(this, arguments);
    };
  } } catch (_) { /* xhr wrap install failed; do not break host page */ }
  // Track failed resource loads (images, scripts, stylesheets)
  window.addEventListener("error", function (e) {
    var t = e.target;
    if (t && t.tagName && (t.tagName === "IMG" || t.tagName === "SCRIPT" || t.tagName === "LINK")) {
      var src = (t.src || t.href || "").substring(0, 200);
      if (src && ec++ < 5) push("nf", { s: 0, u: src, rt: "asset", e: 1, tag: t.tagName.toLowerCase() });
    }
  }, true);

  // 2026-05-23 — sendBeacon observer for GA4. ~30% of GA4 measurement
  // protocol calls fire via navigator.sendBeacon on pagehide/visibilitychange
  // (especially `purchase` and other "user is leaving" events). Wrap as
  // a pass-through that inspects the URL and emits a `ga` event when it
  // matches /g/collect — never modifies the actual call.
  if (navigator.sendBeacon) {
    var oSB = navigator.sendBeacon.bind(navigator);
    navigator.sendBeacon = function (url, data) {
      try { var _gaPayload = _parseGACollect(String(url)); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
      return oSB(url, data);
    };
  }

  // --- Explicit Conversion API ---
  // Sites can call window.__harvv_convert(value) on order confirmation
  window.__harvv_convert = function (value) {
    if (!cvFired) { push("cv", { p: location.pathname, v: typeof value === "number" ? value : undefined }); cvFired = true; }
  };

  // ─── Page-Audit Signals (Phase 2 + Dogfooding, 2026-05-06) ─────────
  // wc / ma / jl fire ONCE per pageview, not per session.
  // PII rules per signal documented inline. Strict design: numbers > text,
  // counts > content, schema-keyword constants > raw schema.
  //
  // Auth-pattern URLs and `data-harvv-private` ancestors short-circuit
  // any signal that would touch DOM text. Defense-in-depth scrubbing of
  // emails/phones/long digit runs on every text field that ships.
  function isPrivatePage() {
    try {
      if (document.body && document.body.matches && document.body.matches('[data-harvv-private="true"]')) return true;
      if (document.body && document.body.closest && document.body.closest('[data-harvv-private="true"]')) return true;
      var p = (location.pathname || '').toLowerCase();
      if (/^\/(login|signup|sign-?in|sign-?up|account|admin|dashboard|app|billing|settings|profile|my-?account|reset-?password|forgot-?password|checkout|cart|orders?|wp-admin)(\/|$)/.test(p)) return true;
    } catch (_) {}
    return false;
  }
  function scrubText(s) {
    return String(s || '')
      .replace(/[\w._%+-]+@[\w.-]+\.[a-z]{2,}/gi, '[email]')
      .replace(/\+?\d[\d\s().-]{8,}\d/g, '[phone]')
      .replace(/\b\d{10,}\b/g, '[num]');
  }

  // wc — word count of the primary content body. Numbers only. Used for
  // "expected reading time" baselines (no_consumption pattern). Never sends
  // the text itself.
  function emitWordCount() {
    try {
      if (isPrivatePage()) return;
      var sel = 'article, [role="article"], main article, main .post, main .entry, .post-content, .entry-content, [itemprop="articleBody"]';
      var node = document.querySelector(sel) || document.querySelector('main') || document.body;
      if (!node) return;
      // textContent is read locally; we only ship the COUNT.
      var txt = (node.textContent || '').replace(/\s+/g, ' ').trim();
      if (!txt) return;
      var n = txt.split(/\s+/).length;
      if (n > 10000) n = 10000;
      var sl = node.tagName ? node.tagName.toLowerCase() : 'body';
      if (node.id) sl += '#' + String(node.id).slice(0, 20);
      else if (node.className && typeof node.className === 'string') {
        var cls = node.className.split(/\s+/)[0];
        if (cls) sl += '.' + cls.slice(0, 20);
      }
      push('wc', { n: n, sl: sl });
    } catch (_) {}
  }

  // ma — meta audit. Tags in <head> are public-by-spec (sent to crawlers).
  // Defense in depth: scrub emails/phones, cap text at 200 chars, skip on
  // private/auth pages anyway. Sends both length AND truncated content for
  // SEO QA — Nina needs to see WHY a title is wrong, not just that it's wrong.
  function emitMetaAudit() {
    try {
      if (isPrivatePage()) return;
      var t = document.title || '';
      var dq = document.querySelector('meta[name="description"]');
      var d = dq ? (dq.getAttribute('content') || '') : '';
      var h1s = document.querySelectorAll('h1');
      var h1t = h1s.length ? (h1s[0].textContent || '').trim() : '';
      var ogt = document.querySelector('meta[property="og:title"]');
      var ogd = document.querySelector('meta[property="og:description"]');
      var ogi = document.querySelector('meta[property="og:image"]');
      var cn = document.querySelector('link[rel="canonical"]');
      var rb = document.querySelector('meta[name="robots"]');
      // Count duplicates (the LinkedIn-no-image bug from 2026-05-06 — we shipped
      // start.html with TWO og:title and og:description tags. Track here so we
      // can detect on customer sites too.)
      var ogtN = document.querySelectorAll('meta[property="og:title"]').length;
      var ogdN = document.querySelectorAll('meta[property="og:description"]').length;
      var ogiN = document.querySelectorAll('meta[property="og:image"]').length;
      push('ma', {
        tl: t.length,
        tt: scrubText(t).slice(0, 200),
        dl: d.length,
        dt: scrubText(d).slice(0, 200),
        h1n: h1s.length,
        h1t: scrubText(h1t).slice(0, 200),
        ogt: ogt ? 1 : 0,
        ogd: ogd ? 1 : 0,
        ogi: ogi ? 1 : 0,
        ogtN: ogtN > 1 ? ogtN : undefined,
        ogdN: ogdN > 1 ? ogdN : undefined,
        ogiN: ogiN > 1 ? ogiN : undefined,
        cn: cn ? 1 : 0,
        ni: rb && /noindex/i.test(rb.getAttribute('content') || '') ? 1 : 0,
      });
    } catch (_) {}
  }

  // jl — JSON-LD schema detect. PII-safe: ships only schema.org @type
  // keywords ("Article", "Product", etc.) — constants, never user data.
  // Never sends the schema content itself.
  function emitSchemaDetect() {
    try {
      var scripts = document.querySelectorAll('script[type="application/ld+json"]');
      var types = [];
      var errors = 0;
      function takeType(item) {
        if (!item) return;
        if (item['@type']) {
          var raw = Array.isArray(item['@type']) ? item['@type'][0] : item['@type'];
          var t = String(raw || '').slice(0, 30);
          if (t && types.indexOf(t) < 0) types.push(t);
        }
        if (Array.isArray(item['@graph'])) item['@graph'].forEach(takeType);
      }
      for (var i = 0; i < scripts.length; i++) {
        try {
          var data = JSON.parse(scripts[i].textContent || '{}');
          var arr = Array.isArray(data) ? data : [data];
          for (var j = 0; j < arr.length; j++) takeType(arr[j]);
        } catch (_) { errors++; }
      }
      push('jl', { n: scripts.length, ts: types.slice(0, 10), err: errors || undefined });
    } catch (_) {}
  }

  // ─── Visual-bug signals (Phase 2.5, 2026-05-06) ────────────────────
  // The static + viewport-overflow QA misses a class of bug: stuff that
  // renders but renders WRONG — text clipped, cards stacked too tight,
  // logo squashed, image wrong-aspect. We catch these in the field by
  // asking the browser, on real devices.
  //
  // PII rules: selectors + numeric pixel deltas only. Never DOM text,
  // never URLs, never alt text. All gated by isPrivatePage().

  // Build a short, safe selector for an element (PII-free).
  function _vbSel(el) {
    if (!el || !el.tagName) return '';
    var t = el.tagName.toLowerCase();
    var id = el.id ? '#' + String(el.id).slice(0, 24) : '';
    var cls = '';
    if (!id && el.className && typeof el.className === 'string') {
      var c = el.className.trim().split(/\s+/)[0];
      if (c) cls = '.' + c.slice(0, 24);
    }
    return (t + id + cls).slice(0, 60);
  }

  // vw — visual warnings. Walks layout-critical selectors and emits any of:
  //   clip_x — content overflows horizontally (text or grid clipped)
  //   clip_y — content overflows vertically beyond an overflow:hidden box
  //   zero_h — element has zero rendered height despite display:block
  //   tight_gap — adjacent <section> peers separated by <8px (stacked)
  //   overlap — two siblings have intersecting bounding rects
  function emitVisualWarnings() {
    try {
      if (isPrivatePage()) return;
      var checks = [];
      var sel = 'section, .testimonial, .audience-card, .customer-logo, .hero-cta, .price-card, .finding, .stat-card, .view-card';
      var nodes = document.querySelectorAll(sel);
      // 2026-06-02 hardening — a live Puppeteer confirmation pass (Tiger Friday +
      // Axel) showed the raw rules were ~95% false positives: carousels/sliders
      // (intentional overflow), ellipsis-truncated text, full-bleed background
      // covers, and collapsed drawers/modals/popups/app-blocks/dividers. These
      // helpers gate them out so a fired check is trustworthy enough to be a case.
      var _vbCarousel = function (node) {
        var hop = node, n = 0;
        while (hop && n < 5) {
          var cls = (hop.className && typeof hop.className === 'string') ? hop.className : '';
          if (/carousel|slider|swiper|flickity|marquee|instafeed|ticker|scroller|slick|splide/i.test(cls)) return true;
          hop = hop.parentElement; n++;
        }
        return false;
      };
      var _vbIntentionalClipX = function (node, st) {
        // 2026-06-02 (round 2): content is only CUT OFF from the user when the
        // box actually clips its horizontal overflow. overflow-x:visible lets the
        // content spill but it stays VISIBLE (Axel header icon rows, footer
        // localization, and tab panels were all confirmed-clean this way);
        // auto/scroll is a scroller. ONLY hidden|clip truly hides content — so
        // anything else is not a clip defect and must not fire.
        if (st.overflowX !== 'hidden' && st.overflowX !== 'clip') return true;
        if (_vbCarousel(node)) return true;                                     // carousel track
        if (st.textOverflow === 'ellipsis') return true;                        // intentional truncation
        var idc = (node.id || '') + ' ' + ((node.className && typeof node.className === 'string') ? node.className : '');
        if (/ellipsis|truncate|marquee/i.test(idc)) return true;
        if (st.backgroundImage && st.backgroundImage !== 'none' && (node.textContent || '').trim().length <= 4) return true; // bg cover
        return false;
      };
      var _ZERO_H_SKIP = /drawer|modal|popup|popover|tooltip|notification|swym|accordion|divider|spacer|sticky|chat|app-block|shopify-block|mobile-nav|search|quick-view|cart|wishlist|country|localization|mailing|overlay|flyout|backdrop/i;
      // Per-element clip + zero-height scan
      for (var i = 0; i < nodes.length && checks.length < 12; i++) {
        var el = nodes[i];
        var r = el.getBoundingClientRect();
        if (r.width < 1 || r.height < 1) continue;
        var cs = window.getComputedStyle(el);
        if (cs.display === 'none' || cs.visibility === 'hidden') continue;
        // clip_x: content wider than the box, excluding intentional cases.
        if (el.scrollWidth > el.clientWidth + 4 && !_vbIntentionalClipX(el, cs)) {
          checks.push({ k: 'clip_x', s: _vbSel(el), d: el.scrollWidth - el.clientWidth });
        }
        // zero_h: collapsed box — only when real content is being clipped
        // (scrollHeight present), never a drawer/modal/app-block/divider that is
        // simply empty or closed until used.
        var _idc = (el.id || '') + ' ' + ((el.className && typeof el.className === 'string') ? el.className : '');
        // 2026-06-02 (round 2): a collapsed box only HIDES its content when it
        // clips overflow-y. With overflow-y:visible the content spills below and
        // still renders (Axel's testimonials/rich_text section wrappers report
        // height ~0 yet display perfectly because their children escape the box).
        // Require hidden|clip so zero_h means "content present but invisible".
        if (r.height < 4 && r.width > 80 && cs.display !== 'inline'
            && (cs.overflowY === 'hidden' || cs.overflowY === 'clip')
            && !_ZERO_H_SKIP.test(_idc) && el.getAttribute('aria-hidden') !== 'true' && !el.hasAttribute('hidden')
            && el.scrollHeight > 8 && ((el.textContent || '').trim().length > 0 || el.querySelector('img,svg,video,picture'))) {
          checks.push({ k: 'zero_h', s: _vbSel(el), d: Math.round(r.width) });
        }
      }
      // Section gap: adjacent <section> elements with too-tight spacing.
      // Detection 2026-05-06 audit revealed that raw bounding-box gap alone
      // is a noisy signal — Shopify (and most CMSes) wrap each section in
      // a thin div, and the visible content has its own padding. Two
      // sections with 0px outer gap can still have 36px of breathing room
      // because of internal padding. Conversely, a 5px outer gap can be a
      // real merge if both sections have zero internal padding AND share
      // a background.
      //
      // To make this filterable on the server without re-rendering each
      // page, ship along:
      //   d  — outer bounding-box gap (px)
      //   tp — A's trailing internal pad (A.bottom - last visible child bottom)
      //   lp — B's leading internal pad (first visible child top - B.top)
      //   bg — 'm' if backgrounds match, 'd' if differ (visual divider)
      //   im — 1 if either side contains <img>/<video>/<picture> (full-bleed media)
      // Server can then derive visible_separation = d + tp + lp and decide.
      function _measureChild(el, which) {
        // which='last' → bottom of last visible child relative to el.top
        // which='first' → top of first visible child relative to el.top
        try {
          var elRect = el.getBoundingClientRect();
          var nodes = el.querySelectorAll('*');
          var bestBottom = null, bestTop = null;
          for (var k = 0; k < nodes.length; k++) {
            var n = nodes[k];
            var nr = n.getBoundingClientRect();
            if (nr.width < 1 || nr.height < 1) continue;
            if (which === 'last') {
              if (bestBottom == null || nr.bottom > bestBottom) bestBottom = nr.bottom;
            } else {
              if (bestTop == null || nr.top < bestTop) bestTop = nr.top;
            }
          }
          if (which === 'last') return bestBottom != null ? Math.max(0, Math.round(elRect.bottom - bestBottom)) : 0;
          return bestTop != null ? Math.max(0, Math.round(bestTop - elRect.top)) : 0;
        } catch (_) { return 0; }
      }
      var sections = document.querySelectorAll('section');
      for (var j = 0; j < sections.length - 1 && checks.length < 16; j++) {
        var aSec = sections[j], bSec = sections[j+1];
        var aBox = aSec.getBoundingClientRect();
        var bBox = bSec.getBoundingClientRect();
        if (aBox.height < 1 || bBox.height < 1) continue;
        var gap = bBox.top - aBox.bottom;
        if (gap >= 0 && gap < 12) {
          var aCs = window.getComputedStyle(aSec);
          var bCs = window.getComputedStyle(bSec);
          var bgA = aCs.backgroundColor || '';
          var bgB = bCs.backgroundColor || '';
          var isTransparent = function (c) { return !c || c === 'transparent' || /rgba\([^)]*,\s*0\)/.test(c); };
          var bgMatch = bgA === bgB || (isTransparent(bgA) && isTransparent(bgB));
          // 2026-05-06: split "either has media" into per-side flags.
          // The discriminating case is "small text bar (no media) bumping
          // a content block (with media)" → real bug. Two full-bleed
          // media sections touching is design intent. We need both flags
          // to tell those apart at aggregation time without AI.
          var aHasImg = !!aSec.querySelector('img,picture,video');
          var bHasImg = !!bSec.querySelector('img,picture,video');
          checks.push({
            k: 'tight_gap',
            s: _vbSel(aSec) + '>' + _vbSel(bSec),
            d: Math.round(gap),
            tp: _measureChild(aSec, 'last'),
            lp: _measureChild(bSec, 'first'),
            bg: bgMatch ? 'm' : 'd',
            ia: aHasImg ? 1 : 0,
            ib: bHasImg ? 1 : 0,
            ah: Math.round(aBox.height),  // A height — small bars (<120px) flag as cross-role merges
            bh: Math.round(bBox.height),  // B height
          });
        }
      }
      // 2026-05-15 — content-overflow scan. The selector list above only
      // covers marketing-page sections; it missed text overflowing
      // interactive UI — chat bubbles, cards, list rows, buttons, table
      // cells — a real bug class (a chat message ran past its box on our
      // own dashboard). This pass walks common content containers and
      // flags clip_x ONLY where the overflow is not an intentional scroll
      // region. Reuses the clip_x kind so the server's layout:text_clipped
      // detector picks it up with no server-side change. Selector + pixel
      // delta only — same PII discipline as the rest of vw.
      try {
        var coSel = 'button, .btn, li, td, th, h1, h2, h3, dd, figcaption,'
          + ' [class*="card"], [class*="bubble"], [class*="message"],'
          + ' [class*="badge"], [class*="chip"], [class*="tag"], [class*="alert"]';
        var coNodes = document.querySelectorAll(coSel);
        for (var ci = 0; ci < coNodes.length && ci < 400 && checks.length < 24; ci++) {
          var coEl = coNodes[ci];
          // Respect the per-element privacy boundary even for structural
          // signals — the pixel never scans inside data-harvv-private UI.
          if (coEl.closest && coEl.closest('[data-harvv-private="true"]')) continue;
          var coR = coEl.getBoundingClientRect();
          if (coR.width < 60 || coR.height < 8) continue;
          var coCs = window.getComputedStyle(coEl);
          if (coCs.display === 'none' || coCs.visibility === 'hidden') continue;
          // Intentional overflow — scroller, carousel, ellipsis truncation, or a
          // full-bleed background cover. Not a bug. (2026-06-02 hardening.)
          if (_vbIntentionalClipX(coEl, coCs)) continue;
          // Real overflow: rendered content wider than the box by >4px.
          if (coEl.scrollWidth > coEl.clientWidth + 4) {
            checks.push({ k: 'clip_x', s: _vbSel(coEl), d: coEl.scrollWidth - coEl.clientWidth });
          }
        }
      } catch (_) {}
      // clip_desc — glyph clipping, measured EXACTLY (not a guessed ratio).
      // Deterministic rule: a line clips its glyphs when the line box is shorter
      // than the ACTUAL painted glyphs of the heading's own text AND the element
      // clips overflow (overflow:hidden/clip, or background-clip:text where
      // pixels outside the glyph fill show nothing). We read the actual glyph
      // box per font + size from Canvas measureText (actualBoundingBoxAscent/
      // Descent) of the element's REAL textContent — what is actually painted,
      // not the font's design padding. clipPx = glyphBox - lineBox - padRescue.
      // 2026-06-15 (#46989): switched from fontBoundingBox of a generic 'Hxgyp'
      // probe to actualBoundingBox of the real text. The design box includes the
      // font's empty descender padding, so it exceeds a line-height:1.0 on most
      // typefaces even when the rendered glyphs fit — that fired a false clip on
      // harvv.com's own gradient hero (background-clip:text, no descenders cut).
      // Measuring the actual glyphs only fires when something is really clipped.
      // Box geometry stays normal so clip_x/clip_y are blind to it. Falls back to
      // a line-height/font-size ratio on engines without TextMetrics box metrics
      // (IE11). d = clipped px. 2026-06-02 (dogfood).
      try {
        var _mc = null;
        var _glyphBox = function (cssFont, txt) { // px height of the ACTUAL painted glyphs, or -1
          try {
            if (!_mc) { var cv = document.createElement('canvas'); _mc = (cv && cv.getContext) ? cv.getContext('2d') : null; }
            if (!_mc) return -1;
            _mc.font = cssFont;
            var m = _mc.measureText((txt && txt.length) ? txt.slice(0, 120) : 'Hxgyp');
            if (typeof m.actualBoundingBoxAscent !== 'number' || typeof m.actualBoundingBoxDescent !== 'number') return -1;
            return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent;
          } catch (_e) { return -1; }
        };
        var heads = document.querySelectorAll('h1, h2');
        for (var hi = 0; hi < heads.length && checks.length < 20; hi++) {
          var hEl = heads[hi];
          var hr = hEl.getBoundingClientRect();
          if (hr.width < 1 || hr.height < 1) continue;
          var hcs = window.getComputedStyle(hEl);
          if (hcs.display === 'none' || hcs.visibility === 'hidden') continue;
          var fsz = parseFloat(hcs.fontSize) || 0;
          if (fsz < 28) continue; // display text only
          // 2026-06-02 hardening (live Puppeteer confirm caught "Testimonials" /
          // "SUBSCRIBE TO OUR" firing with nothing actually clipped): a real clip
          // needs the text to actually carry descender glyphs (g/y/p/q/j) OR use
          // background-clip:text (where any glyph edge clips). And never flag a
          // visually-hidden / screen-reader heading (intentionally off-screen).
          var _hcls = (hEl.className && typeof hEl.className === 'string') ? hEl.className : '';
          if (/visually-hidden|sr-only|screen-reader|visually_hidden/i.test(_hcls)) continue;
          if (hcs.position === 'absolute' && hcs.clip && hcs.clip !== 'auto') continue;
          var _hbg = (hcs.webkitBackgroundClip === 'text' || hcs.backgroundClip === 'text');
          if (!_hbg && !/[gyjpq]/.test(hEl.textContent || '')) continue;
          // Only flag elements that actually clip what leaves the line box.
          var clips = (_hbg || hcs.overflowY === 'hidden' || hcs.overflowY === 'clip');
          if (!clips) continue;
          var lh = parseFloat(hcs.lineHeight); // NaN when computed value is 'normal'
          if (isNaN(lh) || !lh) continue; // 'normal' gives full leading; safe
          var padRescue = Math.max(parseFloat(hcs.paddingBottom) || 0, parseFloat(hcs.paddingTop) || 0);
          var fb = _glyphBox(hcs.fontStyle + ' ' + hcs.fontWeight + ' ' + Math.round(fsz) + 'px ' + hcs.fontFamily, hEl.textContent || '');
          if (fb > 0) {
            var clipPx = fb - lh - padRescue; // ACTUAL glyphs taller than the line box, less any padding rescue
            if (clipPx > 1) checks.push({ k: 'clip_desc', s: _vbSel(hEl), d: Math.round(clipPx) });
          } else if ((lh / fsz) < 1.0) { // IE11 / no-TextMetrics fallback: only when line-height is genuinely sub-em (tight)
            checks.push({ k: 'clip_desc', s: _vbSel(hEl), d: Math.max(1, Math.round(fsz - lh)) });
          }
        }
      } catch (_) {}
      if (checks.length) push('vw', { c: checks });
    } catch (_) {}
  }

  // im — image meta. Per important image: natural vs rendered dims, complete
  // flag, applied filter (truncated). Detects: stretched, broken, filter-
  // wrecked logos. PII-safe: numbers only, never URL or alt text.
  function emitImageMeta() {
    try {
      if (isPrivatePage()) return;
      var entries = [];
      // Physical-pixel context for this device: an image rendered at R CSS px
      // is drawn into R*dpr physical px, so "blurry on large/retina displays"
      // is a natural-vs-physical comparison, not natural-vs-CSS. ES5-safe.
      var dpr = Math.round((window.devicePixelRatio || 1) * 100) / 100;
      var vw = window.innerWidth;
      var imgs = document.querySelectorAll('img');
      for (var i = 0; i < imgs.length && entries.length < 12; i++) {
        var im = imgs[i];
        var r = im.getBoundingClientRect();
        if (r.width < 1 || r.height < 1) continue;
        // Skip tiny/decorative pixels (1x1 trackers, etc.)
        if (r.width < 16 && r.height < 16) continue;
        var info = {
          sl: _vbSel(im),
          nw: im.naturalWidth || 0,
          nh: im.naturalHeight || 0,
          rw: Math.round(r.width),
          rh: Math.round(r.height),
          c: im.complete ? 1 : 0,
        };
        // Aspect-ratio mismatch — squashed/stretched
        if (im.naturalWidth > 0 && im.naturalHeight > 0 && r.width > 0 && r.height > 0) {
          var natRatio = im.naturalWidth / im.naturalHeight;
          var renRatio = r.width / r.height;
          var dr = Math.abs(natRatio - renRatio) / Math.max(natRatio, 0.1);
          if (dr > 0.10) info.ar = +dr.toFixed(2);
        }
        // Upscale / under-resolution for THIS display: natural px below the
        // physical px the device actually renders (rendered CSS px * DPR).
        // up = physical-px-needed / natural-px. >1.15 = visibly soft/blurry —
        // a 1500px hero stretched across a 32" screen, or a 1x asset on retina.
        // Right shape, wrong sharpness — the case `ar` (stretch) never catches.
        if (im.naturalWidth > 0 && r.width > 1) {
          var up = (r.width * dpr) / im.naturalWidth;
          if (up > 1.15) info.up = +up.toFixed(2);
        }
        // Computed filter — detects "brightness(0) invert(1)" wrecking a
        // light-on-dark logo, etc.
        try {
          var cs = window.getComputedStyle(im);
          if (cs.filter && cs.filter !== 'none') info.f = cs.filter.slice(0, 80);
        } catch (_) {}
        // Broken-load flag — only when the image actually had a chance to
        // load and genuinely failed: in the viewport AND not lazy. A
        // `loading="lazy"` / below-fold image legitimately reports
        // naturalWidth 0 before it scrolls into view; flagging those gave a
        // 43%-false-positive rate on lazy-heavy WordPress pages (Prime MD,
        // 2026-06-18 — every "broken" attachment-full image returned 200 +
        // valid webp on the live page). Genuine failed loads are still caught
        // authoritatively by the resource-error ('nf') onerror tracker, so no
        // true-broken coverage is lost — this only drops the false positives.
        if (im.complete && (im.naturalWidth === 0 || im.naturalHeight === 0)) {
          var lazy = ('' + (im.getAttribute('loading') || '')).toLowerCase() === 'lazy';
          var inView = r.top < (window.innerHeight || 0) && r.bottom > 0;
          if (!lazy && inView) info.broken = 1;
        }
        entries.push(info);
      }
      if (entries.length) push('im', { i: entries, vw: vw, dpr: dpr });
    } catch (_) {}
  }

  // ─── Mobile UI signals (Phase 2 dogfood-driven, 2026-05-08) ───────────
  // Five signals seeded from a real bug: mobile UI was bad on harvv.com
  // itself and the existing pixel surface (wo, vw, pf-CLS, lp) didn't
  // catch it. See docs/POSTMORTEM-2026-05-08-mobile-ui-bugs.md.
  //
  // Bucket per emit: 'm' (mobile <600px), 't' (tablet <1024px), 'd'
  // (desktop ≥1024px). Lets detectors file viewport-specific cases
  // ("edge bleed on phones only") instead of one global rule.
  //
  // PII rules: same as the existing visual-bug signals — selectors +
  // numeric measurements only. All gated by isPrivatePage().

  function _vpBucket(w) { return w < 600 ? 'm' : w < 1024 ? 't' : 'd'; }

  // eg — element edge proximity. For an allow-list of important
  // elements, emit any whose bounding rect is within 4px of the
  // viewport's left or right edge. Catches "brand logo at left:0",
  // "CTA bleeding past right edge", etc.
  function emitEdgeProximity() {
    try {
      if (isPrivatePage()) return;
      var W = window.innerWidth;
      var sel = 'header, nav, h1, h2.headline, .hero, .cta, .hero-cta, .primary-cta, button.primary, a.cta, .brand, [data-cta]';
      var nodes = document.querySelectorAll(sel);
      var hits = [];
      for (var i = 0; i < nodes.length && hits.length < 8; i++) {
        var el = nodes[i];
        var r = el.getBoundingClientRect();
        if (r.width < 1 || r.height < 1) continue;
        var l = Math.round(r.left), rt = Math.round(r.right);
        // Skip fixed-position floating CTAs (intentionally near edge)
        var cs = window.getComputedStyle(el);
        if (cs.position === 'fixed' || cs.position === 'absolute') continue;
        if (l < 4 || rt > W - 4) {
          hits.push({ s: _vbSel(el), l: l, r: rt, w: Math.round(r.width) });
        }
      }
      if (hits.length) push('eg', { vw: W, b: _vpBucket(W), h: hits });
    } catch (_) {}
  }

  // cs — container starvation. The largest content block as a fraction
  // of the viewport. Sub-0.7 on phones means a sidebar/modal/aside is
  // eating room; sub-0.5 on tablet/desktop is similarly suspicious.
  function emitContainerStarvation() {
    try {
      if (isPrivatePage()) return;
      var W = window.innerWidth;
      if (W < 1) return;
      var main = document.querySelector('main') || document.querySelector('article') || document.querySelector('[role=main]');
      if (!main) return;
      // Find the widest visible descendant of main, up to depth 3
      var best = { w: 0, sel: '' };
      function walk(el, depth) {
        if (depth > 3) return;
        var kids = el.children || [];
        for (var i = 0; i < kids.length; i++) {
          var k = kids[i];
          var kr = k.getBoundingClientRect();
          if (kr.height < 40 || kr.width < 40) continue;
          if (kr.width > best.w) { best.w = kr.width; best.sel = _vbSel(k); }
          if (depth < 3) walk(k, depth + 1);
        }
      }
      walk(main, 1);
      if (best.w < 1) return;
      var ratio = Math.round((best.w / W) * 100) / 100;
      var b = _vpBucket(W);
      var threshold = b === 'm' ? 0.70 : 0.50;
      if (ratio >= threshold) return;
      // Identify the largest non-main rival eating the space
      var rival = { w: 0, sel: '' };
      var bodyKids = document.body.children || [];
      for (var j = 0; j < bodyKids.length; j++) {
        var c = bodyKids[j];
        if (c === main || c.contains(main)) continue;
        var cr = c.getBoundingClientRect();
        if (cr.width > rival.w && cr.height > 100) { rival.w = cr.width; rival.sel = _vbSel(c); }
      }
      push('cs', {
        vw: W, b: b,
        ratio: ratio,
        ms: Math.round(best.w),
        rs: rival.sel || undefined,
        rw: rival.w ? Math.round(rival.w) : undefined,
      });
    } catch (_) {}
  }

  // tt — tap target audit. Mobile + tablet only (desktop has mouse).
  // Counts interactive elements below 44×44 (Apple HIG threshold) and
  // emits a sample of the worst offenders.
  function emitTapTargets() {
    try {
      if (isPrivatePage()) return;
      var W = window.innerWidth;
      var b = _vpBucket(W);
      if (b === 'd') return;
      var nodes = document.querySelectorAll('a, button, [role="button"], input[type="submit"], input[type="button"]');
      var smalls = [], total = 0;
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        var r = n.getBoundingClientRect();
        if (r.width < 1 || r.height < 1) continue;
        // Skip elements that are visually inline within text (links inside <p>)
        var p = n.parentElement;
        if (p && (p.tagName === 'P' || p.tagName === 'LI') && r.height < 30) continue;
        total++;
        if (r.width < 44 || r.height < 44) {
          smalls.push({ s: _vbSel(n), w: Math.round(r.width), h: Math.round(r.height) });
        }
      }
      if (smalls.length === 0) return;
      smalls.sort(function (a, b) { return (a.w * a.h) - (b.w * b.h); });
      push('tt', { vw: W, b: b, sc: smalls.length, tt: total, h: smalls.slice(0, 6) });
    } catch (_) {}
  }

  // rb — readability baseline. Body-text font-size, line-height,
  // chars-per-line estimate, and contrast vs background. Emits 1/page.
  function emitReadabilityBaseline() {
    try {
      if (isPrivatePage()) return;
      var W = window.innerWidth;
      var b = _vpBucket(W);
      var ps = document.querySelectorAll('main p, article p, section p');
      if (!ps.length) ps = document.querySelectorAll('p');
      // Pick the longest paragraph >50 chars (representative body text)
      var p = null, maxText = 0;
      for (var i = 0; i < ps.length && i < 20; i++) {
        var len = (ps[i].textContent || '').length;
        if (len > maxText && len > 50) { maxText = len; p = ps[i]; }
      }
      if (!p) return;
      var cs = window.getComputedStyle(p);
      var fs = parseFloat(cs.fontSize) || 0;
      var lh = parseFloat(cs.lineHeight) || 0;
      var color = cs.color, bg = '';
      // 2026-06-09 — three measurement fixes (the readability family's
      // near-100% dismissal rate traced partly to wrong ct values):
      //   1. bgi flag: if any ancestor paints a backgroundImage (hero
      //      photo, CSS gradient) before an opaque backgroundColor is
      //      found, the real backdrop is unknowable from computed style.
      //      Previously we fell through to body's white and reported
      //      ct=1.0 for white-on-dark-photo heroes (m-diamond 114
      //      sessions all at ct=1.0). Now: bgi:1 + ct:null so the
      //      server can exclude these from contrast checks.
      //   2. Alpha-aware walker: rgba(255,255,255,0.03) glass panels
      //      were accepted as the background (old regex only skipped
      //      alpha exactly 0). Anything under 0.4 alpha is treated as
      //      see-through and the walk continues.
      //   3. Color parsing goes through _colorRGBA (canvas-normalized),
      //      so lab()/oklch() from Tailwind-4 sites parse correctly
      //      instead of as digit-soup (3vltn reported ct=1.1 for text
      //      that actually measures ~6.6:1).
      var bgImg = 0;
      var walker = p;
      while (walker && walker !== document.body) {
        var pcs = window.getComputedStyle(walker);
        if (pcs.backgroundImage && pcs.backgroundImage !== 'none') { bgImg = 1; break; }
        var wrgba = _colorRGBA(pcs.backgroundColor);
        if (wrgba && wrgba.a >= 0.4) { bg = pcs.backgroundColor; break; }
        walker = walker.parentElement;
      }
      if (!bgImg && !bg) {
        var bcs = window.getComputedStyle(document.body);
        if (bcs.backgroundImage && bcs.backgroundImage !== 'none') bgImg = 1;
        else bg = bcs.backgroundColor || 'rgb(255,255,255)';
      }
      var pr = p.getBoundingClientRect();
      // Approx chars per line: width / (fontSize * 0.5) — rough but
      // useful for "way too long" signal (>80 = readability issue)
      var cpl = pr.width > 0 && fs > 0 ? Math.round(pr.width / (fs * 0.5)) : null;
      var contrast = bgImg ? null : _contrastRatio(color, bg);
      push('rb', {
        vw: W, b: b,
        fs: Math.round(fs),
        lh: Math.round(lh),
        cpl: cpl,
        ct: contrast ? Math.round(contrast * 10) / 10 : null,
        bgi: bgImg || undefined,
      });
    } catch (_) {}
  }
  // Normalize ANY CSS color the browser understands (rgb/rgba/hex/named/
  // lab()/oklch()/color()) to exact {r,g,b,a} bytes by painting a 1x1
  // canvas and reading the pixel back. A digit-regex parser mangles
  // modern color spaces: "lab(65.9 -0.8 -8.2)" parsed as r=65 g=0.8 b=8.
  var _ccCanvas = null, _ccCtx = null;
  function _colorRGBA(s) {
    if (!s || s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
    try {
      if (!_ccCtx) {
        _ccCanvas = document.createElement('canvas');
        _ccCanvas.width = 1; _ccCanvas.height = 1;
        _ccCtx = _ccCanvas.getContext('2d');
      }
      if (!_ccCtx) throw new Error('no2d');
      _ccCtx.clearRect(0, 0, 1, 1);
      _ccCtx.fillStyle = '#000';
      _ccCtx.fillStyle = String(s); // invalid values keep #000; valid ones normalize
      _ccCtx.fillRect(0, 0, 1, 1);
      var d = _ccCtx.getImageData(0, 0, 1, 1).data;
      return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 };
    } catch (_) {
      // Canvas unavailable (ancient engine / blocked) — legacy rgb()/rgba()
      // digit parse as a fallback; returns null for modern syntaxes.
      var m = String(s).match(/\d+(?:\.\d+)?/g);
      if (!m || m.length < 3) return null;
      return { r: +m[0], g: +m[1], b: +m[2], a: m.length > 3 ? +m[3] : 1 };
    }
  }
  function _contrastRatio(c1, c2) {
    function lum(c) {
      var ch = [c.r, c.g, c.b].map(function (v) {
        v = v / 255;
        return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
      });
      return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
    }
    var a = _colorRGBA(c1), b = _colorRGBA(c2);
    if (!a || !b || a.a === 0 || b.a === 0) return null;
    var la = lum(a), lb = lum(b);
    var hi = Math.max(la, lb), lo = Math.min(la, lb);
    return (hi + 0.05) / (lo + 0.05);
  }

  // fb — fallback / degraded UI. Exposed via window.harvv.reportFallback()
  // so the host React app (or any host page) can self-report when a
  // known degraded state renders. The pixel can't see "iframe blocked
  // and our 3.5s fallback fired" — only the app knows.
  function _reportFallback(payload) {
    try {
      if (isPrivatePage()) return;
      if (!payload || typeof payload !== 'object') return;
      push('fb', {
        k: String(payload.kind || payload.k || 'unknown').slice(0, 32),
        cause: payload.cause ? String(payload.cause).slice(0, 32) : undefined,
        ms: typeof payload.ms === 'number' ? Math.round(payload.ms) : undefined,
        sel: payload.sel ? String(payload.sel).slice(0, 60) : undefined,
        vw: window.innerWidth,
        b: _vpBucket(window.innerWidth),
      });
    } catch (_) {}
  }
  try {
    window.harvv = window.harvv || {};
    window.harvv.reportFallback = _reportFallback;
  } catch (_) {}

  function runPageAudits() {
    emitWordCount(); emitMetaAudit(); emitSchemaDetect();
    // Visual-bug audits run twice: once on load (early/best-effort), once
    // 2.2s later (after deferred scripts settle, fonts swap, lazy images
    // resolve). The detection layer dedupes by (page_url, k, s).
    emitVisualWarnings(); emitImageMeta();
    // 2026-05-08 — new mobile UI signals (eg, cs, tt, rb). Run on the
    // same cadence as visual warnings so the second pass picks up
    // post-font-swap measurements (rb in particular is sensitive).
    emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
    setTimeout(function () {
      emitVisualWarnings(); emitImageMeta();
      emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
    }, 2200);
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', runPageAudits, { once: true });
  } else {
    setTimeout(runPageAudits, 50);
  }

  // --- Unload: Final Engagement + Scroll Kinematics + Flush ---
  window.addEventListener("pagehide", function () {
    if (engageStart) totalEngage += Date.now() - engageStart;
    push("ul", { eg: totalEngage, sm: curScrollPctMax }); // unload: total engagement ms + exact peak scroll %
    // Flush scroll kinematics on exit
    if (totalDist > 0) {
      var avgVel = velSamples ? velSum / velSamples : 0;
      push("sk", {
        v: [Math.round(minVel * 1000) / 1000, Math.round(maxVel * 1000) / 1000], // px/ms
        r: reversals,
        p: pauses.slice(0, 10), // Cap at 10 pause positions
        d: Math.round(totalDist),
        // Phase 2 (2026-05-06) — bucketed velocity in px (px/s thresholds).
        // mxv/avv = max/avg in px/s. kd = skim distance (>200 px/s),
        // rd = active-read distance (50-200 px/s). Used by content:scroll_speed_skim
        // and content:shallow_read patterns. PII-safe — pure kinematics.
        mxv: Math.round(maxVel * 1000),
        avv: Math.round(avgVel * 1000),
        kd: Math.round(skimDist) || undefined,
        rd: Math.round(readDist) || undefined,
      });
    }
    // T141.5h — pass 'unload' so flush uses sendBeacon (fire-and-forget,
    // the only viable transport once the page is being torn down) AND
    // mirrors a copy to the localStorage retry queue. drainRetryQueue()
    // on next pageload will redeliver if the beacon was dropped.
    flush('unload');
  });

  // ─── 2026-05-21 — Form submission + API success detector ─────────────────
  // The "is this friction blocking or just annoying?" signal lives in
  // whether the user's data ultimately reached the server. We monitor 3
  // signals to know that:
  //   1. <form>.submit events — declarative submission
  //   2. fetch()/XHR POSTs to same-origin URLs — programmatic submission
  //   3. 2xx response status from those POSTs — confirms backend received
  //
  // Captured event = type 'fs' (form/api submit) with sanitized payload:
  //   { p: path_template, m: method, s: status, t: type ('form'|'xhr'|'fetch') }
  // path_template = the URL pathname with high-cardinality segments collapsed
  //   to :id (numeric ids, uuids). NEVER includes query string, body, or
  //   response. NEVER includes auth headers. NEVER captures form field values.
  // 2026-05-27 — Single wrap point per transport. The fetch + XHR
  // wraps that USED to live here (for fs/form-submission inference)
  // have been folded INTO the nf wraps above. This block keeps only
  // the declarative <form> submit listener — purely additive, never
  // touches XMLHttpRequest.prototype or window.fetch.
  try {
    // Declarative <form> submit. Catches user-driven submits even when
    // the form's action is a server route (full page reload).
    document.addEventListener('submit', function (ev) {
      try {
        var form = ev.target;
        if (!(form instanceof HTMLFormElement)) return;
        var action = form.getAttribute('action') || location.pathname;
        var url; try { url = new URL(action, location.origin); } catch (_) { url = null; }
        var path = url ? templatize(url.pathname) : templatize(action);
        var method = (form.getAttribute('method') || 'POST').toUpperCase();
        push('fs', { p: path, m: method, t: 'form' });
      } catch (_) {}
    }, true);
  } catch (_) { /* form listener install is best-effort; never break host page */ }

  // ─── 2026-05-21 — window.harvv.outcome() customer-side API ──────────────
  // Customers call this from their app after meaningful state transitions:
  //   window.harvv.outcome('kyc_submitted', { step: 'individual', success: true });
  //   window.harvv.outcome('purchase_completed', { value: 99.00, currency: 'USD' });
  //
  // We emit as event type 'oc'. Properties get strictly sanitized:
  //   - Strings: max 100 chars
  //   - Numbers: any (currency values are useful)
  //   - Booleans: pass through
  //   - PII detector: reject keys/values that look like email, phone,
  //     SSN, full credit card. Whole call dropped if any field smells PII.
  try {
    window.harvv = window.harvv || {};
    var PII_KEY = /\b(?:email|phone|ssn|tax_?id|credit_?card|card_?number|cvv|cvc|password|secret|token|api_?key)\b/i;
    var PII_VAL_EMAIL = /[\w.+-]{2,}@[\w-]{1,}\.[a-z]{2,}/i;
    var PII_VAL_PHONE = /\b\+?\d[\d\s\-().]{8,}\d\b/;
    var PII_VAL_CARD = /\b(?:\d[ -]?){12,19}\b/; // 12-19 digit run = likely card/account

    function sanitizeOutcomeProps(props) {
      if (!props || typeof props !== 'object') return null;
      var out = {};
      var keys = Object.keys(props).slice(0, 12); // cap at 12 props
      for (var i = 0; i < keys.length; i++) {
        var k = keys[i];
        if (typeof k !== 'string' || k.length > 40) return null; // suspicious
        if (PII_KEY.test(k)) return null; // PII-flagged key — drop whole event
        var v = props[k];
        if (v == null) continue;
        if (typeof v === 'number' && isFinite(v)) { out[k] = v; continue; }
        if (typeof v === 'boolean') { out[k] = v; continue; }
        if (typeof v === 'string') {
          var s = v.substring(0, 100);
          if (PII_VAL_EMAIL.test(s) || PII_VAL_PHONE.test(s) || PII_VAL_CARD.test(s)) return null;
          out[k] = s;
          continue;
        }
        // Arrays / objects / anything else — drop the prop, keep going
      }
      return out;
    }

    window.harvv.outcome = function (name, props) {
      try {
        if (!name || typeof name !== 'string') return;
        var n = name.substring(0, 60).replace(/[^a-z0-9_:.-]+/gi, '_');
        if (!n) return;
        var safe = props === undefined ? {} : sanitizeOutcomeProps(props);
        if (safe === null) return; // PII detected — silently drop
        push('oc', { n: n, d: safe });
      } catch (_) { /* never throw into customer code */ }
    };

    // 2026-05-21 — Read accessors so customers can correlate pixel sessions
    // with their own server-side data when calling POST /v1/outcomes from
    // their backend. session_id is 8-char random (non-PII), visitor_id is
    // 8-char random + cookied for ~30 days (also non-PII — it's a device
    // signal, not a person signal). Documented in /docs/api.
    //
    // Pattern: customer reads window.harvv.sessionId on the client, stuffs
    // it into a hidden form field or order-create payload, server then
    // POSTs to /v1/outcomes with the same session_id. We do the join.
    window.harvv.sessionId = sid;
    window.harvv.visitorId = vid;
    window.harvv.getSessionId = function () { return sid; };
    window.harvv.getVisitorId = function () { return vid; };
  } catch (_) {}

})();