Harvv
How it worksWhat we findCase studiesGuidesFree auditPricing
Sign in Start free
How it worksWhat we findCase studiesGuidesFree auditPricingSign in

Harvv legal

Pixel Source Code

Last updated: August 26, 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: 304,804 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 harvvBoot() {
  // 2026-09-14 (case #73140): PRERENDER GATE. RUNS BEFORE EVERYTHING ELSE.
  //
  // Chrome prerenders a page into a hidden frame when the site ships
  // Speculation Rules. Shopify storefronts do, WordPress 6.8 does by default,
  // and three of our four busiest sites are Shopify. In that frame the whole
  // pixel used to boot: it minted a session, sent a pageview and every page
  // audit, and then the visitor never clicked the link. The result is a
  // session with one pageview and no interaction, which is our definition of
  // a bounce. We were manufacturing bounces out of pages nobody ever saw.
  //
  // A prerendered page that IS activated has the opposite problem: its clock
  // starts at prerender time, so LCP/FCP/TTFB read impossibly fast. That is
  // corrected at the `pf` push below, using activationStart.
  //
  // Named function expression so the boot can re-enter itself on activation
  // without wrapping 5,600 lines in another function. `document.prerendering`
  // is a plain property read and `prerenderingchange` a plain event, so this
  // stays inside the ES5 grammar the four pixel gates enforce; on a browser
  // that has neither, `document.prerendering` is undefined and this is a
  // no-op, which is exactly the old behaviour.
  //
  // The separate sentinel matters: the double-install guard below has not run
  // yet, so without it two copies of the pixel on a prerendered page would
  // each queue a boot and both would run on activation.
  try {
    if (document.prerendering) {
      if (window.__harvvPrerenderWait__) return;
      window.__harvvPrerenderWait__ = 1;
      document.addEventListener('prerenderingchange', function () {
        window.__harvvPrerenderWait__ = 0;
        harvvBoot();
      }, { once: true });
      return;
    }
  } catch (_) { /* never block the pixel on the prerender check */ }

  // 2026-05-13 — Double-install guard. A site can have the Harvv pixel
  // installed via TWO independent paths: the canonical <script> tag AND
  // the WP plugin's enqueue (or future Shopify app, Laravel package, etc).
  // If both fire, every event doubles. The first instance wins; subsequent
  // ones bail. We stamp window.__harvvLoaded__ with the source string so
  // /internal/site-snapshot can later report which path got the pixel
  // running. The plugin's bundled tracker uses the same sentinel.
  if (window.__harvvLoaded__) {
    try {
      (window.__harvvDuplicates__ = window.__harvvDuplicates__ || []).push({
        first: window.__harvvLoaded__,
        second: 'pixel.js',
        at: Date.now(),
      });
    } catch (_) {}
    return;
  }
  window.__harvvLoaded__ = 'pixel.js';

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

  // --- Consent gate (Harvv-properties only) ---
  // 2026-05-06 — when running on harvv.com properties (harvv.com, *.harvv.com,
  // including admin.harvv.com / flow.harvv.com / pk2 etc.) the pixel must
  // honor the cookie banner's analytics choice. Customer sites are exempt:
  // their consent banner is THEIR responsibility, our pixel just runs as the
  // customer configured. We listen for the harvv:consent event so the pixel
  // can wake up if the user later flips analytics back on without a reload.
  try {
    var hostIsHarvv = /(?:^|\.)harvv\.com$/i.test(location.hostname);
    if (hostIsHarvv) {
      // 2026-08-26 (#66617) — read the STORED record, not only the accessor.
      //
      // The banner ships `defer` and the pixel ships `async`, so the pixel
      // routinely wins the race and sees window.__harvvConsent undefined. The
      // old code treated that as "no consent tooling here" and ran in full.
      // Verified in production on /login: with a stored reject
      // (analytics:false) the pixel still wrote the _pxv cookie, the _pxv
      // localStorage mirror AND the session id. The Reject button worked for
      // exactly one page load and silently stopped applying on every later
      // visit, which is the worst version of this bug because the banner
      // still shows the choice as saved.
      //
      // localStorage is where the banner's own record already lives, so
      // reading it directly makes the decision independent of script order.
      // Mirrors the banner's TTL + version semantics deliberately: a record
      // the banner would treat as expired must not keep suppressing here.
      var consent = null;
      try {
        if (typeof window.__harvvConsent === 'function') consent = window.__harvvConsent();
      } catch (_c1) {}
      if (!consent) {
        try {
          var _raw = localStorage.getItem('harvv_consent_v1');
          if (_raw) {
            var _rec = JSON.parse(_raw);
            var _fresh = _rec && _rec.t && (Date.now() - _rec.t) <= 365 * 86400000;
            if (_fresh && _rec.v === 1) consent = _rec;
          }
        } catch (_c2) {}
      }
      if (consent && consent.choices && consent.choices.analytics === false) {
        // User explicitly declined analytics on this harvv property. Bail.
        // If they later accept, the cookie banner fires the harvv:consent event;
        // we re-bootstrap the pixel by reloading itself.
        window.addEventListener('harvv:consent', function (ev) {
          if (ev && ev.detail && ev.detail.choices && ev.detail.choices.analytics === true) {
            try {
              var s = document.createElement('script');
              s.async = true;
              // 2026-05-26 — Replaced `?.src` optional chaining with explicit
              // ternary because PK Android 7-8 Webviews (Chrome 60-70 era) lack
              // ES2020 support and throw "Uncaught SyntaxError: Unexpected token '.'"
              // when parsing the whole pixel. Found via internal_errors row from
              // a real visitor on /signup?ref=wordpress. Every customer site
              // embedding the pixel was breaking on old Webviews.
              var __q = document.querySelector('script[src*="/pixel.js"]');
              s.src = (document.currentScript && document.currentScript.src) ||
                      (__q && __q.src) ||
                      '/px/5abaa759db95fcf4/pixel.js';
              document.head.appendChild(s);
            } catch (_) {}
          }
        });
        return;
      }
    }
  } catch (_) { /* never block the pixel on consent-check errors */ }

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

  // 2026-08-12 — the native fetch, captured BEFORE we wrap window.fetch far
  // below. The cart-attribute stitch calls the storefront's own /cart.js and
  // /cart/update.js; routed through our wrapper those would be scored as
  // customer network activity (an `nf` on failure, and a pending dead-click
  // cancellation on success), so the pixel would be measuring itself.
  // isOwnBeaconUrl only excludes ENDPOINT/BACKUP_ENDPOINT, and widening it to
  // cover cart paths would also hide the merchant's REAL cart calls, which are
  // exactly the ones a broken-cart finding depends on. Capturing native here
  // keeps both honest. If another script wrapped fetch before us we get theirs,
  // which is correct: we should be as visible to them as they are to us.
  var NATIVE_FETCH = (typeof window !== 'undefined' && typeof window.fetch === 'function')
    ? window.fetch.bind(window) : null;

  // 2026-07-31 — is this URL our OWN beacon?
  // flush() / tryBackupThenPersist() / drainRetryQueue() all POST through
  // window.fetch, which this pixel wraps. The wrapper fires
  // __harvvNetActivity() on every call, and that cancels any pending
  // deferred dead click as "the click triggered async work". So the pixel's
  // own batch upload (a one-shot at 3s, then every 10s) was silently
  // reclassifying real dead clicks as `ac` with reason `network_activity`:
  // we were measuring ourselves and reporting it as site activity.
  // Every internal call site passes ENDPOINT / BACKUP_ENDPOINT verbatim, so
  // strict equality is sufficient AND safe: it can never suppress the
  // signal for a request the host site actually made.
  function isOwnBeaconUrl(u) {
    if (!u || typeof u !== "string") return false;
    return u === ENDPOINT || (!!BACKUP_ENDPOINT && u === BACKUP_ENDPOINT);
  }

  // 1.0.7 — GA4 coexistence (research-recommended pattern).
  // We detect GA4 presence at boot so the server can:
  //   • Dedupe between our DOM-detected events and GA4 dataLayer events
  //   • Show "vs GA4" comparison in the dashboard
  //   • Annotate sessions with ga4_present
  // We also honor GA4 Consent Mode v2: if analytics_storage is 'denied',
  // we downgrade to anonymous-only mode (no persistent visitor_id).
  // We do NOT subscribe to dataLayer events by default (separate-namespace
  // is the FullStory/Mouseflow/Clarity default). We DO push Harvv-detected
  // signals to dataLayer under the harvv_signal namespace so customers
  // can route them to their own GTM-managed downstream tools.
  var GA4_PRESENT = !!(window.gtag || window.google_tag_manager || (Array.isArray(window.dataLayer) && window.dataLayer.length));
  var CONSENT_DENIED = false;
  var CONSENT_SEEN = false;      // page stated an analytics_storage value at all
  var CONSENT_AWAITING = false;  // boot denial is only default+wait_for_update
  var CONSENT_EXPLICIT_GRANT = false; // banner answered 'update: granted' (outranks GPC)
  var IDENTITY_READY = false;         // vid/sid minted; a late consent grant may now persist them
  try {
    // Probe GA4 Consent Mode v2 state. Inline (synchronous) read first;
    // for the async gtag('get', ...) path we'd need to wait, which we
    // skip for byte budget.
    // 2026-07-31 — read consent as a STATE MACHINE, last write wins.
    //
    // The old loop broke on the FIRST 'denied' it saw. That is precisely
    // backwards for Consent Mode v2, whose documented pattern is:
    //     consent('default', { analytics_storage: 'denied', wait_for_update: N })
    //     consent('update',  { analytics_storage: 'granted' })   <- the real answer
    // The default deny EXISTS to be superseded. `wait_for_update` literally
    // says "an update is coming". Breaking on it means we read every
    // correctly-configured consent banner as a refusal.
    //
    // MEASURED ON TIGER FRIDAY (our largest site, 42k sessions/day). Its
    // dataLayer is default:denied -> update:GRANTED -> default:granted. Users
    // consented. We read the first entry, went memory-only, and wrote no
    // cookies, so every page load minted a fresh session and visitor id:
    // 94% of its sessions were single-page (5,155 of 5,473 in 3 hours) and
    // 1.19 distinct pages per session, versus 1.61 on Axel where storage
    // persists. Session counts were page-view counts, returning visitors were
    // invisible, and every funnel was broken.
    //
    // Scan ALL entries in order and keep the last explicit value. Absent or
    // unparseable consent still means NOT denied, matching prior behaviour
    // for sites with no consent tooling at all.
    var dl = window.dataLayer || [];
    for (var ci = 0; ci < dl.length; ci++) {
      var dle = dl[ci];
      if (!dle || dle[0] !== 'consent' || !dle[2]) continue;
      var st = dle[2].analytics_storage;
      if (st === 'denied') CONSENT_DENIED = true;
      else if (st === 'granted') CONSENT_DENIED = false;
      // Consent-buffer bookkeeping (no effect unless __pxCB is on): did the
      // page state consent AT ALL, and is a boot-time denial only the
      // default with an update still pending? A default-denied with
      // wait_for_update is the documented "answer is coming" shape — under
      // the buffer that is PENDING, not a refusal.
      if (st === 'denied' || st === 'granted') {
        CONSENT_SEEN = true;
        CONSENT_AWAITING = (st === 'denied' && dle[1] === 'default' && dle[2].wait_for_update > 0);
        // An explicit banner GRANT is the visitor speaking more recently and
        // specifically than a browser-wide GPC flag (the CPRA reading), so it
        // is the one thing that outranks GPC below. A 'default' entry is the
        // site's configuration, not the visitor's answer.
        if (st === 'granted' && dle[1] === 'update') CONSENT_EXPLICIT_GRANT = true;
      }
    }
  } catch (_) {}

  // Global Privacy Control (2026-09-07, gap audit security #24). Until now the
  // browser's GPC signal was honored ONLY inside the consent-buffer mode
  // (__pxCB), which has been off fleet-wide since 2026-09-01, so on every US
  // site the flag was captured as a field and ignored: captured_but_ignored,
  // on a legally mandated opt-out (12 US states since 2026-01-01). GPC is the
  // visitor speaking directly, so it applies whether or not the site has a
  // banner and whether or not the buffer flag is served. Denied here means
  // identifier-free: no cookies, no localStorage, in-memory ids only, which
  // depends on nothing a third-party consent API says, so it cannot repeat the
  // #70009 failure (silence read as refusal).
  var GPC_DENIED = false; // denied SOLELY because of GPC (a later explicit grant may lift it)
  try {
    if (navigator.globalPrivacyControl === true && !CONSENT_EXPLICIT_GRANT) {
      GPC_DENIED = !CONSENT_DENIED;
      CONSENT_DENIED = true;
    }
  } catch (_) {}

  // --- Consent buffer (serve-time flag __pxCB) ---
  // Cohort: docs/architecture/cohort-consent-buffer.md. When the flag is on
  // and a consent framework is present, the pixel is consent-aware end to
  // end: granted runs normally, denied transmits NOTHING (flag-off keeps the
  // legacy cookieless cd:1 ping), and an undecided visitor is buffered in
  // memory with zero device writes until they answer. A page with no
  // framework behaves exactly as flag-off; that configuration is the site
  // owner's call.
  var CB_ON = window.__pxCB === 1;
  var CB_STATE = 'nogate'; // 'granted' | 'denied' | 'pending' | 'nogate'
  // afterDecision is true ONLY when called from the visitorConsentCollected
  // handler, where the visitor has actually answered and a false
  // analyticsProcessingAllowed() therefore means "no".
  function cbShopifyState(afterDecision) {
    try {
      var cp = window.Shopify && window.Shopify.customerPrivacy;
      if (!cp || typeof cp.analyticsProcessingAllowed !== 'function') return null;
      if (cp.analyticsProcessingAllowed()) return 'granted';
      var banner = false;
      try { banner = typeof cp.shouldShowBanner === 'function' && !!cp.shouldShowBanner(); } catch (_) {}
      if (banner) return 'pending';
      // No banner up, and processing not allowed YET. The old code called this
      // 'denied' -- and its own comment admitted the ambiguity: "the region
      // needs no banner OR the visitor already said no". Those are opposite
      // answers and it picked refusal.
      //
      // MEASURED 2026-09-01, the fleet-wide flip: Tiger Friday fell to 36% of
      // normal sessions the minute the flag went on (292-404 per 10 min ->
      // 67-104), and the vanished traffic was 4372 -> 1100 US sessions. US
      // visitors are exactly the no-banner-required case. Shopify's API also
      // reports not-allowed for a beat while it resolves, and our async pixel
      // routinely wins that race, so the first read is often silence rather
      // than an answer.
      //
      // Silence is NOT refusal. At boot this returns null -> 'nogate' ->
      // ordinary operation, which is also the correct legal reading where no
      // consent is required. A real decline always arrives through
      // visitorConsentCollected, and that path (afterDecision) still denies.
      return afterDecision ? 'denied' : null;
    } catch (_) { return null; }
  }
  function cbPending() { return CB_ON && CB_STATE === 'pending'; }
  function cbGrant() {
    if (!CB_ON || CB_STATE === 'granted') return;
    var was = CB_STATE;
    CB_STATE = 'granted';
    if (was === 'pending' && !CONSENT_DENIED) {
      try {
        // Ids were minted in memory while we waited; persist them now so the
        // visitor survives navigation exactly as if consent preceded boot.
        setCookie('_pxv', vid, 730); lsSet('_pxv', vid);
        setCookie('_pxs', sid); lsSet('_pxs', sid);
        setCookie('_pxss', sessionStart); lsSet('_pxss', sessionStart);
        setCookie('_pxla', lastActivity); lsSet('_pxla', lastActivity);
      } catch (_) {}
    }
    try { drainRetryQueue(); } catch (_) {}
    try { flush(); } catch (_) {}
  }
  function cbDeny() {
    if (!CB_ON || CB_STATE === 'denied') return;
    CB_STATE = 'denied';
    CONSENT_DENIED = true; // engages every existing no-storage guard
    queue = [];
    try {
      // The visitor said no: remove what an earlier granted/nogate load wrote.
      setCookie('_pxv', '', -1); setCookie('_pxs', '', -1);
      setCookie('_pxss', '', -1); setCookie('_pxla', '', -1);
      localStorage.removeItem('_pxv'); localStorage.removeItem('_pxs');
      localStorage.removeItem('_pxss'); localStorage.removeItem('_pxla');
      localStorage.removeItem('harvv_retry_q');
    } catch (_) {}
  }
  if (CB_ON) {
    var cbShop = cbShopifyState();
    if (cbShop) CB_STATE = cbShop;
    else if (CONSENT_SEEN && !GPC_DENIED) CB_STATE = CONSENT_DENIED ? (CONSENT_AWAITING ? 'pending' : 'denied') : 'granted';
    else if (CONSENT_SEEN) CB_STATE = (CONSENT_AWAITING ? 'pending' : 'denied');
    // else: no framework we recognize -> 'nogate' (flag-off behavior).
    // A Shopify banner grant is the visitor speaking more recently and more
    // specifically than the browser-wide GPC flag, so it lifts a denial that
    // GPC alone imposed at boot (the dataLayer 'update: granted' case was
    // already handled there). A dataLayer denial is never lifted here.
    if (CB_STATE === 'granted' && GPC_DENIED) { CONSENT_DENIED = false; GPC_DENIED = false; }
    // Global Privacy Control (2026-09-01, consent audit). Until now GPC was
    // CAPTURED as a signal (the env event's gpc/ccl fields) and honored only
    // by harvv.com's own banner, while the public FAQ claimed we honor it on
    // customer sites: captured_but_ignored, in writing. Under consent-aware
    // mode it now IS a denial. GPC is the visitor speaking directly, so it
    // applies even with no banner on the page; the one thing that outranks
    // it is an explicit banner grant, which is the visitor speaking MORE
    // recently and specifically (the CPRA reading). Flag-off behavior is
    // unchanged, same as every other consent-buffer rule.
    if (navigator.globalPrivacyControl === true && CB_STATE !== 'granted') CB_STATE = 'denied';
    if (CB_STATE === 'denied') CONSENT_DENIED = true;
    try {
      // Shopify fires this on every banner decision; also covers the API
      // loading after us (boot saw no framework, decision arrives later).
      document.addEventListener('visitorConsentCollected', function () {
        // afterDecision: the visitor has now ANSWERED, so a not-allowed
        // reading here is a refusal rather than the API's opening silence.
        var s = cbShopifyState(true);
        if (s === 'granted') cbGrant();
        else if (s === 'denied') cbDeny();
      });
    } catch (_) {}
  }
  // Map our `cm` kinds to GA4 recommended event names so the dashboard
  // can label both.
  var GA4_NAMES = { ca: 'add_to_cart', co: 'begin_checkout' };
  // Inverse map for the dataLayer subscriber — when GA4 emits one of
  // these recommended event names, ingest as a Harvv `cm` event with
  // src:'dl' (more precise than DOM detection because the customer's
  // code chose to fire it).
  // 2026-05-23 — Extended map. Standard GA4 recommended event names
  // (Shopify, GTM defaults, etc.) plus the legacy/custom names we've seen
  // in real customer data (Axel: shopping_cart, checkout, contact_us,
  // product, ads_conversion_*). Mapping multiple GA4 names → same Harvv
  // cm kind is intentional: emitCm() de-dupes on (kind, ga4) so we never
  // double-count.
  var GA4_TO_KIND = {
    // GA4 recommended event names
    add_to_cart: 'ca', begin_checkout: 'co', view_cart: 'vc',
    purchase: 'pu', view_item: 'vi', view_item_list: 'vi',
    generate_lead: 'gl', sign_up: 'su', search: 'sr',
    // Common custom / legacy names from Shopify, WooCommerce, GTM templates
    shopping_cart: 'ca',     // Shopify default; same intent as add_to_cart
    checkout: 'co',          // some sites fire 'checkout' instead of 'begin_checkout'
    contact_us: 'gl',        // contact-form submit is a lead event
    product: 'vi',           // some templates fire 'product' for view_item
  };

  // 1.0.8 — dedupe-aware commerce emitter. The DOM click handler and
  // the dataLayer subscriber can both detect the same logical event
  // (customer's button click + their GTM tag firing on the same click).
  // We hold DOM emissions for 100ms; if a dataLayer-sourced event with
  // the same `ga4` name arrives in that window, we cancel the DOM emit
  // and keep only the (more precise) dataLayer version. The dashboard
  // then sees one event per real conversion regardless of how many
  // signal paths the customer's site has.
  var pendingDom = {};
  function emitCm(src, kind, data) {
    // 2026-08-27 (#67050) — ANY cart-add arms the Shopify cart stamp, not only
    // a DOM click. The stamp used to hang off a four-selector guess at what an
    // add-to-cart button looks like, and Tiger Friday's button
    // (<button name="button" class="add AddtoCart js-ajax-submit">) matches
    // none of them: its cart-adds were 100% dataLayer-sourced, so the stamp
    // never ran and 5,988 orders carried no identity. A selector list is a
    // guess about someone else's HTML; the dataLayer is a contract. Both
    // sources arrive here, so this is the one place that cannot be bypassed.
    // stitchShopifyCart() re-checks its own preconditions and is a no-op after
    // the first success, so calling it on every cart-add is safe.
    if (kind === 'ca') stitchShopifyCart();
    var ga4 = (data && data.ga4) || GA4_NAMES[kind] || null;
    var key = kind + ':' + (ga4 || '');
    var payload = Object.assign({ k: kind, src: src }, data || {});
    if (ga4) payload.ga4 = ga4;
    if (src === 'dom') {
      pendingDom[key] = setTimeout(function () {
        push('cm', payload);
        delete pendingDom[key];
      }, 100);
    } else {
      if (pendingDom[key]) { clearTimeout(pendingDom[key]); delete pendingDom[key]; }
      push('cm', payload);
    }
    // Push to customer's dataLayer under the harvv_signal namespace so
    // their GTM can route to other tools (Meta Pixel, Klaviyo, etc.)
    // WITHOUT double-counting GA4 — we never reuse a GA4 event name as
    // our dataLayer event.
    try {
      if (!window.dataLayer) window.dataLayer = [];
      window.dataLayer.push({ event: 'harvv_signal', harvv_kind: kind, harvv_src: src, harvv_ga4: ga4 || null });
    } catch (_) {}
  }

  // Public custom-event API. Lets the host page record explicit events
  // (lightbox opens, funnel steps, anything) that land alongside the
  // auto-captured signals as a 'cm' event with src='api'. Universal value:
  // any customer can track their own funnel; we (Harvv, customer #1) use it to
  // make every lightbox first-class in our own data. ES5-only, string-coerced,
  // length-capped, PII-discipline left to the caller. Idempotent definition.
  try {
    window.harvv = window.harvv || {};
    if (typeof window.harvv.track !== 'function') {
      window.harvv.track = function (name, props) {
        try {
          var n = String(name == null ? '' : name).slice(0, 60);
          if (!n) return;
          var d = { k: n, src: 'api' };
          if (props && typeof props === 'object') {
            for (var key in props) {
              if (Object.prototype.hasOwnProperty.call(props, key)) {
                var v = props[key];
                if (v != null && typeof v !== 'object') d[String(key).slice(0, 40)] = String(v).slice(0, 120);
              }
            }
          }
          push('cm', d);
        } catch (_) {}
      };
    }
  } catch (_) {}

  // Subscribe to the customer's dataLayer for known GA4 commerce events.
  // Wraps `window.dataLayer.push` so future events flow through us; also
  // replays anything already in the array (events that fired before our
  // pixel booted — e.g. if a server-rendered `gtag('config', ...)` ran
  // before our async script). Subscribe-once guard via window.__harvvDL.
  try {
    if (!window.__harvvDL) {
      window.__harvvDL = 1;
      if (!Array.isArray(window.dataLayer)) window.dataLayer = [];
      var dlIngest = function (a) {
        if (!a) return;
        // Consent-buffer transitions ride the same subscriber: a mid-page
        // consent('update', ...) is the banner answering. Only an explicit
        // update can DENY (a replayed default-denied is the boot state the
        // machine above already classified, not a new refusal).
        if (CB_ON && a[0] === 'consent' && a[2] && a[2].analytics_storage) {
          if (a[2].analytics_storage === 'granted') cbGrant();
          else if (a[2].analytics_storage === 'denied' && a[1] === 'update') cbDeny();
        } else if (!CB_ON && a[0] === 'consent' && a[1] === 'update' && a[2] && a[2].analytics_storage === 'granted') {
          // 2026-09-07 (gap audit #1). Consent Mode v2 is default:denied
          // (wait_for_update) then update:granted. This async pixel routinely
          // boots inside that window, read the default as a refusal, and
          // never looked again because this hook was gated on CB_ON, which has
          // been off fleet-wide since 2026-09-01. On Tiger Friday 58% of
          // session starts were one-pageload throwaway identities (40,767 of
          // 69,862 in a day), 90% of them US visitors who never refused
          // anything, and every traffic number since 07-22 inherited it.
          legacyGrant();
        }
        // TWO dataLayer shapes, because both are in the wild (#67073).
        //   gtag.js pushes its own `arguments`, so the entry is
        //     ['event', 'add_to_cart', {...}]
        //   Google Tag Manager's own convention is a plain object:
        //     { event: 'add_to_cart', ecommerce: {...} }
        // We only ever read the first. For an object a[0] is undefined, so
        // dlIngest returned on its first line and every GTM-shaped commerce
        // event was silently dropped: add_to_cart, begin_checkout, purchase,
        // view_item, and also generate_lead / sign_up / search, which are the
        // ones that matter on a non-commerce site. Since 2026-08-27 a cart-add
        // is also what arms the Shopify cart stamp, so a GTM-only store lost
        // conversion attribution on top of the signals.
        var name;
        if (a[0] === 'event') name = a[1];
        else if (typeof a === 'object' && typeof a.event === 'string') name = a.event;
        if (!name) return;
        var kind = GA4_TO_KIND[name];
        if (!kind) return;
        // Ignore re-firings of our own harvv_signal pushes.
        if (name === 'harvv_signal') return;
        emitCm('dl', kind, { ga4: name });
      };
      for (var di = 0; di < window.dataLayer.length; di++) dlIngest(window.dataLayer[di]);
      var dlOrig = window.dataLayer.push;
      window.dataLayer.push = function () {
        for (var ai = 0; ai < arguments.length; ai++) {
          try { dlIngest(arguments[ai]); } catch (_) {}
        }
        return dlOrig.apply(window.dataLayer, arguments);
      };
    }
  } catch (_) {}
  var SESSION_TIMEOUT = 1800000;    // 30 minutes in ms

  // Absolute session cap. 30-minute inactivity alone never expires a session in
  // a long-lived tab: every event calls touch(), touch() rewrites _pxla, so the
  // inactivity clock never runs out. Measured 2026-08-08 over 7 days of prod:
  // 1,181 sessions spanned more than 24 hours and 713 more sat between 12 and
  // 24, the worst reaching 165 hours (case #58416). A week is not a session, and
  // those rows silently corrupt every per-session engagement number.
  var SESSION_MAX_AGE = 43200000;   // 12 hours in ms

  // A session id we would mint today: 16 lowercase hex chars (rid()).
  //
  // Before T70 (2026-04-28) rid() produced 8 chars, and _pxs is written to
  // localStorage, which never expires. A visitor who first loaded the pixel
  // before that date and keeps a tab alive carries the 8-char id forever: 3,098
  // events across 3 sites in the 3 days to 2026-08-08 still had one, more than
  // three months after the generator was widened. 8 hex chars is a ~4-billion
  // space, which is exactly the birthday-paradox range T70 widened to escape,
  // so those visitors were still generating the collisions T70 fixed (case
  // #57622: one id shared by 8 distinct visitors).
  //
  // Validating on READ is what actually retires them. Widening the generator
  // only ever governed NEW ids.
  var VALID_ID = /^[0-9a-f]{16}$/;
  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
  // ── _scrollTop / _scrollMax: resolve the element that ACTUALLY scrolls ────
  //
  // 2026-08-02 (#57480, the second half). Every scroll-derived signal read
  // `window.pageYOffset` and `documentElement.scrollHeight - innerHeight`, which
  // assumes the VIEWPORT is the scroll container. On a very common layout it is
  // not: `html,body{height:100%}` plus `overflow-x:hidden` makes the BODY the
  // scroller, because per spec a non-visible overflow on one axis computes the
  // other axis to `auto`. When that happens:
  //
  //     documentElement.scrollHeight === innerHeight   -> our max is 0
  //     window.pageYOffset stays 0 forever             -> our position is 0
  //
  // so `sd` (scroll depth) and `sk` (skim vs read segments) never fire at all,
  // while `rd` keeps working because IntersectionObserver does not care which
  // element scrolls. That asymmetry is the fingerprint: read-depth events with
  // zero scroll events.
  //
  // Measured on harvv.com/hvac: documentElement 719/719, body 2354/719. Our own
  // brand baseline caused it, but this is NOT only our bug. Fleet sd-emission
  // rate by site: tigerfriday 64%, axeloffroad 59%, enzositalianmarket 55%,
  // whitewolfmarketing 21%, mentisera 10%, harvv 10%. The low ones are
  // customers with the same layout, silently reporting every visitor as a
  // zero-scroll bounce.
  //
  // Universal value, so it belongs in the pixel rather than in our CSS alone
  // (dogfooding policy). The CSS is fixed too, but that only helps us.
  // 2026-08-18 (#57480, third pass). The 2026-08-03 fix widened the search from
  // documentElement to BODY, which covers `html,body{height:100%;overflow:auto}`.
  // It does NOT cover the other common layout: a wrapper DIV that scrolls while
  // documentElement and body are both fixed at viewport height, which is what
  // Framer and several SPA themes emit. There, the capture-phase listener wakes
  // on every scroll, _scrollTop() and _scrollMax() both resolve 0, the handler
  // hits `if (scrollHeight <= 0) return`, and no `sd` milestone ever fires. The
  // page is exactly as blind as it was before the first fix.
  //
  // Measured 2026-08-18 on the 15-day post-fix window: Nina's p75 max-scroll
  // went 0 -> 52 across the Aug-3 deploy (that site WAS body-scrolling and is
  // now correct), but Orangepro (framer) still reads median 0 / p75 32 and
  // Prime MD Plus still reads median 0 — the shape left when the scroller is
  // neither documentElement nor body.
  //
  // The scroll event already knows the answer: its target IS the element that
  // scrolled. _adoptScroller reads it, but ONLY when the viewport itself does
  // not scroll. Without that guard a carousel or an overflowing code block on a
  // normally-scrolling page would hijack the denominator and understate depth.
  var _scroller = null;       // element scroller, or null for the viewport path
  var _scrollerMax = 0;       // biggest extent seen, so the LARGEST scroller wins

  function _viewportMax() {
    var de = document.documentElement, b = document.body;
    var m = de.scrollHeight - window.innerHeight;
    if (m > 0) return m;
    if (b) {
      var bm = b.scrollHeight - b.clientHeight;
      if (bm > 0) return bm;
    }
    return 0;
  }
  function _adoptScroller(t) {
    if (!t || t.nodeType !== 1) return;
    if (t === document.documentElement || t === document.body) return;
    if (_viewportMax() > 0) return;          // the page scrolls; ignore nested scrollers
    var ext = t.scrollHeight - t.clientHeight;
    if (ext > 0 && ext >= _scrollerMax) { _scroller = t; _scrollerMax = ext; }
  }
  function _scrollTop() {
    if (_scroller) return _scroller.scrollTop || 0;
    var de = document.documentElement, b = document.body;
    var t = window.pageYOffset || de.scrollTop || 0;
    if (t) return t;
    return (b && b.scrollTop) || 0;
  }
  function _scrollMax() {
    if (_scroller) {
      var sm = _scroller.scrollHeight - _scroller.clientHeight;
      if (sm > 0) return sm;
    }
    return _viewportMax();
  }

  var SCROLL_MILESTONES = [10, 25, 50, 75, 100];
  var TEXT_LIMIT = 20;              // max chars of innerText in element ID
  // 2026-09-01 (Umair MCP gap #4) — labels were cut mid-word, producing
  // handles like p:"Mobile LCP over 3 se". Two changes, and deliberately NOT
  // "stop truncating":
  //
  //   TEXT_LIMIT_LABEL   interactive controls (a, button, and friends) carry a
  //                      LABEL. Sixty characters holds a real button label.
  //   TEXT_LIMIT (20)    everything else carries CONTENT. /trust promises "a
  //                      short visible label for interactive controls" and
  //                      "Not content text"; an uncapped <p>.textContent is
  //                      content text by definition, so the tight cap on
  //                      non-interactive tags is what keeps that true.
  //
  // Cost is real but small: ~183k interaction events a day, so every extra 20
  // characters is ~3.5 MB/day crossing customers' networks.
  var TEXT_LIMIT_LABEL = 60;
  // Tags whose visible text is a label, not page content.
  var LABEL_TAGS = { a: 1, button: 1, summary: 1, label: 1, option: 1 };
  var LABEL_ROLES = { button: 1, link: 1, menuitem: 1, tab: 1, option: 1 };

  function isLabelEl(el, tag) {
    if (LABEL_TAGS[tag]) return true;
    var role = el && el.getAttribute && el.getAttribute("role");
    return !!(role && LABEL_ROLES[String(role).toLowerCase()]);
  }

  // Clip on a word boundary so a label never ends mid-word. Falls back to a
  // hard cut when the boundary would throw away most of the budget, because
  // "Add" is worse than "Add to cart and contin".
  function clipText(s, limit) {
    if (s.length <= limit) return s;
    var cut = s.slice(0, limit);
    var sp = cut.lastIndexOf(" ");
    if (sp > 0 && sp >= limit * 0.6) cut = cut.slice(0, sp);
    return cut;
  }

  // --- 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)$/;
  // #65725 (2026-08-25) — walk to the parent ACROSS shadow boundaries. Inside
  // an open shadow root the parentElement chain ends at the root, so any
  // ancestor check that walks it (privacy zones, blind-spot shielding,
  // third-party-widget suppression, interactive-ancestor rescue) silently
  // stopped seeing the light DOM the moment the element lived in a shadow
  // tree. When the chain ends at a ShadowRoot, hop to its host and continue.
  function parentAcross(el) {
    if (!el) return null;
    if (el.parentElement) return el.parentElement;
    var r = el.parentNode;
    return (r && r.host && r.host.tagName) ? r.host : null;
  }

  // #65725 — the element the user actually engaged, not the shadow host it was
  // retargeted to. Events crossing an OPEN shadow boundary reach a document
  // listener with ev.target rewritten to the host element (which for a widget
  // like <quadpay-widget-v3> has no text and no children); composedPath()[0]
  // is the true innermost target. Closed shadow roots keep retargeting to the
  // host, which is correct: their internals are not observable by design.
  // Falls back to ev.target when composedPath is missing (old engines).
  function cTarget(ev) {
    var t = ev.target;
    if (ev.composedPath) {
      try {
        var path = ev.composedPath();
        if (path && path.length && path[0] && path[0].tagName) t = path[0];
      } catch (_) {}
    }
    return t;
  }

  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;
      }
      // Crosses shadow boundaries (#65725): with composed targets, a
      // data-harvv-private wrapper in the light DOM must still protect
      // everything rendered inside a widget's shadow root.
      p = parentAcross(p);
    }
    return false;
  }

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

  // Build a minimal element identifier.
  // Format (public):  tag#id:"text" or tag.class:"text"
  //                   e.g. btn#add-cart:"Add to C" — a human reads the session story.
  // Format (private): tag#id or tag.class (no text — PII guard)
  //                   e.g. div.chat-bubble, div#response-34
  // For INPUT elements: input[type]#id or input[type].name (never value)
  // 2026-09-11 — textContent includes the text of <script>, <style>,
  // <template> and <noscript> descendants, none of which a visitor can see.
  // Clicking the page background on any site that ships JSON-LD produced the
  // label `body.flex:"{"@context":"https:/"`, and a <style> in <head> produced
  // `html.h-full:".ssQIHO-checkbox-men"` (both observed on a live Next.js
  // customer site). Unreadable in a report, and it puts markup we never meant
  // to capture into an element id.
  //
  // There was already a guard for the CSS half, but it tested the CLIPPED
  // text: clipping truncates a stylesheet long before the `{` the regex needs,
  // so the guard could not see what the clip had removed. Fixing it at the
  // source instead means neither shape can reach the label in the first place.
  //
  // Cost: only an element WITH CHILDREN can have such a descendant, so leaves
  // — the overwhelming majority of clicked and hovered elements — pay one
  // .children length check and nothing more.
  function renderedText(el) {
    var t = el.textContent || "";
    if (!t || !el.children || !el.children.length || !el.getElementsByTagName) return t;
    var tags = ["script", "style", "template", "noscript"];
    for (var i = 0; i < tags.length; i++) {
      var nodes = el.getElementsByTagName(tags[i]);
      for (var j = 0; j < nodes.length; j++) {
        var sub = nodes[j].textContent;
        // Length guard: a one-character body would shred ordinary prose.
        if (sub && sub.length > 1) t = t.split(sub).join(" ");
      }
    }
    return t;
  }

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

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

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

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

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

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

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

  // Visitor: persistent, generated once, 2-year cookie + localStorage mirror.
  // 1.0.7 — when GA4 Consent Mode v2 has analytics_storage=denied, we
  // downgrade to anonymous mode: in-memory visitor ID, no cookie write,
  // no localStorage write. The session still works for the current
  // page-view but disappears on navigation, mirroring GA4's
  // "cookieless ping" behavior under denied consent.
  // Consent-buffer PENDING gets the same in-memory treatment as denied: no
  // device writes while the visitor is deciding. cbGrant() persists these
  // exact ids if the answer is yes, so nothing is lost by waiting.
  var vid;
  if (CONSENT_DENIED || cbPending()) {
    vid = rid();
  } else {
    vid = getCookie("_pxv") || lsGet("_pxv") || rid();
    setCookie("_pxv", vid, 730);
    lsSet("_pxv", vid);
  }

  // Session: check for timeout, rotate if stale.
  // 1.0.8 (#52933) — under CONSENT_DENIED the session id must be as
  // ephemeral as the visitor id: previously only vid went in-memory while
  // sid/_pxss/_pxla still wrote cookies+localStorage, so consent-denied
  // visitors on a shared device/profile REUSED one persisted session id
  // across different in-memory visitor ids — the session_id_collision
  // class (20/wk in prod). Same GA4 cookieless-ping semantics as vid:
  // in-memory sid per page load, zero storage writes.
  var now = Date.now();
  var sessionStart, lastActivity, sid;
  if (CONSENT_DENIED || cbPending()) {
    sid = rid();
    sessionStart = now;
    lastActivity = now;
  } else {
    sessionStart = parseInt(getCookie("_pxss") || lsGet("_pxss")) || now;
    lastActivity = parseInt(getCookie("_pxla") || lsGet("_pxla")) || now;
    var stored = getCookie("_pxs") || lsGet("_pxs");
    if (now - lastActivity > SESSION_TIMEOUT || now - sessionStart > SESSION_MAX_AGE
        || !VALID_ID.test(stored || "")) {
      // Expired by inactivity, expired by absolute age, or the stored id is not
      // one we would mint today. Any of the three starts a fresh session.
      sid = rid();
      sessionStart = now;
    } else {
      sid = stored;
    }
    setCookie("_pxs", sid);
    setCookie("_pxss", sessionStart);
    lsSet("_pxs", sid);
    lsSet("_pxss", sessionStart);
  }

  function touch() {
    lastActivity = Date.now();
    if (CONSENT_DENIED || cbPending()) return; // no storage writes under denied/undecided consent
    setCookie("_pxla", lastActivity);
    lsSet("_pxla", lastActivity);
  }
  touch();
  IDENTITY_READY = true;

  // 2026-09-07 (gap audit #1) — a consent GRANT arriving after boot on the
  // legacy (non-buffer) path. The boot read saw default:denied and minted
  // throwaway in-memory ids; the banner has now said yes. Adopt the ids an
  // earlier granted pageload already persisted (so the visit stays ONE
  // session across pages), or persist the in-memory ones if none exist, and
  // restamp anything still queued so no event ships under the discarded ids.
  // Only an explicit 'update: granted' reaches here, and the latest answer
  // wins (a visitor who declined and then accepted is accepted). Never runs
  // before identity exists: the dataLayer replay at install time is covered
  // by the boot scan.
  function legacyGrant() {
    if (!IDENTITY_READY || !CONSENT_DENIED) return;
    CONSENT_DENIED = false;
    GPC_DENIED = false;
    CONSENT_EXPLICIT_GRANT = true;
    try {
      var oldV = vid, oldS = sid;
      var now2 = Date.now();
      var storedV = getCookie("_pxv") || lsGet("_pxv");
      if (storedV && VALID_ID.test(storedV)) vid = storedV;
      var storedS = getCookie("_pxs") || lsGet("_pxs");
      var storedSS = parseInt(getCookie("_pxss") || lsGet("_pxss")) || 0;
      var storedLA = parseInt(getCookie("_pxla") || lsGet("_pxla")) || 0;
      if (storedS && VALID_ID.test(storedS) && storedSS && storedLA
          && now2 - storedLA <= SESSION_TIMEOUT && now2 - storedSS <= SESSION_MAX_AGE) {
        sid = storedS; sessionStart = storedSS;
      }
      setCookie("_pxv", vid, 730); lsSet("_pxv", vid);
      setCookie("_pxs", sid); lsSet("_pxs", sid);
      setCookie("_pxss", sessionStart); lsSet("_pxss", sessionStart);
      lastActivity = now2;
      setCookie("_pxla", lastActivity); lsSet("_pxla", lastActivity);
      for (var qi = 0; qi < queue.length; qi++) {
        if (queue[qi].v === oldV) queue[qi].v = vid;
        if (queue[qi].s === oldS) { queue[qi].s = sid; queue[qi].t = Math.max(0, queue[qi].t | 0); }
        if (queue[qi].e === 'ss' && queue[qi].d) { delete queue[qi].d.cd; queue[qi].d.cg = 1; }
      }
    } catch (_) {}
  }

  // Roll the session mid-flight when it goes stale.
  //
  // The checks at init only run on a PAGE LOAD. A tab that is never navigated
  // never re-runs them, so before 2026-08-08 a single long-lived tab held one
  // session open indefinitely: the worst observed ran 165 hours (#58416).
  // touch() is what kept it alive, since every event pushed the inactivity
  // clock forward, so inactivity alone could never end it.
  //
  // Called from push() before the event is stamped, so an event always carries
  // the session it actually belongs to rather than the one the tab opened with.
  function rollSessionIfStale() {
    var t = Date.now();
    if (t - lastActivity <= SESSION_TIMEOUT && t - sessionStart <= SESSION_MAX_AGE) return;
    sid = rid();
    sessionStart = t;
    if (CONSENT_DENIED || cbPending()) return; // in-memory only, same as init
    setCookie("_pxs", sid);
    setCookie("_pxss", sessionStart);
    lsSet("_pxs", sid);
    lsSet("_pxss", sessionStart);
  }

  // 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) {
          // 2026-09-01 — same word-boundary rule as element labels. A section
          // called "Frequently Asked Ques" is the defect Umair reported, one
          // level up.
          sectionName = clipText((heading.textContent || '').replace(/[\t\n\r\s]+/g, " ").trim(), 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; });
      // null means NO OBSERVATION (no cursor samples in the window). 0 means an
      // observed, stationary cursor. Collapsing those two was a selection bias:
      // see the emit site below.
      if (recent.length < 2) return null;
      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;
      // Floor the interval at one frame. Browsers COALESCE mousemove events and
      // can deliver two samples with near-identical timestamps, so dividing by a
      // sub-millisecond dt produced physically impossible speeds: 89 of 11,861
      // samples over 3 days read above 5,000 px/s, one at 163,009 px/s (forty
      // 4K-screen widths per second). Those 0.75% of samples dragged the MEAN
      // from 117 to 315 px/s, and analysis/atc-hesitation.js averages this
      // field, so its cursor-speed figure was inflated 2.7x.
      if (dt < 0.016) return null;
      var v = Math.round(dist / dt);
      // A real cursor on a real display does not exceed this. Clamping rather
      // than dropping keeps the sample (a genuinely fast flick is signal) while
      // bounding what one bad timestamp can do to an average.
      return v > 5000 ? 5000 : v;
    } catch (_) { return null; }
  }
  // Hover dwell tracking — when the pointer enters an element, stamp the
  // time; on click or leave, compute elapsed. Separate from the existing
  // hover-intent listener so dwell is available even for short hovers.
  var hoverEnterTime = new WeakMap();
  function getHoverDwell(el) {
    try {
      var t = hoverEnterTime.get(el);
      return t ? Math.round(Date.now() - t) : 0;
    } catch (_) { return 0; }
  }

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

  // 2026-05-14 — Async-click suppression. Axel Off-Road's manual review
  // of Clarity dead-clicks showed ~75% false positives caused by modern
  // e-commerce click handlers that respond ASYNC (variant fetch, image
  // lazy-load, pre-order check, animation). Same-class detector here was
  // emitting `dc` immediately on click — never waited to see if a network
  // call, navigation, modal, or loading indicator appeared in response.
  //
  // New behavior: queue the dc candidate, then within 1200ms watch for
  // any of these signals indicating the click DID produce a response:
  //   1. Any fetch/XHR network call started (already hooked for `nf`)
  //   2. URL change (history.pushState / hashchange / navigation)
  //   3. Significant DOM mutation: added element ≥300px tall (modal/page)
  //   4. New element whose class contains loading|spinner|skeleton|overlay|
  //      modal|drawer|lightbox|sheet|popup (any common loading-indicator
  //      class name)
  // If we see ANY signal, the dc is suppressed and an `ac` (async-click)
  // event fires instead — same payload + a `reason` field — so we keep
  // visibility without polluting the dead-click bucket.
  // 2026-07-15 — 1200 -> 1800ms: Tiger Friday's search app (usf-*) responds
  // via JSONP over mobile networks; the round-trip regularly outlasted 1200ms
  // and a WORKING filter click emitted `dc` before its results landed
  // (417 dead vs 34 rage on /search = calm working clicks, not frustration).
  var DEFERRED_DC_WINDOW_MS = 1800;
  var clickEpoch = 0;             // bumped on each click for cancellation
  var pendingDeferredDc = [];     // [{ data, expires, epoch }]
  // 2026-05-19 — capture-phase click marker. The dead-click cancel
  // signals (MutationObserver delivery, __harvvNetActivity) fire during
  // the click's TARGET phase — before the document-bubble handler runs
  // scheduleDeferredDc. Without a landing spot, a cancel raised by the
  // site's own click handler hits an empty pendingDeferredDc and is
  // discarded, so a working element gets a false `dc`. This capture-phase
  // listener runs FIRST in every click dispatch, giving those early
  // signals a per-click object to mark.
  //
  // Rapid-fire safety: event dispatch is one synchronous task
  // (capture -> target -> bubble); the next click is a SEPARATE task. So
  // distinct clicks never interleave — clickInFlight for click A is set
  // in A's capture phase and consumed in A's bubble phase, entirely
  // within A's dispatch, before B's capture can run. Rage clicks are N
  // separate tasks, each with a clean marker. A signal that arrives
  // after the bubble phase is still handled by the pendingDeferredDc
  // path below (by then the candidate exists).
  // See docs/findings/microtask-race-dc-cancels-2026-05-19.md.
  var clickInFlight = null;
  var CLICK_SIGNAL_WINDOW_MS = 250; // window after a click in which a
                                    // cancel signal is attributed to it
  function clickSignalWindowOpen() {
    return !!clickInFlight && (Date.now() - clickInFlight.at) < CLICK_SIGNAL_WINDOW_MS;
  }
  document.addEventListener('click', function (ev) {
    // `el` (2026-09-11): the element that was clicked, so the observer below
    // can tell "the control I clicked answered me" from "something moved
    // somewhere on the page". See ACK_ATTRS.
    clickInFlight = { at: Date.now(), cancelled: false, reason: null, el: ev && ev.target };
  }, { capture: true });

  // ── Selecting text is not a dead click (2026-08-11) ────────────────────
  //
  // Highlighting a sentence produces a mousedown, a drag and a mouseup on a
  // non-interactive element, which is exactly the shape of a dead click. So
  // every visitor who copied a price, a SKU or a paragraph was being recorded
  // as someone clicking something broken. Reported from our own dogfood
  // session: the operator highlighted text and watched the live tracker log a
  // dead click.
  //
  // Same class as the carousel rage-click false positive (#60622): a normal,
  // successful behaviour scored as friction because the rule only looked at the
  // shape of the input and not at what the visitor was doing. Text selection is
  // ENGAGEMENT. Counting it as a broken element inverts its meaning, and it
  // concentrates on exactly the content-heavy pages where reading is the goal.
  //
  // The pixel already emits `ts` for a selection, so the signal to suppress on
  // was captured all along and the dead-click path simply never read it.
  //
  // Two independent guards, because either alone has a gap:
  //   1. A non-collapsed selection at click time. Catches the normal case,
  //      including double-click-to-select-a-word.
  //   2. Pointer travel between mousedown and mouseup. Catches a drag that
  //      ended with an empty selection (dragging across an image, a failed
  //      selection, a drag-scroll on a touch device).
  var downAt = null;
  document.addEventListener('mousedown', function (ev) {
    downAt = { x: ev.clientX, y: ev.clientY };
  }, { capture: true });
  function looksLikeSelection(ev) {
    try {
      var sel = window.getSelection && window.getSelection();
      if (sel && !sel.isCollapsed && String(sel).length > 0) return true;
    } catch (_) {}
    try {
      if (downAt && ev && typeof ev.clientX === 'number') {
        var dx = ev.clientX - downAt.x, dy = ev.clientY - downAt.y;
        // 6px: below a hand tremor on a genuine click, well under any real drag.
        if ((dx * dx + dy * dy) > 36) return true;
      }
    } catch (_) {}
    return false;
  }
  // ── In-place acknowledgement is not a dead click (2026-09-11, #74119) ───
  //
  // Every cancel signal above describes a response that is BIG or ELSEWHERE:
  // a node >= 300px, eight nodes swapped, a URL change, a network call, an
  // attribute flip on something >= 250px tall. They were built from modal and
  // drawer false positives, where the response really is a page-sized region.
  //
  // The most common correct response in modern UI is none of those. A control
  // answers a click ON ITSELF: it disables, it sets aria-busy, it swaps its own
  // label to "Saving…" or "Finding you…". That is small by definition, so no
  // size threshold can ever see it, and lowering the numbers would only
  // re-admit the background churn the thresholds exist to reject. The right
  // discriminator is not size, it is WHOSE subtree changed: the control the
  // visitor just clicked, or something unrelated.
  //
  // Scope is the clicked element's nearest interactive ancestor, or failing
  // that its parent element. One level of generosity, because the label that
  // changes is usually a SIBLING of the span that got clicked
  // (<div><span>Use my location</span><span>One tap…</span></div>), and
  // scoping to the clicked node alone would miss every one of them.
  var ACK_ATTRS = /^(disabled|aria-busy|aria-disabled|aria-expanded|aria-pressed|aria-checked)$/;
  function ackScope() {
    var el = clickInFlight && clickInFlight.el;
    if (!el || el.nodeType !== 1) return null;
    try {
      var ctrl = el.closest && el.closest('button,a,[role="button"],input,select,textarea,summary,label');
      if (ctrl) return ctrl;
    } catch (_) {}
    var par = el.parentElement;
    // Never widen to a page-level container. Scoped to <body>, "did my
    // control answer?" would become "did anything on the page move?", which
    // is the opposite of what the scoping is for.
    if (par && !/^(BODY|HTML|MAIN)$/.test(par.tagName)) return par;
    return el;
  }
  function withinAckScope(node) {
    var ctrl = ackScope();
    if (!ctrl || !node) return false;
    var n = node.nodeType === 1 ? node : node.parentNode;
    for (var guard = 0; n && guard < 12; guard++) {
      if (n === ctrl) return true;
      n = n.parentNode;
    }
    return false;
  }
  function scheduleDeferredDc(data, tgt) {
    // The click's own handler may already have produced a cancel signal
    // (DOM mutation / fetch / navigation) in the target phase, recorded
    // on the capture-phase marker. If so, emit `ac` and skip the dc.
    if (clickInFlight && clickInFlight.cancelled) {
      try {
        push("ac", { el: data.el, sec: data.sec, x: data.x, y: data.y, r: clickInFlight.reason });
      } catch (_) {}
      return;
    }
    var myEpoch = ++clickEpoch;
    var deadline = Date.now() + DEFERRED_DC_WINDOW_MS;
    var entry = { data: data, deadline: deadline, epoch: myEpoch, cancelled: false };
    pendingDeferredDc.push(entry);
    setTimeout(function () {
      if (entry.cancelled) return; // an async response cancelled us
      // No async response within window — emit as dead click.
      push("dc", data);
    }, DEFERRED_DC_WINDOW_MS);
  }
  // Cancel any pending deferred dc, optionally tagging the reason so we
  // can emit `ac` (async-click) for analytics visibility.
  function cancelPendingDc(reason) {
    // A cancel signal can arrive BEFORE the deferred dc is scheduled
    // (target-phase handler -> microtask, vs. bubble-phase schedule).
    // Mark the in-flight click so scheduleDeferredDc — running later in
    // the same dispatch — sees it. First-wins; window-bounded so a stale
    // marker from an old click is not tagged by an unrelated navigation.
    if (clickInFlight && !clickInFlight.cancelled && clickSignalWindowOpen()) {
      clickInFlight.cancelled = true;
      clickInFlight.reason = reason;
    }
    var now = Date.now();
    for (var i = 0; i < pendingDeferredDc.length; i++) {
      var e = pendingDeferredDc[i];
      if (e.cancelled || now > e.deadline) continue;
      e.cancelled = true;
      // Emit a low-severity async-click event so we retain telemetry
      // (operators can still see "this element was clicked + responded
      // async") without it being a dead-click signal.
      try {
        push("ac", { el: e.data.el, sec: e.data.sec, x: e.data.x, y: e.data.y, r: reason });
      } catch (_) {}
    }
    // Garbage-collect cancelled/expired entries.
    pendingDeferredDc = pendingDeferredDc.filter(function (e) { return !e.cancelled && now <= e.deadline; });
  }
  // Tier 1 — URL change signal (history.pushState/replaceState + hashchange + popstate).
  try {
    var _push = history.pushState;
    history.pushState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _push.apply(this, arguments); };
    var _replace = history.replaceState;
    history.replaceState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _replace.apply(this, arguments); };
    window.addEventListener('popstate', function () { cancelPendingDc('navigation'); }, { capture: true });
    window.addEventListener('hashchange', function () { cancelPendingDc('navigation'); }, { capture: true });
    window.addEventListener('beforeunload', function () { cancelPendingDc('navigation'); }, { capture: true });
  } catch (_) {}
  // Tier 2 — DOM mutation signals (loading indicators, modals, big additions).
  var LOADING_RE = /(?:loading|spinner|skeleton|overlay|modal|drawer|lightbox|sheet|popup|preloader|fetching|progress)/i;
  try {
    // 2026-06-09 — list re-render threshold. AJAX pagination / faceted
    // filters / sort / load-more (LiveSearchFilter, WooCommerce/Shopify AJAX,
    // any "load more") swap a GRID of items in place: many small cards
    // added/removed at once, none individually >= 300px and no "loading"
    // class. The two checks below miss that entirely, so a working filter
    // click was logged as a dead click — 100% of these were dismissed by
    // operators across 6 sites. Counting added+removed element nodes catches
    // the re-render: a truly dead click mutates nothing, a working list
    // control mutates a page of items. Threshold is well above incidental
    // background churn (a rotating carousel swaps 1-3 slides).
    var RERENDER_NODE_THRESHOLD = 8;
    var dcSignalObs = new MutationObserver(function (muts) {
      // Also evaluate during an in-flight click — the candidate may not
      // be scheduled yet (see clickInFlight). Window-bounded so the
      // observer is not doing getBoundingClientRect (a forced reflow) on
      // every mutation for the life of the page.
      if (!pendingDeferredDc.length && !clickSignalWindowOpen()) return;
      var changed = 0;
      for (var mi = 0; mi < muts.length; mi++) {
        // 2026-07-15 — attribute-toggle signal (the Redo-chat / drawer case).
        // Widgets that pre-render hidden and open via a class/style flip
        // produce ZERO childList mutations, so a working launcher click read
        // as dead (1,100+ false dc on one widget). A large element whose
        // class/style/hidden just changed inside the click window is UI
        // responding — not a dead click.
        // The control the visitor clicked answered on itself. Checked BEFORE
        // the size gate below, because it is the more specific claim.
        //
        // All three mutation types, because the two obvious ways to relabel a
        // control produce DIFFERENT records and only one of them is the one
        // you would guess: `el.textContent = 'Saving…'` REPLACES the text node
        // (childList), while `node.nodeValue = …` edits it in place
        // (characterData). Watching only characterData catches almost nothing
        // real. A non-`<button>` control also takes `disabled` as a plain JS
        // property with no attribute record at all, which is the third reason
        // this cannot key on attributes alone.
        //
        // Falls THROUGH when out of scope, so a mutation elsewhere still
        // reaches the re-render counting below.
        if (muts[mi].type === 'characterData' || muts[mi].type === 'childList' ||
            (muts[mi].type === 'attributes' && ACK_ATTRS.test(muts[mi].attributeName || ''))) {
          if (withinAckScope(muts[mi].target)) {
            cancelPendingDc('element_ack');
            return;
          }
        }
        if (muts[mi].type === 'attributes') {
          var at = muts[mi].target;
          if (at && at.nodeType === 1) {
            try {
              var arect = at.getBoundingClientRect && at.getBoundingClientRect();
              if (arect && arect.height >= 250 && arect.width >= 200) {
                cancelPendingDc('ui_toggle');
                return;
              }
            } catch (_) {}
          }
          continue;
        }
        var added = muts[mi].addedNodes;
        for (var ni = 0; ni < added.length; ni++) {
          var n = added[ni];
          if (!n || n.nodeType !== 1) continue;
          changed++;
          // 2026-07-15 — count DESCENDANTS too (the usf-* case). An AJAX
          // result swap often inserts ONE wrapper containing the whole new
          // grid; counting only direct nodes saw 1-2 and never reached the
          // re-render threshold. Capped so a huge subtree costs nothing.
          try {
            if (n.getElementsByTagName) {
              var descN = n.getElementsByTagName('*').length;
              changed += descN > 40 ? 40 : descN;
            }
          } catch (_) {}
          // Loading-indicator class name match
          var cls = (n.className && typeof n.className === 'string') ? n.className : '';
          if (cls && LOADING_RE.test(cls)) {
            cancelPendingDc('loading_indicator');
            return;
          }
          // Significant element addition (>= 300px tall = likely modal/page)
          try {
            var rect = n.getBoundingClientRect && n.getBoundingClientRect();
            if (rect && rect.height >= 300 && rect.width >= 200) {
              cancelPendingDc('large_dom_addition');
              return;
            }
          } catch (_) {}
        }
        // Removed element nodes count toward a re-render too — a list swap
        // takes the old items out as it puts the new ones in.
        var removed = muts[mi].removedNodes;
        for (var ri = 0; ri < removed.length; ri++) {
          if (removed[ri] && removed[ri].nodeType === 1) changed++;
        }
      }
      // The click swapped in/out a page of nodes (pagination/filter/sort/
      // load-more). It did something — not a dead click. Emitted as `ac`
      // (async-click) so operators keep the telemetry without the FP.
      if (changed >= RERENDER_NODE_THRESHOLD) {
        cancelPendingDc('dom_rerender');
      }
    });
    // attributes watched for the ui_toggle signal above; attributeFilter
    // keeps the observer cheap (class/style/hidden flips only), and the
    // callback early-returns outside click windows anyway.
    dcSignalObs.observe(document.body || document.documentElement, {
      childList: true, subtree: true,
      attributes: true,
      // 2026-09-11 — the last six are the in-place acknowledgement signal.
      // Without them the mutation is never DELIVERED, so the logic above
      // could not fire however well written: `disabled` was not on this list.
      attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open',
        'disabled', 'aria-busy', 'aria-disabled', 'aria-expanded', 'aria-pressed', 'aria-checked'],
      // A control that swaps its own label answers with TEXT, not attributes.
      // The callback early-returns outside a click window, so the extra
      // delivery volume is bounded to the 1.8s after a click.
      characterData: true,
    });
  } catch (_) {}
  // Tier 3 — network signal: any fetch/XHR call starting within the window
  // is a strong indicator the click triggered async work. We expose a hook
  // here that the existing nf-emit path calls.
  window.__harvvNetActivity = function () {
    // Unconditional: a fetch dispatched inside a click handler fires this
    // BEFORE the deferred dc is scheduled, so a `pendingDeferredDc.length`
    // guard would drop the signal. cancelPendingDc also marks the
    // in-flight click, which scheduleDeferredDc consults.
    cancelPendingDc('network_activity');
  };

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

  // 2026-09-07 (gap audit #2). The page audits (im/vw/eg/cs/tt/rb) run twice
  // per pageload by design (load, then 2.2 s later); the second pass used to
  // ship every payload again even when nothing changed, and those types were
  // 37% of rows and 60% of stored bytes fleet-wide. Pass 1 records what it
  // sent; pass 2 only ships a payload that differs. In memory only: no
  // device write, so it is consent-neutral.
  var AUD_PASS = 0, AUD_SEEN = {};
  function auditKey(eventType, d) {
    try { return eventType + ':' + JSON.stringify(d); } catch (_) { return null; }
  }
  // 2026-09-07 (gap audit #20). A failing asset or request was reported on
  // EVERY fire, capped only per pageload: eight 404ing theme scripts on one
  // site produced 247K rows a day. One nf row per (pageload, url, kind).
  var NF_SEEN = {};
  function pushNf(d) {
    var k = (d.slow ? 'slow:' : 'err:') + (d.u || '');
    if (NF_SEEN[k]) return;
    NF_SEEN[k] = 1;
    push("nf", d);
  }

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

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

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

  function persistFailedSend(payload) {
    // Under the consent buffer, only granted (or no-framework) sessions may
    // write the retry queue — persisting an undecided/denied visitor's
    // events to localStorage is exactly the device write the buffer exists
    // to prevent.
    if (CB_ON && CB_STATE !== 'granted' && CB_STATE !== 'nogate') return;
    try {
      var entry = { ts: Date.now(), p: payload };
      var existing = localStorage.getItem(RETRY_KEY);
      var arr = existing ? JSON.parse(existing) : [];
      if (!Array.isArray(arr)) arr = [];
      arr.push(entry);
      // Trim by total size (FIFO drop oldest until under cap)
      var serialized = JSON.stringify(arr);
      while (serialized.length > RETRY_MAX_BYTES && arr.length > 1) {
        arr.shift();
        serialized = JSON.stringify(arr);
      }
      localStorage.setItem(RETRY_KEY, serialized);
    } catch (_) {}
  }

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

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

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

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

  // Drain any persisted-from-prior-load payload on startup — but only when
  // the consent buffer says this visitor may transmit. pending defers the
  // drain (cbGrant runs it); denied clears the stored payloads instead
  // (cbDeny at boot already did, and the init path lands here too).
  if (!CB_ON || CB_STATE === 'granted' || CB_STATE === 'nogate') {
    drainRetryQueue();
  } else if (CB_STATE === 'denied') {
    try { localStorage.removeItem(RETRY_KEY); } catch (_) {}
  }

  // Flush every 10 seconds
  setInterval(flush, FLUSH_INTERVAL);
  // 2026-06-25 — early first flush. The 10s interval means a short visit that
  // never fires a reliable unload (iOS Safari) had its events stranded. An
  // early one-shot flush gets the session's opening events on the wire within
  // a few seconds so a visit that lasts >~3s is recorded even if the leave
  // event is missed. Pairs with the visibilitychange->hidden flush (which
  // catches sub-3s leaves). Both are send-layer only.
  setTimeout(function () { try { flush(); } catch (_) {} }, 3000);

  // --- 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-07-22 (#grit-ad-test) — landing-variant markers. We strip query
    // strings from page_url for privacy, which made A/B landing variants
    // (?view=..., ?variant=..., ?lp=...) invisible. This captures a SAFE,
    // FIXED allowlist of non-PII landing/ad-test keys ONLY — same entry-time
    // lifecycle as utms/clickIds — so any client's landing test is measurable
    // without them retagging anything. Values length-capped; never free-form.
    var landingVars = {};
    ["view", "variant", "lp", "template", "creative", "placement", "ad", "test", "ab"].forEach(function (k) {
      var v = params.get(k);
      if (v) landingVars[k] = String(v).slice(0, 60);
    });
    // 2026-05-05 — locale + timezone. Cheap geo proxies that don't need IP
    // geolocation. VPNs lie about both, but VPN IPs lie about geo too — same
    // trust level. Used for cohort segmentation (US vs CA users) and as a
    // sanity cross-check against MaxMind country lookup server-side.
    var tz, lg;
    try { tz = (Intl && Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone) || undefined; } catch (_) {}
    try { lg = (navigator.language || (navigator.languages && navigator.languages[0])) || undefined; } catch (_) {}
    if (tz) tz = String(tz).slice(0, 40);
    if (lg) lg = String(lg).slice(0, 20);
    var touch = 'ontouchstart' in window;
    var dt = touch && screen.width < 768 ? 'm' : touch && screen.width < 1024 ? 't' : 'd';
    var ua = navigator.userAgent || '';
    var br = ua.indexOf('Firefox') > -1 ? 'ff' : ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1 ? 'sf' : ua.indexOf('Edg') > -1 ? 'eg' : ua.indexOf('Chrome') > -1 ? 'ch' : '?';
    // T9 (2026-04-24): bot fingerprinting — multi-signal detection for
    // automation that passes UA checks. Each bit flipped contributes to a
    // composite score the server uses alongside the UA regex. Small JS
    // footprint (<200 bytes minified), no external dependencies.
    //
    //   bit 0 (1):   navigator.webdriver === true
    //   bit 1 (2):   no plugins (headless Chrome signature)
    //   bit 2 (4):   no navigator.languages (bot default)
    //   bit 3 (8):   UA contains "Headless" / "Phantom" / "HeadlessChrome"
    //   bit 4 (16):  window.chrome missing runtime on Chrome UA (headless)
    //   bit 5 (32):  window.outerHeight === 0 (automation window)
    //
    // Any non-zero value is suspicious; ≥2 bits set is near-certain bot.
    var bf = 0;
    try {
      if (navigator.webdriver) bf |= 1;
      if (!navigator.plugins || navigator.plugins.length === 0) bf |= 2;
      if (!navigator.languages || navigator.languages.length === 0) bf |= 4;
      if (/Headless|PhantomJS|Electron\/[\d.]+$/i.test(ua)) bf |= 8;
      if (window.chrome && !window.chrome.runtime && /Chrome/i.test(ua) && !/Firefox/i.test(ua)) bf |= 16;
      if (window.outerHeight === 0) bf |= 32;
    } catch (_) {}
    // 2026-05-06 (Tier 1) — added: hardware concurrency (hc), connection
    // downlink/rtt/saveData (dl/rt/sd), a11y media-query prefs (mq), Global
    // Privacy Control (gpc), cookie-consent library state (ccl). All signals
    // are zero-PII (browser-stated capability/preference flags) and add
    // ~40 bytes per session.
    var hc, dl, rt, sd, gpc, mq, ccl;
    try { hc = navigator.hardwareConcurrency || undefined; } catch (_) {}
    try {
      var c = navigator.connection;
      if (c) { dl = c.downlink || undefined; rt = c.rtt || undefined; sd = c.saveData ? 1 : undefined; }
    } catch (_) {}
    try {
      // Global Privacy Control — explicit opt-out signal recognized under CPRA
      // and (increasingly) GDPR. When true we still SEND the session-start
      // event but tag it so the server downgrades to anonymous-only mode.
      gpc = navigator.globalPrivacyControl ? 1 : undefined;
    } catch (_) {}
    try {
      // Compact bitmask of accessibility/preference media queries.
      // bit 0 (1): prefers-reduced-motion
      // bit 1 (2): prefers-color-scheme: dark
      // bit 2 (4): prefers-contrast: more
      // bit 3 (8): forced-colors: active
      // bit 4 (16): prefers-reduced-data
      var m = 0;
      if (matchMedia('(prefers-reduced-motion: reduce)').matches) m |= 1;
      if (matchMedia('(prefers-color-scheme: dark)').matches) m |= 2;
      if (matchMedia('(prefers-contrast: more)').matches) m |= 4;
      if (matchMedia('(forced-colors: active)').matches) m |= 8;
      if (matchMedia('(prefers-reduced-data: reduce)').matches) m |= 16;
      if (m) mq = m;
    } catch (_) {}
    try {
      // Detect common cookie-consent library state. Zero PII, just whether
      // analytics consent has been given by the visitor on the customer's
      // banner. Each integration is wrapped so a single broken library
      // doesn't break the rest.
      // Returns: 'a' = analytics consent given; 'n' = denied; 'p' = pending;
      //          'g' = GPC auto-decline; undefined = no consent library detected.
      if (gpc) ccl = 'g';
      else if (window.OneTrust && typeof window.OneTrust.GetDomainData === 'function') {
        try {
          var groups = (document.cookie.match(/OptanonConsent=[^;]*groups=([^&;]+)/) || [])[1] || '';
          ccl = /C0002:1/.test(decodeURIComponent(groups)) ? 'a' : 'n';
        } catch (_) {}
      }
      else if (window.Cookiebot && typeof window.Cookiebot.consent === 'object') {
        ccl = window.Cookiebot.consent.statistics ? 'a' : 'n';
      }
      else if (window.cookieconsent && window.cookieconsent.status) {
        ccl = window.cookieconsent.status === 'allow' ? 'a' : 'n';
      }
      else if (window.CookieConsent && typeof window.CookieConsent.acceptedCategory === 'function') {
        ccl = window.CookieConsent.acceptedCategory('analytics') ? 'a' : 'n';
      }
      else if (window.tarteaucitron && window.tarteaucitron.state && window.tarteaucitron.state.analytics === true) {
        ccl = 'a';
      }
    } catch (_) {}

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

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

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

  // --- User State Inference ---
  // Session-scoped categorical tagset — lets the server cross-tab cases by
  // "what kind of user saw this" (logged_in/anonymous, has_cart, first_visit).
  // Entirely DOM-derived + optional window.harvv.user opt-in. Zero PII.
  (function captureUserState() {
    try {
      var tags = [];
      var ck = document.cookie || "";
      // ── Auth state (#60667, 2026-08-10) ────────────────────────────────
      //
      // The old test counted `a[href*="/my-account"]` and `a[href*="/account/"]`
      // as proof of being signed in. Those are STANDARD NAV LINKS that
      // WooCommerce, WordPress and most Shopify themes render for ANONYMOUS
      // visitors, so the tag fired for everybody and measured whether the SITE
      // has an account link, not whether the VISITOR is signed in.
      //
      // Measured over 7 days before the fix, share of sessions tagged logged_in:
      //   lureve.pk 100% · m-diamond.com (woo) 96% · trynina.co (wp) 64%
      //   tigerfriday.com (shopify) 26% · axeloffroad.com 0% · primemdplus 0%
      // That tracks theme markup, not behaviour. A storefront where 96% of
      // visitors are signed in is not plausible.
      //
      // The cookie fallback did not rescue it either: WordPress sets
      // LOGGED_IN_COOKIE with HttpOnly, so document.cookie can NEVER contain
      // wordpress_logged_in. That branch was dead code on every WP site.
      //
      // Now: POSITIVE markers only — things a page renders solely for a signed-in
      // user. A logout control is the strongest (you cannot log out if you are
      // not in). Admin-bar and body classes are exact and framework-emitted.
      //
      // `reason` is recorded so a tag is auditable rather than a bare assertion.
      // The old value cannot be reinterpreted, so consumers can tell old from
      // new by its presence.
      var authReason = null;
      // Sign-OUT only. Never account/profile links: anonymous visitors see those.
      if (document.querySelector('a[href*="logout"],a[href*="log-out"],a[href*="sign-out"],a[href*="signout"],a[href*="wp-login.php?action=logout"]')) {
        authReason = "logout_control";
      // WordPress renders the admin bar only for a logged-in user, and adds
      // body.logged-in / body.admin-bar server-side. Exact, not heuristic.
      } else if (document.getElementById("wpadminbar")
                 || (document.body && document.body.className
                     && / (logged-in|admin-bar) /.test(" " + document.body.className + " "))) {
        authReason = "wp_logged_in_markup";
      // WooCommerce renders these ONLY on the account pages of a signed-in user.
      } else if (document.querySelector(".woocommerce-MyAccount-navigation")) {
        authReason = "woocommerce_account_nav";
      // Shopify's customer cookies are readable and only set once signed in.
      } else if (ck.indexOf("_shopify_customer") !== -1 || ck.indexOf("_secure_customer_sig") !== -1) {
        authReason = "shopify_customer_cookie";
      // Server-side truth, when an integration supplies it (the WordPress plugin
      // sets this from is_user_logged_in()). Categorical only, never an identity.
      } else if (window.harvv && window.harvv.auth === "logged_in") {
        authReason = "server_declared";
      }
      if (authReason) {
        tags.push("logged_in");
      } else if (window.harvv && window.harvv.auth === "anonymous") {
        // Server said anonymous — believe it over any DOM guess.
        authReason = "server_declared";
        tags.push("anonymous");
      } else if (document.querySelector('a[href*="/login"],a[href*="/signin"],a[href*="/sign-in"],a[href*="/register"],a[href*="/account/login"]')) {
        // A sign-IN link is decent evidence of being signed OUT: most themes
        // swap it for a logout/account control once you are in.
        authReason = "login_link_present";
        tags.push("anonymous");
      } else {
        // Genuinely unknown. NEVER report this as anonymous — a third of sites
        // render neither control above the fold, and collapsing unknown into
        // anonymous is what makes an auth split look precise when it is not.
        authReason = "no_marker";
        tags.push("auth_unknown");
      }
      // Staff/admin, distinct from merely signed in. This is the signal that
      // was missing when a customer's own hover data contained WordPress
      // admin-bar elements while zero sessions were flagged internal.
      if ((window.harvv && window.harvv.staff === true)
          || document.getElementById("wpadminbar")
          || document.querySelector('a.ab-item[href*="wp-admin"],#wp-admin-bar-edit,#wp-admin-bar-site-name')
          || (document.referrer && document.referrer.indexOf("/wp-admin") !== -1)) {
        tags.push("cms_admin");
      }
      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;
            // 2026-09-01 (Umair MCP gap #2) — ARRAYS of short strings.
            //
            // Customer tags are a list on every platform that has them:
            // Shopify's customer.tags, Woo's user roles, a B2B flag beside a
            // wholesale tier. This hook accepted only scalars, so a merchant
            // literally could not pass them and the only segment signal left
            // was DOM-derived guessing -- which the auth-state comments above
            // record getting badly wrong (96% of Woo visitors tagged
            // logged_in). This is the trustworthy path: the merchant states
            // it, we do not infer it.
            //
            // Capped hard because this rides on every `us` event: at most 12
            // tags, 40 chars each, strings only. Nested objects and arrays are
            // dropped rather than flattened, so a merchant cannot accidentally
            // ship a whole customer record through a tag list.
            else if (Object.prototype.toString.call(v) === '[object Array]') {
              var arr = [];
              for (var ai = 0; ai < v.length && arr.length < 12; ai++) {
                var av = v[ai];
                if (typeof av === 'string' && av.length && av.length < 40) arr.push(av);
              }
              if (arr.length) explicit[k] = arr;
            }
          }
          if (!Object.keys(explicit).length) explicit = null;
        }
      } catch (_) {}
      push("us", { tags: tags, auth_reason: authReason || undefined, 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) {
    // #65725 — name the composed target. A click inside an open-shadow widget
    // used to land as the bare host tag with no text ("quadpay-widget-v3":"")
    // because ev.target is retargeted at the document listener. rawTgt is kept
    // for the hover-dwell lookup: that map is keyed by the RAW targets the
    // mouseover handler stored, and mixing key spaces would lose every pairing.
    var rawTgt = ev.target, tgt = cTarget(ev), 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();
    // 2026-07-27: csp/dwell are CURSOR signals and only exist for a real
    // pointing device. Touch devices fire synthesized mousemove events after a
    // tap, so cursorTrack picks up phantom motion and we were emitting a cursor
    // speed for taps where no cursor exists — measured on /ads-audit: 327 of
    // 1,203 touch clicks carried a csp value. That is not a small number and it
    // is not noise-shaped, so any consumer averaging csp on a mobile-heavy site
    // is averaging an artifact. Same for hover dwell: there is no hover on
    // touch, so a dwell figure there is the gap between two synthesized events.
    // Emit both ONLY for mouse/pen. Absent is honest; zero would be a claim.
    var isCursor = ptype === 'mouse' || ptype === 'pen';
    // `cursorSpeedPxPerSec() || undefined` dropped every ZERO, so the field was
    // present only when the cursor was STILL MOVING as the click landed. That
    // silently threw away the most common and most deliberate desktop pattern:
    // move, settle, click. Measured 2026-08-06 — only 11,699 of 51,144 mouse
    // clicks over 3 days carried a speed, so 77% of them recorded nothing and
    // "settled click" was indistinguishable from "no data". Any statistic built
    // on the field described moving clicks only, while reading like it described
    // all of them.
    var csRaw = isCursor ? cursorSpeedPxPerSec() : null;
    var csPx = (csRaw === null) ? undefined : csRaw;
    var dwell = isCursor ? (getHoverDwell(rawTgt) || undefined) : undefined;
    // 2026-09-01 (Umair MCP gap #3) — `sec` on ix.
    //
    // dc and rc have carried the section path since T40; ix never did, and ix
    // is the event get_element_clicks reads. So the click list was flat: a
    // ranked set of elements with no way to tell a header nav link from a
    // footer one with the same label. getSection() already walks to the
    // nearest SECTION/ARTICLE/ASIDE/MAIN/HEADER/FOOTER/NAV and names it, so
    // this reuses that rather than inventing a second notion of "where".
    push("ix", { el: elId, x: cx, y: cy, dt: delta, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell, sp: curScrollPct || undefined, sec: getSection(tgt) || undefined });

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

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

  // --- Commerce signals (1.0.6) ----------------------------------------
  // Three high-value conversion signals consolidated into a single event
  // type `cm` to save bytes. Each fires only when its specific trigger
  // matches, so steady-state cost is zero on non-commerce pages.
  //   k=ca  — cart-add: click on element matching the cart-add selector
  //   k=co  — coupon attempt: focus into an input named coupon/discount/promo
  //   k=ei  — exit intent: mouseleave from top of viewport on a cart/checkout URL
  //
  // Why one event with `k` discriminator (not three event types): each is
  // rare enough that splitting would add three event-type strings to the
  // wire format; sharing the type tag is ~30 bytes cheaper gzip-wise.
  // ── Shopify cart-attribute stitch (2026-08-12, case #61315) ───────────────
  // WHY. A Shopify checkout completes on Shopify's domain, where our pixel
  // cannot run. The Web Pixel sandbox that CAN run there has no cookies and no
  // DOM, so the conversion it reports carries no visitor identity: receiver.js
  // mints a synthetic session_id from the hashed order id purely so the row has
  // a key. Measured on Axel Off-Road, ZERO of 171 conversions joined to a
  // session. We knew a purchase happened and could never say who it happened
  // to, so no finding could ever be priced.
  //
  // A cart attribute is the one piece of state that survives the hop: set on
  // the storefront where we DO have cookies, readable from checkout.attributes
  // in the sandbox. Same trick the WooCommerce plugin already uses
  // (class-harvv-woo.php stamps _pxv/_pxs at order creation).
  //
  // THIS WRITES TO THE MERCHANT'S CART, which is a bigger thing than anything
  // else this file does — every other line only observes. So it is fenced:
  //   - per-site opt-in (window.__pxStitch), default OFF for everyone
  //   - Shopify storefronts only
  //   - only on an add-to-cart, never on a bare pageview
  //   - only when the cart ALREADY has items, so a visitor who never shops
  //     never has a cart created for them (that would inflate the merchant's
  //     own abandoned-cart numbers with people who never abandoned anything)
  //   - once per session, and skipped entirely if the value is already ours
  //   - native fetch, so it never appears in our own network telemetry
  // Every failure is swallowed: a stitch that breaks a checkout is far worse
  // than a conversion we cannot attribute.
  var stitchDone;   // hoisted; never re-initialised (emitCm can call in early)
  // 2026-09-18 — the DOM click fires BEFORE the theme's /cart/add.js has
  // landed, so /cart.js read on the click says item_count 0 and the stamp gave
  // up until "the next add". A shopper who adds one item and goes straight to
  // checkout never adds again: Carbon (gocarbon.co) 02:20 UTC, add-to-cart seen
  // from the DOM only, checkout started 16 s later, order unlinked. Themes that
  // also push add_to_cart to the dataLayer re-arm the stamp after the add and
  // hide this; 3 of 23 Carbon add-to-cart sessions and 6 of 21 on Axel were
  // DOM-only. So an empty cart on the click is re-read a bounded number of
  // times, still never creating a cart. STITCH_RETRY_MS is the schedule.
  var STITCH_RETRY_MS = [1500, 4000, 8000];
  var stitchRetries = 0;
  function stitchShopifyCart() {
    try {
      if (stitchDone || !window.__pxStitch || !window.Shopify || !NATIVE_FETCH) return;
      if (!sid || lsGet("_pxstitch") === sid) { stitchDone = true; return; }
      stitchDone = true;   // one attempt per pageview, success or not
      NATIVE_FETCH("/cart.js", { credentials: "same-origin" })
        .then(function (r) { return (r && r.ok) ? r.json() : null; })
        .then(function (c) {
          if (!c || !c.item_count) {
            // no cart yet: retry on the next add, and on a short timer in
            // case the add is still in flight and no further add ever comes
            stitchDone = false;
            if (stitchRetries < STITCH_RETRY_MS.length) {
              setTimeout(stitchShopifyCart, STITCH_RETRY_MS[stitchRetries++]);
            }
            return null;
          }
          var a = c.attributes || {};
          if (a.harvv_sid === sid) { lsSet("_pxstitch", sid); return null; }
          var attrs = {};
          attrs.harvv_sid = sid;
          return NATIVE_FETCH("/cart/update.js", {
            method: "POST",
            credentials: "same-origin",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ attributes: attrs })
          }).then(function (r2) { if (r2 && r2.ok) lsSet("_pxstitch", sid); });
        })["catch"](function () {});
    } catch (_) {}
  }

  var CART_SEL = '[data-add-to-cart],.add_to_cart_button,.single_add_to_cart_button,button[name="add"]';
  var COUPON_SEL = 'input[name*="coupon" i],input[name*="discount" i],input[name*="promo" i]';
  document.addEventListener('click', function (e) {
    var t = e.target;
    if (!t || !t.closest) return;
    var hit = t.closest(CART_SEL);
    if (hit) { emitCm('dom', 'ca', { el: eid(hit), sp: curScrollPct || undefined }); }
  }, true);
  document.addEventListener('focusin', function (e) {
    var t = e.target;
    if (t && t.matches && t.matches(COUPON_SEL)) emitCm('dom', 'co', { el: eid(t) });
  }, true);
  // Exit intent only on commerce URLs (no GA4 equivalent). One emit/pageload.
  if (/\/(cart|checkout|basket|payment)\b/i.test(location.pathname)) {
    var eiSent = false;
    document.addEventListener('mouseout', function (e) {
      if (eiSent) return;
      if (!e.relatedTarget && e.clientY < 10) {
        eiSent = true;
        emitCm('dom', 'ei', { p: location.pathname.slice(0, 80) });
      }
    });
  }

  // --- Scroll Depth Milestones ---
  // Fire once per session per threshold (25/50/75/100%).
  // Throttled — only checks every 100ms via rAF.
  // 2026-05-06 (Tier 2): also tracks scroll velocity + direction reversals
  // (scrolling down then up = "I missed something / lost my place").
  // One sv event emitted at end of session via beforeunload.
  var firedScrolls = {};
  var scrollCheckPending = false;
  var scrollLastY = 0, scrollLastT = 0, scrollLastDir = 0;
  var scrollReversals = 0, scrollVelocityMaxPxs = 0;
  // 1.0.6 — current scroll-percentage cache. Click handler reads this
  // so every `ix` event can carry the scroll-depth at click time
  // (`sp`). Lets the server answer "is this CTA being clicked from
  // the top of the page or after the user scrolled past it?" without
  // re-computing on every click.
  var curScrollPct = 0;
  // 2026-06-02 — exact peak scroll depth (continuous %, not the 25% milestone
  // bucket). Milestones [10,25,50,75,100] only tell us "reached >=N%", so
  // anything 0-9% recorded as max_scroll=0 and we couldn't tell a bounce from a
  // 9% scroll. curScrollPctMax tracks the true high-water mark and ships on the
  // unload `ul` event as `sm`, so the server can store the exact peak.
  var curScrollPctMax = 0;
  // Listen on DOCUMENT in the CAPTURE phase, not on window.
  //
  // 2026-08-02 (#57480). Resolving the right scroll metrics is only half the
  // fix. Scroll events do not bubble: when BODY is the scroll container the
  // event targets body and never reaches a window listener, so the handler
  // below simply never ran on those pages. A capturing listener on document
  // sees both cases (the viewport scroll, which targets document, and any
  // descendant scroller including body).
  //
  // Cost of the wider net: a nested scroller (a carousel, an overflowing code
  // block) also wakes this handler. That is harmless — _scrollMax() only ever
  // measures documentElement and body, so the computed percentage does not
  // move and no milestone fires. The rAF guard already coalesces bursts.
  document.addEventListener("scroll", function (ev) {
    // The event target IS the element that scrolled. On a div-scroller layout
    // that is the only place the information exists, so read it before the rAF
    // coalesce (by then the event object is gone).
    try { _adoptScroller(ev && ev.target); } catch (_e) {}
    if (scrollCheckPending) return;
    scrollCheckPending = true;
    requestAnimationFrame(function () {
      scrollCheckPending = false;
      var scrollTop = _scrollTop();
      var scrollHeight = _scrollMax();
      curScrollPct = scrollHeight > 0 ? Math.round((scrollTop / scrollHeight) * 100) : 0;
      if (curScrollPct > curScrollPctMax) curScrollPctMax = curScrollPct > 100 ? 100 : curScrollPct;
      // Velocity + reversal tracking (independent of milestone check)
      var nowT = Date.now();
      if (scrollLastT && nowT - scrollLastT < 1000) {
        var dy = scrollTop - scrollLastY;
        var dt = nowT - scrollLastT;
        if (dt > 0) {
          var pxs = Math.abs(dy) / dt * 1000;
          if (pxs > scrollVelocityMaxPxs) scrollVelocityMaxPxs = pxs;
          var dir = dy > 0 ? 1 : dy < 0 ? -1 : 0;
          if (dir && scrollLastDir && dir !== scrollLastDir) scrollReversals++;
          if (dir) scrollLastDir = dir;
        }
      }
      scrollLastY = scrollTop;
      scrollLastT = nowT;
      // Milestone fire
      if (scrollHeight <= 0) return;
      var pct = Math.round((scrollTop / scrollHeight) * 100);
      SCROLL_MILESTONES.forEach(function (m) {
        if (pct >= m && !firedScrolls[m]) {
          firedScrolls[m] = 1;
          push("sd", { p: m });
          snapViewport("sd" + m);
        }
      });
    });
  }, { passive: true, capture: true });
  // Emit final scroll-velocity summary on unload-ish events.
  //
  // 2026-07-27 — the sub-milestone blind spot. SCROLL_MILESTONES starts at 10%,
  // so a session that peaks at 7% emits NO sd event at all and this summary
  // was gated behind reversals/velocity that a barely-scrolling session never
  // trips either. Measured on harvv.com/ads-audit: 2,478 sessions, 86% peaked
  // under 5%, and sd fired ZERO times across all of them. We were blind in
  // exactly the band where nearly all the behaviour was.
  //
  // Fix: always ship the high-water mark (`mx`), and fire whenever the session
  // never crossed the first milestone. That distinguishes "landed and never
  // moved" (mx 0-2) from "started reading and stopped" (mx 5-9) — two very
  // different problems that were previously one undifferentiated silence.
  // Costs one extra event only for sessions that scrolled almost nothing, and
  // those are precisely the ones with no other events to reason from.
  function emitScrollSummary() {
    var subMilestone = !firedScrolls[SCROLL_MILESTONES[0]];
    if (scrollReversals > 0 || scrollVelocityMaxPxs > 100 || subMilestone) {
      push("sv", {
        rev: scrollReversals,
        vp: Math.round(scrollVelocityMaxPxs),
        mx: curScrollPctMax,
      });
    }
  }
  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
      // 2026-06-25 — flush on visibilitychange->hidden. Before this, the ONLY
      // flush triggers were the 10s interval and `pagehide`. iOS Safari does
      // not fire `pagehide` reliably (tab swipe-away, app switch, bfcache), so
      // any session shorter than 10s on iOS sent ZERO events and was invisible
      // — the Tiger-Friday-Repurpose ~65% undercount vs Clarity. visibilitychange
      // ->hidden is the lifecycle event browsers (incl. iOS Safari) fire most
      // reliably when a user leaves, and a normal flush() uses keepalive fetch
      // which completes even if the page then unloads (and stays retryable,
      // unlike sendBeacon). Captures even a 1-second bounce at the moment of
      // leaving. Send-layer only — does not touch the fetch/XHR wrappers, so it
      // cannot re-create the SearchPie/Punchmark host-app interference.
      try { flush(); } catch (_) {}
    } 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.
  //
  // 2026-07-06 — scroll/click guards. Browsers re-dispatch mouseenter when the
  // PAGE moves under a STATIONARY pointer (scrolling, smooth-scroll nav, DOM
  // swaps), so every element passing beneath the cursor collected exactly
  // ~HOVER_DELAY of "dwell" and fired a false hi — prod showed walls of
  // dwell=501ms events, 6-in-one-second on nested containers, and hi on a
  // button the user had ALREADY clicked (label re-render re-entered it).
  // A real hover starts with the POINTER moving onto the element:
  //   1. only arm the timer if a mousemove happened in the last 150ms
  //      (cursorTrack is already maintained by the T7 cursor-speed tracker);
  //   2. any scroll cancels all pending timers (page moved, pointer didn't);
  //   3. any click cancels all pending timers (a decisive action is not
  //      hesitation, and post-click re-renders must not re-arm).
  var hoverTimers = new Map();
  function clearAllHoverTimers() {
    hoverTimers.forEach(function (t) { clearTimeout(t); });
    hoverTimers.clear();
  }
  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;
    // Scroll-under-cursor guard: no recent pointer movement = the element
    // moved under the pointer, not the other way round. Not a hover.
    var lastMove = cursorTrack.length ? cursorTrack[cursorTrack.length - 1].t : 0;
    if (Date.now() - lastMove > 150) 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 });
  window.addEventListener("scroll", clearAllHoverTimers, { passive: true, capture: true });
  document.addEventListener("click", clearAllHoverTimers, { passive: true, capture: true });

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

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

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

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

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

  // --- Occlusion probe (2026-06-25) -------------------------------------
  // Detects an interactive element (link / button / input) that is visually
  // COVERED by a fixed overlay (a chat or calendar widget, a floating bar) so a
  // real visitor cannot click it. widgetOverlapProbe only catches
  // widget-vs-widget; this catches widget-over-CONTENT. Uses
  // document.elementFromPoint at the element's center to ask "what is actually
  // painted on top here" — the only reliable occlusion test (it accounts for
  // z-index + real paint). The canonical case is a bottom-right floating widget
  // covering a footer link once the page is scrolled to the bottom, so we run on
  // scroll-settle (piggybacked on the scroll-pause timer below) plus one pass
  // after load for above-the-fold occlusion (e.g. a chat bubble over a hero CTA).
  // The covered element otherwise emits ZERO events (the overlay eats the click),
  // so this is the only way the loss becomes visible. Bounded: <=OC_SCAN elements
  // per pass, <=OC_CAP emissions per page, deduped by covered element.
  var OC_CAP = 6, OC_SCAN = 80, ocSeen = {}, ocCount = 0;
  // Is the overlay a REAL, VISIBLE, position:fixed pin (or one within 4 ancestors)?
  // Returns "fixed" or null. Three deliberate narrowings to kill false positives:
  //  - fixed only (NOT sticky): a sticky top nav a link transiently scrolls under
  //    is not a real block; floating widgets (chat/calendar/cookie bars) are fixed.
  //  - reject any ancestor that is opacity:0 / visibility:hidden / pointer-events:
  //    none. elementFromPoint does NOT skip opacity:0 elements, so a CLOSED off-
  //    canvas drawer (fixed, inset:0, opacity:0) would otherwise look like it
  //    covers the whole page. A transparent/non-interactive overlay isn't visible,
  //    so it isn't occluding anything.
  function ocPinned(el) {
    var fixed = null;
    for (var d = 4; el && el.nodeType === 1 && d--; el = el.parentElement) {
      var cs = getComputedStyle(el);
      if (parseFloat(cs.opacity) === 0 || cs.visibility === "hidden" || cs.pointerEvents === "none") return null;
      if (!fixed && cs.position === "fixed") fixed = "fixed";
    }
    return fixed;
  }
  function occlusionScan() {
    try {
      if (ocCount >= OC_CAP) return;
      var W = window.innerWidth, H = window.innerHeight, checked = 0;
      var els = document.querySelectorAll('a[href],button,[role="button"],input:not([type="hidden"]),select,textarea');
      for (var i = 0; i < els.length && checked < OC_SCAN && ocCount < OC_CAP; i++) {
        var el = els[i], r = el.getBoundingClientRect();
        if (r.width < 8 || r.height < 8) continue;                              // decorative / zero-size
        if (r.bottom <= 0 || r.top >= H || r.right <= 0 || r.left >= W) continue; // off-screen right now
        checked++;
        var cx = Math.min(W - 1, Math.max(0, Math.round(r.left + r.width / 2)));
        var cy = Math.min(H - 1, Math.max(0, Math.round(r.top + r.height / 2)));
        var top = document.elementFromPoint(cx, cy);
        if (!top || top === el || el.contains(top) || top.contains(el)) continue; // clickable: the point reaches el
        var pos = ocPinned(top);
        if (!pos) continue;                                                     // occluder is not a real visible fixed overlay
        var ovr = top.getBoundingClientRect();
        if (ovr.width >= W * 0.85 && ovr.height >= H * 0.85) continue;          // full-page modal is intentional
        // Coverage floor: the overlay must genuinely BLOCK the control, not graze
        // it. center-occluded + cov>=50% means the primary hit region is covered;
        // a bar clipping the top 30% of a link (cov<50) is not a real block.
        var ix = Math.max(0, Math.min(r.right, ovr.right) - Math.max(r.left, ovr.left));
        var iy = Math.max(0, Math.min(r.bottom, ovr.bottom) - Math.max(r.top, ovr.top));
        var cov = Math.round((ix * iy) / (r.width * r.height) * 100);
        if (cov < 50) continue;
        var key0 = eid(el);
        if (!key0 || ocSeen[key0]) continue;                                    // one emission per covered element
        ocSeen[key0] = 1; ocCount++;
        // NOTE event type "og" (occlusion-geometry): "oc" is taken by outcomes.
        push("og", { el: key0, ov: eid(top), ovp: pos, cov: cov, vw: W, vh: H });
      }
    } 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
    setTimeout(occlusionScan, 3500); // occlusion: just after widgets load, catch above-the-fold cover-ups
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", onReady);
  } else {
    onReady();
  }

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

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

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

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

  // Same two corrections as the milestone listener above (#57480): document +
  // capture, because scroll events do not bubble and a body-scroller page never
  // reaches a window listener; and _scrollTop() instead of window.scrollY,
  // which is pinned at 0 on those pages. This handler is the source of `sk`
  // (skim vs reading-speed distance), so leaving it on window would have fixed
  // sd and left sk just as dark. Caught by grepping for the OTHER scroll
  // listeners after the first fix was already deployed.
  document.addEventListener("scroll", function () {
    if (rafPending) return;
    rafPending = true;
    requestAnimationFrame(function () {
      rafPending = false;
      var y = _scrollTop(), 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 = _scrollMax();
        if (scrollHeight > 0) {
          pauses.push(Math.round((_scrollTop() / scrollHeight) * 100));
        }
        // Scroll settled — re-check occlusion. This is what catches a footer
        // link sitting under a bottom-anchored fixed widget: the element only
        // enters the viewport (under the overlay) once the user scrolls to it.
        occlusionScan();
      }, 2000);
    });
  }, { passive: true, capture: true });

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

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

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

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

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

    // 2026-09-07 (gap audit #19) — per-field friction, content-free. Four
    // consumers (scoring, baselines, queries, engine) have read ft/cc/fc
    // from fi rows since spring and the pixel never emitted them, so the
    // form-abandonment deviation could never fire. ft = focus-to-blur ms,
    // cc = corrections (Backspace/Delete presses) while focused, fc = fields
    // in the form. Emitted once per field on blur as an fi row tagged b:1,
    // and on the fa row for the last field. No values, no keys typed.
    var fieldFocusAt = {}, fieldCorr = {}, fieldSummaryEmitted = {};
    function fieldCount(el) {
      try { var f = el.form || null; return f ? f.querySelectorAll('input, textarea, select').length : 1; } catch (_) { return null; }
    }
    document.addEventListener('keydown', function (e) {
      if (!isFormField(e.target)) return;
      if (e.key !== 'Backspace' && e.key !== 'Delete') return;
      var k0 = fieldKey(e.target);
      if (k0) fieldCorr[k0] = (fieldCorr[k0] || 0) + 1;
    }, true);
    document.addEventListener('focusout', function (e) {
      if (!isFormField(e.target)) return;
      var k1 = fieldKey(e.target);
      if (!k1 || !fieldFocusAt[k1] || fieldSummaryEmitted[k1]) return;
      fieldSummaryEmitted[k1] = true;
      var ft = Date.now() - fieldFocusAt[k1];
      if (ft > 0 && ft < 3600000) {
        push("fi", { f: k1, fp: fieldPosition(e.target) || undefined, ft: ft, cc: fieldCorr[k1] || 0, fc: fieldCount(e.target) || undefined, b: 1 });
      }
    }, true);

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

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

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

    function emitAbandonment() {
      if (faEmitted) return;
      if (formSubmitted) return;
      if (!lastField) return;
      faEmitted = true;
      // n = distinct fields touched in THIS form (scoped), not page-wide.
      var n = (lastField.scope && lastField.scope.count) || 0;
      var ftA = fieldFocusAt[lastField.key] ? Date.now() - fieldFocusAt[lastField.key] : undefined;
      push("fa", { f: lastField.key, fp: lastField.pos || undefined, n: n,
        ft: (ftA > 0 && ftA < 3600000) ? ftA : undefined, cc: fieldCorr[lastField.key] || 0,
        fc: (lastField.el && fieldCount(lastField.el)) || undefined });
    }

    // 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.
    // 2026-08-10 — capture WHICH element, not just what kind.
    //
    // We recorded the tag name ('img') and a boolean for "did it have a URL",
    // and threw the URL itself away. So a customer asking "my Largest
    // Contentful Paint is slow, which image is it?" could not be answered from
    // stored data at all: 42 of one site's LCP elements were images and we
    // could not name one of them. The finding said "slow" and stopped.
    //
    // The URL is safe to keep and we already keep this class: broken-asset
    // findings store full first-party asset URLs from `nf` events. An LCP image
    // is a static asset on the customer's own public page. The query string is
    // stripped anyway, on the same discipline used elsewhere in this file —
    // that is where a stray token or email would ever appear, never the path.
    var lcpEl = '', lcpHasUrl = 0, lcpUrl = '', lcpSel = '';
    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;
          if (last.url) lcpUrl = String(last.url).split('?')[0].split('#')[0].slice(0, 300);
          // A selector for the text case, where there is no URL to name.
          // 2026-08-26 — STRUCTURAL ONLY. eid() appends a :"text" excerpt,
          // which is right for clicked UI controls (button labels are
          // chrome) but wrong here: the LCP element is the page's biggest
          // CONTENT block, so the excerpt shipped article headlines and
          // paragraph text — the zero-PII break the pixel-new-signals gate
          // flags. Strip the excerpt; the path alone answers "which element".
          if (last.element) lcpSel = String(eid(last.element) || '').replace(/:".*$/, '').slice(0, 120);
        } 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;
      // 2026-09-14 (case #73140): PRERENDER CORRECTION.
      //
      // On a page that was prerendered and then activated, every
      // time-to-something in navigation timing is measured from the moment
      // the hidden prerender started, not from the moment the visitor
      // arrived. activationStart is the offset between the two, so the
      // numbers the visitor actually experienced are (value - activationStart)
      // floored at 0. Without this a prerendered Shopify PDP reports a
      // sub-100ms LCP it never delivered, and the site's p75 quietly improves
      // in proportion to how much the theme prerenders.
      //
      // Durations are NOT corrected: cls, tbt and inp measure how long
      // something took, not when it happened, so subtracting an offset from
      // them would be wrong.
      //
      // `pre` marks the session's page as prerendered (pf carries session_id,
      // so it identifies the session) and `act` keeps the magnitude, so a
      // surface quoting vitals can separate prerendered loads rather than
      // having to trust that the correction happened.
      var act = (n && n.activationStart) ? Math.round(n.activationStart) : 0;
      function afterActivation(v) { return v > 0 ? Math.max(0, v - act) : v; }
      // 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 ? afterActivation(n.responseStart | 0) : 0,
        fcp: fcp ? afterActivation(fcp) : undefined,
        lcp: afterActivation(lcp),
        cls: (cls * 1e3 | 0) / 1e3,
        tbt: tbt || undefined,
        tti: afterActivation(tti),
        plt: afterActivation(plt),
        // Prerendered-and-activated. Absent on a normal load.
        pre: act > 0 ? 1 : undefined,
        act: act > 0 ? act : undefined,
        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,
        // The actual offender, so "which image is slow" is answerable (2026-08-10).
        lcp_src: lcpUrl || undefined,
        lcp_sel: lcpSel || 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 (_) {}

  // --- Which script blocked the main thread (2026-09-11) ---
  //
  // WHY `lt` ABOVE IS NOT THIS. The Long Tasks API reports a duration and an
  // `attribution` whose `name` is almost always "self" or "unknown": it names
  // the CONTAINER (a frame), never the script. Measured on Axel Off-Road:
  // 16,978 long tasks in 7 days, avg 94ms, max 2,410ms, and not one of them
  // says what ran. A customer with Google Tag Manager, GA4, the Meta pixel and
  // four more tags learns their page blocks and nothing about which tag did it.
  //
  // Long Animation Frames DO name it. Each entry carries a `scripts` array and
  // each script has a `sourceURL`, so blocking time can be added up per origin
  // and mapped to a vendor server-side (src/lib/known-services.js already knows
  // googletagmanager.com is "the container that loads your other marketing
  // tags"). That turns "your page is slow" into "this tag costs you 400ms".
  //
  // ORIGIN ONLY, NEVER THE FULL URL. The path and query string are dropped
  // before anything is recorded. A third-party script URL is not usually
  // personal, but it is not ours to assume: query strings are exactly where
  // ids and emails end up, and the vendor is identifiable from the origin
  // alone, which is the entire question being asked. It also keeps the key
  // cardinality to a handful of hosts rather than one row per cache-busted URL.
  //
  // CHROMIUM ONLY, AND THE PAYLOAD SAYS SO. long-animation-frame does not exist
  // in Safari or Firefox. `sup` is the browser's own answer, not our guess, so
  // a zero can be told apart from a browser that cannot report. Any surface
  // quoting these numbers has to state the share of sessions they cover.
  try {
    var loafBy = {};        // origin -> blocking ms
    var loafTotal = 0;      // total scripted blocking ms seen
    var loafFrames = 0;
    var LOAF_OK = false;
    try {
      LOAF_OK = !!(window.PerformanceObserver && PerformanceObserver.supportedEntryTypes
        && PerformanceObserver.supportedEntryTypes.indexOf("long-animation-frame") > -1);
    } catch (_) {}
    if (LOAF_OK) {
      new PerformanceObserver(function (l) {
        var entries = l.getEntries();
        for (var i = 0; i < entries.length; i++) {
          var scripts = entries[i].scripts || [];
          loafFrames++;
          for (var j = 0; j < scripts.length; j++) {
            var src = scripts[j].sourceURL || "";
            var ms = Math.round(scripts[j].duration || 0);
            if (ms <= 0) continue;
            var origin = "";
            // No URL parser: ES5, and a malformed sourceURL must not throw
            // inside an observer callback and kill every later frame.
            var m = /^https?:\/\/[^/?#]+/i.exec(src);
            if (m) origin = m[0];
            else if (src.indexOf("data:") === 0) origin = "(inline)";
            else origin = "(unknown)";
            if (origin.length > 80) origin = origin.slice(0, 80);
            loafBy[origin] = (loafBy[origin] || 0) + ms;
            loafTotal += ms;
          }
        }
      }).observe({ type: "long-animation-frame", buffered: true });
    }
    setTimeout(function () {
      // Top offenders only. A page with 30 origins has a tail nobody acts on,
      // and the tail is where the bytes go.
      var keys = [];
      for (var k in loafBy) if (Object.prototype.hasOwnProperty.call(loafBy, k)) keys.push(k);
      keys.sort(function (a, b) { return loafBy[b] - loafBy[a]; });
      var top = {};
      for (var i = 0; i < keys.length && i < 8; i++) top[keys[i]] = loafBy[keys[i]];
      push("sa", {
        // Browser support, stated rather than inferred from an empty payload.
        sup: LOAF_OK ? 1 : 0,
        by: keys.length ? top : undefined,
        tot: loafTotal || undefined,
        fr: loafFrames || undefined,
        // How long the observation ran, so a small number is readable as
        // "5 seconds of blocking" and not as "the whole visit".
        w: 5e3,
        // Origins seen beyond the 8 reported, so the top list never reads as
        // the complete list.
        more: keys.length > 8 ? keys.length - 8 : undefined,
      });
    }, 5e3);
  } catch (_) {}

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

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

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

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

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

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

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

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

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

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

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

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

  // 2026-05-23 — GA4 Measurement Protocol observer. If a customer has GA4
  // (gtag.js) installed, every page-view + event fires an HTTP call to
  // `/g/collect` (or `/r/collect`, `/j/collect` for legacy). The query
  // string carries the event name, tracking ID, GA's client/session IDs,
  // and any numeric event parameters (value, items_qty, etc.). This
  // helper extracts a SAFE subset (no string event params, no user
  // properties — those can carry PII) and emits a `ga` event. Two
  // capabilities unlock from this:
  //   (1) Discrepancy / data-quality cases. We now know which GA4 events
  //       fired per session and can compare to our own detections to
  //       surface "your GA4 isn't catching this conversion" findings.
  //   (2) GA's session/client ID joins to ours, so server-side we can
  //       cross-reference our visitor_id with GA's perspective.
  // Returns null when the URL is not a collect call, or an object
  // suitable for push("ga", ...). Capped at 30 events per session to
  // avoid floods on event-chatty sites.
  //
  // 2026-08-02 (#56755 cost review) — TWO NARROWINGS, both measured.
  //
  // (a) EVENT ALLOWLIST. 24 hours of production `ga`: 99,467 page_view,
  //     31,988 user_engagement, 3,765 scroll. That is 64% of the stream
  //     mirroring signals this pixel already collects first-hand — page views,
  //     engagement time, scroll depth ARE the product. We were paying ingest,
  //     storage, index and S3 archive to be told our own numbers by Google.
  //     The commerce events that made this worth building (view_item,
  //     add_to_cart, begin_checkout) also already arrive on the `cm` channel,
  //     which parses the dataLayer directly and is the richer of the two.
  //     So `ga` now keeps ONLY what `cm` cannot see: the tid, and events that
  //     tell us GA4 disagreed with us. ~135K events/day stop being sent.
  //
  // (b) NO cid. The old comment called the GA client ID "already opaque". It is
  //     opaque, but it is also STABLE ACROSS SESSIONS — that is its entire
  //     purpose, it is how GA recognises a returning person. A durable
  //     cross-visit identifier is exactly what /trust promises we do not keep,
  //     and "we didn't generate it, Google did" is not a defence. sid stays:
  //     it is per-session and it is what the discrepancy join actually needs.
  var GA_KEEP = { purchase: 1, begin_checkout: 1, add_to_cart: 1, remove_from_cart: 1,
                  view_cart: 1, add_payment_info: 1, add_shipping_info: 1, generate_lead: 1,
                  sign_up: 1, refund: 1 };
  var _gaEvCount = 0;
  function _parseGACollect(rawUrl) {
    if (_gaEvCount >= 30) return null;
    // Cheap rejects first — most fetches aren't GA.
    if (!rawUrl || rawUrl.indexOf("/g/collect") === -1 && rawUrl.indexOf("/r/collect") === -1 && rawUrl.indexOf("/j/collect") === -1) return null;
    // Must be a known GA endpoint host. Don't match accidentally on a
    // first-party path like /collect — only Google Analytics ones.
    if (rawUrl.indexOf("analytics.google.com") === -1 &&
        rawUrl.indexOf("google-analytics.com") === -1 &&
        rawUrl.indexOf("www.google.com/g/collect") === -1) return null;
    var qIdx = rawUrl.indexOf("?");
    if (qIdx === -1) return null;
    var qs = rawUrl.slice(qIdx + 1);
    // Lightweight parse — avoid URL/URLSearchParams to keep this fast on
    // event-chatty pages.
    var en = "", tid = "", sid = "", value = null;
    var pairs = qs.split("&");
    for (var i = 0; i < pairs.length; i++) {
      var eq = pairs[i].indexOf("=");
      if (eq === -1) continue;
      var k = pairs[i].slice(0, eq);
      var v = pairs[i].slice(eq + 1);
      if (k === "en" && !en) en = decodeURIComponent(v).slice(0, 40);
      else if (k === "tid" && !tid) tid = decodeURIComponent(v).slice(0, 32);
      else if (k === "sid" && !sid) sid = v.slice(0, 20);
      else if (k === "epn.value" && value === null) {
        var n = parseFloat(decodeURIComponent(v));
        if (!isNaN(n) && isFinite(n)) value = n;
      }
    }
    if (!en && !tid) return null;
    // Drop the 64% that duplicates our own first-hand signals.
    if (en && !GA_KEEP[en]) return null;
    _gaEvCount++;
    var payload = { en: en || undefined, tid: tid || undefined };
    if (sid) payload.sid = sid;
    if (value !== null) payload.v = value;
    return payload;
  }
  // 2026-05-27 — Defensive wrap install. Punchmark + Quantum Qarat
  // hit "Maximum call stack size exceeded" because:
  //   1. We wrapped XMLHttpRequest.prototype.open and window.fetch in
  //      TWO places each, with no idempotency guard.
  //   2. A third-party tag with the (common) anti-pattern of calling
  //      `XMLHttpRequest.prototype.open.apply(this, args)` instead of
  //      saving its own reference creates a self-referential loop
  //      with our wrapper after both run.
  // Fix has four layers matching the Posthog/Hotjar/Segment standard:
  //   (a) Single wrap point per transport. The fs (form-submission)
  //       inference logic is folded into the same wrap that does nf
  //       (network-failure) detection. Below the form-submit listener
  //       there are no more transport wraps.
  //   (b) Idempotency marker on the prototype/fn so we never wrap twice
  //   (c) try/catch around the wrap installation so if anything fails
  //       to assign, we exit silently — the host page is never broken
  //   (d) try/catch INSIDE the wrapper so internal logic bugs cannot
  //       block the original call. We always reach `apply(this, args)`.
  // Symptom on Quantum Qarat: 1,586 XHR failures across acsbapp,
  // affirm, klaviyo, gtag, and the site's own /api/async.php.

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

  var slowCount = 0, errCount = 0;
  try { if (window.fetch && !window.fetch.__harvv_wrapped) {
    var oF = window.fetch;
    // Confirm we're saving a real function reference, not a hostile
    // proxy that already re-references the live window.fetch.
    if (typeof oF !== 'function') throw new Error('fetch_not_fn');
    window.fetch.__harvv_wrapped = 1;
    // 2026-05-19 perf fix — resolve the `initiator` from a stack ONLY when
    // an `nf` event is actually about to be pushed. Previously this wrapper
    // formatted `new Error().stack` and ran a regex scan on EVERY fetch —
    // pure overhead on the 99% of requests that are fast + OK, since `nf`
    // only fires for failed/slow requests (and is capped at 5 each).
    // `Error.stack` formatting is one of the most expensive per-call ops in
    // JS; on a dashboard that fires many API calls it was a measurable tax.
    function _initiatorFrom(errObj) {
      try {
        var lines = ((errObj && errObj.stack) || "").split("\n");
        for (var li = 1; li < lines.length && li < 8; li++) {
          var line = lines[li];
          if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
            var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
            if (fileMatch) return fileMatch[1].replace(/.*\//, "").substring(0, 60);
            var pathMatch = line.match(/([^\/()\s]+\.js[:\d]*)/);
            if (pathMatch) return pathMatch[1].substring(0, 60);
          }
        }
      } catch (_) {}
      return undefined;
    }
    window.fetch = function (input, init) {
      var url = (typeof input === "string") ? input : (input && input.url) || "";
      // 2026-05-14 — signal post-click async response so any pending
      // deferred dead-click can be cancelled (Axel Off-Road learning).
      // 2026-07-31 — but NEVER for our own beacon (isOwnBeaconUrl). flush()
      // POSTs to ENDPOINT through this very wrapper; a batch upload landing
      // inside a click's DEFERRED_DC_WINDOW_MS turned a genuine dead click
      // into `ac` with reason "network_activity". Our own upload is not
      // evidence that the site responded to the click.
      // 2026-09-06 — the same "are we measuring ourselves" question, asked once
      // and answered for BOTH consumers below. The 2026-07-31 fix guarded the
      // dead-click cancellation and stopped there, so flush() POSTing to
      // ENDPOINT through this wrapper still emitted `nf` about our own upload:
      // a slow or cancelled beacon was published to the customer as a network
      // failure ON THEIR SITE. Measured 2026-09-06 over 7 days, share of each
      // site's nf events that were our own beacon: enzositalianmarket.net 63%,
      // QisstPay.com 50%, halohair.pk 33%, eleganceinteriorsdesign.com 26%,
      // lyverro.com 17%, tigerfriday.com 78,779 events. Most of those beacons
      // fail because the page is unloading, which the retry queue already
      // handles; none of them are the customer's site breaking.
      var _pxOwnBeacon = isOwnBeaconUrl(url);
      if (!_pxOwnBeacon) {
        try { if (window.__harvvNetActivity) window.__harvvNetActivity(); } catch (_) {}
      }
      var t0 = performance.now();
      // 2026-05-23 — GA4 collect observer. Inspect the raw URL BEFORE
      // it gets scrubbed below (the scrub strips epn.value etc.).
      try { var _gaPayload = _parseGACollect(url); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
      var fullUrl = "";
      var path = "";
      try {
        var u = new URL(url, location.origin);
        var scrubbedSearch = scrubQuery(u.pathname + u.search).slice(u.pathname.length);
        fullUrl = u.origin !== location.origin ? (u.origin + scrubQuery(u.pathname + u.search)).substring(0, 200) : (u.pathname + scrubbedSearch).substring(0, 80);
        path = u.pathname.substring(0, 80);
      } catch (_) {
        fullUrl = scrubQuery(url).substring(0, 200);
        path = url.split("?")[0].substring(0, 80);
      }
      // Classify request type from extension/path (no PII)
      var ext = path.split(".").pop().toLowerCase();
      var rt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg" || ext === "gif" || ext === "ico") ? "asset"
        : (path.indexOf("/api/") > -1 || path.indexOf("/graphql") > -1 || path.indexOf("/cart") > -1) ? "api" : "page";
      // Detect method
      var method = (init && init.method) ? init.method.toUpperCase() : "GET";
      // Capture a bare Error at call time so the initiator stays accurate
      // (the stack inside a .then() microtask would be wrong). `.stack` is
      // NOT accessed here — only formatted later in the rare nf branch.
      // Once both nf caps are exhausted, skip the capture entirely.
      var callerErr = (errCount < 5 || slowCount < 5) ? new Error() : null;
      // 2026-05-27 — fs (form-submission) inference, folded in from
      // the formerly-separate second wrap block. Mutating same-origin
      // requests to submission-like paths get an 'fs' event in the
      // same .then() callback below.
      var _fsMutating = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
      var _fsSameOrigin = false, _fsPathHint = false, _fsPath = null, _fsUrlObj = null;
      try {
        _fsUrlObj = new URL(url, location.origin);
        _fsSameOrigin = _fsUrlObj.origin === location.origin;
        _fsPathHint = SUBMIT_PATH_HINTS.test(_fsUrlObj.pathname) && !FS_PATH_EXCLUDE.test(_fsUrlObj.pathname);
        if (_fsMutating && _fsSameOrigin && _fsPathHint) _fsPath = templatize(_fsUrlObj.pathname);
      } catch (_) {}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  // tt — tap target audit. Mobile + tablet only (desktop has mouse).
  // Counts interactive elements below 44×44 (Apple HIG threshold) and
  // emits a sample of the worst offenders.
  function emitTapTargets() {
    try {
      if (isPrivatePage()) return;
      var W = window.innerWidth;
      var b = _vpBucket(W);
      if (b === 'd') return;
      var nodes = document.querySelectorAll('a, button, [role="button"], input[type="submit"], input[type="button"]');
      var smalls = [], total = 0;
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        var r = n.getBoundingClientRect();
        if (r.width < 1 || r.height < 1) continue;
        // Skip elements that are visually inline within text (links inside <p>)
        var p = n.parentElement;
        if (p && (p.tagName === 'P' || p.tagName === 'LI') && r.height < 30) continue;
        total++;
        if (r.width < 44 || r.height < 44) {
          // 2026-07-30 — thumb reachability. x/y are the target centre as a
          // percentage of the viewport. Vendors all ship WHERE TAPS HAPPENED;
          // nobody ships what was REACHABLE, and the counterfactual is the
          // whole point: an element with high visibility, low tap rate and
          // elevated tap-offset error is a reach problem, and the fix is
          // "move it into the thumb zone", not "restyle it to look clickable"
          // (Bergstrom-Lehtovirta & Oulasvirta, CHI 2014; Mayer et al., ISS
          // 2019).
          //
          // Normalised viewport zones, NOT physical millimetres: no web API
          // exposes screen PPI, so the CHI 2014 thumb arc cannot be fitted in
          // real units. All scoring is server-side and cross-session — per
          // -session grip classification does not survive sparse, UI-
          // constrained web tap clouds and is deliberately not attempted.
          smalls.push({
            s: _vbSel(n), w: Math.round(r.width), h: Math.round(r.height),
            x: Math.max(0, Math.min(100, Math.round((r.left + r.width / 2) / W * 100))),
            y: Math.max(0, Math.min(100, Math.round((r.top + r.height / 2) / (window.innerHeight || 1) * 100))),
          });
        }
      }
      if (smalls.length === 0) return;
      smalls.sort(function (a, b) { return (a.w * a.h) - (b.w * b.h); });
      var sample = smalls.slice(0, 6);
      // rch — how many of the small targets sit in the upper third, the band
      // a one-handed grip cannot reach without shifting the phone. A blunt
      // on-device count; the real scoring happens server-side against the
      // sweet-spot distribution.
      var upper = 0;
      for (var q = 0; q < smalls.length; q++) if (smalls[q].y < 33) upper++;
      push('tt', { vw: W, b: b, sc: smalls.length, tt: total, h: sample, rch: upper });
    } catch (_) {}
  }

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

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

  // ─── Mobile depth signals (2026-07-30): at, ir, sg, rd ────────────────
  //
  // Four signals from docs/research/pixel-signal-candidates-2026-07-30.md.
  // Mobile has had no hesitation signal at all: desktop gets hover-abandon
  // (`hi`), mobile gets nothing, so a phone user who almost tapped Add To
  // Cart and thought better of it is invisible to every detector we own.
  //
  //   at — aborted tap. Finger lands on an interactive element and lifts
  //        without committing. The uncommitted touch lifecycle; every
  //        commercial tool keys off completed taps only.
  //   ir — inter-tap rhythm. Session tap tempo + the pause before a
  //        high-stakes element. AGGREGATES ONLY (see ethos below).
  //   sg — swipe gesture kinematics. Gesture QUALITY, where `sk` sees only
  //        page displacement. Coarse per-region aggregates only.
  //   rd — read / skim / skip per text block, dwell normalised by word
  //        count. Emitted from the IntersectionObserver block further down.
  //
  // ETHOS (research doc section 5, non-negotiable):
  //   - Raw inter-tap interval sequences are person-stable biometric
  //     templates (Balerna & Ghosh 2017). Per-gesture kinematic vectors
  //     identify individuals (Frank et al., Touchalytics, IEEE TIFS 2013).
  //     Everything here is computed ON DEVICE and leaves only as coarse
  //     aggregates. The raw series never crosses the network.
  //   - No clinical or affective framing anywhere. Several source papers
  //     are mental-health studies; our output describes INTERFACE
  //     behaviour on a page, never a person.
  //   - No cause attribution. "Degradation observed", never "this user
  //     was walking" — that would mislabel motor-disabled users.
  //
  // The code is `sg`, NOT the `sw` the research doc proposed: `sw` has been
  // the service-worker signal since 2026-05-06 and still emits. Reusing it
  // would have silently merged two unrelated data layers into one column,
  // and a live GROUP BY on a quiet day would not have shown the clash
  // (`sw` runs ~6 events/24h fleet-wide). src/lib/event-registry.js exists
  // to make that class of collision impossible to repeat.
  //
  // ONE listener set serves all three touch signals. Registering three
  // independent touchstart/touchmove pairs would triple the per-move work
  // on the hottest event on mobile for no benefit.
  try {
    if ('ontouchstart' in window && !isPrivatePage()) {
      var _atCount = 0;          // emitted `at` events this page (capped)
      var _tapTimes = [];        // touchstart ms for committed, non-scroll taps
      var _preTap = [];          // {el, ms} pause before a high-stakes tap
      var _sgAgg = {};           // region -> accumulated gesture kinematics
      var _sgRegions = 0;

      // Gesture-local state. Reset on every touchstart.
      var _tStart = 0, _tX = 0, _tY = 0, _tScrollY = 0, _tEl = null;
      var _tTravel = 0, _tPath = 0, _tLastX = 0, _tLastY = 0, _tLastT = 0;
      var _tScrolled = false, _tMulti = false;
      var _spd = [], _revs = 0, _lastDir = 0, _sampleT = 0;

      // Capture-phase scroll flag. MANDATORY, not defensive: iOS Safari
      // ends a scroll gesture with touchend, NOT touchcancel (touchcancel
      // is reserved for system-gesture takeovers like edge swipes). A
      // touchend-vs-touchcancel discriminator would therefore classify
      // every single iOS scroll as an aborted tap.
      try {
        addEventListener('scroll', function () { _tScrolled = true; },
          { passive: true, capture: true });
      } catch (_) {
        addEventListener('scroll', function () { _tScrolled = true; }, true);
      }

      // Nearest interactive ancestor, walked manually (max 6 levels) rather
      // than Element.closest, matching the house pattern in isPrivateElement.
      function _interactiveAncestor(el) {
        var n = el, i = 0;
        while (n && n.tagName && i++ < 6) {
          var t = n.tagName.toLowerCase();
          if (t === 'a' || t === 'button' || t === 'select' || t === 'label') return n;
          if (t === 'input') {
            var ty = (n.type || '').toLowerCase();
            if (ty === 'submit' || ty === 'button' || ty === 'checkbox' || ty === 'radio') return n;
          }
          var role = n.getAttribute && n.getAttribute('role');
          if (role === 'button' || role === 'link' || role === 'tab') return n;
          if (n.hasAttribute && (n.hasAttribute('onclick') || n.hasAttribute('data-cta'))) return n;
          n = n.parentElement;
        }
        return null;
      }

      // High-stakes = the elements whose hesitation is worth money. Kept
      // narrow on purpose: a pre-tap pause is only interpretable against a
      // per-element baseline, and widening this floods `pre` with noise.
      function _highStakes(el) {
        try {
          var t = el.tagName.toLowerCase();
          if (t === 'button' || (t === 'input' && (el.type === 'submit' || el.type === 'button'))) return true;
          if (el.hasAttribute && el.hasAttribute('data-cta')) return true;
          var c = typeof el.className === 'string' ? el.className.toLowerCase() : '';
          return /\b(cta|add-to-cart|addtocart|checkout|buy|submit|purchase|order)\b/.test(c);
        } catch (_) { return false; }
      }

      // Viewport-normalised coordinates, 0..100. visualViewport where it
      // exists so pinch-zoom does not shift the frame of reference; plain
      // innerWidth/Height otherwise (ES5-safe, feature-detected).
      function _norm(x, y) {
        var vv = window.visualViewport;
        var W = (vv && vv.width) || window.innerWidth || 1;
        var H = (vv && vv.height) || window.innerHeight || 1;
        return [Math.max(0, Math.min(100, Math.round(x / W * 100))),
                Math.max(0, Math.min(100, Math.round(y / H * 100)))];
      }

      addEventListener('touchstart', function (e) {
        try {
          if (!e.touches || e.touches.length !== 1) { _tMulti = true; return; }
          _tMulti = false;
          var t = e.touches[0];
          _tStart = Date.now();
          _tX = _tLastX = t.clientX; _tY = _tLastY = t.clientY;
          _tLastT = _tStart; _sampleT = 0;
          _tScrollY = _scrollTop();
          _tScrolled = false;
          _tTravel = 0; _tPath = 0;
          _spd = []; _revs = 0; _lastDir = 0;
          _tEl = e.target && e.target.nodeType === 1 ? e.target : null;
        } catch (_) {}
      }, { passive: true });

      addEventListener('touchmove', function (e) {
        try {
          if (_tMulti || !e.touches || e.touches.length !== 1 || !_tStart) return;
          var t = e.touches[0], now = Date.now();
          // touchmove is coalesced to ~1/frame on both platforms, so a true
          // third derivative would be noise-dominated (Balasubramanian 2015).
          // Sample at ~16ms and use noise-robust proxies instead.
          if (now - _sampleT < 16) return;
          _sampleT = now;
          var dx = t.clientX - _tLastX, dy = t.clientY - _tLastY;
          var step = Math.sqrt(dx * dx + dy * dy);
          var dt = now - _tLastT;
          _tPath += step;
          _tTravel = Math.sqrt((t.clientX - _tX) * (t.clientX - _tX)
                             + (t.clientY - _tY) * (t.clientY - _tY));
          if (dt > 0 && _spd.length < 40) _spd.push(step / dt);
          // Direction reversals with a 4px deadband so a steady drag does
          // not register jitter as agitation.
          if (Math.abs(dy) > 4) {
            var dir = dy > 0 ? 1 : -1;
            if (_lastDir && dir !== _lastDir) _revs++;
            _lastDir = dir;
          }
          _tLastX = t.clientX; _tLastY = t.clientY; _tLastT = now;
        } catch (_) {}
      }, { passive: true });

      addEventListener('touchend', function (e) {
        try {
          if (_tMulti || !_tStart) { _tStart = 0; return; }
          var hold = Date.now() - _tStart;
          var scrollDelta = Math.abs(_scrollTop() - _tScrollY);
          // The scroll guard. Either signal disqualifies the gesture from
          // being read as a tap: the capture-phase flag catches scrolls that
          // ended where they began, the offset delta catches the rest.
          var wasScroll = _tScrolled || scrollDelta > 8;

          // ── sg: gesture kinematics ──────────────────────────────────
          if (_tPath > 30 && _spd.length >= 3) {
            var mean = 0, i;
            for (i = 0; i < _spd.length; i++) mean += _spd[i];
            mean /= _spd.length;
            var vsum = 0;
            for (i = 0; i < _spd.length; i++) vsum += (_spd[i] - mean) * (_spd[i] - mean);
            var sd = Math.sqrt(vsum / _spd.length);
            var cvv = mean > 0 ? Math.round(sd / mean * 100) : 0;
            var straight = Math.sqrt((_tLastX - _tX) * (_tLastX - _tX)
                                   + (_tLastY - _tY) * (_tLastY - _tY));
            var str = _tPath > 0 ? Math.round(straight / _tPath * 100) : 100;
            // REGION KEYING. A swipe that scrolled the page is not "on" an
            // element in any stable sense: by the next gesture the page has
            // moved and the same screen position hits different markup, so
            // every gesture lands in its own bucket, every bucket has n=1,
            // and the n>=2 aggregation floor discards all of them. That is
            // how the first version of this signal emitted exactly nothing
            // while parsing, loading and throwing no errors — the silent
            // failure this whole exercise is about.
            //
            // So key on what is actually stable for each kind of gesture:
            //   within-widget swipe (page did not scroll) -> the element.
            //     This is the actionable case: "agitated gestures in your
            //     size-selector" localises to a component worth fixing.
            //   page scroll -> a viewport band (top/mid/bottom third).
            //     Agitated scrolling is still a real signal, and three
            //     stable buckets aggregate instead of scattering.
            var rgn;
            if (!_tScrolled && Math.abs(_scrollTop() - _tScrollY) <= 8 && _tEl) {
              rgn = _vbSel(_tEl);
            }
            if (!rgn) {
              var band = _norm(_tX, _tY)[1];
              rgn = 'vp:' + (band < 33 ? 't' : band < 67 ? 'm' : 'b');
            }
            if (!_sgAgg[rgn] && _sgRegions < 6) { _sgAgg[rgn] = { n: 0, rev: 0, cvv: 0, str: 0 }; _sgRegions++; }
            var a = _sgAgg[rgn];
            if (a) { a.n++; a.rev += _revs; a.cvv += cvv; a.str += str; }
          }

          // ── ir: tap rhythm (scroll gestures excluded, or the tempo is
          //        really scroll cadence) ────────────────────────────────
          if (!wasScroll && _tTravel < 10) {
            var prev = _tapTimes.length ? _tapTimes[_tapTimes.length - 1] : 0;
            if (_tapTimes.length < 80) _tapTimes.push(_tStart);
            var el0 = _tEl ? _interactiveAncestor(_tEl) : null;
            if (prev && el0 && _highStakes(el0) && _preTap.length < 4) {
              _preTap.push({ el: _vbSel(el0), ms: Math.min(60000, _tStart - prev) });
            }
          }

          // ── at: aborted tap ─────────────────────────────────────────
          // Only meaningful on an interactive target that was NOT scrolled.
          if (!wasScroll && _atCount < 8 && hold >= 40 && hold < 4000) {
            var el = _tEl ? _interactiveAncestor(_tEl) : null;
            if (el) {
              var r = el.getBoundingClientRect();
              var ex = _tLastX, ey = _tLastY;
              var outside = ex < r.left || ex > r.right || ey < r.top || ey > r.bottom ? 1 : 0;
              var travel = Math.round(_tTravel);
              // Only a slip-off or a long waver is worth the bytes. A clean
              // quick tap that simply did not fire a click is a DEAD click,
              // which `dc` already owns — do not double-report it here.
              //
              // TRAVEL IS BOUNDED BY OUTCOME, and that calibration comes from
              // live traffic, not theory. The first version fired on
              // `travel > 12` regardless, and within 15 minutes of shipping,
              // 53 of the first 65 aborts fleet-wide were ONE element:
              // Tiger Friday's `div.js-product-image a`, averaging 131px of
              // travel with out=0. Those are not people reconsidering a tap,
              // they are people swiping a product-image carousel. The finger
              // moves a long way and still ends inside a large image.
              //
              // So: a big excursion only counts when the finger actually LEFT
              // the target (a real slip-off). Ending inside after 131px is a
              // swipe, and `sg` already owns gesture quality. Reporting it
              // here would have dollarized carousel browsing as checkout
              // hesitation, which is worse than not measuring it.
              //
              // 2026-08-02 (#57497) — the 160px ceiling was still far too
              // generous, and the same lesson applied a second time. Seven days
              // of the shipped signal, fleet-wide:
              //
              //     out=0   n=871    travel p50=24px
              //     out=1   n=5424   travel p50=94px  p75=122  p95=151
              //
              // Ending OUTSIDE the target after 94px is also a scroll: the
              // finger starts on a full-width product image, flicks the page,
              // and lands past the element's edge. The out=1 travel histogram
              // has a clear trough at 40px (per-20px buckets:
              // 237 / 218 / 583 / 913 / 1077 ...), so above 40px a slip-off is
              // not distinguishable from a scroll and must not be recorded as
              // one. The consumer (analysis/tap-slip-detector.js) already
              // discards everything above 40px, so this drops 79% of `at`
              // volume that no reader can act on: pure ingest, storage and S3
              // archive cost on every customer.
              //
              // SCROLL_MAX is deliberately the same 40px number the detector
              // uses. If one moves, the other has to move with it.
              var isSlipOff = outside === 1 && travel <= 40;
              var isMicroDrift = outside === 0 && travel > 12 && travel <= 40;
              var isWaver = hold > 600 && travel <= 12;
              if (isSlipOff || isMicroDrift || isWaver) {
                var startedAt = _tStart;
                var target = el;
                var clicked = false;
                var onClick = function (ev) {
                  try { if (ev.target === target || target.contains(ev.target)) clicked = true; } catch (_) { clicked = true; }
                };
                addEventListener('click', onClick, true);
                // Correlation window: iOS fires no click once movement
                // exceeds slop, so a genuine slip-off resolves as no-click
                // on both platforms.
                setTimeout(function () {
                  try {
                    removeEventListener('click', onClick, true);
                    if (clicked) return;               // committed, not an abort
                    if (Date.now() - startedAt > 6000) return;
                    var n = _norm(ex, ey);
                    _atCount++;
                    push('at', {
                      el: eid(target), hold: hold, trav: travel, out: outside,
                      nx: n[0], ny: n[1],
                    });
                  } catch (_) {}
                }, 500);
              }
            }
          }
          _tStart = 0;
        } catch (_) { _tStart = 0; }
      }, { passive: true });

      // Emit the session aggregates once, on the way out.
      //
      // ORDERING, and it is load-bearing. Two exit paths, opposite hazards:
      //
      //   pagehide          — the main handler (which pushes `ul`/`sk` and
      //                       then flushes) is registered AFTER this block in
      //                       source, so ours runs FIRST and our events ride
      //                       along in that flush. Nothing to do.
      //
      //   visibilitychange  — the pixel's own flush-on-hidden is registered
      //                       at the TOP of the file, thousands of lines
      //                       before this. Listeners fire in registration
      //                       order, so flush() has already run by the time
      //                       we push. Our events would sit in the queue
      //                       waiting for the 10s interval that never comes
      //                       on a tab-switch exit, and silently vanish.
      //
      // So the visibility path flushes for itself. This is the dominant exit
      // on iOS (tab swipe-away / app background rarely fires pagehide) —
      // precisely the case that produced the Tiger-Friday undercount — so
      // getting it wrong would have made these signals look chronically
      // under-populated with no error anywhere to explain it.
      var _depthFlushed = false;
      function _flushDepthSignals(selfFlush) {
        if (_depthFlushed) return;
        _depthFlushed = true;
        try {
          // ir — needs enough taps to mean anything. The research is
          // multi-day and task-based; per-session tempo is noisy, so the
          // server treats med/cv/drift as aggregate-only and gates
          // interpretation at higher n. 4 is the floor for emitting at all.
          if (_tapTimes.length >= 5) {
            var iv = [], i;
            for (i = 1; i < _tapTimes.length; i++) {
              var d = _tapTimes[i] - _tapTimes[i - 1];
              if (d > 40 && d < 120000) iv.push(d);
            }
            if (iv.length >= 4) {
              var sorted = iv.slice().sort(function (a, b) { return a - b; });
              var med = sorted[Math.floor(sorted.length / 2)];
              var mn = 0;
              for (i = 0; i < iv.length; i++) mn += iv[i];
              mn /= iv.length;
              var vs = 0;
              for (i = 0; i < iv.length; i++) vs += (iv[i] - mn) * (iv[i] - mn);
              var cv = mn > 0 ? Math.round(Math.sqrt(vs / iv.length) / mn * 100) : 0;
              // Drift: second-half median vs first-half median, as a percent.
              // A cheap, noise-tolerant stand-in for a regression slope.
              var half = Math.floor(iv.length / 2), drift = 0;
              if (half >= 2) {
                var f = iv.slice(0, half).sort(function (a, b) { return a - b; });
                var s = iv.slice(half).sort(function (a, b) { return a - b; });
                var fm = f[Math.floor(f.length / 2)], sm = s[Math.floor(s.length / 2)];
                if (fm > 0) drift = Math.max(-999, Math.min(999, Math.round((sm - fm) / fm * 100)));
              }
              // NOTE: `iv` itself never leaves this function. Emitting the
              // raw interval sequence would ship a biometric template.
              var irPayload = { n: iv.length, med: med, cv: cv, drift: drift };
              if (_preTap.length) irPayload.pre = _preTap;
              push('ir', irPayload);
            }
          }

          // sg — one row per region, coarse means only.
          var keys = [], k;
          for (k in _sgAgg) if (Object.prototype.hasOwnProperty.call(_sgAgg, k)) keys.push(k);
          for (var j = 0; j < keys.length && j < 4; j++) {
            var g = _sgAgg[keys[j]];
            if (!g || g.n < 2) continue;
            var mcvv = Math.round(g.cvv / g.n), mstr = Math.round(g.str / g.n);
            // b: coarse agitation bucket 0..3. Deliberately blunt — a fine
            // score would invite per-session reads, and per-session
            // kinematics are confounded by walking, transit vibration, grip,
            // age and tremor. Baselining happens server-side, across
            // sessions, per region.
            var b = 0;
            if (mcvv > 60) b++;
            if (mstr < 70) b++;
            if (g.rev / g.n > 2) b++;
            push('sg', { rgn: keys[j], n: g.n, rev: g.rev, cvv: mcvv, str: mstr, b: b });
          }
        } catch (_) {}
        if (selfFlush) { try { flush(); } catch (_) {} }
      }
      addEventListener('pagehide', function () { _flushDepthSignals(false); });
      // iOS does not reliably fire pagehide on tab switch or app background;
      // visibilitychange is the dependable exit signal there. Same lesson as
      // the short-visit undercount fix.
      document.addEventListener('visibilitychange', function () {
        if (document.visibilityState === 'hidden') _flushDepthSignals(true);
      }, { passive: true });
    }
  } catch (_) {}

  // ─── rd: read / skim / skip per text block (2026-07-30) ───────────────
  //
  // Per text block, was it read, skimmed or skipped — dwell time in the
  // viewport normalised by the block's word count. Validated construct:
  // Lagun & Lalmas, WSDM 2016 measured viewport-time attention across
  // 267,210 real page views; Hauger et al., UMAP 2011 validated scroll
  // dwell against eye tracking. The novelty is the CLASSIFICATION: vendors
  // ship scroll-depth maps and zone exposure, none classify read vs skim vs
  // skip per block normalised by length.
  //
  // Why this powers cases nothing else can: "the paragraph answering the
  // shipping question is skipped by 84% of sessions that then abandon at
  // checkout" is a content finding, and today we can only say the page was
  // scrolled past.
  //
  // IntersectionObserver only, no scroll fallback. A scroll-driven version
  // would need per-scroll getBoundingClientRect (forced layout on the
  // hottest handler on the page) or cached offsets that go stale on exactly
  // the CLS-heavy pages where this matters most. Absent IO, the signal is
  // simply not collected — a missing measurement, honestly missing, rather
  // than a wrong one. IO is Chrome 51+ / Safari 12.1+, so the Android 7-8
  // floor is covered.
  //
  // PII: block identity is structural only (element path + word count).
  // Never a text excerpt. Private zones are skipped outright.
  try {
    if (typeof IntersectionObserver === 'function' && !isPrivatePage()) {
      var _rdBlocks = [];
      var _rdVisible = {};
      var _rdIO = null;

      function _rdCollect() {
        try {
          if (_rdBlocks.length) return;
          var nodes = document.querySelectorAll('main p, article p, section p, main li, article li');
          if (!nodes.length) nodes = document.querySelectorAll('p');
          for (var i = 0; i < nodes.length && _rdBlocks.length < 30; i++) {
            var el = nodes[i];
            if (isPrivateElement(el)) continue;
            var txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
            if (txt.length < 60) continue;
            var w = txt.split(' ').length;
            if (w < 15) continue;                      // trivial blocks are noise
            var r = el.getBoundingClientRect();
            if (r.width < 1 || r.height < 1) continue;
            var idx = _rdBlocks.length;
            _rdBlocks.push({ el: el, blk: _vbSel(el) + ':' + idx, w: w, dw: 0, since: 0, seen: 0 });
            el.setAttribute('data-hv-rd', String(idx));
          }
          if (!_rdBlocks.length) return;
          _rdIO = new IntersectionObserver(function (entries) {
            var now = Date.now();
            for (var j = 0; j < entries.length; j++) {
              var en = entries[j];
              var id = en.target.getAttribute('data-hv-rd');
              if (id === null) continue;
              var b = _rdBlocks[parseInt(id, 10)];
              if (!b) continue;
              // 0.5 ratio: half the block on screen counts as "in view".
              // A sliver at the fold is not reading.
              if (en.isIntersecting && en.intersectionRatio >= 0.5) {
                if (!b.since) b.since = now;
                b.seen = 1;
                _rdVisible[id] = 1;
              } else if (b.since) {
                b.dw += now - b.since; b.since = 0; delete _rdVisible[id];
              }
            }
          }, { threshold: [0, 0.5, 1] });
          for (var m = 0; m < _rdBlocks.length; m++) _rdIO.observe(_rdBlocks[m].el);
        } catch (_) {}
      }

      // Dwell must not accrue while the tab is hidden, or a backgrounded tab
      // reads as the most attentive session on the site.
      document.addEventListener('visibilitychange', function () {
        try {
          var now = Date.now(), id;
          if (document.visibilityState === 'hidden') {
            for (id in _rdVisible) {
              if (!Object.prototype.hasOwnProperty.call(_rdVisible, id)) continue;
              var b = _rdBlocks[parseInt(id, 10)];
              if (b && b.since) { b.dw += now - b.since; b.since = 0; }
            }
          } else {
            for (id in _rdVisible) {
              if (!Object.prototype.hasOwnProperty.call(_rdVisible, id)) continue;
              var b2 = _rdBlocks[parseInt(id, 10)];
              if (b2) b2.since = now;
            }
          }
        } catch (_) {}
      }, { passive: true });

      var _rdFlushed = false;
      // selfFlush: see the ordering note on _flushDepthSignals. The
      // visibilitychange path must flush for itself or these events are
      // queued after the pixel's own flush has already gone out.
      function _flushRead(selfFlush) {
        if (_rdFlushed) return;
        _rdFlushed = true;
        try {
          var now = Date.now(), out = [];
          for (var i = 0; i < _rdBlocks.length; i++) {
            var b = _rdBlocks[i];
            if (b.since) { b.dw += now - b.since; b.since = 0; }
            if (!b.seen) {
              // NEVER ENTERED THE VIEWPORT. Its own class, because it is a
              // different fact from "skipped" and collapsing the two is the
              // error this entire cohort exists to avoid.
              //
              // First live data made that concrete: rd reported 89-92% of
              // blocks "skipped", which reads as "your copy is being ignored".
              // Most of those were simply never scrolled to — the reader left
              // higher up the page. "Seen and ignored" is a content problem;
              // "never reached" is a page-length or engagement problem, and
              // they have opposite fixes. Reporting both as 'k' would have
              // sent customers to rewrite paragraphs nobody ever loaded.
              out.push({ blk: b.blk, w: b.w, dw: 0, cls: 'n' });
              continue;
            }
            if (b.dw < 150) {
              // Entered the viewport but only flickered past.
              out.push({ blk: b.blk, w: b.w, dw: Math.round(b.dw), cls: 'k' });
              continue;
            }
            // 240 wpm = 4 words/sec is the conventional silent-reading rate.
            var expected = b.w / 4 * 1000;
            var ratio = b.dw / expected;
            out.push({
              blk: b.blk, w: b.w, dw: Math.round(b.dw),
              cls: ratio < 0.25 ? 'k' : ratio < 0.75 ? 's' : 'r',
            });
          }
          // Cap the emission: the read blocks and the skipped ones are both
          // interesting, a middling 30-block dump is not.
          // Prefer blocks that were actually SEEN — those carry the signal.
          // Never-seen blocks are reported too (a page whose content is never
          // reached is worth knowing) but must not crowd out the read ones.
          out.sort(function (a, b) { return b.dw - a.dw; });
          var seenOnly = [], unseen = [];
          for (var s2 = 0; s2 < out.length; s2++) {
            (out[s2].cls === 'n' ? unseen : seenOnly).push(out[s2]);
          }
          var send = seenOnly.slice(0, 8).concat(unseen.slice(0, 4));
          for (var j = 0; j < send.length; j++) push('rd', send[j]);
        } catch (_) {}
        if (selfFlush) { try { flush(); } catch (_) {} }
      }

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { setTimeout(_rdCollect, 1200); }, { once: true });
      } else {
        setTimeout(_rdCollect, 1200);
      }
      addEventListener('pagehide', function () { _flushRead(false); });
      document.addEventListener('visibilitychange', function () {
        if (document.visibilityState === 'hidden') _flushRead(true);
      }, { passive: true });
    }
  } 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).
    AUD_PASS = 1;
    try {
      emitVisualWarnings(); emitImageMeta();
      // 2026-05-08 — new mobile UI signals (eg, cs, tt, rb). Run on the
      // same cadence as visual warnings so the second pass picks up
      // post-font-swap measurements (rb in particular is sensitive).
      emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
    } catch (_) {}
    AUD_PASS = 0;
    setTimeout(function () {
      // Pass 2 ships only what changed since pass 1 (see push).
      AUD_PASS = 2;
      try {
        emitVisualWarnings(); emitImageMeta();
        emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
      } catch (_) {}
      AUD_PASS = 0;
    }, 2200);
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', runPageAudits, { once: true });
  } else {
    setTimeout(runPageAudits, 50);
  }

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

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

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

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

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

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

})();
Harvv

The behavioral pixel for sites you ship to real users. Harvv finds what is broken, explains it in plain English, and hands the answer to your AI.

Product

How it works What we find Pricing For agencies Docs Free audit

Learn

Case studies Guides Harvv vs Clarity Harvv vs Cloudflare Clarity & privacy

Company

Contact Trust & security Privacy Terms
© 2026 Harvv Zero PII. Every number on this site is measured, not modelled.