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: 443,987 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)$/;
  // 2026-09-28 (#80408, zero-PII): third-party chat widgets render the
  // visitor's own messages as ordinary divs and paragraphs, with no role=log
  // and no textarea once sent. On Tiger Friday the Redo chat panel's message
  // bubbles were captured as click labels: about 680 events in 7 days carried
  // chat text, some with order-like numbers. Any element inside a known chat
  // widget container is private: tag, id and class are kept, text never is.
  var CHAT_WIDGET_ID_RX = /^(redo-chat|redo-text-input|intercom|zendesk|ze-|gorgias|tidio|drift|crisp|tawk|freshchat|fc-widget|kustomer|gladly|olark|hubspot-messages|hs-messages|livechat|chat-widget)|(^|-)(chat-?widget|live-?chat|chat-?window|chat-?container)(-|$)/i;
  var CHAT_WIDGET_CLASS_RX = /(^|\s)(redo-chat|intercom-|zEWidget|gorgias-chat|tidio-|drift-|crisp-client|tawk-|fc-widget|kustomer-|olark|hubspot-messages|livechat-)|(^|[\s-])(chat-?widget|live-?chat|chat-?window|chat-?container)([\s-]|$)/i;
  // #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;
  }

  // #81849 (2026-09-30) — open shadow roots the visitor has touched. Only the
  // click listener followed #65725 into shadow roots. Every form listener
  // still read ev.target (the host), and `submit` is not a composed event, so
  // it never reaches a document listener from inside a root. Shopify Forms
  // (the email popup on shopjwest.com), UpCart and Redo all render in open
  // roots: 94 clicks on one popup's email field in 30 days, and not one field
  // or submit event. A root registers the first time a focus or pointer
  // event's composed path passes through it (a visitor has to focus or tap
  // inside a form before submitting it), and each ROOT_HOOK runs on it once.
  // Nested roots each register. Closed roots never appear in the path.
  var hvRoots = [], ROOT_HOOKS = [];
  function noteRoots(ev) {
    try {
      var p = ev.composedPath ? ev.composedPath() : null;
      for (var i = 0; p && i < p.length && i < 60; i++) {
        var n = p[i];
        if (n && n.nodeType === 11 && n.host && !n.__hvR) {
          n.__hvR = 1;
          if (hvRoots.length < 30) hvRoots.push(n);
          for (var k = 0; k < ROOT_HOOKS.length; k++) { try { ROOT_HOOKS[k](n); } catch (_) {} }
        }
      }
    } catch (_) {}
  }
  function onRoot(fn) {
    ROOT_HOOKS.push(fn);
    for (var i = 0; i < hvRoots.length; i++) { try { fn(hvRoots[i]); } catch (_) {} }
  }
  document.addEventListener('focusin', noteRoots, true);
  document.addEventListener('pointerdown', noteRoots, true);
  // The widget an element lives in: its OUTERMOST shadow host, as a tag
  // (custom elements name their vendor: shopify-forms-embed, redo-...) or,
  // for a plain host, tag#id (div#upCart). Ids with long digit runs are
  // per-install, so those keep the tag only. `sh` on field and submit events;
  // the server's pattern library (src/lib/known-patterns.js) names the app.
  function hostSig(el) {
    try {
      var h = null, r;
      for (var d = 0; el && d < 8 && el.getRootNode && (r = el.getRootNode()) && r.host; d++) h = el = r.host;
      if (!h) return undefined;
      var t = h.tagName.toLowerCase(), id = h.getAttribute('id') || '';
      return (t.indexOf('-') > 0 || !/^[A-Za-z][\w-]{1,39}$/.test(id) || /\d{5,}/.test(id) ? t : t + '#' + id).slice(0, 60);
    } catch (_) { return undefined; }
  }
  // document.contains() does not see into shadow roots; isConnected does.
  function attached(el) {
    return el.isConnected !== undefined ? el.isConnected : document.documentElement.contains(el);
  }

  // 2026-10-12 (pixel v25) — a whole page can be private. A signed-in app
  // (Harvv's own Studio showed a person's name, other customers' site names
  // and a credit balance in labels) renders account data in nearly every
  // container, so it marks <html> or <body> data-harvv-private="true" and
  // every element keeps its selector and loses its text. The ancestor walk
  // below also went only 20 levels, so a node deeper than that under a
  // private root still leaked; it now walks to the root (capped at 200).
  function privateAttr(n) {
    var v = n && n.getAttribute ? n.getAttribute("data-harvv-private") : null;
    return v === "true" || v === "" || v === "1";
  }
  function isPrivateDocument() {
    try { return privateAttr(document.documentElement) || privateAttr(document.body); } catch (_) { return false; }
  }
  function isPrivateElement(el) {
    if (isPrivateDocument()) return true;
    var p = el, depth = 200;
    while (p && depth--) {
      if (p.getAttribute) {
        if (privateAttr(p)) 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;
        if (p.id && CHAT_WIDGET_ID_RX.test(p.id)) return true;
        var pcls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
        if (pcls && CHAT_WIDGET_CLASS_RX.test(pcls)) 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;
  }

  // 2026-10-06 (pixel v21, Patrol's manual review of a storefront) — what a
  // visitor TYPED never rides inside an element label. The leak they quoted:
  // the search drawer's button reads `See all results for "patrolprobe helmet"`,
  // so the ix click row and the fg form row carried the query. Under CIPA
  // §631 a typed search or chat message is a communication; a button label
  // is not. Two rules, both universal (no vendor list):
  //   a) any label that contains the CURRENT value of an input or textarea on
  //      the page has that value replaced by "..." (echoed queries,
  //      "you searched for" chips, inline chat echoes);
  //   b) inside a search context (form[role=search], an input[type=search]
  //      form, or an ancestor whose id/class says search / suggest /
  //      autocomplete / predictive / typeahead) a double-quoted run is
  //      redacted too, for the results header that quotes a term the drawer
  //      no longer holds.
  // Tag, id and class stay, so detection keys on search pages still group.
  // Gate: tests/pixel-typed-text-redaction.test.js (Patrol's payloads).
  var _typedAt = 0, _typedVals = [];
  function typedValues() {
    var now = Date.now();
    if (now - _typedAt < 250) return _typedVals;
    _typedAt = now;
    var out = [];
    try {
      var els = document.querySelectorAll("input, textarea");
      for (var i = 0; i < els.length && i < 80; i++) {
        var f = els[i], t = (f.type || "").toLowerCase();
        if (t === "hidden" || t === "checkbox" || t === "radio" || t === "submit" || t === "button" || t === "reset" || t === "image" || t === "file" || t === "range" || t === "color") continue;
        var v = String(f.value || "").replace(/\s+/g, " ").trim();
        if (v.length >= 2 && v.length <= 200) out.push(v.toLowerCase());
      }
      // The focused field, through open shadow roots (a drawer's own input).
      var a = document.activeElement, d = 8;
      while (a && a.shadowRoot && a.shadowRoot.activeElement && d--) a = a.shadowRoot.activeElement;
      if (a && typeof a.value === "string") {
        var av = a.value.replace(/\s+/g, " ").trim();
        if (av.length >= 2 && av.length <= 200) out.push(av.toLowerCase());
      }
    } catch (_) {}
    _typedVals = out;
    return out;
  }
  // 2026-10-07 (pixel v22, case #83485) — DISPLAYED account text. The v21 rule
  // covers what a visitor typed; this covers what the site shows a signed-in
  // person about themselves. What leaked on an Orchid eCommerce checkout: the
  // address block is a plain div whose text is the buyer's full name, and the
  // header's account menu shows the first name as a link beside "My Account"
  // and "Log out", so every vs snapshot while signed in carried it. Two rules,
  // universal, no vendor list; the server mirrors rule 1 in
  // src/lib/el-privacy.js (ACCOUNT_PATH_RX and CONTAINER_TAGS must stay equal):
  //   1. on an account-shaped path, a non-interactive container keeps its
  //      selector and loses its text; links and buttons keep theirs
  //      ("Continue as a guest", "Edit shipping address" are what checkout
  //      detection needs);
  //   2. inside an account menu (a container that also holds a log-out
  //      control) or under an ancestor whose id/class says account, customer,
  //      greeting, welcome or user-menu, a link or button keeps its text only
  //      when it is standard account vocabulary; a container never does.
  // Gate: tests/pixel-account-text-redaction.test.js.
  var ACCOUNT_PATH_RX = /(^|\/)(checkout|cart|account|my-account|myaccount|profile|orders?|order[_-]history|login|signin|sign-in|register|create[_-]account|wishlist|mywishlist|customer|billing|shipping|address(es)?|saved[-_]?cards|manage[_-]saved[_-]cards)(\/|$|[?#._-])/i;
  var CONTAINER_TAGS = /^(div|p|span|td|th|li|dd|dt|section|article|address|h[1-6]|small|strong|em|b|i|label|legend|figcaption|blockquote|cite|pre)$/i;
  var ACCOUNT_CTX_RX = /(^|[\s_-])(account|customer|address|billing|shipping|profile|greeting|welcome|user[-_]?(name|menu|nav|info)|logged[-_]?in|signed[-_]?in)([\s_-]|$)/i;
  var LOGOUT_RX = /^\s*(log\s?out|sign\s?out)\s*$/i;
  var ACCOUNT_SAFE_RX = /^(my )?(account|wish ?list|orders?|order history|profile( information)?|settings|cart|checkout|log ?out|sign ?out|log ?in|sign ?in|register|dashboard|addresses?|(manage )?saved cards|rewards?|returns?|track order|gift cards?|favou?rites?|view cart|continue|back|close|edit|save|cancel|home|shop|menu|search)$/i;
  var INTERACTIVE_TAGS = /^(a|button|summary|option|select)$/;
  function onAccountPath() {
    try { return ACCOUNT_PATH_RX.test(String(location.pathname || "")); } catch (_) { return false; }
  }
  // Account-menu containers, found from their log-out control and cached for
  // two seconds (eid() runs for every control in a vs snapshot).
  var _menuAt = 0, _menus = [];
  function accountMenus() {
    var now = Date.now();
    if (now - _menuAt < 2000) return _menus;
    _menuAt = now;
    var out = [];
    try {
      var ctl = document.querySelectorAll("a, button");
      for (var i = 0; i < ctl.length && i < 400 && out.length < 6; i++) {
        var c = ctl[i];
        if (!LOGOUT_RX.test(String(c.textContent || ""))) continue;
        // The menu is the nearest ancestor that holds at least two controls.
        var p = c.parentElement, d = 6;
        while (p && d-- && p !== document.body) {
          if (p.querySelectorAll("a, button").length >= 2) { out.push(p); break; }
          p = p.parentElement;
        }
      }
    } catch (_) {}
    _menus = out;
    return out;
  }
  function inAccountContext(el) {
    var menus = accountMenus();
    for (var i = 0; i < menus.length; i++) { if (menus[i].contains(el)) return true; }
    var p = el, depth = 12;
    while (p && depth--) {
      if (p.getAttribute) {
        var pid = p.id || "";
        var pcls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
        if ((pid && ACCOUNT_CTX_RX.test(pid)) || (pcls && ACCOUNT_CTX_RX.test(pcls))) return true;
      }
      p = parentAcross(p);
    }
    return false;
  }
  function isAccountText(el, tag) {
    var container = CONTAINER_TAGS.test(tag);
    if (container && onAccountPath()) return true;
    if (!container && !INTERACTIVE_TAGS.test(tag)) return false;
    if (!inAccountContext(el)) return false;
    if (container) return true;
    var t = String(renderedText(el) || "").replace(/\s+/g, " ").trim();
    return !ACCOUNT_SAFE_RX.test(t);
  }

  function inSearchContext(el) {
    var SEARCH_CTX_RX = /(^|[\s_-])(search|suggest|suggestions|autocomplete|predictive|typeahead)([\s_-]|$)/i;
    var p = el, depth = 20;
    while (p && depth--) {
      if (p.getAttribute) {
        if (p.getAttribute("role") === "search") return true;
        if (p.tagName === "FORM" && p.querySelector && p.querySelector("input[type=search]")) return true;
        var pid = p.id || "";
        var pcls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
        if ((pid && SEARCH_CTX_RX.test(pid)) || (pcls && SEARCH_CTX_RX.test(pcls))) return true;
      }
      p = parentAcross(p);
    }
    return false;
  }
  function redactTyped(txt, el) {
    if (!txt) return txt;
    var vals = typedValues(), low = txt.toLowerCase();
    for (var i = 0; i < vals.length; i++) {
      var at = low.indexOf(vals[i]);
      // A typed value that itself contains "..." could reappear after the
      // replacement; cap the passes so the loop always ends.
      var passes = 0;
      while (at !== -1 && passes++ < 20) {
        txt = txt.slice(0, at) + "..." + txt.slice(at + vals[i].length);
        low = txt.toLowerCase();
        at = low.indexOf(vals[i]);
      }
    }
    if (inSearchContext(el)) txt = txt.replace(/"[^"]*"|\u201c[^\u201d]*\u201d/g, '"..."');
    return txt;
  }

  // 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;
    }
    // 2026-10-07 (pixel v22, case #83485) — a signed-in person's own details
    // never ride in a label: an address block on a checkout page, the first
    // name in the header's account menu. Selector stays, text goes.
    if (isAccountText(el, tag)) {
      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 = redactTyped(renderedText(el).replace(/[\t\n\r\s]+/g, " ").trim(), el);
    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;
  }

  // 2026-09-25 — a radio/checkbox group and its choice rows. A click on the
  // group's padding, its legend or a choice row beside the label is someone
  // picking an answer, not a dead click (primemdplus.com: Gravity Forms'
  // fieldset#field_1_7 "Sex Male Female" and div.gchoice "Male" were flagged).
  // Gravity Forms choice wrappers by class; any other <fieldset> only when it
  // holds radios or checkboxes and no text fields (so a fieldset wrapping a
  // whole form does not swallow real dead clicks).
  var GF_CHOICE = /(^|\s)(gchoice|ginput_container_(radio|checkbox|consent)|gfield_(radio|checkbox)|gfield--type-(radio|checkbox|consent))(\s|$)/;
  function isChoiceGroup(el) {
    if (!el || !el.querySelector) return false;
    var c = typeof el.className === "string" ? el.className : "";
    if (el.tagName !== "FIELDSET" && !GF_CHOICE.test(c)) return false;
    if (!el.querySelector("input[type=radio],input[type=checkbox]")) return false;
    return el.tagName !== "FIELDSET" || GF_CHOICE.test(c) || !el.querySelector("textarea,input[type=text],input[type=email],input[type=tel]");
  }

  // 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;
      // A label that WRAPS its control (no `for`) toggles it just the same.
      if (el.querySelector && el.querySelector("input,select,textarea")) return true;
    }
    if (isChoiceGroup(el)) 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)) || (p.querySelector && p.querySelector("input,select,textarea")))) return true;
      if (isChoiceGroup(p)) 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,}/;
  // redo: Tiger Friday's Redo chat widget (ids redo-chat-widget-*, redo-text-input-*).
  // Its controls answered 89% of taps yet were filed as dead/orphan elements (#80408).
  var WIDGET_ATTR_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|hs|redo)-/;
  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 || "") + " " + (hostSig(node) || "")).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 = [].slice.call(document.querySelectorAll(CS_SEL));
      // An open drawer or popup inside a touched shadow root counts too (#81849).
      for (var ri = 0; ri < hvRoots.length; ri++) { try { nodes = nodes.concat([].slice.call(hvRoots[ri].querySelectorAll(CS_SEL))); } catch (_) {} }
      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();
        // #81907: the button that OPENS a drawer is not an open drawer. CS_SEL's
        // [class*="drawer"][class*="open"] matched Impulse's own opener
        // (button.js-drawer-open-collection-filters), so every tap on a
        // collection page carried cs "filter_panel" with the drawer shut.
        if (/^(A|BUTTON|INPUT|SELECT|LABEL|SUMMARY)$/.test(n.tagName) || n.getAttribute("role") === "button") continue;
        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.
  // v23 (2026-10-07, #80211 follow-up): each key is read cookie-first then
  // localStorage, so when the two stores name DIFFERENT visitors the visitor
  // and the session can come from different people. Every write path stores
  // _pxv in both, so a disagreement means one store changed behind our back.
  // Tiger Friday efbeb128248277bd (Instagram in-app browser): page one stored
  // visitor 5877ac798bcc1c7b with that session; page two read _pxv
  // 39aa90e66f8dbbd8, an id the same phone last used on 2026-09-07, and
  // adopted the session. A stored session is only adopted when they agree.
  function idStoresAgree() {
    var c = getCookie("_pxv"), l = lsGet("_pxv");
    return !c || !l || c === l;
  }
  var vid, vidStored = false, idsAgree = true;
  if (CONSENT_DENIED || cbPending()) {
    vid = rid();
  } else {
    idsAgree = idStoresAgree(); // before the writes below make them agree
    vid = getCookie("_pxv") || lsGet("_pxv");
    vidStored = !!vid;
    if (!vid) vid = 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 || "") || !vidStored || !idsAgree) {
      // 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.
      // #80211: so does a stored session with no stored visitor. Adopting it
      // under a visitor id minted just now puts two visitors in one session,
      // the same pairing legacyGrant() already refuses (#78249). v23: and so
      // do stores that disagree on the visitor (idStoresAgree above).
      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();
      // 2026-09-25 (#78249): the stored visitor is taken on the same terms
      // boot takes it (any stored value). VALID_ID exists to retire 8-char
      // SESSION ids; applied to the visitor here it rejected the pre-T70
      // 8-char visitor ids boot still uses, so every late-grant page load
      // minted a new visitor inside the stored session. One Tiger Friday
      // phone read as 17 visitors sharing one session, and 7 of 7 shared
      // sessions in 3 days carried an 8-char visitor id. A stored session is
      // adopted only WITH its stored visitor: pairing it with a fresh visitor
      // is the collision itself.
      var agree = idStoresAgree();
      var storedV = getCookie("_pxv") || lsGet("_pxv");
      var hasStoredV = !!storedV;
      if (hasStoredV) 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 (agree && hasStoredV && storedS && VALID_ID.test(storedS) && storedSS && storedLA
          && now2 - storedLA <= SESSION_TIMEOUT && now2 - storedSS <= SESSION_MAX_AGE) {
        sid = storedS; sessionStart = storedSS;
      } else if (vid !== oldV) {
        // v23 (2026-10-07): the visitor changed but no stored session was
        // adopted. The in-memory session id may already be on the wire with
        // the in-memory visitor (the 3 s early flush runs before most grants),
        // so keeping it put a one-page throwaway visitor and the returning one
        // in one session: Tiger Friday, 12 h of 2026-10-03, 7 of 7 such
        // sessions, each sessions row named after the throwaway id.
        sid = rid();
      }
      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.
  //
  // 2026-09-26 (#79522) — THE ROLL IS WHERE THREE IMPOSSIBLE READINGS CAME
  // FROM. Several accumulators are PAGE-level (ms since the last click, engaged
  // time, time to first interaction, scroll kinematics) but were never told
  // the session had changed under them. Measured over three days:
  //   ix.dt < 0      204 of 221 rows had the click earlier in its session than
  //                  the gap claimed: ts() moved to the new sessionStart while
  //                  lastIx still referenced the old one.
  //   ul.eg > 1 h    61 of 65 rows reported more engaged time than the session
  //                  was old (median session age at unload: 46 s): a desktop
  //                  tab left open, rolled on the next event, and the whole
  //                  page's engagement landed in a session seconds old. The
  //                  same mechanism put engagement above duration on 511 of
  //                  781 sessions.
  //   tf.tf > 1 h    113 of 115 rows exceeded their session's age; 103 followed
  //                  a became-visible event. The page's first interaction
  //                  after hours hidden landed in a session that had just begun.
  // Each accumulator registers a hook. Hooks run BEFORE the ids change (so a
  // handoff event can be stamped with the session it belongs to) and reset
  // their state for the new session. `_rolling` stops the handoff's own push()
  // from re-entering here.
  var _rollHooks = [], _rolling = false;
  function onSessionRoll(fn) { _rollHooks.push(fn); }
  function rollSessionIfStale() {
    if (_rolling) return;
    var t = Date.now();
    if (t - lastActivity <= SESSION_TIMEOUT && t - sessionStart <= SESSION_MAX_AGE) return;
    _rolling = true;
    var old = { sid: sid, sessionStart: sessionStart, lastActivity: lastActivity, now: t };
    for (var hi = 0; hi < _rollHooks.length; hi++) { try { _rollHooks[hi](old); } catch (_) {} }
    _rolling = false;
    sid = rid();
    sessionStart = t;
    if (CONSENT_DENIED || cbPending()) return; // in-memory only, same as init
    // #80211: store the visitor WITH the new session. A tab left open for
    // hours can outlive its _pxv (cleared by the site or a privacy tool); a
    // rolled _pxs stored alone was adopted by the next page under a fresh
    // visitor id, so one shopper read as two visitors in one session.
    setCookie("_pxv", vid, 730);
    lsSet("_pxv", vid);
    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)) {
        // v25: a private section is named by its tag or id, never by its
        // heading or aria-label (Studio's "Your studio · 17 sites tracked").
        if (isPrivateElement(node)) {
          sectionName = node.id ? node.id.replace(/[-_]/g, ' ').substring(0, 30) : tag.charAt(0) + tag.slice(1).toLowerCase();
          break;
        }
        // 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 && isPrivateElement(node)) label = null;
        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).
  // downSel (#74200, 2026-10-01): the selection as it stood when the press
  // began. This capture listener runs before the browser's own mousedown
  // handling, which is where a double-click picks a word and a triple-click a
  // paragraph, so comparing it with the selection at click time tells whether
  // THIS press selected text (see pressSelected, read by the rage rule).
  var downAt = null, downSel = null;
  document.addEventListener('mousedown', function (ev) {
    downAt = { x: ev.clientX, y: ev.clientY };
    downSel = selSig();
  }, { capture: true });
  function selSig() {
    try {
      var s = window.getSelection && window.getSelection();
      if (!s || s.isCollapsed) return null;
      var n = String(s).length;
      return n ? { a: s.anchorNode, ao: s.anchorOffset, f: s.focusNode, fo: s.focusOffset, n: n } : null;
    } catch (_) { return null; }
  }
  // True when the press that just ended left a non-empty selection that is not
  // the one it started with: it made or extended a selection. A press on an
  // unselectable element leaves an older selection untouched, so a selection
  // made earlier elsewhere on the page never counts.
  function pressSelected() {
    var now = selSig(), was = downSel;
    if (!now) return false;
    return !was || now.a !== was.a || now.ao !== was.ao || now.f !== was.f || now.fo !== was.fo || now.n !== was.n;
  }
  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
      // The control dismissed itself (2026-10-14). Every signal above asks
      // "did something appear or change?" A close button answers by making
      // something GO AWAY: Krush Kandy's app banner X sets display:none on its
      // own 59px banner, one style record on an ancestor that is too small for
      // ui_toggle (and 0x0 by the time it is measured) and outside ACK_ATTRS.
      // 138 working X taps a week read as dead clicks and fired an "Urgent:
      // paid ad traffic is hitting a broken page" email. An element can only be
      // clicked while it is rendered, so one that is gone now went with the
      // click.
      if (clickTargetGone(tgt)) {
        try { push("ac", { el: data.el, sec: data.sec, x: data.x, y: data.y, r: 'dismissed' }); } catch (_) {}
        return;
      }
      // No async response within window — emit as dead click.
      push("dc", data);
    }, DEFERRED_DC_WINDOW_MS);
  }
  function clickTargetGone(el) {
    if (!el || el.nodeType !== 1) return false;
    try {
      if (!document.documentElement.contains(el)) return true;   // removed
      // No boxes: it or an ancestor is display:none.
      if (el.getClientRects && el.getClientRects().length === 0) return true;
      var cs = window.getComputedStyle && window.getComputedStyle(el);
      if (cs && cs.visibility === 'hidden') return true;
    } catch (_) {}
    return false;
  }
  // 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 });
    // The click took the visitor out of the page (2026-10-14). An app-store
    // button sets location.href to apps.apple.com and iOS opens the App Store
    // without unloading Safari, so none of the navigation signals above fire;
    // window.open into a new tab is the same shape. The page going to the
    // background inside the window means the click went somewhere.
    window.addEventListener('pagehide', function () { cancelPendingDc('left_page'); }, { capture: true });
    document.addEventListener('visibilitychange', function () {
      if (document.visibilityState === 'hidden') cancelPendingDc('left_page');
    }, { 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;
    // 2026-09-25 (#75302) — ui_toggle needs a causal link to the click.
    // The attribute-toggle cancel (6a96856) fired on ANY large element whose
    // class/style/hidden changed in the click window, and storefronts have
    // carousels, sticky headers and lazy grids changing all the time: on one
    // store it ate 60,728 dead clicks in 14 days and kept 203. A panel that
    // opens BECAUSE of the click was not changing before it; a carousel was.
    // So every attribute change is noted (after the batch is judged), and a
    // toggle only cancels when its element had not changed in the
    // ATTR_CHURN_MS before. Longer than common autoplay intervals (3-7s).
    var ATTR_CHURN_MS = 12000;
    // 2026-11-02 (pixel v28) — a carousel SLOWER than ATTR_CHURN_MS was never
    // churning: harvv.com's own hero (#hv-track, 1054x640) steps on
    // setInterval(12000), right on the window, so a tick inside a dead click's
    // 1.8s turned it into ac ui_toggle even with the carousel scrolled off
    // screen. Raising the window only moves the cliff. What tells a carousel
    // from a panel is who moved it: an element that changed with no
    // pointerdown, keydown or touchstart in the SELF_INPUT_GAP_MS before was
    // moved by the page itself, and stays "churning" for ATTR_SELF_MS. A panel
    // the visitor opens and closes is moved by input, so it never lands here.
    var ATTR_SELF_MS = 60000;
    var SELF_INPUT_GAP_MS = 2000;
    var attrSeen = (typeof WeakMap === 'function') ? new WeakMap() : null;
    var attrSelf = attrSeen ? new WeakMap() : null;
    var lastInputAt = 0;
    var noteInput = function () { lastInputAt = Date.now(); };
    document.addEventListener('pointerdown', noteInput, true);
    document.addEventListener('keydown', noteInput, true);
    document.addEventListener('touchstart', noteInput, { capture: true, passive: true });
    document.addEventListener('mousedown', noteInput, true);
    function wasChurning(el, now) {
      if (!attrSeen) return true; // cannot tell: keep the dead click
      var prev = attrSeen.get(el);
      if (typeof prev === 'number' && now - prev < ATTR_CHURN_MS) return true;
      var selfAt = attrSelf.get(el);
      return typeof selfAt === 'number' && now - selfAt < ATTR_SELF_MS;
    }
    // 2026-11-03 (pixel v29) — a change the visitor cannot see is not the
    // page answering their click. harvv.com's hero schedules a chat bubble
    // 2.5s after load; with the hero scrolled away, a dead click on the
    // privacy pills landed on that first change and read as ac ui_toggle.
    // History cannot help with a first change, so this asks where it is: an
    // element wholly outside the viewport is out of view, unless it or an
    // ancestor is fixed or sticky (a drawer or chat panel sliding in starts
    // off screen and is exactly the response a click produces).
    function outOfView(el, r) {
      var vh = window.innerHeight || document.documentElement.clientHeight;
      var vw = window.innerWidth || document.documentElement.clientWidth;
      if (r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw) return false;
      for (var oe = el, oi = 0; oe && oe.nodeType === 1 && oi < 40; oe = oe.parentNode, oi++) {
        var pos = '';
        try { pos = window.getComputedStyle(oe).position; } catch (_) {}
        if (pos === 'fixed' || pos === 'sticky') return false;
      }
      return true;
    }
    function noteAttrChanges(muts, now) {
      if (!attrSeen) return;
      var selfDriven = now - lastInputAt > SELF_INPUT_GAP_MS;
      for (var ai = 0; ai < muts.length; ai++) {
        var am = muts[ai];
        if (am.type === 'attributes' && am.target && am.target.nodeType === 1) {
          try {
            attrSeen.set(am.target, now);
            if (selfDriven) attrSelf.set(am.target, now);
          } catch (_) {}
        }
      }
    }
    function dcSignalCheck(muts, nowM) {
      // 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 && !wasChurning(at, nowM) && !outOfView(at, arect)) {
                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');
      }
    }
    var dcSignalObs = new MutationObserver(function (muts) {
      var nowM = Date.now();
      try { dcSignalCheck(muts, nowM); } catch (_) {}
      noteAttrChanges(muts, nowM);
    });
    // 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);
    return true;
  }
  // #81865 (pixel v27): the 5-per-page failure cap counts only rows actually
  // sent. It used to count before the de-dupe above, so six failures of one
  // URL spent the cap and a later, different failure (a /cart/update.js 500)
  // was never recorded. nfNew() is checked first so a repeat does not pay for
  // the stack read in _initiatorFrom.
  function nfNew(u) { return !NF_SEEN['err:' + (u || '')]; }

  // #81850 (2026-09-30) — a request the PAGE cancelled is not a failure. An
  // app that supersedes its own in-flight call (AbortController, xhr.abort())
  // or a request cut off because the visitor is leaving never got an answer,
  // but nothing is broken. Fetch rejections all became nf e:1, and an aborted
  // XHR that had run past 2 s was sent as nf slow:1 with status 0, which the
  // MCP then listed as a slow success on a "network_blocked" /cart.js. On one
  // Axel Off-Road product page, /cart.js was called 12 times in 6 s and the
  // page cancelled 2 of them. Cancelled requests are their own event type, so
  // none of the ~56 queries that read nf as "failed" can count them.
  // w: 'a' = cancelled by the page, 'l' = ended while the page was unloading.
  var nxCount = 0, hvLeaving = 0, NX_SEEN = {};
  window.addEventListener('beforeunload', function () { hvLeaving = 1; });
  window.addEventListener('pagehide', function () { hvLeaving = 1; });
  function pushNx(d) {
    if (nxCount >= 5 || NX_SEEN[d.u]) return;
    NX_SEEN[d.u] = 1; nxCount++;
    push('nx', d);
  }
  // #81850 — resource type from the PATH first. Shopify's Ajax API answers
  // JSON at /cart.js, /cart/add.js, /cart/change.js, /cart/update.js,
  // /products/<handle>.js and friends. The extension test ran first, so each
  // was labelled a static asset, and "/cart.js, asset, status 0" read to every
  // consumer as a broken theme script (#46939, #47116, #47174).
  var SHOP_API_RE = /^(\/[a-z]{2}(-[a-z]{2,4})?)?\/(cart(\/(add|change|update|clear))?|products\/[^\/]+|recommendations\/products|search\/suggest)\.js(on)?$/i;
  function rtOf(path) {
    if (SHOP_API_RE.test(path) || path.indexOf("/api/") > -1 || path.indexOf("/graphql") > -1) return "api";
    if (/\.(js|css|woff2|png|jpg|svg|gif|ico)$/i.test(path)) return "asset";
    return path.indexOf("/cart") > -1 ? "api" : "page";
  }

  // `opts.t` stamps an explicit session-relative time instead of ts(); only the
  // session-roll handoff uses it (the unload it emits for the OLD session must
  // end at that session's last activity, not at the moment of the roll).
  function push(eventType, data, opts) {
    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();
    var stampT = (opts && typeof opts.t === 'number') ? Math.max(0, Math.round(opts.t)) : ts();
    queue.push({ v: vid, s: sid, e: eventType, t: stampT, 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.
    //
    // 2026-09-30 — Google's iOS and campaign keys, plus X. Where the iOS user
    // has not granted tracking, Google Ads sends wbraid/gbraid INSTEAD of a
    // gclid: measured over 30 days on Axel Off-Road, 271 of 1,708 iPhone
    // Safari sessions tagged utm_source=google + a paid medium had no gclid
    // (15.9%), vs 4 of 1,266 on mobile Chrome (0.3%). Without these keys an
    // untagged iOS Google Ads click reads as organic or direct. gad_source and
    // gad_campaignid are Google's own aggregate keys (which ad surface, which
    // campaign), not per-person ids. twclid is X Ads' click id. All of them
    // are opaque tokens minted by the ad platform; none carries a name, email
    // or anything the visitor typed. Deliberately NOT captured: mc_eid
    // (Mailchimp's per-subscriber id, which maps to an email address) and
    // igshid (identifies the Instagram user who SHARED the link, not an ad).
    var clickIds = {};
    ["gclid", "wbraid", "gbraid", "gad_source", "gad_campaignid", "fbclid", "msclkid", "ttclid", "twclid", "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: scrubReferrer(document.referrer),
      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 (_) {}
  })();

  // ── Fast clicks on a form field are editing, not rage (2026-09-28, #79273) ──
  //
  // Three clicks inside 800 ms on a text field is how a visitor selects what
  // they typed (a triple-click selects the whole line). Repeated clicks on a
  // number field's arrows or on a slider is how the value changes. A select
  // opens and closes on every click. All of these passed the rage rule (3+
  // clicks, 800 ms, 50 px), which never asked what was clicked, while the
  // dead-click path already skips text selection (looksLikeSelection above).
  // Found closing dogfood case #75223: 3 rage bursts on input[number] and
  // input[text] in Studio settings.
  //
  // Measured 7 days to 2026-09-28, events.event_type = 'rc' with data->>'el'
  // matching ^(input|textarea|select): 221 of 5,181 fleet rage clicks named a
  // form field. 119 are covered here: input[text] 60, number 24, tel 13,
  // select 8, email 7, textarea 3, url 2, range 1, password 1 (17 of the 27
  // sites with any rage click). On these controls a burst is the normal way
  // to use them, so reporting it to a customer as frustration was a false
  // alarm.
  //
  // Radio (90) and checkbox (12) are NOT exempt. A second click on a swatch
  // that is already selected changes nothing, which is the "did it take?"
  // moment rage exists to catch. Custom +/- quantity buttons are buttons and
  // stay on the normal rule too.
  //
  // contenteditable is included because it is a text field in all but name
  // (comment boxes, rich editors); isContentEditable is true on every node
  // inside the editable host. Date and time inputs are included because
  // clicking one selects a segment (month, day, year) for typing: 0 rage
  // clicks in the window, listed so a date field cannot become the next case.
  var RAGE_EDIT_INPUT = /^(text|search|email|tel|url|password|number|range|date|time|datetime-local|month|week)$/;
  function isFieldEdit(el) {
    if (!el || !el.tagName) return false;
    var tag = el.tagName;
    if (tag === "TEXTAREA" || tag === "SELECT" || tag === "OPTION") return true;
    // el.type is the browser's normalised type: lower case, and "text" for a
    // missing or unknown type attribute (the same value eid() records).
    if (tag === "INPUT") return RAGE_EDIT_INPUT.test(String(el.type || "text").toLowerCase());
    return el.isContentEditable === true;
  }

  // ── One press on a label is one click, not two (2026-09-28, #80866) ──
  //
  // A click on a <label>, or on anything inside one, makes the browser fire a
  // second click of its own on the label's control: the input the label
  // wraps, or the one its for= names. The visitor pressed once and the page
  // sees two clicks at the same spot. We counted both, so every label press
  // was two ix, and two real taps on a swatch label were four clicks, which
  // the rage rule (3 in 800 ms within 50 px) reports as a rage click. Seen in
  // headless Chrome while fixing #79273: two clicks on a swatch label, four
  // click events, one rc.
  //
  // Measured 7 days to 2026-09-28 15:50 UTC (events, event_type 'ix', per
  // site, received_at bounded): 7,159 of 629,469 fleet ix (1.1%) were an
  // echo, a click on a form control at the same x/y as the click before it,
  // on a different element, within 50 ms. 6,991 of them followed a press on
  // a label (4,190) or on a span or image inside one (2,801, mostly Shopify
  // span.swatch-color). By control: radio 6,068 (10% of all radio ix),
  // checkbox 682 (46% of all checkbox ix), text-like inputs 362. 20 sites,
  // 2,590 sessions. Replaying the rage rule over the ix of every session
  // with an rc reproduces 5,140 of the 5,147 rage clicks observed; without
  // the echo it gives 201 fewer (4.0%): on labels 70 to 6, on radios 79 to
  // 15, on checkboxes 12 to 1, on elements inside labels 62 fewer, on 10
  // sites. Buttons unchanged.
  //
  // How the echo is recognised: it arrives inside the same task as the label
  // click (the browser dispatches it from the label's activation, before any
  // timer can run), its target is label.control, and it carries the label
  // click's clientX/clientY. Seen in Chrome; WebKit and Firefox copy the
  // coordinates too, by their source, and an engine that did not would only
  // fall back to counting both, as before v8. isTrusted is true on the echo,
  // and its detail is 0 in Chrome but the click count in Firefox, so neither
  // can tell it apart. A keyboard Space on a checkbox is a detail-0 click
  // too, and it must still count.
  //
  // The label press is the one we keep: it is what the visitor saw and
  // pressed (a swatch label usually hides its radio), and its position and
  // section are the ones that belong in ix, dc and rc. A click on the control
  // itself is unchanged, and so is the browser's behaviour: nothing here
  // cancels or stops the echo, it is only left out of our counts.
  var labelEcho = null;
  function labelControl(el) {
    var p = el, depth = 8;
    while (p && depth--) {
      if (p.tagName === "LABEL") {
        // label.control is the browser's own answer (null when for= names
        // nothing labelable). Engines without it: for= first, else the first
        // form control inside, which is the rule label.control follows.
        if ("control" in p) return p.control;
        if (p.htmlFor) return document.getElementById(p.htmlFor);
        return p.querySelector ? p.querySelector("button,input,select,textarea") : null;
      }
      p = p.parentElement;
    }
    return null;
  }
  function isLabelEcho(ev, tgt) {
    var e = labelEcho;
    if (!e || tgt !== e.c || ev.clientX !== e.x || ev.clientY !== e.y) return false;
    labelEcho = null;
    return true;
  }
  function armLabelEcho(ev, tgt) {
    var ctl = labelControl(tgt);
    // A click on the control itself (or inside it) gets no echo.
    if (!ctl || ctl === tgt || (ctl.contains && ctl.contains(tgt))) return;
    var armed = labelEcho = { c: ctl, x: ev.clientX, y: ev.clientY };
    // The echo is dispatched before this timer can run. Once it has run, no
    // echo is coming (the site cancelled the label click, or the press was on
    // a link inside the label), so nothing later can be mistaken for one.
    setTimeout(function () { if (labelEcho === armed) labelEcho = null; }, 0);
  }

  // --- 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;
  // #79522: the gap to the previous click is measured against sessionStart;
  // after a roll the previous click belongs to another session, so there is
  // no gap (same as the first click of a page).
  onSessionRoll(function () { lastIx = 0; clicks = []; });
  // 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;
  // #81907 (2026-09-30): CAPTURE phase. In the bubble phase a page handler that
  // calls stopPropagation() hides the click from us entirely. Impulse's drawer
  // openers do: on J West, 0 ix rows on the Filter and menu buttons in 6 days
  // while 60 and 171 sessions tapped inside the drawers they open, and two
  // "never tapped" findings were filed on that silence. Nothing below reads
  // state a page handler sets: the dead-click verdict is deferred and cancels
  // through clickInFlight, which was already set in the capture phase.
  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);
    // #80866: the click a label fires on its own control is the same press
    // as the label click just recorded. It is not a second ix, it does not
    // join a rage burst, and it gets no dead-click check.
    if (isLabelEcho(ev, tgt)) return;
    armLabelEcho(ev, tgt);
    var elId = eid(tgt), cx = ev.clientX, cy = ev.clientY;
    lastClickEid = elId; lastClickAt = Date.now();

    // #79522: if this click ends a stale session, roll BEFORE reading the
    // page-level click clock. push() rolls too, but by then `delta` below
    // would already hold a 31-minute gap measured against the old session
    // and be stamped with the new one.
    rollSessionIfStale();
    // 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, s: pressSelected() });
    // 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;
      });
      // #79273: a burst that lands on a text field, stepper or select is
      // selecting or changing a value, not rage. The burst is consumed, so
      // these clicks cannot count toward a burst on whatever sits beside the
      // field (its unit label, a wrapper) inside the 50 px radius. The ix
      // events above still record every click.
      if (clustered && isFieldEdit(tgt)) {
        clicks = [];
      } else if (clustered && !isInteractive(tgt) && clicks.some(function (c) { return c.s; })) {
        // #74200 (2026-10-01): selecting text is not rage either. A physical
        // triple-click is three clicks at one point inside 800 ms: the second
        // selects a word, the third the paragraph, so every visitor who
        // selected body copy, a price or a command in a code block this way
        // emitted rc (top rage elements on one docs site were a code block and
        // body copy). A burst on a NON-interactive target in which a press made
        // or extended a selection is consumed, like the field-edit burst above.
        // Buttons, links and other controls stay on the rule even when Chrome
        // selects their label, because a missed rage click on a control is the
        // worse error. An unselectable dead element still rages: its presses
        // change no selection, including an older one elsewhere on the page.
        clicks = [];
      } else 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 = [];
      }
    }
  }, true);

  // --- 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]';
  // #79526: begin_checkout ('co') is a click on the way INTO checkout: the cart
  // or drawer checkout button, a checkout link, or an express button (Shopify
  // "Buy it now" / Shop Pay, whose shadow DOM retargets the click to its host
  // element). It used to be a FOCUS on a coupon box, so on Axel the funnel's
  // "began checkout" was coupon-box use: 207 sessions against 354 web orders,
  // 189 of 250 session-linked buyers never fired it, and on /cart all 38 'co'
  // sessions were coupon focuses. Coupon focus is now its own kind, 'cu', with
  // no GA4 name, so no funnel step counts it.
  var CHECKOUT_SEL = 'button[name="checkout"],input[name="checkout"],a[href$="/checkout"],a[href$="/checkout/"],'
    + 'a[href*="/checkout?"],a[href*="/checkouts/"],.checkout-button,.shopify-payment-button,'
    + 'shopify-accelerated-checkout,shopify-accelerated-checkout-cart';
  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 }); }
    var co = t.closest(CHECKOUT_SEL);
    if (co) { emitCm('dom', 'co', { el: eid(co) }); }
  }, true);
  document.addEventListener('focusin', function (e) {
    var t = e.target;
    if (t && t.matches && t.matches(COUPON_SEL)) emitCm('dom', 'cu', { 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;
  // #79522 — a scroll sample faster than this is a PROGRAMMATIC JUMP: scroll
  // lock (position:fixed on body zeroes scrollTop, then restores it — how iOS
  // drawers and menus lock scroll), scrollTo, anchor jumps, scroll
  // restoration. 70 px/ms is 3,500 px in one 50 ms sample; a finger flick
  // peaks near 10 px/ms. 2,596 of 3,123 rows above 50 px/ms in three days
  // were mobile Safari, and the fleet histogram decays smoothly to a flat
  // plateau above ~35 px/ms. Jumps are counted, never averaged.
  var JUMP_PX_PER_MS = 20;
  onSessionRoll(function () { scrollReversals = 0; scrollVelocityMaxPxs = 0; scrollLastDir = 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;
  // #79522: the new session's peak scroll starts where the page is now, not
  // at the old session's peak.
  onSessionRoll(function () { curScrollPctMax = curScrollPct > 100 ? 100 : curScrollPct; });
  // 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).
      // 2026-09-28 (#79582): measured across at least 50 ms on a sub-ms clock,
      // like the kinematics handler. Two frames 1-3 ms apart on Date.now()
      // made |dy|/dt read 20 px as 20,000 px/s: sv.vp sat on a grid of
      // k*1000, k*500, k*333 and 10.5% of v5 rows were exact multiples of
      // 1000. Below 50 ms the baseline is kept so the next frame measures the
      // whole window; a gap of a second or more restarts it.
      var nowT = (window.performance && performance.now) ? performance.now() : Date.now();
      var dtS = nowT - scrollLastT;
      if (!scrollLastT || dtS >= 1000 || dtS < 0) {
        scrollLastY = scrollTop; scrollLastT = nowT;
      } else if (dtS >= 50) {
        var dy = scrollTop - scrollLastY;
        var pxs = Math.abs(dy) / dtS * 1000;
        // #79522: above JUMP_PX_PER_MS the page moved, not the finger (see
        // the kinematics handler below). Not a velocity, not a reversal.
        if (pxs <= JUMP_PX_PER_MS * 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() {
    rollSessionIfStale(); // #79522: page-level scroll totals belong to the session they land in
    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;

  // 2026-09-26 (#79521) — PAGE-LOAD IDENTITY AND EXITS THAT ARRIVE.
  //
  // `ul` is per page LOAD, not per page: a visitor who goes back to a page
  // they already scrolled gets a second load, and its exit says sm=0 (J West,
  // session 2cd697: five loads of two product pages, the last one eg:0 sm:0
  // after loads that reached 87%). Readers that join exits to (session,
  // page) read that as "left without scrolling". Every ul now carries `ld`,
  // an id for the load, so a reader can count loads and take MAX(sm) per
  // (session, page). A bfcache restore is a new load for this purpose.
  //
  // Half of human sessions have NO ul at all (16,888 of 32,573 in three
  // days, desktop and mobile alike): the pagehide beacon of the LAST page is
  // the one delivery nothing can retry, since the retry queue drains on a
  // next page load that never comes. 5,111 of those sessions did deliver a
  // hidden `vc`, so the exit summary is now ALSO sent on visibilitychange ->
  // hidden, flagged hv:1, at most once per 30 s per load. The receiver's
  // session upsert takes GREATEST for engagement and duration, so several
  // ul rows per load are safe there; readers count loads by `ld`, not rows.
  var loadId = Math.random().toString(36).slice(2, 8);
  var ulHiddenAt = 0;
  try {
    addEventListener('pageshow', function (e) {
      if (e && e.persisted) { loadId = Math.random().toString(36).slice(2, 8); ulHiddenAt = 0; }
    }, { passive: true });
  } catch (_) {}
  function emitHiddenUnload() {
    var now = Date.now();
    if (now - ulHiddenAt < 30000) return;
    ulHiddenAt = now;
    push("ul", { eg: totalEngage, sm: curScrollPctMax, ld: loadId, hv: 1 });
  }

  // #79522 — HIDDEN SPANS. Seven of the ten impossible readings in the audit
  // were durations that ran across a period the page was hidden, frozen or
  // asleep: Event Timing (next paint after the visitor came back, 82 of 139
  // rows over 60 s), network timing (beacons fired on backgrounding that
  // settled on thaw, 323 rows over 10 min), paint timing (first paint when
  // the tab was first shown, every LCP over 60 s), engaged time. The clock
  // ran; nobody was there. This tracks when the page was hidden so a
  // measurement can refuse to report across such a span. performance.now()
  // throughout, the same clock the observers use. Chrome's visibility-state
  // entries cover the time before this script ran.
  var _hidInit = !!document.hidden;
  var _firstHidAt = _hidInit ? 0 : -1, _lastHidAt = _hidInit ? 0 : -1, _lastShownAt = _hidInit ? -1 : 0;
  try {
    var _vsE = performance.getEntriesByType('visibility-state') || [];
    for (var _vi = 0; _vi < _vsE.length; _vi++) {
      if (_vsE[_vi].name === 'hidden') {
        var _st = _vsE[_vi].startTime;
        if (_firstHidAt < 0 || _st < _firstHidAt) _firstHidAt = _st;
        if (_st > _lastHidAt) _lastHidAt = _st;
      } else if (_vsE[_vi].name === 'visible' && _vsE[_vi].startTime > _lastShownAt) _lastShownAt = _vsE[_vi].startTime;
    }
  } catch (_) {}
  // Was the page hidden at any point before time t (performance.now() ms)?
  function hiddenBefore(t) { return _firstHidAt >= 0 && _firstHidAt <= t; }
  // Does any hidden period intersect [a, b]? Approximate from first/last
  // hide and last show; catches the shapes measured in the audit.
  function hiddenWithin(a, b) {
    if (_lastHidAt >= a && _lastHidAt <= b) return true;
    if (_firstHidAt >= a && _firstHidAt <= b) return true;
    if (_lastHidAt >= 0 && _lastHidAt <= a && _lastShownAt < _lastHidAt) return true; // hidden at a, not shown since
    // 2026-09-28 (#79580): a request that starts after the pixel's own hide
    // handler ran and settles after the page is shown again failed every
    // clause above. A transition to visible inside (a, b] means the page was
    // hidden just before it, so a hidden span sits inside the window: 27 of
    // 31 v5 nf rows missing ms settled within 3.4 s of a became-visible event.
    if (_lastShownAt > a && _lastShownAt <= b) return true;
    return false;
  }

  // Session roll: bank this session's engaged time UP TO ITS LAST ACTIVITY
  // (the idle tail that caused the roll is not engagement) as a handoff
  // unload stamped with the old ids, then start the new session at zero.
  onSessionRoll(function (old) {
    var banked = totalEngage;
    if (engageStart && old.lastActivity > engageStart) banked += old.lastActivity - engageStart;
    push("ul", { eg: banked, sm: curScrollPctMax, ld: loadId, ro: 1 }, { t: old.lastActivity - old.sessionStart });
    totalEngage = 0;
    engageStart = document.hidden ? 0 : old.now;
  });

  document.addEventListener("visibilitychange", function () {
    rollSessionIfStale(); // #79522: see pagehide
    if (document.hidden) {
      _lastHidAt = performance.now();
      if (_firstHidAt < 0) _firstHidAt = _lastHidAt;
      // Tab hidden — bank the time
      if (engageStart) totalEngage += Date.now() - engageStart;
      engageStart = 0;
      emitHiddenUnload(); // #79521: the exit summary the pagehide beacon may never deliver
      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 {
      _lastShownAt = performance.now();
      // 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 shSnapT = null, shSnaps = 0;
  var io = new IntersectionObserver(function (entries) {
    var shIn = false;
    entries.forEach(function (entry) {
      entry.isIntersecting ? visibleSet.add(entry.target) : visibleSet.delete(entry.target);
      if (entry.isIntersecting && entry.target._pxs) shIn = true;
    });
    // v20: a control inside an open shadow root coming on screen (a drawer
    // sliding in, its contents scrolled) is a snapshot moment, because the
    // page-level scroll marks never fire for a scroll inside a drawer. At
    // most 8 a page, settled 1.2 s after the last such entry.
    if (shIn && shSnaps < 8) {
      clearTimeout(shSnapT);
      shSnapT = setTimeout(function () { shSnaps++; snapViewport("sh"); }, 1200);
    }
  });

  // Observe all interactive elements under a root
  var VS_SEL = "a,button,input,select,textarea,[role=button],[tabindex]";
  // v20: a tabindex="-1" container (a drawer or dialog wrapper that scripts
  // focus) is not a control a visitor can see or tap; listing it put
  // div#CartPopup in 861 J West snapshots while the drawer was shut.
  function vsSkip(el) {
    return el.getAttribute('tabindex') === '-1' && !/^(A|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(el.tagName) && el.getAttribute('role') !== 'button';
  }
  function observeInteractive(root) {
    (root || document).querySelectorAll(VS_SEL).forEach(function (el) {
      if (!el._pxo && !vsSkip(el)) { el._pxo = 1; io.observe(el); }
    });
    if (!root) observeShadowRoots();
  }

  // 2026-10-04 (pixel v18) — controls inside OPEN shadow roots. querySelectorAll
  // stops at a shadow boundary, so a cart drawer, upsell or popup that an app
  // renders inside a custom element (UpCart, Rebuy, Shopify Forms, Redo) was
  // never observed: 86,066 snapshots on J West & Co, zero naming the drawer,
  // while taps inside it were recorded (clicks compose out of a shadow root).
  // "Seen but not tapped" could not be measured for any shadow-root widget.
  // Hosts are found with the same walk the panel tracker uses (cap 8,000
  // nodes, refreshed at most every 4 s); each root gets the mutation observer
  // too, because a drawer's content renders only when it opens and the light
  // DOM sees none of it. Elements observed this way carry _pxs so a snapshot
  // can list them under their own cap instead of being crowded out by the
  // page behind the drawer.
  var shHosts = [], shHostsAt = 0, shRootsObserved = 0;
  function observeShadowRoots() {
    try {
      if (Date.now() - shHostsAt > 4000) {
        shHostsAt = Date.now(); shHosts = [];
        var all = document.getElementsByTagName('*');
        for (var i = 0; i < all.length && i < 8000; i++) if (all[i].shadowRoot) shHosts.push(all[i]);
      }
      for (var h = 0; h < shHosts.length && h < 40; h++) {
        var sr = shHosts[h].shadowRoot;
        if (!sr) continue;
        if (!sr._pxm && vsMo && shRootsObserved < 20) {
          sr._pxm = 1; shRootsObserved++;
          try { vsMo.observe(sr, { childList: true, subtree: true }); } catch (_) {}
        }
        sr.querySelectorAll(VS_SEL).forEach(function (el) {
          if (!el._pxo && !vsSkip(el)) { el._pxo = 1; el._pxs = 1; io.observe(el); }
        });
      }
    } catch (_) {}
  }

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

  // Take a viewport snapshot — reads the running Set. Page elements and
  // shadow-root elements are listed under separate caps of 20 (v18), page
  // first; before v18 the single cap of 20 applied to the page alone.
  function snapViewport(trigger) {
    var els = [], sh = [];
    visibleSet.forEach(function (el) {
      if (el._pxs) { if (sh.length < 20) sh.push(eid(el)); }
      else if (els.length < 20) els.push(eid(el));
    });
    push("vs", { tr: trigger, el: sh.length ? els.concat(sh) : els });
  }

  // v18: a panel opening is a snapshot moment. The panel tracker calls this
  // once per panel it sees open (at most 10 a page): re-observe so the
  // panel's freshly rendered controls join the running Set, give the
  // IntersectionObserver a tick to report them, then snapshot.
  var pnSnaps = 0;
  function snapOnPanel() {
    if (pnSnaps++ >= 10) return;
    setTimeout(function () {
      try { observeInteractive(); } catch (_) {}
      setTimeout(function () { snapViewport("pn"); }, 500);
    }, 300);
  }

  // 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;
  }
  // Two covers that do not make a control unclickable (ClickUp 17thgbzueur,
  // Prime MD Plus 2026-09-29: every og row on the site for 14 days named its
  // fixed Elementor header). Both are flagged, never dropped, so the server
  // decides and the rows stay countable.
  //  hdr: the cover is a bar pinned to the top edge (a sticky site header, the
  //       WP admin bar) and the control sits below that bar in the document,
  //       so scrolling up brings it out. A control under a transparent header
  //       at the very top of the page can never be scrolled out: no flag.
  //  dup: another link with the same href is reachable at its centre right
  //       now (the header's own copy of a phone or booking CTA).
  function ocBarReachable(top, r, W, H) {
    for (var d = 8; top && top.nodeType === 1 && d--; top = top.parentElement) {
      if (getComputedStyle(top).position !== "fixed") continue;
      var b = top.getBoundingClientRect();
      return b.top <= 2 && b.width >= W * 0.9 && b.height <= H * 0.4 &&
        r.top + _scrollTop() >= b.bottom - 1;
    }
    return false;
  }
  function ocDupReachable(el, els, W, H) {
    var h = el.href;
    if (!h || el.getAttribute("href").charAt(0) === "#") return false;
    for (var j = 0; j < els.length; j++) {
      var c = els[j];
      if (c === el || c.href !== h) continue;
      var q = c.getBoundingClientRect();
      if (q.width < 8 || q.height < 8 || q.bottom <= 0 || q.top >= H || q.right <= 0 || q.left >= W) continue;
      var t = document.elementFromPoint(Math.round(q.left + q.width / 2), Math.round(q.top + q.height / 2));
      if (t && (t === c || c.contains(t))) return true;
    }
    return false;
  }
  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
        // The covered control must itself be visible. Elementor's sticky header
        // leaves a visibility:hidden copy of itself in the page
        // (.elementor-sticky__spacer, 39 links on Prime MD Plus, its phone and
        // BOOK ONLINE included) directly under the fixed original. That copy
        // can never be clicked, covered or not; it was most of the site's og
        // rows (ClickUp 17thgbzueur, reproduced live 2026-09-29).
        if (getComputedStyle(el).visibility === "hidden") continue;
        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,
          hdr: ocBarReachable(top, r, W, H) ? 1 : 0, dup: ocDupReachable(el, els, W, H) ? 1 : 0 });
      }
    } 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();
  var ttfiPending = true; // the current session has not reported its tf yet
  function disarmFirstInteraction() {
    document.removeEventListener("click", onFirstInteraction, true);
    document.removeEventListener("keydown", onFirstInteraction, true);
    document.removeEventListener("scroll", onFirstInteraction, true);
  }
  function onFirstInteraction() {
    disarmFirstInteraction();
    // #79522: if THIS interaction is what ends a stale session, roll first so
    // the roll hook below resets ttfiStart; the value then belongs to the
    // session the event is stamped with. Before this, the value was computed
    // against the page load and pushed under the new session id: 113 of 115
    // rows over an hour exceeded their session's age.
    rollSessionIfStale();
    ttfiPending = false;
    push("tf", { tf: Math.round(performance.now() - ttfiStart) });
  }
  function armFirstInteraction() {
    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 });
  }
  armFirstInteraction();
  // After a roll the next interaction is the new session's first, measured
  // from the roll. If the session already reported its tf, re-arm; if the
  // roll is happening inside onFirstInteraction (ttfiPending still true), that
  // handler is about to push the new session's tf itself.
  onSessionRoll(function () {
    ttfiStart = performance.now();
    if (!ttfiPending) { ttfiPending = true; armFirstInteraction(); }
  });

  // --- 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;
  var jumps = 0, jumpDist = 0;           // #79522: programmatic jumps, kept apart
  onSessionRoll(function () {
    prevScrollY = _scrollTop(); prevScrollT = performance.now();
    scrollDir = 0; reversals = 0; totalDist = 0; minVel = Infinity; maxVel = 0;
    velSamples = 0; velSum = 0; skimDist = 0; readDist = 0; pauses = []; jumps = 0; jumpDist = 0;
  });

  // 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
      // #79522: a jump (see JUMP_PX_PER_MS) is the page moving under the
      // reader. It used to set maxVel to 70-100 px/ms and put its whole
      // distance into skimDist (3,114 of 3,123 such rows had skim >= half the
      // page). Counted on its own; nothing else sees it.
      if (vel > JUMP_PX_PER_MS) {
        jumps++; jumpDist += Math.abs(dy);
      } else {
        if (vel > 0.01) { // 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).

  // 2026-09-25 — which popup, if any, holds this element. 'elementor' for an
  // Elementor Pro popup, 'dialog' for a <dialog>, 'modal' for any other
  // role=dialog / aria-modal container. Tagged as `pu` on fs, fa and gf, so a
  // form in a popup is told apart from the same form on the page (a popup
  // form hidden in the DOM made a homepage look like it "had a form").
  // `pu` is a contract field the server reads; do not rename it.
  function popupKind(el) {
    var kind = null;
    // parentAcross (#81849): a form in a shadow root inside a light-DOM dialog.
    for (var d = 0; el && el.nodeType === 1 && d < 40; d++, el = parentAcross(el)) {
      var c = typeof el.className === 'string' ? el.className : '';
      if (/(^|\s)elementor-(popup-modal|location-popup)(\s|$)/.test(c) || el.getAttribute('data-elementor-type') === 'popup') return 'elementor';
      if (!kind && el.tagName === 'DIALOG') kind = 'dialog';
      if (!kind && (el.getAttribute('role') === 'dialog' || el.getAttribute('aria-modal') === 'true')) kind = 'modal';
    }
    return kind;
  }

  // 2026-09-25 — ONE place that records a form submit (primemdplus.com
  // postmortem: 18 sessions reached the thank-you page, 2 fs recorded, 12
  // false fa). Gravity Forms 2.9 never fires a `submit` event: its button
  // cancels the CLICK, waits for the reCAPTCHA v3 token, then calls
  // jQuery(form).trigger('submit'), which ends in the native form.submit(),
  // and form.submit() dispatches no event by spec (its AJAX method fetches
  // admin-ajax.php instead). So a submit is recorded from three places: the
  // submit event, form.submit() itself, and a click on a Gravity Forms submit
  // button. One attempt can pass through two of them (a cancelled submit
  // event, then form.submit() once the token is back), so one form records
  // at most one fs per 8 s. submitHooks lets the abandonment tracker hear the
  // same submit, which is what stops a submit from also reading as an fa.
  var submitHooks = [];
  // Set by the form tracker below (it owns the field helpers): the form key
  // (`fk`) for a form, and the one-per-form structure census (`fx`).
  var hvFormKeyOf = null, hvFormCensus = null;
  function recordSubmit(form, btn) {
    try {
      if (!form || form.tagName !== 'FORM') return;
      var now = Date.now();
      if (form.__hvFs && now - form.__hvFs < 8000) return;
      form.__hvFs = now;
      var action = form.getAttribute('action') || location.pathname;
      var url; try { url = new URL(action, location.origin); } catch (_) { url = null; }
      var fsd = { p: url ? templatize(url.pathname) : templatize(action), m: (form.getAttribute('method') || 'POST').toUpperCase(), t: 'form' };
      // Gravity Forms: gf = form id; gs = 1 when this submit is a page step.
      // The button says so (GF's own data-submission-type or class); without
      // a button, GF sets the target page to 0 only for the final submit.
      var gm = form.id ? /^gform_(\d{1,6})$/.exec(form.id) : null;
      if (gm) {
        fsd.gf = gm[1];
        var st = btn && btn.getAttribute ? (btn.getAttribute('data-submission-type') || '') : '';
        var bc = btn && typeof btn.className === 'string' ? btn.className : '';
        if (st === 'next' || st === 'previous' || /gform_(next|previous)_button/.test(bc)) fsd.gs = 1;
        else if (!btn) {
          var tp = document.getElementById('gform_target_page_number_' + gm[1]);
          if (tp && tp.value && tp.value !== '0') fsd.gs = 1;
        }
      }
      var pu = popupKind(form);
      if (pu) fsd.pu = pu;
      if (hvFormKeyOf) { var fk = hvFormKeyOf(form); if (fk) fsd.fk = fk; }
      var sh = hostSig(form); if (sh) fsd.sh = sh;
      push('fs', fsd);
      for (var i = 0; i < submitHooks.length; i++) { try { submitHooks[i](form); } catch (_) {} }
    } catch (_) {}
  }

  (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;
      // A honeypot (hidden bot-trap field) is not a field a person fills in.
      if (isHoneypot(el)) return null;
      // Honor private container, across shadow boundaries (#81849): a
      // data-harvv-private wrapper around a widget covers its shadow fields.
      var p = el;
      for (var i = 0; i < 20 && p; i++) {
        if (p.getAttribute && (p.getAttribute('data-harvv-private') === 'true')) return null;
        p = parentAcross(p);
      }
      // 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).
    // 2026-09-25 (#73549). The field's container: its <form> when it has
    // one, else the nearest ancestor (up to 8 levels) holding 2+ fields.
    // Lead forms built without a <form> element (React, page builders) had no
    // position on 59% of abandon rows and a field count of 1, because every
    // helper below keyed on el.form.
    //
    // 2026-09-28 (#73549, pixel v9). Two shapes still left fp empty on 666 of
    // 7,147 fa rows (3 days, v5+). (1) 492 rows, 5 Shopify sites: the field is
    // tied to its form by a form="..." attribute and sits OUTSIDE the <form>
    // element (Dawn-style variant radios), so el.form is set but a query inside
    // the form never finds it. form.elements lists attribute-tied fields too,
    // in document order, so a <form> is now read through it. (2) 174 rows,
    // 7 sites: a field with no other field within 8 levels (newsletter box,
    // search box, a lone variant select); its position is 1 of 1, sent with
    // fm:'s' so it is never mistaken for the first field of a longer form.
    // Hidden and button-type inputs are not fields a visitor fills in, so
    // they no longer take a position or count toward fc (a form that opens
    // with hidden inputs gave its first visible field a position past 1).
    // fm: absent = a real <form>; 'c' = a formless container; 's' = standalone.
    var NOT_FIELD = /^(hidden|submit|button|reset|image)$/;
    function fieldsIn(box) {
      var out = [];
      var src = box.tagName === 'FORM' ? box.elements : null;
      // A control named "elements" shadows form.elements; fall back to a query.
      if (!src || src.tagName || typeof src.length !== 'number') src = box.querySelectorAll('input, textarea, select');
      for (var i = 0; i < src.length; i++) {
        var t = src[i].tagName;
        if ((t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') && !NOT_FIELD.test(String(src[i].type || '').toLowerCase()) && !isHoneypot(src[i])) out.push(src[i]);
      }
      return out;
    }
    function fieldContainer(el) {
      try {
        if (el.form) return el.form;
        var p = el.parentElement;
        for (var d = 0; d < 8 && p && p !== document.body; d++) {
          if (fieldsIn(p).length >= 2) return p;
          p = p.parentElement;
        }
      } catch (_) {}
      return null;
    }

    // { p: position, m: method } or null. null only when the field is not
    // among its container's fields (a hidden or button-type input).
    function fieldPosition(el) {
      try {
        var box = fieldContainer(el);
        if (!box) return NOT_FIELD.test(String(el.type || '').toLowerCase()) ? null : { p: 1, m: 's' };
        var fields = fieldsIn(box);
        for (var i = 0; i < fields.length; i++) {
          if (fields[i] === el) return { p: i + 1, m: box.tagName === 'FORM' ? undefined : 'c' };
        }
      } 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 = fieldContainer(el) || 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-29 — which form, which step, how the field was left ───────
    // trynina.co: 66 visitors opened a three-step signup popup built from
    // <div>s, and the field events could not say which form a field was in,
    // which step it was on, whether it was left empty, or whether an error
    // was showing when the visitor quit. Every answer took hand-written SQL.
    // Every fi/fa (and fs) now carries:
    //   fk      form key: data-harvv-form, else the form's or dialog's id,
    //           else its first class, else form[action path]. Never text.
    //   st/sn   step N of M: visible "Step 2 of 3" text inside the form, else
    //           the position of the visible field panel among hidden sibling
    //           panels. Absent when the form has no steps.
    //   fl      1 when the field holds a value (checked, chosen, typed), else 0.
    //   lb      length bucket of a filled text field: 1 = 1-3 characters,
    //           2 = 4-20, 3 = 21+. Only the LENGTH is read; the value itself
    //           is never put in a payload, compared or kept.
    //   er      1 when the field is showing an error (aria-invalid, or an
    //           error class on the field or its wrapper).
    // A honeypot (hidden bot-trap field) is no longer a field: it took a
    // position and a count, and bots filling it read as visitors typing.
    // Text-type fields only: a checkbox marked aria-hidden is a real field
    // with a custom visual (Nina's "I agree" box), not a trap.
    var HP_RE = /honeypot|(^|[_-])hp([_-]|$)|[a-z]Hp$|^hp[A-Z_-]/;
    function isHoneypot(el) {
      try {
        if (el.tagName !== 'TEXTAREA' && !/^(text|email|url|tel|)$/.test(String(el.type || '').toLowerCase())) return false;
        if (el.getAttribute('aria-hidden') === 'true') return true;
        var parts = (String(el.name || '') + ' ' + String(el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : '')).split(/\s+/);
        for (var i = 0; i < parts.length; i++) if (parts[i] && HP_RE.test(parts[i])) return true;
        return el.getAttribute('tabindex') === '-1' && String(el.getAttribute('autocomplete') || '').toLowerCase() === 'off';
      } catch (_) { return false; }
    }
    function shown(el) {
      try { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); } catch (_) { return false; }
    }
    function isDialog(el) {
      return el.tagName === 'DIALOG' || el.getAttribute('role') === 'dialog' || el.getAttribute('aria-modal') === 'true';
    }
    function cleanId(id) {
      id = String(id || '');
      return /^[A-Za-z][\w-]{0,39}$/.test(id) && !/\d{5,}|[0-9a-f]{8}-/i.test(id) ? id : '';
    }
    // The element that holds the whole form: its <form>, else the nearest
    // [data-harvv-form] or dialog, else the widest ancestor that still holds
    // no nav/header/footer/main and no <form> (a multi-step form's panels
    // and its Continue button sit in different branches). Must hold fields.
    function formRootOf(el) {
      try {
        if (!el || el.nodeType !== 1) return null;
        if (el.__hvRoot !== undefined) return el.__hvRoot;
        var root = el.form || null;
        if (!root) {
          var best = null;
          for (var p = el.parentElement, d = 0; p && d < 15; d++, p = p.parentElement) {
            if (p.getAttribute('data-harvv-form') || isDialog(p)) { best = p; break; }
            if (/^(BODY|HTML|MAIN|ARTICLE)$/.test(p.tagName) || p.querySelector('nav, header, footer, main, form') || fieldsIn(p).length > 60) break;
            best = p;
          }
          root = best && fieldsIn(best).length ? best : null;
        }
        el.__hvRoot = root;
        return root;
      } catch (_) { return null; }
    }
    function formKeyOf(root) {
      try {
        if (!root) return undefined;
        if (root.__hvFk) return root.__hvFk;
        var tag = root.tagName.toLowerCase(), hf = root.getAttribute('data-harvv-form');
        // getAttribute, not .id/.className: a <form> holding a field named
        // "id" (every Shopify product form) returns that FIELD from form.id.
        var id = cleanId(root.getAttribute('id')) || (tag === 'form' ? cleanId(root.getAttribute('name')) : '');
        var c = cleanId(String(root.getAttribute('class') || '').replace(/^\s+/, '').split(/\s+/)[0]);
        var k, sh = hostSig(root);
        if (hf) k = String(hf).slice(0, 40);
        else if (id) k = tag + '#' + id;
        else if (c) k = tag + '.' + c;
        else if (sh) k = tag;
        else if (tag === 'form') {
          var u = ''; try { u = new URL(root.getAttribute('action') || location.pathname, location.href).pathname; } catch (_) {}
          k = 'form' + (u ? '[' + templatize(u).slice(0, 40) + ']' : '');
        } else k = tag;
        // A form inside a widget's shadow root is keyed by its widget
        // (shopify-forms-embed>form), not by the page it happened to open on.
        if (sh && !hf) k = (sh + '>' + k).slice(0, 100);
        root.__hvFk = k;
        return k;
      } catch (_) { return undefined; }
    }
    hvFormKeyOf = formKeyOf;
    var STEP_RE = /\bstep\s*(\d{1,2})\s*(?:of|\/)\s*(\d{1,2})\b/i;
    function stepOf(root, el) {
      try {
        if (!root) return null;
        var s = null;
        if (document.createTreeWalker) {
          var w = document.createTreeWalker(root, 4, null, false), t;
          for (var i = 0; i < 400 && (t = w.nextNode()); i++) {
            var m = STEP_RE.exec(t.nodeValue || '');
            if (m && t.parentNode && shown(t.parentNode) && +m[1] >= 1 && +m[1] <= +m[2] && +m[2] <= 20) { s = { st: +m[1], sn: +m[2] }; break; }
          }
        }
        // No step text: the one visible panel among sibling panels that each
        // hold real fields, and only when the form has a wizard button (Next,
        // Continue, Back, Previous, Step). v10 took any hidden sibling as a
        // step: Shopify variant pickers read "step 4 of 7" and a search box
        // with a hidden suggestions panel "step 1 of 3" (tigerfriday.com,
        // first minutes of v10). A panel is a container, never a field, and a
        // wizard shows exactly one panel at a time.
        if (!s && el && hasWizardButton(root)) {
          for (var a = el.parentElement, d = 0; !s && a && a !== root && a.parentElement && d < 12; a = a.parentElement, d++) {
            var n = 0, idx = 0, vis = 0;
            for (var c = a.parentElement.firstElementChild; c && n < 20; c = c.nextElementSibling) {
              if (isFormField(c) || !fieldsIn(c).length) continue;
              n++;
              if (c === a) idx = n;
              if (shown(c)) vis++;
            }
            if (n >= 2 && vis === 1 && idx && shown(a)) s = { st: idx, sn: n };
          }
        }
        return s;
      } catch (_) { return null; }
    }
    function hasWizardButton(root) {
      try {
        var bs = root.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"], a');
        for (var i = 0; i < bs.length && i < 60; i++) {
          var t = (bs[i].textContent || '') + ' ' + (bs[i].value || '') + ' ' + (bs[i].getAttribute('aria-label') || '');
          if (/\b(next|continue|back|previous|prev|step)\b/i.test(t) && shown(bs[i])) return true;
        }
      } catch (_) {}
      return false;
    }
    var ERR_RE = /(^|[\s_-])(err|error|errors|invalid|not-valid)([\s_-]|$)/i;
    function hasErr(el) {
      try {
        if (el.getAttribute('aria-invalid') === 'true') return true;
        for (var p = el, d = 0; p && d < 3; d++, p = p.parentElement) {
          if (typeof p.className === 'string' && ERR_RE.test(p.className)) return true;
        }
      } catch (_) {}
      return false;
    }
    function fieldState(el, d) {
      try {
        var t = String(el.type || '').toLowerCase();
        if (t === 'checkbox' || t === 'radio') d.fl = el.checked ? 1 : 0;
        else if (el.tagName === 'SELECT') d.fl = el.value ? 1 : 0;
        else {
          var len = String(el.value || '').length;
          d.fl = len ? 1 : 0;
          if (len) d.lb = len < 4 ? 1 : len < 21 ? 2 : 3;
        }
        if (hasErr(el)) d.er = 1;
      } catch (_) {}
      return d;
    }
    // { fk, st, sn } for a field, read when it is focused.
    function formTags(el) {
      var o = {}, root = formRootOf(el);
      if (!root) return o;
      o.fk = formKeyOf(root);
      var s = stepOf(root, el);
      if (s) { o.st = s.st; o.sn = s.sn; if (!(root.__hvSr >= s.st)) root.__hvSr = s.st; }
      root.__hvTouched = 1;
      return o;
    }
    function withTags(d, tg) {
      if (tg) for (var x in tg) if (tg.hasOwnProperty(x) && tg[x] != null) d[x] = tg[x];
      return d;
    }

    // ─── fg — what a Continue/Submit click did (2026-09-29) ────────────────
    // After a click on an advance control inside a form (or a submit), watch
    // the form for up to 10 s and record the first thing that happens:
    //   err   fields show errors: ne = how many, ef = the first one, na = how
    //         many of those a screen reader is not told about (no aria-invalid)
    //   step  a different set of fields is on screen (st -> st2)
    //   done  the form is still on screen with no fields left (thank-you)
    //   gone  the form closed or left the page (a closed popup, or a success
    //         that removes the form: the two cannot be told apart here)
    //   nav   the page unloaded (a normal submit, or a link to the next step)
    //   none  nothing changed in 10 s: the click did nothing a visitor could see
    // n = clicks on the form during the watch (repeat clicking). A form a
    // visitor has not touched is only watched when it is a real <form>, a
    // dialog or [data-harvv-form], so a page CTA near a search box is not an
    // "attempt". Reads classes, attributes and which fields are visible only.
    var ADV_RE = /next|continue|submit|send|sign\s?up|register|get started|start|confirm|pay|book|request|apply|join|subscribe|save|finish|complete|create|claim|download|check\s?out|order|log\s?in|sign\s?in/i;
    var NOT_ADV_RE = /back|prev|cancel|close|dismiss|skip|edit|remove|delete|show|hide|toggle|clear|search/i;
    var watches = [];
    function shownFields(root) {
      var out = [], all = fieldsIn(root);
      for (var i = 0; i < all.length && out.length < 80; i++) if (shown(all[i])) out.push(all[i]);
      return out;
    }
    function sigOf(f) {
      var s = f.length + ':';
      for (var i = 0; i < f.length && i < 4; i++) s += (fieldKey(f[i]) || '') + ',';
      return s;
    }
    function endWatch(w, k, extra) {
      try {
        clearInterval(w.iv);
        for (var i = 0; i < watches.length; i++) if (watches[i] === w) { watches.splice(i, 1); break; }
        var d = withTags(w.d, extra);
        d.k = k; d.ms = Date.now() - w.t0;
        if (w.n > 1) d.n = Math.min(w.n, 50);
        if (k === 'done' || k === 'nav') w.root.__hvSub = 1;
        if (d.st2 && !(w.root.__hvSr >= d.st2)) w.root.__hvSr = d.st2;
        push('fg', d);
      } catch (_) {}
    }
    function watchTick(w) {
      try {
        var root = w.root;
        if (!attached(root) || !shown(root)) return endWatch(w, 'gone');
        var f = shownFields(root);
        if (sigOf(f) !== w.sig) {
          if (!f.length) return endWatch(w, 'done');
          var s2 = stepOf(root, f[0]);
          // sn2 only when the step count changed (a branch into another flow).
          return endWatch(w, 'step', s2 ? { st2: s2.st, sn2: s2.sn !== w.d.sn ? s2.sn : undefined } : null);
        }
        var ne = 0, na = 0, ef;
        for (var i = 0; i < f.length; i++) {
          if (!hasErr(f[i])) continue;
          ne++;
          if (!ef) ef = fieldKey(f[i]) || undefined;
          if (f[i].getAttribute('aria-invalid') !== 'true') na++;
        }
        if (ne) return endWatch(w, 'err', { ne: ne, na: na || undefined, ef: ef });
        if (Date.now() - w.t0 > 10000) endWatch(w, 'none');
      } catch (_) { endWatch(w, 'none'); }
    }
    function watchAdvance(root, btn, how) {
      try {
        if (!root || !(root.__hvTouched || root.tagName === 'FORM' || isDialog(root) || root.getAttribute('data-harvv-form'))) return;
        for (var i = 0; i < watches.length; i++) {
          if (watches[i].root === root) { if (Date.now() - watches[i].t0 > 300) watches[i].n++; return; }
        }
        var f0 = shownFields(root);
        if (!f0.length) return;
        var d = { fk: formKeyOf(root), how: how };
        if (btn) d.el = eid(btn) || undefined;
        var s0 = stepOf(root, f0[0]);
        if (s0) { d.st = s0.st; d.sn = s0.sn; }
        var pu = popupKind(root); if (pu) d.pu = pu;
        var w = { root: root, t0: Date.now(), n: 1, sig: sigOf(f0), d: d };
        watches.push(w);
        w.iv = setInterval(function () { watchTick(w); }, 250);
      } catch (_) {}
    }
    document.addEventListener('click', function (e) {
      try {
        var b = cTarget(e);
        for (var k = 0; b && b.nodeType === 1 && k < 4; k++, b = b.parentElement) {
          if (b.tagName === 'BUTTON' || b.tagName === 'A' || b.getAttribute('role') === 'button' || (b.tagName === 'INPUT' && /^(submit|button|image)$/i.test(b.type))) break;
        }
        if (!b || b.nodeType !== 1 || !(b.tagName === 'BUTTON' || b.tagName === 'A' || b.tagName === 'INPUT' || b.getAttribute('role') === 'button')) return;
        var txt = ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || '') + ' ' + (b.tagName === 'INPUT' ? b.value || '' : '')).slice(0, 80);
        if (NOT_ADV_RE.test(txt)) return;
        // An EXPLICIT submit, or advance wording. A <button> with no type is
        // a submit by default, so filter chips inside a collection <form>
        // ("CS", "M") were watched as attempts on v10.
        if (!((String(b.getAttribute('type') || '').toLowerCase() === 'submit' && b.form) || ADV_RE.test(txt))) return;
        watchAdvance(formRootOf(b), b, 'c');
      } catch (_) {}
    }, true);
    submitHooks.push(function (form) { watchAdvance(form, null, 's'); });
    window.addEventListener('pagehide', function () {
      while (watches.length) endWatch(watches[0], 'nav');
    });

    // ─── fx — form structure census (2026-09-29) ───────────────────────────
    // Once per form per page: on the first focus into it, and on the second
    // audit pass for <form>s already on screen. Counts, plus up to three
    // field keys per problem. What it counts:
    //   nf   fields (not hidden, not buttons, not honeypots)
    //   nof  1 when the fields are not in a <form>: Enter does not submit,
    //        and browser autofill and password managers see no form
    //   nl   fields with no programmatic label (label[for], wrapping label,
    //        aria-label/-labelledby or title); ph = how many of those lean on
    //        a placeholder instead
    //   nrq  fields whose label shows "*" but that are not marked required
    //   ner  fields with an error message beside them that is not linked to
    //        the field (no aria-describedby / aria-errormessage)
    //   nty  email or phone fields typed as plain text (wrong mobile keyboard)
    //   nac  name/email/phone/company/address fields with no autocomplete
    //   nbt  div/span "buttons" (a btn/button class, no role, no real control)
    //   hpk  honeypots a keyboard user can still tab into
    var AC_RE = /e-?mail|phone|\btel\b|mobile|first.?name|last.?name|full.?name|given|family|surname|^name$|company|organi[sz]ation|business.?name|street|address|city|province|zip|postal|postcode|country/i;
    var fxCount = 0;
    function labelled(el) {
      try {
        if ((el.getAttribute('aria-label') || '').replace(/\s+/g, '') || el.getAttribute('aria-labelledby') || (el.getAttribute('title') || '').replace(/\s+/g, '')) return true;
        if (el.labels) return el.labels.length > 0;
        return !!((el.id && document.querySelector('label[for="' + String(el.id).replace(/["\\]/g, '') + '"]')) || (el.closest && el.closest('label')));
      } catch (_) { return true; }
    }
    function formCensus(root, tr) {
      try {
        if (!root || root.__hvFx || fxCount >= 6) return;
        // isPrivateElement walks across shadow boundaries; closest() does not.
        if (isPrivatePage() || isPrivateElement(root)) return;
        root.__hvFx = 1; fxCount++;
        var f = fieldsIn(root), d = { fk: formKeyOf(root), tr: tr, nf: Math.min(f.length, 100), nof: root.tagName === 'FORM' ? 0 : 1, nl: 0, nac: 0 };
        var ph = 0, nrq = 0, ner = 0, nty = 0, xl = [], xa = [];
        for (var i = 0; i < f.length && i < 100; i++) {
          var el = f[i], t = String(el.type || '').toLowerCase(), key = fieldKey(el);
          var purpose = String(el.name || '') + ' ' + String(el.id || '') + ' ' + t;
          if (!labelled(el)) {
            d.nl++;
            if (el.getAttribute('placeholder')) ph++;
            if (key && xl.length < 3) xl.push(key);
          }
          var wrap = el.parentElement;
          var vis = (el.labels && el.labels[0]) || (wrap && wrap.querySelector('label')) || null;
          if (vis && /\*/.test(vis.textContent || '') && !el.required && el.getAttribute('aria-required') !== 'true') nrq++;
          if (wrap && !el.getAttribute('aria-describedby') && !el.getAttribute('aria-errormessage')) {
            for (var c = wrap.firstElementChild; c; c = c.nextElementSibling) {
              if (c !== el && typeof c.className === 'string' && ERR_RE.test(c.className)) { ner++; break; }
            }
          }
          if (el.tagName === 'INPUT' && /^(text|)$/.test(t) && (/e-?mail/i.test(purpose) || /phone|\btel\b|mobile/i.test(purpose))) nty++;
          if (el.tagName !== 'SELECT' && t !== 'checkbox' && t !== 'radio' && AC_RE.test(purpose) && !el.getAttribute('autocomplete')) {
            d.nac++;
            if (key && xa.length < 3) xa.push(key);
          }
        }
        var nbt = 0, fake = root.querySelectorAll('div, span');
        for (var j = 0; j < fake.length && j < 400; j++) {
          var fc = typeof fake[j].className === 'string' ? fake[j].className : '';
          if (/(^|[\s_-])(btn|button)([\s_-]|$)/i.test(fc) && !fake[j].getAttribute('role') && !fake[j].querySelector('a, button, input, select, textarea')) nbt++;
        }
        var hpk = 0, all = root.querySelectorAll('input, textarea');
        for (var h = 0; h < all.length && h < 200; h++) {
          if (isHoneypot(all[h]) && all[h].type !== 'hidden' && all[h].getAttribute('tabindex') !== '-1') hpk++;
        }
        if (ph) d.ph = ph;
        if (nrq) d.nrq = nrq;
        if (ner) d.ner = ner;
        if (nty) d.nty = nty;
        if (nbt) d.nbt = Math.min(nbt, 50);
        if (hpk) d.hpk = hpk;
        if (xl.length) d.xl = xl;
        if (xa.length) d.xa = xa;
        var pu = popupKind(root); if (pu) d.pu = pu;
        var sh = hostSig(root); if (sh) d.sh = sh;
        push('fx', d);
      } catch (_) {}
    }
    hvFormCensus = function () {
      try {
        var fs2 = document.forms;
        for (var i = 0; i < fs2.length && i < 10; i++) if (shownFields(fs2[i]).length) formCensus(fs2[i], 'a');
      } catch (_) {}
    };

    // 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 = {}, fieldTags = {};
    function fieldCount(el) {
      // null (unknown), not 1, when no container is found: a count that
      // reads as measured must be measured (#73549).
      try { var f = fieldContainer(el); return f ? fieldsIn(f).length : null; } catch (_) { return null; }
    }
    // cTarget, not e.target (#81849): a field inside an open shadow root
    // reaches these document listeners retargeted to its host element.
    document.addEventListener('keydown', function (e) {
      var t = cTarget(e);
      if (!isFormField(t)) return;
      if (e.key !== 'Backspace' && e.key !== 'Delete') return;
      var k0 = fieldKey(t);
      if (k0) fieldCorr[k0] = (fieldCorr[k0] || 0) + 1;
    }, true);
    // Focus moving between two elements of the SAME shadow root never reaches
    // the document: the DOM drops an event from every node where its target
    // and relatedTarget retarget to the same host. So the second field of a
    // shadow form, and every blur inside one, is only heard on the root.
    // Each root gets the same handlers (onRoot below); an event that does
    // reach both is handled once (document capture runs first and marks it).
    function once(e, k) { if (e[k]) return false; e[k] = 1; return true; }
    function onFocusOut(e) {
      if (!once(e, '__hvFo')) return;
      var t = cTarget(e);
      if (!isFormField(t)) return;
      var k1 = fieldKey(t);
      if (!k1 || !fieldFocusAt[k1] || fieldSummaryEmitted[k1]) return;
      fieldSummaryEmitted[k1] = true;
      var ft = Date.now() - fieldFocusAt[k1];
      if (ft > 0 && ft < 3600000) {
        var po = fieldPosition(t) || {};
        var t1 = fieldTags[k1] || {}, s1 = fieldState(t, {});
        push("fi", { f: k1, fp: po.p, fm: po.m, ft: ft, cc: fieldCorr[k1] || 0, fc: fieldCount(t) || undefined, b: 1,
          fk: t1.fk, st: t1.st, sn: t1.sn, fl: s1.fl, lb: s1.lb, er: s1.er });
      }
    }
    function onFocusIn(e) {
      if (!once(e, '__hvFi')) return;
      var t = cTarget(e);
      if (!isFormField(t)) return;
      var key = fieldKey(t);
      if (!key) return;
      var sc = scopeFor(t);
      if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
      fieldFocusAt[key] = Date.now();
      var tg = formTags(t);
      fieldTags[key] = tg;
      formCensus(formRootOf(t), 'f');
      lastField = { key: key, pos: fieldPosition(t), scope: sc, el: t, tg: tg };
      // 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;
        var p0 = lastField.pos || {};
        push("fi", { f: key, fp: p0.p, fm: p0.m, fk: tg.fk, st: tg.st, sn: tg.sn, sh: hostSig(t) });
      }
    }
    document.addEventListener('focusout', onFocusOut, true);
    document.addEventListener('focusin', onFocusIn, true);
    onRoot(function (r) { r.addEventListener('focusout', onFocusOut, true); r.addEventListener('focusin', onFocusIn, true); });

    document.addEventListener('input', function (e) {
      var t = cTarget(e);
      if (!isFormField(t)) return;
      var key = fieldKey(t);
      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(t);
      if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
      // Keep el (fa's field count reads it; this path dropped it on 38% of
      // rows) and reuse the position while typing in the same field, so the
      // container walk does not run on every keystroke.
      var pos = (lastField && lastField.el === t) ? lastField.pos : fieldPosition(t);
      var tg2 = (lastField && lastField.el === t) ? lastField.tg : (fieldTags[key] || (fieldTags[key] = formTags(t)));
      lastField = { key: key, pos: pos, scope: sc, el: t, tg: tg2 };
    }, true);

    // Any recorded submit (event, form.submit(), GF button) ends the form.
    // Recorded per form (__hvSub) as well: before 2026-09-29 one submit
    // anywhere on the page (a footer newsletter) silenced the abandonment of
    // every other form on it. formSubmitted stays for a field with no form.
    submitHooks.push(function (form) { formSubmitted = true; if (form) form.__hvSub = 1; });

    // 2026-09-23 — Gravity Forms. A GF "Next" or "Previous" is a real form
    // submit, and so is a submit the server sends back with errors. Both set
    // formSubmitted above, so anyone who quit on page 2+ or after an error was
    // never counted as abandoning, and every step looked like a completion.
    // GF re-renders the form after each of those, so we watch for the
    // re-render and read what it shows: the page number, the error fields,
    // or the confirmation. `gf` records it; a re-render that is not the
    // confirmation puts the visitor back in progress (formSubmitted = false).
    // Reads GF's own ids and classes only, never a field value. Works for
    // AJAX forms (form node replaced in place) and non-AJAX ones (full reload;
    // the load scan reads the state the server rendered).
    var gfSeen = {}, gfWatch = {};
    function gfIdOf(form) {
      var m = form && form.id ? /^gform_(\d{1,6})$/.exec(form.id) : null;
      return m ? m[1] : null;
    }
    function gfRead(fid) {
      var d = document;
      if (d.getElementById('gform_confirmation_message_' + fid) || d.getElementById('gform_confirmation_wrapper_' + fid)) {
        return { k: 'ok' };
      }
      var w = d.getElementById('gform_wrapper_' + fid);
      var f = d.getElementById('gform_' + fid);
      if (!w || !f) return null;
      var sp = d.getElementById('gform_source_page_number_' + fid);
      var pg = sp ? (parseInt(sp.value, 10) || 1) : 1;
      var np = w.querySelectorAll('.gform_page').length || 1;
      var errs = w.querySelectorAll('.gfield_error');
      var s = { k: 'v', pg: pg, np: np };
      if (errs.length || /(^|\s)gform_validation_error(\s|$)/.test(w.className)) {
        s.k = 'err';
        s.ne = errs.length;
        if (errs[0] && errs[0].id) s.ef = String(errs[0].id).slice(0, 40);
      }
      return s;
    }
    function gfEmit(fid, s, extra) {
      var d = { fid: fid, k: s.k };
      if (s.pg) d.pg = s.pg;
      if (s.np) d.np = s.np;
      if (s.ne) d.ne = s.ne;
      if (s.ef) d.ef = s.ef;
      if (extra) { for (var x in extra) { if (extra.hasOwnProperty(x) && extra[x] != null) d[x] = extra[x]; } }
      var ge = document.getElementById('gform_wrapper_' + fid) || document.getElementById('gform_confirmation_wrapper_' + fid) || document.getElementById('gform_confirmation_message_' + fid);
      var pu = ge ? popupKind(ge) : null;
      if (pu) d.pu = pu;
      push('gf', d);
    }
    // After a GF submit, wait for the form node to be replaced (AJAX) or the
    // confirmation to appear, then record what came back. Bounded: 30 s, and
    // one watch per form at a time. A full-page (non-AJAX) submit unloads the
    // page first and the load scan on the next page takes over.
    function gfStartWatch(form) {
      var fid = gfIdOf(form);
      if (!fid || gfWatch[fid]) return;
      var before = gfRead(fid);
      var t0 = Date.now();
      gfWatch[fid] = setInterval(function () {
        var s = null;
        try {
          var now = document.getElementById('gform_' + fid);
          var confirmed = document.getElementById('gform_confirmation_message_' + fid) || document.getElementById('gform_confirmation_wrapper_' + fid);
          if (confirmed || (now && now !== form)) s = gfRead(fid);
          else if (now) {
            // Some GF versions refill the same form node instead of replacing it.
            var c = gfRead(fid);
            if (c && before && (c.pg !== before.pg || c.k !== before.k || c.ne !== before.ne)) s = c;
          }
        } catch (_) {}
        var waited = Date.now() - t0;
        if (!s && waited < 30000) return;
        clearInterval(gfWatch[fid]);
        gfWatch[fid] = null;
        if (!s) return;
        if (s.k === 'v') s.k = 'pg';
        gfEmit(fid, s, { pf: before && before.pg, ms: waited });
        // Still on the form (next page, previous page, or errors): the visitor
        // has not finished, so leaving from here is an abandonment again.
        // The same form node can be submitted again right away.
        if (s.k !== 'ok') { formSubmitted = false; faEmitted = false; form.__hvFs = 0; form.__hvSub = 0; }
      }, 300);
    }
    submitHooks.push(gfStartWatch);
    // A form rendered late (popup, tab, page builder) is noted on first touch.
    document.addEventListener('focusin', function (e) {
      try {
        var ft = cTarget(e), fid = ft && ft.form ? gfIdOf(ft.form) : null;
        if (fid && !gfSeen[fid]) { gfSeen[fid] = 1; var s = gfRead(fid); if (s) gfEmit(fid, s, { late: 1 }); }
      } catch (_) {}
    }, true);
    function gfScan() {
      try {
        var forms = document.querySelectorAll('form[id^="gform_"]');
        for (var i = 0; i < forms.length && i < 10; i++) {
          var fid = gfIdOf(forms[i]);
          if (!fid || gfSeen[fid]) continue;
          gfSeen[fid] = 1;
          var s = gfRead(fid);
          if (!s) continue;
          // A non-AJAX step or error comes back as a fresh page load.
          if (s.k === 'v' && s.pg > 1) s.k = 'pg';
          gfEmit(fid, s);
        }
        // Non-AJAX confirmation: the form is gone, only the message is left.
        var oks = document.querySelectorAll('[id^="gform_confirmation_message_"]');
        for (var j = 0; j < oks.length && j < 10; j++) {
          var m = /^gform_confirmation_message_(\d{1,6})$/.exec(oks[j].id);
          if (m && !gfSeen[m[1]]) { gfSeen[m[1]] = 1; gfEmit(m[1], { k: 'ok' }); }
        }
      } catch (_) {}
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', gfScan);
    else gfScan();

    function emitAbandonment() {
      if (faEmitted) return;
      if (!lastField) return;
      var lr = lastField.el ? formRootOf(lastField.el) : null;
      if (lr ? lr.__hvSub : formSubmitted) 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;
      var lp = lastField.pos || {};
      // Form and step at the last field's focus (fk/st/sn); how it was left
      // as the visitor goes (fl/lb/er); sr = the furthest step the form
      // reached, when past that field's step (got to step 2, touched nothing).
      var tA = lastField.tg || {}, sA = lastField.el ? fieldState(lastField.el, {}) : {};
      var fr = lastField.el ? formRootOf(lastField.el) : null;
      push("fa", { f: lastField.key, fp: lp.p, fm: lp.m, n: n,
        ft: (ftA > 0 && ftA < 3600000) ? ftA : undefined, cc: fieldCorr[lastField.key] || 0,
        fc: (lastField.el && fieldCount(lastField.el)) || undefined,
        pu: (lastField.el && popupKind(lastField.el)) || undefined,
        fk: tA.fk, st: tA.st, sn: tA.sn, sr: (fr && fr.__hvSr > (tA.st || 0)) ? fr.__hvSr : undefined,
        fl: sA.fl, lb: sA.lb, er: sA.er, sh: lastField.el ? hostSig(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 (_) {}
      }
      // 2026-09-26 (#79522) — two corrections, both measured.
      //
      // NEGATIVE responseStart (Safari and Firefox only, never Chrome). 54 of
      // 54 rows below -1 s had the same page visited earlier in the same
      // session at exactly the |ttfb| offset: WebKit/Gecko report the
      // ORIGINAL response's responseStart for a repeat navigation served
      // from cache, against a timeOrigin that is now later. Sub-100 ms
      // negatives are timer skew between the navigation timeline and the
      // network thread. Either way the visitor waited 0 ms for bytes: send
      // 0, keep the raw offset as `tneg` so the mechanism stays queryable.
      //
      // HIDDEN BEFORE FIRST PAINT. Every LCP over 60 s in the audit had FCP
      // over 60 s too while domInteractive was normal: the document was
      // parsed on time and painted when the tab was first shown, because a
      // hidden tab does not paint. Those are not load timings (the
      // web-vitals firstHiddenTime rule). fcp/lcp/plt are omitted and
      // `hid:1` says why; ttfb and tti are network and parse, which run
      // while hidden, so they stay.
      var rawTtfb = n ? (n.responseStart | 0) : 0;
      var firstPaintAt = fcp || lcp || plt || 0;
      var paintHidden = firstPaintAt > 0 && hiddenBefore(firstPaintAt);
      push("pf", {
        ttfb: rawTtfb < 0 ? 0 : afterActivation(rawTtfb),
        tneg: rawTtfb < 0 ? -rawTtfb : undefined,
        fcp: paintHidden ? undefined : (fcp ? afterActivation(fcp) : undefined),
        lcp: paintHidden ? undefined : afterActivation(lcp),
        cls: (cls * 1e3 | 0) / 1e3,
        tbt: tbt || undefined,
        tti: afterActivation(tti),
        plt: paintHidden ? undefined : afterActivation(plt),
        hid: paintHidden ? 1 : undefined,
        // Interactions whose next paint came after a hidden period, excluded
        // from inp (below). A count, so "no slow interaction" is not confused
        // with "the slow ones were excluded".
        inpx: inpExcluded || undefined,
        // 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;
  var inpExcluded = 0;
  try {
    new PerformanceObserver(function (l) {
      l.getEntries().forEach(function (e) {
        // 2026-09-26 (#79520, #79522): `duration` runs from the event to the
        // NEXT PAINT. When the page goes hidden after the interaction (leave
        // events fire as the visitor leaves: pointerleave, touchend,
        // pointercancel), the next paint is whenever they come back — five
        // days on iOS (508,006,672 ms, twice on one site). 82 of 139 rows over
        // 60 s in three days sat in such a window. No interaction takes a
        // minute to paint on a visible page: an entry that spans a hidden
        // period, or exceeds 60 s, is not a responsiveness measurement.
        if (e.duration > 60000 || hiddenWithin(e.startTime, e.startTime + e.duration)) { inpExcluded++; return; }
        // 2026-09-26 (#79520, second half): INP is defined over DISCRETE
        // interactions, the entries with interactionId > 0 (clicks, taps,
        // keys). Hover and pointer-movement events (pointerover/enter/out/
        // leave, mouseover) carry interactionId 0 and were about 100k of
        // 213k ie rows in three days, so both the per-element ie list and
        // the page inp were describing hovers. Not counted, not excluded
        // (inpx counts hidden-span exclusions only).
        if (!e.interactionId) return;
        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) {
            // v23 (#82639): the duration runs from the tap to the paint, and a
            // hidden tab does not paint. Both rows over 60 s on 2026-10-02
            // spanned a hide (122 s and 115 s, each ending as the tab came
            // back), so across a hidden span ms is omitted and hs:1 says why.
            var snHid = hiddenWithin(e.startTime, e.startTime + e.duration);
            push("sn", {
              p: e.name && e.name.replace(/^https?:\/\/[^/]+/, '') || location.pathname,
              ms: snHid ? undefined : Math.round(e.duration),
              hs: snHid ? 1 : undefined,
            });
          }
        });
      }).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 });
  })();

  // ─── pn: panel lifecycle (2026-09-30, #81907) ───────────────────────────
  //
  // One event per panel the page opens (filter drawer, menu, cart drawer,
  // search overlay, size chart, timed signup popup), sent when it closes or
  // when the page goes away with it open:
  //   k    kind, from csType() and then the panel's contents
  //   el   the panel, structural selector only (its text can be page content)
  //   by   't' a tap opened it, op names the control | 'a' the page opened it
  //   t0   ms from page start to the open (a timed popup's delay)
  //   ms   time open; n taps inside; ch changes inside (toggles, selects,
  //        fields typed in); sub 1 when a form inside was submitted
  //   out  close (a close/dismiss control) | in (another control inside it,
  //        e.g. Apply) | esc | outside (a tap outside it) | nav (a link inside
  //        it) | auto (closed with no input) | left (page hidden or unloaded)
  //   cov  % of the viewport it covered
  // Why: on J West the Filter button's click never reached a bubble listener,
  // so "how many opened the filters and changed nothing" had no answer. That
  // is a question about a KIND of thing, not one theme's button, so a panel is
  // recognised by what it is on screen: visible, fixed or a modal dialog,
  // covering a real share of the viewport, holding something to tap. Opener
  // buttons are never panels. Panels already open when tracking starts are
  // page furniture and are ignored. No text is read; counts only.
  (function trackPanels() {
    var PN_SEL = 'dialog,[role="dialog"],[aria-modal="true"],[class*="drawer"],[class*="modal"],[class*="offcanvas"],[class*="popup"],[class*="flyout"],[id*="drawer"],[id*="modal"],[id*="popup"]';
    var CLOSE_RE = /close|dismiss|cancel|no thanks|not now|icon-x\b|×|✕|✖/i;
    var open = [], base = null, hosts = [], hostsAt = 0, sent = 0;
    var lastTap = null, lastEsc = 0, scanT = null, lastScan = 0, poll = null;
    function cover(n) {
      try {
        if (!n || n.nodeType !== 1) return 0;
        var tag = n.tagName;
        if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|LABEL|SUMMARY|OPTION|IMG)$/.test(tag) || n.getAttribute('role') === 'button') return 0;
        if (tag === 'DIALOG' && !n.open) return 0;
        var r = n.getBoundingClientRect(), W = innerWidth, H = innerHeight;
        var w = Math.min(r.right, W) - Math.max(r.left, 0), h = Math.min(r.bottom, H) - Math.max(r.top, 0);
        if (w < 120 || h < 120) return 0;
        var c = (w * h) / (W * H);
        if (c < 0.2 && h < H * 0.6) return 0;
        var cs = getComputedStyle(n);
        if (cs.visibility === 'hidden' || +cs.opacity < 0.1) return 0;
        if (tag !== 'DIALOG' && n.getAttribute('aria-modal') !== 'true') {
          for (var p = n, i = 0, fx = false; p && p.nodeType === 1 && i < 4; i++, p = p.parentNode) {
            if (getComputedStyle(p).position === 'fixed') { fx = true; break; }
          }
          if (!fx) return 0;
        }
        if (!n.querySelector('a[href],button,input,select,textarea,[role="button"]')) return 0;
        return Math.round(c * 100) || 1;
      } catch (_) { return 0; }
    }
    function cands() {
      var out = [], i, j, l;
      try { l = document.querySelectorAll(PN_SEL); for (i = 0; i < l.length && i < 200; i++) out.push([l[i], l[i]]); } catch (_) {}
      try {
        // Open shadow roots (app popups render inside custom elements). The
        // host list costs one walk of the DOM, so it is refreshed at most
        // every 4 s rather than on every scan.
        if (Date.now() - hostsAt > 4000) {
          hostsAt = Date.now(); hosts = [];
          var all = document.getElementsByTagName('*');
          // v20 (2026-10-06): ANY element with an open shadow root, not only
          // custom elements. UpCart mounts its drawer in a plain div#upCart, so
          // every cart open on J West & Co went unreported as a panel and the
          // drawer never got a panel snapshot.
          for (i = 0; i < all.length && i < 8000 && hosts.length < 40; i++) if (all[i].shadowRoot) hosts.push(all[i]);
        }
        for (i = 0; i < hosts.length; i++) {
          var sr = hosts[i].shadowRoot;
          if (!sr) continue;
          // Dialog markup anywhere, plus the root's top two levels, where an
          // app's overlay container sits. Not every div: a big widget's shadow
          // tree would cost a style read per node on every scan.
          l = sr.querySelectorAll('dialog,[role="dialog"],[aria-modal="true"]');
          for (j = 0; j < l.length && j < 10; j++) out.push([l[j], hosts[i]]);
          for (j = 0; j < sr.children.length && j < 10; j++) {
            out.push([sr.children[j], hosts[i]]);
            for (var g = 0; g < sr.children[j].children.length && g < 10; g++) out.push([sr.children[j].children[g], hosts[i]]);
          }
        }
      } catch (_) {}
      return out;
    }
    function kindOf(el, host) {
      var k = csType(host);
      if (k === 'modal' && host !== el) k = csType(el);
      if (k !== 'modal') return k;
      try {
        if (el.querySelector('input[type="email"]')) return 'popup';
        if (el.querySelector('input[name^="filter"],input[name*="filter."],[class*="facet"]')) return 'filter_panel';
        if (el.querySelector('input[type="search"],input[name="q"]')) return 'search_modal';
        if (el.querySelectorAll('a[href]').length > 6) return 'menu';
      } catch (_) {}
      return 'modal';
    }
    function has(o, node) { return !!node && (o.host === node || o.host.contains(node) || o.el.contains(node)); }
    function finish(o, out) {
      o.done = 1;
      // A theme that applies a filter by script fires no change event; the URL
      // moving after a tap inside is the change.
      if (!o.ch && o.tapped && o.href0 !== location.href) o.ch = 1;
      if (sent++ >= 20) return;
      push("pn", { k: o.k, el: String(eid(o.host) || '').replace(/:".*$/, '').slice(0, 100), by: o.by, op: o.op,
        t0: o.t0, ms: Date.now() - o.at, n: Math.min(o.n, 200), ch: Math.min(o.ch, 200), sub: o.sub, out: out, cov: o.cov });
    }
    // Inside/outside is decided AT TAP TIME (lastTap.ins): a theme that
    // applies a filter by script re-renders the drawer, so the tapped node is
    // detached by the time the panel closes and a containment check then would
    // call it a tap outside (Impulse, J West, 2026-09-30).
    function how(o, now) {
      if (now - lastEsc < 1500) return 'esc';
      var t = lastTap && now - lastTap.at < 1500 ? lastTap : null;
      if (t && t.ins.indexOf(o) === -1) return 'outside';
      if (o.link && now - o.link < 1500) return 'nav';
      if (t) return CLOSE_RE.test(t.eid || '') ? 'close' : 'in';
      // The page closed it a few seconds after a tap inside that changed the
      // URL: the visitor applied something and the theme shut the panel.
      if (o.tapped && now - o.tapped < 6000 && o.href0 !== location.href) return 'in';
      return 'auto';
    }
    function scan() {
      scanT = null; lastScan = Date.now();
      var now = lastScan, c = cands(), vis = [], i, j, o, v, d;
      for (i = 0; i < c.length; i++) {
        for (j = 0, d = false; j < vis.length; j++) if ((c[i][1] !== c[i][0] && vis[j].host === c[i][1]) || vis[j].el.contains(c[i][0])) { d = true; break; }
        if (d) continue;
        var cv = cover(c[i][0]);
        if (cv) vis.push({ el: c[i][0], host: c[i][1], cov: cv });
      }
      if (!base) { base = []; for (j = 0; j < vis.length; j++) base.push(vis[j].el); return; }
      for (i = base.length - 1; i >= 0; i--) {
        for (j = 0, d = false; j < vis.length; j++) if (vis[j].el === base[i]) { d = true; break; }
        if (!d) base.splice(i, 1);
      }
      for (i = open.length - 1; i >= 0; i--) {
        o = open[i];
        for (j = 0, d = false; j < vis.length; j++) if (vis[j].el === o.el) { d = true; break; }
        if (!d) {
          open.splice(i, 1);
          var hw = how(o, now);
          // A tap that closed this panel cannot also be the tap that opened
          // the next one (a timed popup landing just after a drawer closes).
          if (lastTap && /^(close|in|nav)$/.test(hw)) lastTap.used = 1;
          if (!o.done) finish(o, hw);
        }
      }
      for (j = 0; j < vis.length; j++) {
        v = vis[j];
        for (i = 0, d = false; i < open.length; i++) if (open[i].el === v.el) { d = true; break; }
        if (d || base.indexOf(v.el) !== -1) continue;
        var tap = lastTap && !lastTap.used && now - lastTap.at < 1500;
        open.push({ el: v.el, host: v.host, cov: v.cov, at: now, n: 0, ch: 0, f: [], href0: location.href,
          t0: Math.round(window.performance && performance.now ? performance.now() : 0),
          by: tap ? 't' : 'a', op: tap ? lastTap.eid : undefined, k: kindOf(v.el, v.host) });
        // v18: what the panel put on screen is a snapshot moment (vs tr "pn").
        try { snapOnPanel(); } catch (_) {}
      }
      if (open.length && !poll) poll = setInterval(function () {
        for (var k = 0; k < open.length; k++) if (!cover(open[k].el)) { soon(0); return; }
      }, 1000);
      if (!open.length && poll) { clearInterval(poll); poll = null; }
    }
    function soon(ms) {
      if (scanT || !base) return;
      scanT = setTimeout(scan, Math.max(ms, 1000 - (Date.now() - lastScan)));
    }
    function inside(ev, fn) {
      for (var i = 0; i < open.length; i++) if (has(open[i], ev.target) || has(open[i], cTarget(ev))) fn(open[i]);
    }
    try {
      var echo = null;
      addEventListener('click', function (ev) {
        try {
          var node = cTarget(ev);
          // A label press makes the browser click its control a moment later
          // (same point). That echo is not a second tap, and when the theme has
          // already re-rendered the panel the control is detached, so it would
          // overwrite the real tap with one that looks like it landed outside.
          // Own check: isLabelEcho() consumes the main handler's state.
          if (echo && node === echo.c && ev.clientX === echo.x && ev.clientY === echo.y) { echo = null; return; }
          var lc = labelControl(node);
          echo = lc && lc !== node && !(lc.contains && lc.contains(node)) ? { c: lc, x: ev.clientX, y: ev.clientY } : null;
          var a = node && node.closest && node.closest('a[href]');
          var link = a && !/^(#|javascript:)/i.test(a.getAttribute('href') || '#');
          // op names the CONTROL, not the icon inside it (a tap on a
          // hamburger lands on its svg path).
          var ctl = (node && node.closest && node.closest('a,button,label,summary,[role="button"]')) || node;
          var ins = [];
          inside(ev, function (o) { o.n++; o.tapped = Date.now(); ins.push(o); if (link) o.link = Date.now(); });
          lastTap = { at: Date.now(), tgt: ev.target, node: node, eid: eid(ctl), ins: ins };
          setTimeout(function () { soon(0); }, 350);
          setTimeout(function () { soon(0); }, 1100);
        } catch (_) {}
      }, true);
      // ch counts FIELDS changed, once each: a checkbox fires both input and
      // change, and a typed email fires input per key.
      var touched = function (ev) {
        try { var t = cTarget(ev); inside(ev, function (o) { if (o.f.indexOf(t) === -1) { o.f.push(t); o.ch++; } }); } catch (_) {}
      };
      addEventListener('change', touched, true);
      addEventListener('input', touched, true);
      addEventListener('submit', function (ev) { try { inside(ev, function (o) { o.sub = 1; }); } catch (_) {} }, true);
      addEventListener('keydown', function (ev) {
        if (ev.key === 'Escape' || ev.keyCode === 27) { lastEsc = Date.now(); setTimeout(function () { soon(0); }, 350); }
      }, true);
      var leave = function () {
        var now = Date.now();
        for (var i = 0; i < open.length; i++) if (!open[i].done) finish(open[i], open[i].link && now - open[i].link < 3000 ? 'nav' : 'left');
      };
      addEventListener('pagehide', leave);
      document.addEventListener('visibilitychange', function () {
        if (!document.hidden || !open.length) return;
        leave();
        try { flush(); } catch (_) {}
      });
      setTimeout(function () {
        try {
          scan();
          // Class churn is constant on most pages (carousels, lazy images), so
          // only a mutation that could be a panel asks for a scan: nodes added,
          // or a class/state change on <html>, <body> or a panel-shaped element.
          new MutationObserver(function (recs) {
            for (var i = 0; i < recs.length; i++) {
              var r = recs[i], t = r.target;
              if (r.type === 'childList' ? r.addedNodes.length : (t === document.body || t === document.documentElement || (t.matches && t.matches(PN_SEL)))) { soon(400); return; }
            }
          }).observe(document.documentElement, {
            childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'open', 'hidden', 'aria-hidden', 'aria-modal'] });
          // A popup that renders inside a shadow root changes no light-DOM
          // node the observer can see, so pages with shadow hosts get a slow
          // poll instead.
          setInterval(function () { if (hosts.length && !document.hidden) soon(0); }, 3000);
        } catch (_) {}
      }, 1500);
    } catch (_) {}
  })();

  // 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|term|terms|keyword|keywords|kw|search_query|search_text|searchterm|query_text)$/i;
  // 2026-10-06 (pixel v21, Patrol #2) — the referrer used to leave the browser
  // whole, so a product page reached from /search?q=... carried the query on
  // the wire (the server scrubbed it on arrival; CIPA §631 is about transit).
  // Keep origin and path and only the campaign keys the server keeps
  // (REFERRER_KEEP_QS_KEYS in receiver.js); drop every other key and the
  // fragment. Same shape the server would have produced, one hop earlier.
  // The keep-list is a literal INSIDE the function: ss fires during init,
  // thousands of lines before a `var` here would be assigned (a hoisted var is
  // undefined until its line runs; a function declaration is not).
  function scrubReferrer(r) {
    if (!r) return undefined;
    var REF_KEEP_RX = /^(utm_(source|medium|campaign|term|content)|gclid|wbraid|gbraid|gad_campaignid|gad_source|fbclid|ttclid|twclid|msclkid|li_fat_id|epik|ref|src)$/i;
    r = String(r);
    var h = r.indexOf("#"); if (h !== -1) r = r.slice(0, h);
    var q = r.indexOf("?"); if (q === -1) return r.slice(0, 240);
    var base = r.slice(0, q), parts = r.slice(q + 1).split("&"), keep = [];
    for (var i = 0; i < parts.length; i++) {
      var eq = parts[i].indexOf("="), k = eq === -1 ? parts[i] : parts[i].slice(0, eq);
      if (REF_KEEP_RX.test(k)) keep.push(parts[i].slice(0, 100));
    }
    return (keep.length ? base + "?" + keep.join("&") : base).slice(0, 240);
  }
  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;
  // 2026-05-19 perf fix: the initiator is read from a stack only when an nf
  // row is about to be sent, never on every fetch (Error.stack formatting is
  // one of the most expensive per-call ops in JS).
  // #82071 (pixel v27): Shopify's web pixels manager (hashed wpm bundles),
  // shop_events_listener and trekkie all patch window.fetch, so whichever
  // wrapped last was the top frame and was named as the caller: 60 of 126
  // failed product lookups on J West named the wrapper, the real caller was
  // upcart-bundle.js. Frames from those files are skipped and the first frame
  // outside the pixel and the wrappers is reported; when every frame is a
  // wrapper (the platform's own request) the outermost one, nearest the
  // caller, is. Same rule as FETCH_WRAPPER_FILE_RE in src/lib/known-patterns.js
  // (tests/known-patterns.test.js holds them equal). 12 lines: Chrome keeps 10
  // frames by default.
  var NF_WRAPPER_RE = /^[a-f0-9]{8,9}e?w[a-z0-9]+m\.js|shop_events_listener|trekkie/;
  function _initiatorFrom(errObj) {
    var last;
    try {
      var lines = ((errObj && errObj.stack) || "").split("\n");
      for (var li = 1; li < lines.length && li < 12; li++) {
        var line = lines[li], f = null;
        if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
          var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
          var pathMatch = fileMatch ? null : line.match(/([^\/()\s]+\.js[:\d]*)/);
          if (fileMatch) f = fileMatch[1].replace(/.*\//, "").substring(0, 60);
          else if (pathMatch) f = pathMatch[1].substring(0, 60);
          if (f) {
            if (!NF_WRAPPER_RE.test(f)) return f;
            last = f;
          }
        }
      }
    } catch (_) {}
    return last;
  }
  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;
    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 path, then extension (no PII)
      var rt = rtOf(path);
      // 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);
        // 2026-09-26 (#79522): a request that settled after the page was
        // hidden measured the freeze, not the server. 323 rows over 10 min in
        // three days were analytics beacons (analytics.google.com, Shopify
        // monorail, merchant-center) and collect POSTs fired at the moment of
        // backgrounding and settled on thaw; not one was a slow server. Across
        // a hidden span the duration is not reported (`hs:1` on an error row
        // says the failure is real but untimed) and nothing is marked slow.
        var hs = hiddenWithin(t0, performance.now());
        // nf: network-failure or slow-request signal
        if (_pxOwnBeacon) { /* our own upload: never the customer's network */ }
        else if (r.status >= 400 && errCount < 5) { if (nfNew(fullUrl) && pushNf({ s: r.status, ms: hs ? undefined : ms, hs: hs ? 1 : undefined, u: fullUrl, rt: rt, e: 1, mt: method, ini: _initiatorFrom(callerErr) })) errCount++; }
        else if (!hs && ms > 2000 && ms <= 120000 && 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);
        var hs2 = hiddenWithin(t0, performance.now()); // #79522: see above
        var ab = err && err.name === "AbortError";
        if (_pxOwnBeacon) { /* our own upload */ }
        else if (ab || hvLeaving) pushNx({ u: fullUrl, rt: rt, ms: hs2 ? undefined : ms, tp: "fetch", mt: method, w: ab ? "a" : "l" });
        else if (errCount < 5 && nfNew(fullUrl) && pushNf({ s: 0, ms: hs2 ? undefined : ms, hs: hs2 ? 1 : undefined, u: fullUrl, rt: rt, e: 1, to: (!hs2 && ms > 30000) ? 1 : 0, m: (err.message || "").substring(0, 40), mt: method, ini: _initiatorFrom(callerErr) })) errCount++;
        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);
      }
      this._pxRt = rtOf(this._pxPath);
      // 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 = _initiatorFrom(new Error()) || "";
      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();
      // #81850: how the request ended. loadend fires after exactly one of
      // load / abort / error / timeout, and status is 0 for the last three.
      var how = 0;
      xhr.addEventListener("abort", function () { how = 1; });
      xhr.addEventListener("error", function () { how = 2; });
      xhr.addEventListener("timeout", function () { how = 3; });
      xhr.addEventListener("loadend", function () {
        var ms = Math.round(performance.now() - t0);
        var hs = hiddenWithin(t0, performance.now()); // #79522: same rule as the fetch hook
        // nx: cancelled (by the page, or by the visitor leaving). nf: failed
        // (4xx/5xx, or a network error/timeout with no answer), or slow.
        if (how === 1 || (!xhr.status && hvLeaving)) {
          pushNx({ u: xhr._pxUrl, rt: xhr._pxRt, ms: hs ? undefined : ms, tp: "xhr", mt: xhr._pxMethod, w: how === 1 ? "a" : "l" });
        } else if ((xhr.status >= 400 || how > 1) && errCount < 5) {
          if (pushNf({ s: xhr.status, ms: hs ? undefined : ms, hs: hs ? 1 : undefined, u: xhr._pxUrl, rt: xhr._pxRt, e: 1, to: how === 3 ? 1 : undefined, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" })) errCount++;
        } else if (xhr.status && !hs && ms > 2000 && ms <= 120000 && 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.
  //
  // Case #78055: this used to take the FIRST match of the selector, and on a
  // page built from card-style <article>s that is one small card. harvv.com
  // /vs/clarity reported 45 words (its first <article class="glass bs">)
  // while <main> held 6,245, and the SEO report called it thin. Shopify and
  // WordPress blog and product grids are built the same way. Now: count each
  // candidate, keep the largest, and fall back to <main> (or <body>) when the
  // largest holds under half of it, because then it is a card, not the body.
  function wcWords(el) {
    // textContent is read locally; we only ship the COUNT.
    var t = el ? (el.textContent || '').replace(/\s+/g, ' ').trim() : '';
    return t ? t.split(' ').length : 0;
  }
  function emitWordCount() {
    try {
      if (isPrivatePage()) return;
      var sel = 'article, [role="article"], .post-content, .entry-content, [itemprop="articleBody"], main .post, main .entry';
      var outer = document.querySelector('main') || document.body;
      if (!outer) return;
      var outerN = wcWords(outer);
      var cands = document.querySelectorAll(sel);
      var node = null, n = 0;
      for (var ci = 0; ci < cands.length && ci < 25; ci++) {
        var k = wcWords(cands[ci]);
        if (k > n) { n = k; node = cands[ci]; }
      }
      if (!node || n < outerN * 0.5) { node = outer; n = outerN; }
      if (!n) return;
      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, wv: 2 });
    } 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,
        };
        // 2026-09-25 (#78325): an SVG's natural size is only its width/height
        // attribute and a vector scales free, so natural-vs-rendered says
        // nothing about waste or blur. Flag it (sv, no URL sent) and skip the
        // blur ratio; consumers exclude sv from oversized/waste/upscaled.
        var isSvg = false;
        try {
          var isrc = String(im.currentSrc || im.src || '');
          isSvg = /^data:image\/svg\+xml/i.test(isrc) || /\.svg(?:[?#]|$)/i.test(isrc);
        } catch (_) {}
        if (isSvg) info.sv = 1;
        // 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 (!isSvg && 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 _atMoved = 0;          // emitted `at` mv=1 rows 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 _tEndX = 0, _tEndY = 0;  // the latest touchmove, never sampled away (v24)
      var _tScrolled = false, _tMulti = false;
      var _spd = [], _revs = 0, _lastDir = 0, _sampleT = 0;
      var _tRect = null;         // where the touched element sat at touchstart
      var _tPic = '';            // the picture under the finger at touchstart (v30)
      // The person's own last swipe (v30): when it ended and which way the
      // finger went. _tPrev* is that, copied at touchstart for this gesture.
      var _lgEnd = 0, _lgDx = 0, _lgDy = 0;
      var _tPrevEnd = 0, _tPrevDx = 0, _tPrevDy = 0;

      // The image at a screen point, by its source. elementsFromPoint, not
      // elementFromPoint: a gallery's zoom button usually covers its photo,
      // so the topmost element is the button and the image sits under it.
      function _picAt(x, y) {
        try {
          if (!document.elementsFromPoint) return '';
          var els = document.elementsFromPoint(x, y), i;
          for (i = 0; i < els.length && i < 8; i++) {
            if (els[i].tagName === 'IMG') return String(els[i].currentSrc || els[i].src || '').slice(0, 300);
          }
        } catch (_) {}
        return '';
      }

      // 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 = _tEndX = t.clientX; _tY = _tLastY = _tEndY = 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;
          // Read once per gesture so the `at` check below can tell whether
          // the swipe MOVED the thing under the finger (see "mv" there).
          _tRect = null;
          if (_tEl) { var r0 = _tEl.getBoundingClientRect(); _tRect = { l: r0.left, t: r0.top }; }
          _tPrevEnd = _lgEnd; _tPrevDx = _lgDx; _tPrevDy = _lgDy;
          // One hit test, only when this touch could become an `at` row.
          _tPic = _atCount < 8 && _tEl && _interactiveAncestor(_tEl) ? _picAt(_tX, _tY) : '';
        } 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();
          // Where the finger is now, kept on EVERY move: the sampler below
          // drops a move that lands within 16ms of the last kept one, and
          // the last move of a quick flick is often one of those (v24).
          _tEndX = t.clientX; _tEndY = t.clientY;
          // 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) });
            }
          }

          // Where the finger lifted (see v24 in `at` below), and, when it
          // travelled more than 8px, this gesture as "the last swipe" for
          // the next one's carried-motion check (v30, mk 'c').
          var lct = e.changedTouches && e.changedTouches[0];
          var _tLiftX = lct ? lct.clientX : _tEndX, _tLiftY = lct ? lct.clientY : _tEndY;
          if ((_tLiftX - _tX) * (_tLiftX - _tX) + (_tLiftY - _tY) * (_tLiftY - _tY) > 64) {
            _lgEnd = Date.now(); _lgDx = _tLiftX - _tX; _lgDy = _tLiftY - _tY;
          }

          // ── 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();
              // v24: judged at the LIFT-OFF point. _tLastX/Y is the last move
              // the 16ms sampler kept, and Chrome on Android sends no
              // touchmove at all while the finger is inside the touch slop,
              // so a quick flick that left the button was read at a point it
              // had already passed (out=0, no row) or where it started (trav
              // 0, filed as the layout moving under a still finger: 800 of
              // the 1,367 out=1 mv=0 rows in the 7 days to 2026-10-12 read
              // trav <= 3). touchend's changedTouches is where the finger
              // actually lifted; the last raw touchmove is the fallback.
              var ex = _tLiftX, ey = _tLiftY;
              var outside = ex < r.left || ex > r.right || ey < r.top || ey > r.bottom ? 1 : 0;
              var travel = Math.round(Math.sqrt((ex - _tX) * (ex - _tX) + (ey - _tY) * (ey - _tY)));
              // 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 touched = _tEl, rect0 = _tRect, pic0 = _tPic;
                var sx = _tX, sy = _tY;
                var pEnd = _tPrevEnd, pdx = _tPrevDx, pdy = _tPrevDy;
                // The finger's direction, from the same lift-off point (v19
                // read it from touchend for the reason above; v24 reads out
                // and trav from it too).
                var fdx = ex - _tX;
                var fdy = ey - _tY;
                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);
                    // mv: DID THE SWIPE MOVE WHAT WAS UNDER THE FINGER?
                    // A swipe on a product-image carousel that changes the
                    // picture is the carousel working, not a missed tap. The
                    // scroll guard above cannot see it: Swiper, Flickity,
                    // Slick and Splide move slides with a CSS transform and
                    // fire no scroll event, and a native scroll-snap strip
                    // often scrolls only AFTER touchend. Either way, by the
                    // end of this 500ms window the touched element has
                    // travelled the way the finger pushed it, so: moved more
                    // than 24px, in the direction the finger went, is mv=1.
                    // A swipe that did nothing (or snapped straight back) is
                    // mv=0 and stays a finding. A still finger (fdx/fdy ~0)
                    // can never be mv=1, so an auto-advancing carousel moving
                    // under a held finger is still a shift. Detached = the
                    // swipe replaced the content, which is also it working.
                    //
                    // v30 (Axel Off-Road again, 2026-11-03): two more ways
                    // the gallery worked, each named in mk so they can be
                    // counted apart. mk is sent on every row from v30, which
                    // is how the detector tells a v30 mv=0 from an older one.
                    //   'f' moved the way the finger went (the v19 rule).
                    //   'd' the touched element was removed (content replaced).
                    //   'c' CARRIED: the finger did not move (trav <= 8), the
                    //       person's own swipe ended under 1s before this
                    //       touch, and the element kept going that swipe's
                    //       way. Flicking photo to photo, the next finger
                    //       lands while the 300ms slide animation is still
                    //       running, so the photo slides out from under a
                    //       still finger. Reproduced on Axel's live Swiper:
                    //       trav 0, out 1, the zoom button 239px away at
                    //       lift-off, mv 0 under v19 (a still finger has no
                    //       direction to compare), filed as "moves while
                    //       people are pressing it" on 9 photos.
                    //   'p' the PICTURE under where the finger landed is a
                    //       different image (fade galleries, src swaps: the
                    //       picture changes but nothing moves).
                    //   'n' none of these: the swipe did nothing (a finding).
                    // A carousel moving on its own under a finger that has
                    // not swiped it (autoplay) is none of these, mv 0: a
                    // shift, still reported.
                    var mv = 0, mk = 'n';
                    if (touched && rect0) {
                      if (!document.documentElement.contains(touched)) { mv = 1; mk = 'd'; }
                      else {
                        var r1 = touched.getBoundingClientRect();
                        var rdx = r1.left - rect0.l, rdy = r1.top - rect0.t;
                        var moved = Math.abs(rdx) > 24 || Math.abs(rdy) > 24;
                        var ownSwipe = travel <= 8 && pEnd && startedAt - pEnd < 1000;
                        if (moved && rdx * fdx + rdy * fdy > 0) { mv = 1; mk = 'f'; }
                        else if (moved && ownSwipe && rdx * pdx + rdy * pdy > 0) { mv = 1; mk = 'c'; }
                      }
                    }
                    if (!mv && pic0 && (travel > 8 || (pEnd && startedAt - pEnd < 1000))) {
                      var pic1 = _picAt(sx, sy);
                      if (pic1 && pic1 !== pic0) { mv = 1; mk = 'p'; }
                    }
                    // A working swipe must not use up the page's 8 real
                    // aborts (a shopper flicking through 14 photos would),
                    // so mv=1 rows have their own cap of 4: enough to measure
                    // how often the swipe check fires, not a row per photo.
                    if (mv) { if (_atMoved >= 4) return; _atMoved++; } else _atCount++;
                    push('at', {
                      el: eid(target), hold: hold, trav: travel, out: outside,
                      nx: n[0], ny: n[1], mv: mv, mk: mk,
                    });
                  } 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 (_) {}

  // sc — script census (2026-09-22). One row per page load naming WHICH
  // scripts the page loads, so the server can tell a customer the day a new
  // one appears (an app install, a tag a marketer added, a theme update) and
  // measure its effect against the days before. Until now the only script
  // signal was `rs`, which records a resource only when it is slower than
  // 500 ms, so a well-behaved new script never showed up anywhere: Patrol was
  // installed on Axel Off-Road and nothing in Harvv noticed.
  //
  // ORIGIN ONLY for third parties, the same rule as `sa`: the host, never the
  // path or the query string, which is where ids and emails end up. ONE
  // deliberate path read, and only this one: Shopify serves every app embed
  // from cdn.shopify.com/extensions/<uuid>/<handle>-<build>/..., so the host
  // alone collapses every app into "Shopify CDN". The handle segment is the
  // app's name (patrol, redo, discounty); the trailing version run is dropped
  // (patrol-80 -> patrol, redo-v2.16901 -> redo) because a vendor
  // re-publishing (Redo shipped three builds in one hour) is not a new script. Nothing else from that path is kept. Non-http sources
  // (data:, blob:, chrome-extension:) are the visitor's browser, not the
  // site, and are skipped. Runs once per page load on the second audit pass
  // (2.2 s after DOMContentLoaded) so deferred and app-injected scripts are
  // present. ~400 bytes on a typical store.
  // FIRST-PARTY FILE NAMES, sampled: an app with theme access (Patrol,
  // Klaviyo) adds its script as a theme asset, which is first party by host
  // and would otherwise only move a count. So on one load in five the
  // basenames of first-party scripts ship too (`fs`), with hash and version
  // runs stripped so a Shopify asset rebuild is not a new file, and without
  // our own pixel. Names only, never a path or query.
  // v13 (2026-09-30): the list kept the FIRST 15 names in page order, and
  // apps append their assets at the end of <body>, so Patrol's three theme
  // files (positions 17 to 22 on every Axel Off-Road template) were never
  // reported. Shopify's own platform scripts no longer take slots, the cap is
  // 30, and `fm` counts what is beyond it.
  function emitScriptCensus() {
    try {
      var list = document.querySelectorAll('script[src]');
      var pageHost = String(location.hostname || '').replace(/^www\./, '').toLowerCase();
      var hosts = {}, hostKeys = [], handles = {}, handleKeys = [], pixels = {}, pixelKeys = [];
      var files = {}, fileKeys = [], fileMore = 0, sampleFiles = Math.random() < 0.2;
      var n = 0, fp = 0, tp = 0;
      var rb = 0, rkSeen = {}, rkKeys = [], seenAt = {}, dpSeen = {}, dpKeys = [];
      var theme = _shopifyTheme();
      var rbs = _renderBlockingMap();
      for (var i = 0; i < list.length; i++) {
        var el = list[i];
        var src = String(el.getAttribute('src') || '');
        var host;
        var m = /^(?:https?:)?\/\/([^\/?#:]+)/i.exec(src);
        if (m) host = m[1].toLowerCase();
        else if (/^[a-z][a-z0-9+.\-]*:/i.test(src)) continue;
        else host = String(location.hostname || '').toLowerCase();
        n++;
        var bare = host.replace(/^www\./, '');
        var first = !pageHost || bare === pageHost || bare.slice(-(pageHost.length + 1)) === '.' + pageHost;
        // The file name, with hash and version runs stripped so a rebuild is
        // not a new file. Read on every load now: rk and dp need it too.
        var base = '';
        var fm = /\/([^\/?#]+\.m?js)(?:[?#]|$)/i.exec(src);
        if (fm) {
          base = fm[1].toLowerCase()
            .replace(/[.\-_][0-9a-f]{6,}(?=[.\-_]|$)/g, '')
            .replace(/[.\-_]v?\d+(?:\.\d+)+(?=[.\-_]|$)/g, '')
            .slice(0, 48);   // was 30, which cut patrol_privacy_script_blocker.js to "...blocker."
        }
        var own = /\/px\/[0-9a-f]{16}\/pixel\.js/i.test(src) || /^pixel(\.min)?\.js$/.test(base);
        // Shopify serves its own scripts from the store's domain; they are the
        // platform, not the theme, and they used to fill the file list.
        var platform = /\/(shopifycloud|cdn\/wpm|cdn\/s|checkouts\/internal)\/|\/(web-pixels|wpm)@/i.test(src);
        if (first) {
          fp++;
          if (sampleFiles && base && !own && !platform && !files[base]) {
            files[base] = 1;
            if (fileKeys.length < 30) fileKeys.push(base); else fileMore++;
          }
        }
        else {
          tp++;
          if (!hosts[host]) { hosts[host] = 1; hostKeys.push(host.slice(0, 60)); }
        }
        var x = /\/extensions\/[0-9a-f\-]{36}\/([a-z0-9][a-z0-9._\-]*?)(?:-[a-z]?[0-9][0-9.]*)?\//i.exec(src);
        var h = '';
        if (x) {
          h = x[1].toLowerCase().slice(0, 40);
          if (!handles[h]) { handles[h] = 1; handleKeys.push(h); }
        }
        var w = /\/web-pixels@[^\/]*\/app\/web-pixel-(\d+)@/i.exec(src);
        if (w && !pixels[w[1]]) { pixels[w[1]] = 1; pixelKeys.push(w[1]); }
        // rb / rk: scripts the page cannot draw until they have run. The key
        // is the one the server already counts this script under: the file
        // name for a first-party file, ~handle for an app embed, @host otherwise.
        if (_blocksRender(el, rbs)) {
          rb++;
          var rk = first ? base : (h ? '~' + h : '@' + host.slice(0, 60));
          if (rk && !rkSeen[rk] && rkKeys.length < 10) { rkSeen[rk] = 1; rkKeys.push(rk); }
        }
        // dp: one file name arriving from two different HOSTS (an app embed
        // on the platform CDN and a theme copy of the same module; a theme
        // jQuery and a CDN jQuery). Two URLs on ONE host are not counted:
        // v13 compared URLs, and in its first hour reported bootstrap.min.js
        // "twice" on 40 of 53 Tiger Friday loads, which was one vendor's two
        // widgets (/widgets/also-bought/ and /widgets/frequently-bought-
        // together/), each with its own loader of that name.
        if (base && !own && !platform && !_genericName(base)) {
          if (seenAt[base] === undefined) seenAt[base] = bare;
          else if (seenAt[base] !== bare && !dpSeen[base] && dpKeys.length < 10) { dpSeen[base] = 1; dpKeys.push(base); }
        }
      }
      push('sc', {
        n: n,
        fp: fp,
        tp: tp,
        h: hostKeys.length ? hostKeys.slice(0, 20) : undefined,
        x: handleKeys.length ? handleKeys.slice(0, 20) : undefined,
        px: pixelKeys.length ? pixelKeys.slice(0, 10) : undefined,
        fs: sampleFiles && fileKeys.length ? fileKeys : undefined,
        // Hosts seen beyond the 20 reported, so the list never reads as complete.
        mo: hostKeys.length > 20 ? hostKeys.length - 20 : undefined,
        // File names beyond the 30 reported (sampled loads only), same reason.
        fm: fileMore || undefined,
        rb: rb,
        rk: rkKeys.length ? rkKeys : undefined,
        dp: dpKeys.length ? dpKeys : undefined,
        th: theme.th,
        tn: theme.tn,
        tr: theme.tr,
      });
    } catch (_) {}
  }

  // How a script loads (2026-09-30, pixel v13). Patrol's privacy blocker is a
  // plain <script src> first in <head>: the page cannot draw until it has
  // downloaded and run. The census knew the file existed, not that. A script
  // blocks rendering when it is in <head> with no async, no defer, and is
  // classic JavaScript (a module defers itself; text/plain and templates never
  // run; a blocker that parks a script as text/plain has un-blocked it).
  // `async` is read as the PROPERTY, which is true on a script another script
  // injected, so injected scripts are not counted. Chrome also reports its own
  // verdict per fetch (renderBlockingStatus); when it says non-blocking for a
  // script fetch, that wins over the markup (an injected script with
  // async = false is ordered, not blocking).
  function _blocksRender(el, rbs) {
    try {
      if (el.async || el.defer || el.noModule) return false;
      var ty = String(el.getAttribute('type') || '').toLowerCase();
      if (ty && !/javascript|ecmascript/.test(ty)) return false;
      var p = el.parentNode, inHead = false;
      while (p) { if (p === document.head) { inHead = true; break; } p = p.parentNode; }
      if (!inHead) return false;
      if (rbs && rbs[el.src] === 'non-blocking') return false;
      return true;
    } catch (_) { return false; }
  }
  // Only fetches a <script> started: a preloaded script has one entry, from
  // the <link>, marked non-blocking, while the script tag that uses it still
  // blocks. With no script entry the markup verdict stands.
  function _renderBlockingMap() {
    try {
      if (!window.performance || !performance.getEntriesByType) return null;
      var es = performance.getEntriesByType('resource'), map = null;
      for (var i = 0; i < es.length; i++) {
        var st = es[i].renderBlockingStatus;
        if (!st || es[i].initiatorType !== 'script') continue;
        if (!map) map = {};
        if (map[es[i].name] !== 'blocking') map[es[i].name] = st;
      }
      return map;
    } catch (_) { return null; }
  }
  // Names every vendor uses (main.js, sdk.js, bundle.js): two of them on a
  // page are two vendors, not one file loaded twice. bootstrap is here because
  // it names both the CSS framework and "my widget's loader".
  function _genericName(base) {
    var stem = base.replace(/(\.min)?\.m?js$/, '');
    return stem.length < 6 || /^(bootstrap|bundle|loader|scripts?|widget|vendor|runtime|polyfills?|tracking|analytics|storefront|common|global|custom|client|config|settings)$/.test(stem);
  }

  // Shopify theme identity (2026-09-30). Every Shopify app and agency ships
  // by PUBLISHING a theme: Patrol cloned Axel's live theme into "Patrol
  // Release Theme - 2026-09-24", changed 41 files and the merchant published
  // the copy. Harvv only knew because a person wrote an annotation. Shopify
  // puts the live theme on every storefront page as window.Shopify.theme
  // ({id, name, role}); reading it per page load turns a publish into a dated
  // before-and-after on the server with nobody in the loop. The name is the
  // merchant's own label for the theme, not visitor data. role is 'main' on
  // the published theme; a preview carries another role, which lets the
  // server drop preview pages the preview_theme_id guard above misses once
  // Shopify keeps the preview in a cookie. Non-Shopify pages send nothing.
  function _shopifyTheme() {
    var out = {};
    try {
      var st = window.Shopify && window.Shopify.theme;
      if (!st || typeof st !== 'object') return out;
      var id = Number(st.id);
      if (id > 0 && id < 1e15 && Math.floor(id) === id) out.th = id;
      if (st.name) out.tn = String(st.name).slice(0, 60);
      if (st.role) out.tr = String(st.role).slice(0, 16);
    } catch (_) {}
    return out;
  }

  // ay — accessibility census (2026-09-22). Once per page load, on the second
  // audit pass: how many rendered images have no alt attribute, how many
  // rendered buttons and links have no accessible name, how many heading
  // levels are skipped, which landmarks exist, and how many new-window links
  // carry no warning. Counts plus up to five element keys (the same
  // PII-guarded eid()/selector keys every click already ships), never text.
  //
  // Why: Patrol's static audit of Axel Off-Road listed 5,566 violations, 139
  // images without alt and 250 unnamed buttons among them, counted per
  // occurrence across every template. Harvv had no equivalent on customer
  // sites (the crawl audit checks SEO structure; the teardown checks these
  // only for prospects). This census counts them where it matters: on the
  // pages real visitors see, weighted by sessions, so "139 images" becomes
  // "3 images on the product page 1,038 people viewed this week".
  // Rendered elements only (has a box, not inside aria-hidden or hidden): a
  // closed drawer's contents are not what a visitor is looking at.
  function _accName(el) {
    try {
      if ((el.getAttribute('aria-label') || '').replace(/\s+/g, '')) return true;
      var lb = el.getAttribute('aria-labelledby');
      if (lb) {
        var ids = lb.split(/\s+/);
        for (var i = 0; i < ids.length; i++) {
          var ref = document.getElementById(ids[i]);
          if (ref && (ref.textContent || '').replace(/\s+/g, '')) return true;
        }
      }
      if ((el.getAttribute('title') || '').replace(/\s+/g, '')) return true;
      var tag = el.tagName.toLowerCase();
      if (tag === 'input') {
        if (el.type === 'image') return !!(el.getAttribute('alt') || '').replace(/\s+/g, '');
        return !!(el.value || '').replace(/\s+/g, '');
      }
      if ((el.textContent || '').replace(/\s+/g, '')) return true;
      var inner = el.querySelector('img[alt], [aria-label], svg title');
      if (inner) {
        var a = inner.tagName.toLowerCase() === 'title' ? inner.textContent
          : (inner.getAttribute('alt') || inner.getAttribute('aria-label'));
        if ((a || '').replace(/\s+/g, '')) return true;
      }
      return false;
    } catch (_) { return true; }
  }
  function emitAccessCensus() {
    try {
      if (isPrivatePage()) return;
      var rendered = function (el) {
        try {
          if (el.closest && el.closest('[aria-hidden="true"],[hidden]')) return false;
          var r = el.getBoundingClientRect();
          return r.width > 0 && r.height > 0;
        } catch (_) { return false; }
      };
      var imgs = document.querySelectorAll('img');
      var ic = 0, ia = 0, il = 0, ie = [];
      for (var i = 0; i < imgs.length && ic < 300; i++) {
        var im = imgs[i];
        if (!rendered(im)) continue;
        var ir = im.getBoundingClientRect();
        if (ir.width < 16 && ir.height < 16) continue;
        ic++;
        var role = (im.getAttribute('role') || '').toLowerCase();
        if (im.hasAttribute('alt') || role === 'presentation' || role === 'none') continue;
        ia++;
        if (im.closest && im.closest('a[href]')) il++;
        if (ie.length < 5) ie.push(_vbSel(im));
      }
      var ctrls = document.querySelectorAll('a[href], button, [role="button"], input[type="button"], input[type="submit"], input[type="image"]');
      var bc = 0, bn = 0, be = [];
      for (var j = 0; j < ctrls.length && bc < 400; j++) {
        var c = ctrls[j];
        if (!rendered(c)) continue;
        bc++;
        if (_accName(c)) continue;
        bn++;
        if (be.length < 5) be.push(String(eid(c) || _vbSel(c)).slice(0, 60));
      }
      var hs = 0, last = 0;
      var heads = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
      for (var k = 0; k < heads.length && k < 200; k++) {
        if (!rendered(heads[k])) continue;
        var lv = +heads[k].tagName.charAt(1);
        if (last && lv > last + 1) hs++;
        last = lv;
      }
      var lm = 0;
      if (document.querySelector('main, [role="main"]')) lm |= 1;
      var anchors = document.querySelectorAll('a[href^="#"]');
      for (var s = 0; s < anchors.length && s < 10; s++) {
        if (/skip/i.test(anchors[s].textContent || '')) { lm |= 2; break; }
      }
      if (document.querySelector('nav, [role="navigation"]')) lm |= 4;
      if (document.documentElement.getAttribute('lang')) lm |= 8;
      var nw = 0, nwu = 0;
      var blanks = document.querySelectorAll('a[target="_blank"]');
      for (var t = 0; t < blanks.length && t < 200; t++) {
        if (!rendered(blanks[t])) continue;
        nw++;
        var txt = (blanks[t].textContent || '') + ' ' + (blanks[t].getAttribute('aria-label') || '') + ' ' + (blanks[t].getAttribute('title') || '');
        if (!/new (window|tab)|opens in/i.test(txt)) nwu++;
      }
      push('ay', {
        ic: ic, ia: ia, il: il || undefined, ie: ie.length ? ie : undefined,
        bc: bc, bn: bn, be: be.length ? be : undefined,
        hs: hs, lm: lm,
        nw: nw || undefined, nwu: nwu || undefined,
      });
    } catch (_) {}
  }

  // #81849 (2026-09-30) — widget census. Which third-party widgets live where
  // the pixel cannot read them from the light DOM: hosts of OPEN shadow roots
  // (h, with the form controls inside each, c), elements that look like
  // closed-root hosts (a custom element with a size but no light children, cl)
  // and visible cross-origin iframes (fr, origin only). The server's pattern
  // library names each one (shopify-forms-embed = Shopify Forms) and files a
  // case for a signature it does not know, so a new widget is found by a job,
  // not by a customer question we could not answer. Tags, plain ids and
  // origins only, never text. Sampled on 1 page load in 4 (no device storage
  // to dedupe with, and the scan is skipped on the other 3): consumers count
  // signatures and sites, never read a wd count as a page-view count.
  // Two passes: the audit pass (2.2 s) and 12 s after it, because popups
  // attach late (shopjwest.com's Shopify Forms root appeared at ~8 s, after
  // the first pass). The second pass sends only what the first did not.
  var WD_ON = Math.random() < 0.25, WD_SENT = {};
  function emitWidgetCensus(late) {
    try {
      if (!WD_ON || isPrivatePage()) return;
      var all = document.getElementsByTagName('*'), h = [], c = [], seen = {}, nh = 0, cl = 0;
      for (var i = 0; i < all.length && i < 8000; i++) {
        var el = all[i], sr = el.shadowRoot;
        if (sr) {
          nh++;
          var sg = hostSig(sr.firstElementChild || sr) || el.tagName.toLowerCase();
          if (!seen[sg] && !WD_SENT[sg] && h.length < 12) { seen[sg] = 1; h.push(sg); c.push(sr.querySelectorAll('input,select,textarea,button').length); }
        } else if (el.tagName.indexOf('-') > 0 && !el.firstElementChild && el.offsetWidth > 20 && el.offsetHeight > 20) cl++;
      }
      var fr = [], ifs = document.getElementsByTagName('iframe');
      for (var j = 0; j < ifs.length && fr.length < 8; j++) {
        try {
          var o = new URL(ifs[j].src, location.href).origin;
          if (o !== location.origin && /^https?:/.test(o) && ifs[j].offsetWidth > 20 && ifs[j].offsetHeight > 20 && fr.indexOf(o) < 0 && !WD_SENT[o]) fr.push(o);
        } catch (_) {}
      }
      if (late ? !h.length && !fr.length : !nh && !cl && !fr.length) return;
      for (var k = 0; k < h.length; k++) WD_SENT[h[k]] = 1;
      for (var q = 0; q < fr.length; q++) WD_SENT[fr[q]] = 1;
      push('wd', { h: h.length ? h : undefined, c: h.length ? c : undefined, nh: nh, cl: cl || undefined, fr: fr.length ? fr : undefined, lt: late ? 1 : undefined });
    } 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;
      emitScriptCensus();
      emitAccessCensus();
      emitWidgetCensus();
      setTimeout(function () { emitWidgetCensus(1); }, 12000);
      if (hvFormCensus) hvFormCensus();
    }, 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 () {
    // #79522: a stale session rolls (and hands off its engagement) before
    // this page's totals are read, so they describe the session they land in.
    rollSessionIfStale();
    if (engageStart) totalEngage += Date.now() - engageStart;
    push("ul", { eg: totalEngage, sm: curScrollPctMax, ld: loadId }); // unload: total engagement ms + exact peak scroll % (+ load id, #79521)
    // 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,
        // #79522: programmatic jumps (count, px), kept out of every figure above.
        jp: jumps || undefined,
        jd: Math.round(jumpDist) || 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 (capture, so a page that cancels it and
    // submits later is still counted once, here).
    var onSubmitEv = function (ev) { recordSubmit(ev.target, ev.submitter || null); };
    document.addEventListener('submit', onSubmitEv, true);
    // `submit` is not composed: a form inside a shadow root never reaches the
    // document listener, so each touched root gets its own (#81849).
    onRoot(function (r) { r.addEventListener('submit', onSubmitEv, true); });
    // form.submit() fires no submit event. Gravity Forms (postback and iframe
    // methods), reCAPTCHA v3 flows and hand-written JS all end here. Wrapped
    // once, and the original always runs.
    var FP = window.HTMLFormElement && window.HTMLFormElement.prototype;
    if (FP && typeof FP.submit === 'function' && !FP.submit.__hv) {
      var nativeSubmit = FP.submit;
      var hvSubmit = function () {
        recordSubmit(this, null);
        return nativeSubmit.apply(this, arguments);
      };
      hvSubmit.__hv = 1;
      FP.submit = hvSubmit;
    }
    // Gravity Forms 2.9 AJAX method: the button cancels the click and posts
    // to admin-ajax.php, with no submit event and no form.submit(). The click
    // on GF's own submit/next/previous button is the only sign of it.
    document.addEventListener('click', function (ev) {
      try {
        var b = ev.target;
        for (var k = 0; b && k < 4 && b.tagName !== 'BUTTON' && b.tagName !== 'INPUT'; k++) b = b.parentElement;
        if (!b || !b.form || !/^gform_\d{1,6}$/.test(b.form.id || '')) return;
        if (b.type !== 'submit' && !/gform_(button|next_button|previous_button)/.test(typeof b.className === 'string' ? b.className : '')) return;
        recordSubmit(b.form, b);
      } 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 Audit your site

Learn

Case studies Guides Harvv vs Clarity Harvv vs Cloudflare Clarity & privacy Clarity vs Google Analytics Clarity vs Hotjar Dead clicks Rage clicks Shopify bot traffic CIPA and tracking

Company

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