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