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: 109,240 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 () {
  // --- 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;
              s.src = (document.currentScript && document.currentScript.src) ||
                      document.querySelector('script[src*="/pixel.js"]')?.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");
  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 = [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;
  }

  // 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 : "";
    var cls = !id2 && el.className && typeof el.className === "string"
      ? "." + el.className.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 = "";
    txt = txt ? ':"' + txt + '"' : "";
    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;
  }

  // --- 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.
  var 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
  }

  // 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;
    }
    // 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();
  }

  // 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';
  var RETRY_MAX_BYTES = 50000;
  var RETRY_TTL_MS = 24 * 60 * 60 * 1000;

  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 (_) {}
  }

  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;
        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) remaining.push(fresh[i]);
      }
      if (remaining.length) localStorage.setItem(RETRY_KEY, JSON.stringify(remaining));
      else localStorage.removeItem(RETRY_KEY);
    } catch (_) {}
  }

  function flush() {
    if (!queue.length) return;
    var payload = JSON.stringify(queue);
    queue = [];
    var blob = new Blob([payload], { type: "application/json" });
    // sendBeacon first (works on unload), fetch+keepalive fallback, retry-queue if both fail
    if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
    try {
      fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true })
        .catch(function () { persistFailedSend(payload); });
      return;
    } catch (_) {
      persistFailedSend(payload);
    }
  }

  // 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 (_) {}

    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,
      wb: navigator.webdriver ? 1 : undefined,
      bf: bf || undefined
    });
  })();

  // --- 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 });

    // 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 };
          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.
          if (!insideInteractive) {
            if (throttleDc(elId)) {
              // Attach the count we suppressed since the last successful emit.
              if (dcSuppressedCount[elId]) {
                data.sup = dcSuppressedCount[elId];
                dcSuppressedCount[elId] = 0;
              }
              push("dc", data);
            }
          }
        }
      }
    }

    // 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) {
        push("rc", { el: elId, n: clicks.length, sec: getSection(tgt) || undefined });
        snapViewport("rage");
        clicks = [];
      }
    }
  });

  // --- 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;
  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;
      // 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;
        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; };
  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?
    var fieldsTouched = 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';
    }

    document.addEventListener('focusin', function (e) {
      if (!isFormField(e.target)) return;
      var key = fieldKey(e.target);
      if (!key) return;
      lastField = { key: key, pos: fieldPosition(e.target) };
      fieldsTouched++;
      // First-touch event per field — server uses for has_form_interaction.
      if (!fiEmitted[key]) {
        fiEmitted[key] = true;
        push("fi", { f: key, p: lastField.pos || undefined });
      }
    }, true);

    document.addEventListener('input', function (e) {
      if (!isFormField(e.target)) return;
      var key = fieldKey(e.target);
      if (!key) return;
      lastField = { key: key, pos: fieldPosition(e.target) };
    }, true);

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

    function emitAbandonment() {
      if (faEmitted) return;
      if (formSubmitted) return;
      if (!lastField) return;
      faEmitted = true;
      push("fa", { f: lastField.key, p: lastField.pos || undefined, n: fieldsTouched });
    }

    // 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;
    function tick() {
      try {
        var now = Date.now();
        var gap = now - lastActive;
        if (gap >= IDLE_THRESHOLD_MS && document.visibilityState === 'visible' && idleEmits++ < 8) {
          push("id", { ms: Math.round(gap) });
        }
        lastActive = now;
      } catch (_) {}
    }
    addEventListener('click', tick, { passive: true, capture: true });
    addEventListener('keydown', tick, { passive: true, capture: true });
    addEventListener('scroll', tick, { passive: true, capture: 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("&");
  }
  if (window.fetch) {
    var oF = window.fetch, slowCount = 0, errCount = 0;
    window.fetch = function (input, init) {
      var t0 = performance.now();
      var url = (typeof input === "string") ? input : (input && input.url) || "";
      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";
      // Detect initiator from call stack
      var initiator = "";
      try {
        var stack = new Error().stack || "";
        var lines = stack.split("\n");
        // Find first line that isn't this pixel file
        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) {
            // Extract filename:line from stack frame
            var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
            if (fileMatch) {
              initiator = fileMatch[1].replace(/.*\//, "").substring(0, 60);
              break;
            }
            // Fallback: just grab last path segment
            var pathMatch = line.match(/([^\/()\s]+\.js[:\d]*)/);
            if (pathMatch) {
              initiator = pathMatch[1].substring(0, 60);
              break;
            }
          }
        }
      } catch (_) {}

      return oF.apply(this, arguments).then(function (r) {
        var ms = Math.round(performance.now() - t0);
        if (r.status >= 400 && errCount++ < 5) push("nf", { s: r.status, ms: ms, u: fullUrl, rt: rt, e: 1, mt: method, ini: initiator || undefined });
        else if (ms > 2000 && slowCount++ < 5) push("nf", { s: r.status, ms: ms, slow: 1, u: fullUrl, rt: rt, mt: method, ini: initiator || undefined });
        return r;
      }).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: initiator || undefined });
        throw err;
      });
    };
  }
  // Track XHR (XMLHttpRequest) — many sites still use it (jQuery, legacy code)
  if (window.XMLHttpRequest) {
    var origOpen = XMLHttpRequest.prototype.open;
    var origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (method, url) {
      this._pxMethod = (method || "GET").toUpperCase();
      this._pxUrl = "";
      this._pxPath = "";
      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";
      // 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 () {
      var xhr = this;
      var t0 = performance.now();
      xhr.addEventListener("loadend", function () {
        var ms = Math.round(performance.now() - t0);
        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" });
        }
      });
      return origSend.apply(this, arguments);
    };
  }
  // 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);

  // --- 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);
      // 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: scrollWidth exceeds clientWidth (text or grid cut off)
        if (el.scrollWidth > el.clientWidth + 2) {
          checks.push({ k: 'clip_x', s: _vbSel(el), d: el.scrollWidth - el.clientWidth });
        }
        // zero_h: zero rendered height but display says it should have content
        if (r.height < 4 && r.width > 80 && cs.display !== 'inline') {
          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
          });
        }
      }
      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 = [];
      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);
        }
        // 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
        if (im.complete && (im.naturalWidth === 0 || im.naturalHeight === 0)) info.broken = 1;
        entries.push(info);
      }
      if (entries.length) push('im', { i: entries });
    } 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();
    setTimeout(function () { emitVisualWarnings(); emitImageMeta(); }, 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 }); // unload with total engagement ms
    // 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,
      });
    }
    flush();
  });

})();