Last updated: March 29, 2026
This is the complete, unminified source code of the Harvv behavioral analytics pixel. Every site running Harvv loads a minified version of exactly this code.
Size: 192,659 bytes unminified · ~3,363 bytes gzipped · 18 behavioral signals · Zero PII
/**
* TheReallyFastPixel — Behavioral analytics in under 2KB gzipped.
* Drop-in, zero-config, first-party behavioral analytics.
* Captures the behavioral story of every session as a first-class data structure.
*
* Configure: optionally set window.__px = "https://yourdomain.com/px" before this script loads.
* Or just load the script — it auto-detects its own origin and POSTs back to /px on it.
*/
(function () {
// 2026-05-13 — Double-install guard. A site can have the Harvv pixel
// installed via TWO independent paths: the canonical <script> tag AND
// the WP plugin's enqueue (or future Shopify app, Laravel package, etc).
// If both fire, every event doubles. The first instance wins; subsequent
// ones bail. We stamp window.__harvvLoaded__ with the source string so
// /internal/site-snapshot can later report which path got the pixel
// running. The plugin's bundled tracker uses the same sentinel.
if (window.__harvvLoaded__) {
try {
(window.__harvvDuplicates__ = window.__harvvDuplicates__ || []).push({
first: window.__harvvLoaded__,
second: 'pixel.js',
at: Date.now(),
});
} catch (_) {}
return;
}
window.__harvvLoaded__ = 'pixel.js';
// 2026-05-26 — Shopify theme-editor / theme-preview guard.
// Tiger Friday reported that BOOST theme previews would not render
// when our pixel was in the theme code. Confirmed root cause: the
// Shopify theme-preview screenshotter is a headless browser that
// waits for the page to be "idle" before snapshotting. Our pixel
// attaches three MutationObservers on document.body with subtree:true
// plus continuous beacon traffic to harvv.com, which keeps the
// network/main-thread busy enough that the screenshotter times out
// and renders the placeholder thumbnail. The CUSTOMER's actual
// visitors are unaffected — this only matters in the editor.
//
// The detection covers three Shopify editor surfaces:
// 1. Theme customizer: window.Shopify.designMode === true
// 2. Theme preview: ?preview_theme_id=... in the URL
// 3. Staff/ATC preview: ?_ab=0&_ab_test_id=... (deny-staff preview)
//
// Other platforms with similar editor modes can extend this list.
// Override: append ?harvv_force=1 to test pixel behavior in preview.
try {
var _href = (location && location.href) || '';
var _isShopifyDesign = !!(window.Shopify && window.Shopify.designMode);
var _isShopifyPreview = _href.indexOf('preview_theme_id=') > -1;
var _isShopifyStaff = _href.indexOf('_ab=0&_ab_test_id=') > -1;
var _isForced = _href.indexOf('harvv_force=1') > -1;
if (!_isForced && (_isShopifyDesign || _isShopifyPreview || _isShopifyStaff)) {
// Stamp a global so dashboard install-health can show "pixel
// detected but bailed (editor mode)". No events emitted.
window.__harvvEditorBail__ = _isShopifyDesign ? 'shopify_designmode'
: _isShopifyPreview ? 'shopify_preview' : 'shopify_staff_ab';
return;
}
} catch (_) { /* never block the pixel on the editor-guard check */ }
// --- Consent gate (Harvv-properties only) ---
// 2026-05-06 — when running on harvv.com properties (harvv.com, *.harvv.com,
// including admin.harvv.com / flow.harvv.com / pk2 etc.) the pixel must
// honor the cookie banner's analytics choice. Customer sites are exempt:
// their consent banner is THEIR responsibility, our pixel just runs as the
// customer configured. We listen for the harvv:consent event so the pixel
// can wake up if the user later flips analytics back on without a reload.
try {
var hostIsHarvv = /(?:^|\.)harvv\.com$/i.test(location.hostname);
if (hostIsHarvv) {
var consent = (typeof window.__harvvConsent === 'function') ? window.__harvvConsent() : null;
if (consent && consent.choices && consent.choices.analytics === false) {
// User explicitly declined analytics on this harvv property. Bail.
// If they later accept, the cookie banner fires the harvv:consent event;
// we re-bootstrap the pixel by reloading itself.
window.addEventListener('harvv:consent', function (ev) {
if (ev && ev.detail && ev.detail.choices && ev.detail.choices.analytics === true) {
try {
var s = document.createElement('script');
s.async = true;
// 2026-05-26 — Replaced `?.src` optional chaining with explicit
// ternary because PK Android 7-8 Webviews (Chrome 60-70 era) lack
// ES2020 support and throw "Uncaught SyntaxError: Unexpected token '.'"
// when parsing the whole pixel. Found via internal_errors row from
// a real visitor on /signup?ref=wordpress. Every customer site
// embedding the pixel was breaking on old Webviews.
var __q = document.querySelector('script[src*="/pixel.js"]');
s.src = (document.currentScript && document.currentScript.src) ||
(__q && __q.src) ||
'/px/5abaa759db95fcf4/pixel.js';
document.head.appendChild(s);
} catch (_) {}
}
});
return;
}
}
} catch (_) { /* never block the pixel on consent-check errors */ }
// --- Configuration ---
// Auto-detect endpoint from script src so a single <script src="..."> just works.
// document.currentScript is null for defer scripts, so find ourselves by src attribute.
var scripts = document.querySelectorAll('script[src*="/pixel.js"]');
var scriptSrc = scripts.length && scripts[scripts.length - 1].src;
var ENDPOINT = window.__px || (scriptSrc ? scriptSrc.replace(/\/[^/]*$/, "/px") : "/px");
// 2026-05-19 — edge spillover. When the primary endpoint can't be reached
// (Railway-wide outage = the very situation we just hit), the pixel falls
// over to this Cloudflare-hosted backup ingest. The receiver injects
// `__pxFallback` into the preamble only when INGEST_FALLBACK_URL is set
// on the receiver side; absent → no fallback try → no behaviour change.
// The fallback receives the SAME POST body the primary would have, stores
// it in Cloudflare KV, and the receiver drains it back into PG when
// primary is healthy. See docs/runbooks/edge-spillover.md.
var BACKUP_ENDPOINT = (typeof window !== 'undefined' && typeof window.__pxFallback === 'string' && window.__pxFallback) ? window.__pxFallback : null;
// 1.0.7 — GA4 coexistence (research-recommended pattern).
// We detect GA4 presence at boot so the server can:
// • Dedupe between our DOM-detected events and GA4 dataLayer events
// • Show "vs GA4" comparison in the dashboard
// • Annotate sessions with ga4_present
// We also honor GA4 Consent Mode v2: if analytics_storage is 'denied',
// we downgrade to anonymous-only mode (no persistent visitor_id).
// We do NOT subscribe to dataLayer events by default (separate-namespace
// is the FullStory/Mouseflow/Clarity default). We DO push Harvv-detected
// signals to dataLayer under the harvv_signal namespace so customers
// can route them to their own GTM-managed downstream tools.
var GA4_PRESENT = !!(window.gtag || window.google_tag_manager || (Array.isArray(window.dataLayer) && window.dataLayer.length));
var CONSENT_DENIED = false;
try {
// Probe GA4 Consent Mode v2 state. Inline (synchronous) read first;
// for the async gtag('get', ...) path we'd need to wait, which we
// skip for byte budget.
var dl = window.dataLayer || [];
for (var ci = 0; ci < dl.length; ci++) {
var dle = dl[ci];
if (dle && dle[0] === 'consent' && dle[2] && dle[2].analytics_storage === 'denied') {
CONSENT_DENIED = true; break;
}
}
} catch (_) {}
// Map our `cm` kinds to GA4 recommended event names so the dashboard
// can label both.
var GA4_NAMES = { ca: 'add_to_cart', co: 'begin_checkout' };
// Inverse map for the dataLayer subscriber — when GA4 emits one of
// these recommended event names, ingest as a Harvv `cm` event with
// src:'dl' (more precise than DOM detection because the customer's
// code chose to fire it).
// 2026-05-23 — Extended map. Standard GA4 recommended event names
// (Shopify, GTM defaults, etc.) plus the legacy/custom names we've seen
// in real customer data (Axel: shopping_cart, checkout, contact_us,
// product, ads_conversion_*). Mapping multiple GA4 names → same Harvv
// cm kind is intentional: emitCm() de-dupes on (kind, ga4) so we never
// double-count.
var GA4_TO_KIND = {
// GA4 recommended event names
add_to_cart: 'ca', begin_checkout: 'co', view_cart: 'vc',
purchase: 'pu', view_item: 'vi', view_item_list: 'vi',
generate_lead: 'gl', sign_up: 'su', search: 'sr',
// Common custom / legacy names from Shopify, WooCommerce, GTM templates
shopping_cart: 'ca', // Shopify default; same intent as add_to_cart
checkout: 'co', // some sites fire 'checkout' instead of 'begin_checkout'
contact_us: 'gl', // contact-form submit is a lead event
product: 'vi', // some templates fire 'product' for view_item
};
// 1.0.8 — dedupe-aware commerce emitter. The DOM click handler and
// the dataLayer subscriber can both detect the same logical event
// (customer's button click + their GTM tag firing on the same click).
// We hold DOM emissions for 100ms; if a dataLayer-sourced event with
// the same `ga4` name arrives in that window, we cancel the DOM emit
// and keep only the (more precise) dataLayer version. The dashboard
// then sees one event per real conversion regardless of how many
// signal paths the customer's site has.
var pendingDom = {};
function emitCm(src, kind, data) {
var ga4 = (data && data.ga4) || GA4_NAMES[kind] || null;
var key = kind + ':' + (ga4 || '');
var payload = Object.assign({ k: kind, src: src }, data || {});
if (ga4) payload.ga4 = ga4;
if (src === 'dom') {
pendingDom[key] = setTimeout(function () {
push('cm', payload);
delete pendingDom[key];
}, 100);
} else {
if (pendingDom[key]) { clearTimeout(pendingDom[key]); delete pendingDom[key]; }
push('cm', payload);
}
// Push to customer's dataLayer under the harvv_signal namespace so
// their GTM can route to other tools (Meta Pixel, Klaviyo, etc.)
// WITHOUT double-counting GA4 — we never reuse a GA4 event name as
// our dataLayer event.
try {
if (!window.dataLayer) window.dataLayer = [];
window.dataLayer.push({ event: 'harvv_signal', harvv_kind: kind, harvv_src: src, harvv_ga4: ga4 || null });
} catch (_) {}
}
// Subscribe to the customer's dataLayer for known GA4 commerce events.
// Wraps `window.dataLayer.push` so future events flow through us; also
// replays anything already in the array (events that fired before our
// pixel booted — e.g. if a server-rendered `gtag('config', ...)` ran
// before our async script). Subscribe-once guard via window.__harvvDL.
try {
if (!window.__harvvDL) {
window.__harvvDL = 1;
if (!Array.isArray(window.dataLayer)) window.dataLayer = [];
var dlIngest = function (a) {
if (!a || a[0] !== 'event') return;
var name = a[1];
var kind = GA4_TO_KIND[name];
if (!kind) return;
// Ignore re-firings of our own harvv_signal pushes.
if (name === 'harvv_signal') return;
emitCm('dl', kind, { ga4: name });
};
for (var di = 0; di < window.dataLayer.length; di++) dlIngest(window.dataLayer[di]);
var dlOrig = window.dataLayer.push;
window.dataLayer.push = function () {
for (var ai = 0; ai < arguments.length; ai++) {
try { dlIngest(arguments[ai]); } catch (_) {}
}
return dlOrig.apply(window.dataLayer, arguments);
};
}
} catch (_) {}
var SESSION_TIMEOUT = 1800000; // 30 minutes in ms
var FLUSH_INTERVAL = 10000; // 10 seconds
var RAGE_THRESHOLD = 3; // clicks within window
var RAGE_WINDOW = 800; // ms
var RAGE_RADIUS = 50; // px
var HOVER_DELAY = 500; // ms to qualify as hover intent
var SCROLL_MILESTONES = [10, 25, 50, 75, 100];
var TEXT_LIMIT = 20; // max chars of innerText in element ID
// --- Utilities ---
// Generate a random ID for session_id + visitor_id.
//
// T70 (2026-04-28): expanded from 8 → 16 hex chars after a real production
// collision was observed: session_id `ce51b784` appeared on two distinct
// sites (51 events on one, 14 on the other). 8 hex chars = 4B-ish space,
// and at Tiger Friday's 35M-events/month scale the birthday-paradox math
// makes inter-site collisions inevitable.
//
// 16 hex chars = 64-bit space (~1.8e19), making collisions effectively
// impossible at any realistic scale. Two Math.random calls produce ~64
// effective bits (Math.random itself caps at ~53 bits each but the
// concatenation comfortably saturates the 64-bit hex output).
//
// Cost: +8 bytes per event payload, +~50 bytes to the pixel. Trivial
// vs the data-integrity gain. Backwards-compatible — receiver treats
// ID as a string, and existing visitors keep their old 8-char IDs from
// cookies/localStorage until those expire naturally.
function rid() {
return ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0") +
((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0");
}
// Read a cookie by name
function getCookie(n) {
var m = document.cookie.match(new RegExp("(?:^|; )" + n + "=([^;]*)"));
return m ? m[1] : null;
}
// Set a first-party cookie, 2-year expiry for visitor, session for session.
// Domain set to registrable domain so subdomains share the same visitor/session.
//
// [PC-019] The naive two-label extraction (`parts.slice(-2).join('.')`) produced
// a public suffix for ccTLDs like .co.uk, .com.br, .com.au — cookies set on a
// public suffix are rejected by browsers, silently killing visitor tracking.
// This was a live bug affecting oxfordcityfalafel.co.uk (703 sessions, all
// visitors undercounted as new). Fix: compressed Public Suffix List of common
// two-label ccTLD suffixes. The list covers ~98% of real-world ccTLD traffic;
// exotic suffixes fall back to the two-label heuristic, which is correct for
// non-ccTLD domains.
var PSL_2LABELS = "co.uk,org.uk,me.uk,ac.uk,gov.uk,net.uk,com.au,net.au,org.au,edu.au,co.nz,net.nz,org.nz,co.jp,or.jp,ne.jp,ac.jp,com.br,net.br,org.br,com.mx,com.ar,com.cn,com.sg,com.my,com.ph,com.hk,com.tw,com.tr,com.ua,com.co,com.pe,com.ve,co.in,net.in,org.in,co.za,org.za,co.kr,or.kr,co.il,org.il,co.th,or.th,com.pk,net.pk,org.pk,co.id,or.id,com.ng,com.eg,com.bd,co.ke,co.tz,com.do,com.gt,com.sv,com.hn,com.ni,com.py,com.bo,com.ec,com.uy";
var PSL_SET = {};
PSL_2LABELS.split(",").forEach(function(s) { PSL_SET[s] = 1; });
var rootDomain = (function() {
var parts = location.hostname.split(".");
if (parts.length <= 2) return location.hostname; // example.com → example.com
// Check if the last 2 labels form a known public suffix (ccTLD)
var last2 = parts.slice(-2).join(".");
if (PSL_SET[last2] && parts.length >= 3) {
// ccTLD: www.example.co.uk → example.co.uk (take last 3 labels)
return parts.slice(-3).join(".");
}
// Standard TLD: www.example.com → example.com (take last 2 labels)
return last2;
})();
// [2026-04-21] Dropped the RFC 2109 leading dot from `domain=.example.com`. Modern
// browsers normalize it, but some privacy tools + ad blockers strip cookies with
// the leading-dot syntax, which was eating session continuity on same-origin
// navigations (e.g. "clicked 30-day exchange button → new page, new session").
// Skip the domain attribute entirely on localhost / single-label hosts (browser
// defaults to the current host, which is what we want).
function setCookie(n, v, days) {
var e = days ? ";expires=" + new Date(Date.now() + days * 864e5).toUTCString() : "";
var dom = (rootDomain && rootDomain.indexOf(".") !== -1) ? (";domain=" + rootDomain) : "";
document.cookie = n + "=" + v + e + ";path=/;SameSite=Lax" + dom;
}
// LocalStorage backup for session continuity. Cookies can be stripped by
// ad-blockers, strict privacy modes, or host policies, which would orphan
// events across navigations. localStorage is more durable; we write BOTH
// on every update and read from either on init (cookie wins if both present).
function lsSet(k, v) { try { localStorage.setItem(k, String(v)); } catch (_) {} }
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
// --- PII Scrubbing ---
// An element is "private" (text-scrubbed) if it OR any ancestor up to the
// document root:
// • has data-harvv-private="true"
// • is contenteditable (a chat composer, comment editor, etc.)
// • is a TEXTAREA
// • matches a role that typically contains user-generated content
// (role=log, role=textbox, role=feed, role=listitem inside role=log)
// Private elements still get identified by tag/id/class (for detection) but
// their textContent is NEVER captured — the `:"..."` suffix is dropped.
//
// Sites can opt IN more aggressively by setting data-harvv-private="true" on
// any container (e.g. wrap your chat bubbles in <div data-harvv-private>...)
var PRIVATE_ROLE = /^(log|textbox|feed)$/;
function isPrivateElement(el) {
var p = el, depth = 20;
while (p && depth--) {
if (p.getAttribute) {
var priv = p.getAttribute("data-harvv-private");
if (priv === "true" || priv === "" || priv === "1") return true;
if (p.isContentEditable) return true;
var tag = p.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
// Password, credit card, hidden, email inputs — never capture surrounding text
var t = (p.type || "").toLowerCase();
if (t === "password" || t === "hidden" || t === "email" || t === "tel" || t === "number") return true;
}
var role = p.getAttribute("role");
if (role && PRIVATE_ROLE.test(role)) return true;
}
p = p.parentElement;
}
return false;
}
// Walk up (max 5) to the nearest ancestor that identifies the control a bare
// glyph belongs to. Prefers a semantic control (button / a / role=button);
// otherwise the first ancestor carrying an id, class, or aria-label. Reads
// STRUCTURAL attributes only (id/class/aria-label/role/tag), never
// textContent, so the PII guard is preserved. Returns a short selector
// fragment (e.g. button.cart-close, a[aria-label="Close"]) or null. Used by
// eid() for the bare-tag rescue (ICX-020, 2026-06-18).
function ancestorHandle(el) {
var p = el.parentElement, d = 5, fb = null;
while (p && d--) {
if (p.getAttribute) {
var pt = (p.tagName || "").toLowerCase();
var pid = p.id ? "#" + p.id : "";
var pcRaw = typeof p.className === "string" ? p.className
: (p.className && p.className.baseVal) || "";
var pc = (!pid && pcRaw.trim()) ? "." + pcRaw.trim().split(/\s+/)[0] : "";
var pa = p.getAttribute("aria-label");
pa = pa ? '[aria-label="' + String(pa).replace(/[\t\n\r\s]+/g, " ").trim().slice(0, TEXT_LIMIT) + '"]' : "";
var pr = p.getAttribute("role");
if (pt === "button" || pt === "a" || (pr && /^(button|link|menuitem|tab|switch)$/.test(pr))) {
return pt + pid + pc + pa;
}
if (!fb && (pid || pc || pa)) fb = pt + pid + pc + pa;
}
p = p.parentElement;
}
return fb;
}
// Build a minimal element identifier.
// Format (public): tag#id:"text" or tag.class:"text"
// e.g. btn#add-cart:"Add to C" — a human reads the session story.
// Format (private): tag#id or tag.class (no text — PII guard)
// e.g. div.chat-bubble, div#response-34
// For INPUT elements: input[type]#id or input[type].name (never value)
function eid(el) {
if (!el || !el.tagName) return null;
var tag = el.tagName.toLowerCase();
// For inputs, include type attribute and prefer name for the class slot
if (tag === "input") {
var type = el.type || "text";
var id = el.id ? "#" + el.id : "";
var name = !id && el.name ? "." + el.name : "";
return "input[" + type + "]" + id + name;
}
var id2 = el.id ? "#" + el.id : "";
// className is a plain string on HTML nodes but an SVGAnimatedString on
// SVG/MathML nodes (icons!). Read .baseVal in that case so <svg class="x">
// resolves to svg.x instead of a useless bare "svg" (ICX-020).
var rawCls = typeof el.className === "string" ? el.className
: (el.className && el.className.baseVal) || "";
var cls = !id2 && rawCls.trim() ? "." + rawCls.trim().split(/\s+/)[0] : "";
// PII guard — if this element is inside a private zone, skip text content
// entirely. We still report the tag/id/class so detection (dead clicks,
// blind spots, etc.) works, but the user-typed/generated text never leaks.
if (isPrivateElement(el)) {
return tag + id2 + cls;
}
// Use textContent (fast) instead of innerText (triggers layout/style calc).
// Sanitize: strip control chars (tab/newline/CR) and collapse whitespace so
// titles like `Engaging New Mexico\t` don't surface the escape char to users.
// Also reject text that contains "<" — if textContent starts with an HTML-like
// fragment, something rendered raw markup as a text child; capturing that as
// a "title" produces broken strings like `"<img width="150" hei`.
var rawTxt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
var txt = rawTxt.slice(0, TEXT_LIMIT);
if (txt.indexOf("<") !== -1) txt = "";
// 2026-05-13 — reject CSS-like content. Inline <style> blocks inside SVGs
// surface in textContent as `.cls-1 { fill: #fff }` which was getting
// captured as the "label" for icon elements (issue 6270 on Prime MD:
// `a.elementor-icon:".cls-1 { fill: #fff;"` instead of an empty label).
// Also catches `@media`, `@keyframes`, `:root {`, etc.
if (/^[.#:@][\w-]+\s*\{|@(media|keyframes|font-face|supports|import|charset)/i.test(txt)) txt = "";
// 2026-05-13 — prefer ARIA / title attributes for icon elements that have
// no real text content. SVG icons + <i> tags often have aria-label or
// title set (e.g. Elementor sets aria-label="Instagram" on social icons).
// That's the real semantic label, not whatever the SVG's textContent leaked.
if (!txt && (tag === "svg" || tag === "i" || /icon/i.test(cls))) {
var aria = el.getAttribute && (el.getAttribute("aria-label") || el.getAttribute("title"));
if (aria) {
aria = String(aria).replace(/[\t\n\r\s]+/g, " ").trim().slice(0, TEXT_LIMIT);
if (aria) txt = aria;
}
}
txt = txt ? ':"' + txt + '"' : "";
// Bare-tag rescue (ICX-020, 2026-06-18): an icon/glyph with no id, class,
// or label of its own resolves to just "svg"/"path"/"use" — useless to a
// customer ("svg icons"). Anchor it to the nearest ancestor that names the
// control it belongs to: button.cart-close svg, a[aria-label="Close"] use.
// Structural attributes only (id/class/aria-label/role/tag), never text —
// the PII guard above is unaffected.
if (!id2 && !cls && !txt) {
var anc = ancestorHandle(el);
if (anc) return anc + " " + tag;
}
return tag + id2 + cls + txt;
}
// Check if an element is interactive (clickable by nature)
function isInteractive(el) {
if (!el || !el.tagName) return false;
var tag = el.tagName;
// Interactive tags
if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|DETAILS|SUMMARY)$/.test(tag)) return true;
// ARIA roles (button, radio, checkbox, option, menuitem, tab, switch)
var role = el.getAttribute && el.getAttribute("role");
if (role && /^(button|radio|checkbox|option|menuitem|tab|switch)$/.test(role)) return true;
// Tabindex = explicitly interactive
if (el.getAttribute && el.getAttribute("tabindex")) return true;
// Label-for-input (clicks on labels natively toggle their associated input)
if (tag === "LABEL") {
var f = el.htmlFor || (el.getAttribute && el.getAttribute("for"));
if (f && document.getElementById(f)) return true;
}
// Data attributes indicating JS-handled interactive elements (swatches, variant selectors)
if (el.hasAttribute && (el.hasAttribute("data-value") || el.hasAttribute("data-option-value") || el.hasAttribute("data-variant-id") || el.hasAttribute("data-action"))) return true;
// Walk up to see if inside an anchor, button, label[for], ARIA button,
// tabindex-enabled element, or custom element (max 8 levels). Shopify themes
// nest deeply: div.product__info-wrapper > div > div > span > a.product-link.
// 5 levels was too shallow. Elementor/React/Vue use role="button" tabindex="0"
// on divs instead of <button> — previously we fired false-positive dead clicks
// on child icons inside those (e.g. <i class="menu-toggle__icon--open"> inside
// <div role="button" tabindex="0">). Covers M-Diamond's mobile menu icon
// (68 sessions of false positives verified against actual ix events).
var p = el.parentElement, i = 8;
while (p && i--) {
if (/^(A|BUTTON)$/.test(p.tagName)) return true;
if (p.tagName === "LABEL" && p.htmlFor && document.getElementById(p.htmlFor)) return true;
// ARIA button / interactive role pattern (Elementor, React, Tailwind, etc.)
if (p.getAttribute) {
var pRole = p.getAttribute("role");
if (pRole && /^(button|radio|checkbox|option|menuitem|tab|switch|link)$/.test(pRole)) return true;
// tabindex=0/1/... explicitly makes the element keyboard-interactive
var pTab = p.getAttribute("tabindex");
if (pTab !== null && pTab !== "-1") return true;
// Event-handler hints from common frameworks
if (p.hasAttribute("onclick") || p.hasAttribute("@click") || p.hasAttribute("v-on:click") || p.hasAttribute("ng-click")) return true;
}
// Shopify custom elements for variant selectors + product cards
if (/^(VARIANT-SELECTS|VARIANT-RADIOS|PRODUCT-FORM)$/.test(p.tagName)) return true;
// Shopify product cards: clicking product title text inside a card-wrapper that
// contains a link should NOT be a dead click — the click navigates via the link.
if (p.hasAttribute && p.hasAttribute("data-product-id")) return true;
p = p.parentElement;
}
return false;
}
// 2026-05-14 — Host-shielded element detection. True when the element
// (or any ancestor up to 5 levels) is something the host-page pixel
// cannot see inside of:
// - <iframe>: cross-origin iframes hide their DOM from us
// - <video> / <canvas>: native renderers, no DOM mutations to observe
// - Custom elements (tag name contains `-`): may have closed shadow
// roots; even open shadow DOM hides mutations from our MutationObserver
// - element.shadowRoot present: open shadow DOM
// dc/rc events on host-shielded elements carry `bs:1` so server-side
// detection can filter them out (the canonical case is Shopify Inbox —
// `inbox-online-store-chat` is a custom-element wrapper for a cross-
// origin iframe; every chat interaction looked like a dead click).
function isHostShielded(el) {
for (var d = 5; el && d--; el = el.parentElement) {
var t = el.tagName;
if (!t) continue;
if (t === 'IFRAME' || t === 'VIDEO' || t === 'CANVAS') return 1;
if (t.indexOf('-') > 0) return 1;
if (el.shadowRoot) return 1;
}
return 0;
}
// --- Third-party widget / editor-preview detection ---
// Clicks inside embedded widgets (Setmore, Calendly, Acuity, Intercom, etc.)
// or inside CMS-editor previews are not the host site's UX — agencies can't
// fix them. Suppress dc events for elements whose class matches hashed
// styled-components (`Foo-sc-a1b2c3d4-0`) or known widget containers.
var WIDGET_CLASS_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|livechat|fc-|__intercom|hubspot)-|^sc-[a-z0-9]{6,}|^[A-Z][\w]*__[A-Z][\w]*-sc-[a-z0-9]{6,}/;
var WIDGET_ATTR_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|hs)-/;
function isInsideThirdPartyWidget(el) {
var p = el, depth = 10;
while (p && depth--) {
if (p.getAttribute) {
var cls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
if (cls) {
var classList = cls.split(/\s+/);
for (var ci = 0; ci < classList.length; ci++) {
if (WIDGET_CLASS_RX.test(classList[ci])) return true;
}
}
var id = p.id || "";
if (id && WIDGET_ATTR_RX.test(id)) return true;
}
p = p.parentElement;
}
return false;
}
// Detect CMS editor previews (Elementor preview, WP admin, Shopify theme editor,
// Wix editor, Webflow designer, Framer preview, etc.). Those sessions are not
// real visitor traffic and shouldn't contribute dead clicks or cases.
function isInCmsEditor() {
try {
var s = location.search || "";
var p = location.pathname || "";
if (/(elementor-preview|preview=1|preview_id=|ver=preview|theme-preview|shopify-preview|wix-editor|framer-preview)/i.test(s)) return true;
if (/(^|\/)(wp-admin|wp-login|admin|editor|designer)(\/|$)/i.test(p)) return true;
// Running inside an iframe from the host CMS editor
if (window.self !== window.top) {
try {
var ref = document.referrer || "";
if (/(wp-admin|editor\.shopify|my\.shopify|editor\.wix|webflow\.com|framer\.com)/i.test(ref)) return true;
} catch (_) {}
}
} catch (_) {}
return false;
}
var IN_EDITOR = isInCmsEditor();
// Detect visible modal/drawer/overlay UI state at click time. Critical for
// case framing — "add to cart" inside an open cart drawer is a drawer bug,
// not a button bug. Emits `cs` tags on dc+ix events. Compact: single selector
// + single classifier regex keeps the footprint minimal (~250 bytes gzip).
var CS_SEL = '[aria-modal="true"],[role="dialog"],[data-state="open"],[class*="drawer"][class*="open"],[class*="modal"][class*="show"],[class*="modal"][class*="open"],[class*="offcanvas"][class*="show"],[class*="active"][class*="cart"],.fancybox-container,.pswp--open';
function csType(node) {
var hay = ((typeof node.className === "string" ? node.className : (node.className && node.className.baseVal) || "") + " " + (node.id || "")).toLowerCase();
return /cart/.test(hay) ? "cart_drawer"
: /search/.test(hay) ? "search_modal"
: /filter|facet|refine/.test(hay) ? "filter_panel"
: /(menu|nav).*(mobile|drawer)|(mobile|drawer).*(menu|nav)/.test(hay) ? "mobile_menu"
: /lightbox|gallery|photoswipe|pswp/.test(hay) ? "gallery_lightbox"
: /login|signin|register|account/.test(hay) ? "auth_modal"
: /checkout/.test(hay) ? "checkout_modal"
: /consent|cookie|gdpr/.test(hay) ? "consent_banner"
: /newsletter|popup|exit/.test(hay) ? "popup"
: "modal";
}
function getContextState() {
try {
var nodes = document.querySelectorAll(CS_SEL);
if (!nodes.length) return null;
var seen = {}, out = [];
for (var i = 0; i < nodes.length && out.length < 4; i++) {
var n = nodes[i], r = n.getBoundingClientRect();
if (r.width < 40 || r.height < 40) continue;
if (n.offsetParent === null && n.getAttribute && n.getAttribute("aria-hidden") === "true") continue;
var t = csType(n);
if (!seen[t]) { seen[t] = 1; out.push(t); }
}
return out.length ? out : null;
} catch (_) { return null; }
}
// --- Identity ---
// Cookies are primary; localStorage is the fallback so we survive
// cookie-stripping ad blockers + strict privacy modes across navigations.
// Visitor: persistent, generated once, 2-year cookie + localStorage mirror.
// 1.0.7 — when GA4 Consent Mode v2 has analytics_storage=denied, we
// downgrade to anonymous mode: in-memory visitor ID, no cookie write,
// no localStorage write. The session still works for the current
// page-view but disappears on navigation, mirroring GA4's
// "cookieless ping" behavior under denied consent.
var vid;
if (CONSENT_DENIED) {
vid = rid();
} else {
vid = getCookie("_pxv") || lsGet("_pxv") || rid();
setCookie("_pxv", vid, 730);
lsSet("_pxv", vid);
}
// Session: check for timeout, rotate if stale.
var now = Date.now();
var sessionStart = parseInt(getCookie("_pxss") || lsGet("_pxss")) || now;
var lastActivity = parseInt(getCookie("_pxla") || lsGet("_pxla")) || now;
var sid;
if (now - lastActivity > SESSION_TIMEOUT) {
// Session expired — start fresh.
sid = rid();
sessionStart = now;
} else {
sid = getCookie("_pxs") || lsGet("_pxs") || rid();
}
setCookie("_pxs", sid);
setCookie("_pxss", sessionStart);
lsSet("_pxs", sid);
lsSet("_pxss", sessionStart);
function touch() {
lastActivity = Date.now();
setCookie("_pxla", lastActivity);
lsSet("_pxla", lastActivity);
}
touch();
// Relative timestamp: ms since session start
function ts() {
return Date.now() - sessionStart;
}
// --- Cached values (avoid recalc on every event) ---
var cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
window.addEventListener("resize", function () {
cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
});
// --- Section Detection ---
// Walk up from an element to find the nearest section/landmark and its heading.
// Returns "Page Name / Section Name" like "Homepage / Hero Section"
function getSection(el) {
if (!el) return null;
var page = location.pathname;
// Map common paths to human names
var pageName = page === '/' ? 'Homepage' : page.replace(/^\//, '').replace(/[-_]/g, ' ').replace(/\/$/, '');
if (pageName.length > 30) pageName = pageName.substring(0, 30);
// Walk up to find nearest section, article, or landmark
var node = el, depth = 15, sectionName = '';
while (node && depth--) {
var tag = node.tagName;
if (!tag) { node = node.parentElement; continue; }
// Check for section-like containers
if (/^(SECTION|ARTICLE|ASIDE|MAIN|HEADER|FOOTER|NAV)$/.test(tag)) {
// Try to find a heading inside this section
var heading = node.querySelector('h1, h2, h3');
if (heading) {
sectionName = (heading.textContent || '').trim().substring(0, 40);
} else if (node.id) {
sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
} else if (node.getAttribute && node.getAttribute('aria-label')) {
sectionName = node.getAttribute('aria-label').substring(0, 30);
} else {
sectionName = tag.charAt(0) + tag.slice(1).toLowerCase(); // "Section", "Header", etc.
}
break;
}
// Check for div/container with meaningful id or aria-label
if (tag === 'DIV' && (node.id || (node.getAttribute && node.getAttribute('aria-label')))) {
var label = node.getAttribute && node.getAttribute('aria-label');
if (label) { sectionName = label.substring(0, 30); break; }
if (node.id && !/^(root|app|__next|content|wrapper|container)$/i.test(node.id)) {
sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
break;
}
}
node = node.parentElement;
}
if (!sectionName) return pageName;
return pageName + ' / ' + sectionName;
}
// --- Event Queue ---
var queue = [];
// T7 (2026-04-23): pointer type + cursor speed + hover dwell tracking.
// These 4 fields distinguish touch users, stalled cursors, and genuine
// hesitation from false-positive signals on click events.
//
// ptype → "mouse" | "touch" | "pen" — mobile vs desktop vs stylus
// cs → cursor speed over last 200ms in pixels/second (0 = stalled)
// dwell → ms the element was hovered before click (user hesitation)
//
// BUG FIX (2026-04-24): relying on PointerEvent.pointerType alone misses
// desktop mouse clicks in browsers that return "" for mouse pointerType,
// or where pointerdown doesn't fire reliably before click. We now layer 4
// signals and pick the best one at click time:
// 1. pointerdown → ev.pointerType (if non-empty)
// 2. touchstart → "touch"
// 3. mousedown → "mouse" (if no touch seen recently)
// 4. fallback at click: navigator.maxTouchPoints > 0 + no touchstart = "mouse"
var lastPointerType = null;
var lastTouchAt = 0;
var hasTouchCapability = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
var cursorTrack = []; // [{x, y, t}] last 10 mousemoves
try {
// PointerEvent — modern path. Sets ptype if non-empty string.
document.addEventListener('pointerdown', function (ev) {
if (ev.pointerType && typeof ev.pointerType === 'string' && ev.pointerType.length > 0) {
lastPointerType = ev.pointerType;
}
}, { capture: true, passive: true });
// touchstart — fires reliably on every touch device regardless of PointerEvent support.
document.addEventListener('touchstart', function () {
lastPointerType = 'touch';
lastTouchAt = Date.now();
}, { capture: true, passive: true });
// mousedown — fires on desktop even when pointerdown reports "". Only
// sets "mouse" if we haven't seen a touch in the last 500ms (to avoid
// synthesized mouse events that touch devices fire after a tap).
document.addEventListener('mousedown', function () {
if (Date.now() - lastTouchAt > 500) lastPointerType = 'mouse';
}, { capture: true, passive: true });
document.addEventListener('mousemove', function (ev) {
cursorTrack.push({ x: ev.clientX, y: ev.clientY, t: Date.now() });
if (cursorTrack.length > 10) cursorTrack.shift();
}, { passive: true });
} catch (_) {}
// Read-time fallback: if lastPointerType is still null at click, infer
// from device capability. No touch capability at all = must be mouse.
function resolvePointerType() {
if (lastPointerType) return lastPointerType;
return hasTouchCapability ? 'touch' : 'mouse';
}
function cursorSpeedPxPerSec() {
try {
var now = Date.now();
var recent = cursorTrack.filter(function (p) { return now - p.t < 200; });
if (recent.length < 2) return 0;
var first = recent[0], last = recent[recent.length - 1];
var dx = last.x - first.x, dy = last.y - first.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var dt = (last.t - first.t) / 1000;
return dt > 0 ? Math.round(dist / dt) : 0;
} catch (_) { return 0; }
}
// Hover dwell tracking — when the pointer enters an element, stamp the
// time; on click or leave, compute elapsed. Separate from the existing
// hover-intent listener so dwell is available even for short hovers.
var hoverEnterTime = new WeakMap();
function getHoverDwell(el) {
try {
var t = hoverEnterTime.get(el);
return t ? Math.round(Date.now() - t) : 0;
} catch (_) { return 0; }
}
// Dead-click throttle — T3 (2026-04-23).
// Rate-limits the `dc` emission per element so a frustrated user clicking
// the same broken element 50 times produces one detection signal rather
// than 50 separate ones. Uses a sliding window: at most DC_MAX per element
// per DC_WINDOW ms. Suppressed clicks are NOT lost — they'll still count
// toward rage detection (which runs on raw clicks) and the next flushed
// dc event embeds a suppressed-count so the server can see the true volume.
var DC_WINDOW = 1000; // 1-second window
var DC_MAX = 3; // at most 3 dc per element per second
var dcTimes = {}; // elId → [timestamps]
var dcSuppressedCount = {}; // elId → count suppressed since last emit
function throttleDc(elId) {
var now = Date.now();
var arr = dcTimes[elId] || [];
// drop timestamps older than the window
arr = arr.filter(function (t) { return now - t < DC_WINDOW; });
if (arr.length >= DC_MAX) {
dcSuppressedCount[elId] = (dcSuppressedCount[elId] || 0) + 1;
dcTimes[elId] = arr;
return false; // suppress
}
arr.push(now);
dcTimes[elId] = arr;
return true; // allow
}
// 2026-05-14 — Async-click suppression. Axel Off-Road's manual review
// of Clarity dead-clicks showed ~75% false positives caused by modern
// e-commerce click handlers that respond ASYNC (variant fetch, image
// lazy-load, pre-order check, animation). Same-class detector here was
// emitting `dc` immediately on click — never waited to see if a network
// call, navigation, modal, or loading indicator appeared in response.
//
// New behavior: queue the dc candidate, then within 1200ms watch for
// any of these signals indicating the click DID produce a response:
// 1. Any fetch/XHR network call started (already hooked for `nf`)
// 2. URL change (history.pushState / hashchange / navigation)
// 3. Significant DOM mutation: added element ≥300px tall (modal/page)
// 4. New element whose class contains loading|spinner|skeleton|overlay|
// modal|drawer|lightbox|sheet|popup (any common loading-indicator
// class name)
// If we see ANY signal, the dc is suppressed and an `ac` (async-click)
// event fires instead — same payload + a `reason` field — so we keep
// visibility without polluting the dead-click bucket.
var DEFERRED_DC_WINDOW_MS = 1200;
var clickEpoch = 0; // bumped on each click for cancellation
var pendingDeferredDc = []; // [{ data, expires, epoch }]
// 2026-05-19 — capture-phase click marker. The dead-click cancel
// signals (MutationObserver delivery, __harvvNetActivity) fire during
// the click's TARGET phase — before the document-bubble handler runs
// scheduleDeferredDc. Without a landing spot, a cancel raised by the
// site's own click handler hits an empty pendingDeferredDc and is
// discarded, so a working element gets a false `dc`. This capture-phase
// listener runs FIRST in every click dispatch, giving those early
// signals a per-click object to mark.
//
// Rapid-fire safety: event dispatch is one synchronous task
// (capture -> target -> bubble); the next click is a SEPARATE task. So
// distinct clicks never interleave — clickInFlight for click A is set
// in A's capture phase and consumed in A's bubble phase, entirely
// within A's dispatch, before B's capture can run. Rage clicks are N
// separate tasks, each with a clean marker. A signal that arrives
// after the bubble phase is still handled by the pendingDeferredDc
// path below (by then the candidate exists).
// See docs/findings/microtask-race-dc-cancels-2026-05-19.md.
var clickInFlight = null;
var CLICK_SIGNAL_WINDOW_MS = 250; // window after a click in which a
// cancel signal is attributed to it
function clickSignalWindowOpen() {
return !!clickInFlight && (Date.now() - clickInFlight.at) < CLICK_SIGNAL_WINDOW_MS;
}
document.addEventListener('click', function () {
clickInFlight = { at: Date.now(), cancelled: false, reason: null };
}, { capture: true });
function scheduleDeferredDc(data, tgt) {
// The click's own handler may already have produced a cancel signal
// (DOM mutation / fetch / navigation) in the target phase, recorded
// on the capture-phase marker. If so, emit `ac` and skip the dc.
if (clickInFlight && clickInFlight.cancelled) {
try {
push("ac", { el: data.el, sec: data.sec, x: data.x, y: data.y, r: clickInFlight.reason });
} catch (_) {}
return;
}
var myEpoch = ++clickEpoch;
var deadline = Date.now() + DEFERRED_DC_WINDOW_MS;
var entry = { data: data, deadline: deadline, epoch: myEpoch, cancelled: false };
pendingDeferredDc.push(entry);
setTimeout(function () {
if (entry.cancelled) return; // an async response cancelled us
// No async response within window — emit as dead click.
push("dc", data);
}, DEFERRED_DC_WINDOW_MS);
}
// Cancel any pending deferred dc, optionally tagging the reason so we
// can emit `ac` (async-click) for analytics visibility.
function cancelPendingDc(reason) {
// A cancel signal can arrive BEFORE the deferred dc is scheduled
// (target-phase handler -> microtask, vs. bubble-phase schedule).
// Mark the in-flight click so scheduleDeferredDc — running later in
// the same dispatch — sees it. First-wins; window-bounded so a stale
// marker from an old click is not tagged by an unrelated navigation.
if (clickInFlight && !clickInFlight.cancelled && clickSignalWindowOpen()) {
clickInFlight.cancelled = true;
clickInFlight.reason = reason;
}
var now = Date.now();
for (var i = 0; i < pendingDeferredDc.length; i++) {
var e = pendingDeferredDc[i];
if (e.cancelled || now > e.deadline) continue;
e.cancelled = true;
// Emit a low-severity async-click event so we retain telemetry
// (operators can still see "this element was clicked + responded
// async") without it being a dead-click signal.
try {
push("ac", { el: e.data.el, sec: e.data.sec, x: e.data.x, y: e.data.y, r: reason });
} catch (_) {}
}
// Garbage-collect cancelled/expired entries.
pendingDeferredDc = pendingDeferredDc.filter(function (e) { return !e.cancelled && now <= e.deadline; });
}
// Tier 1 — URL change signal (history.pushState/replaceState + hashchange + popstate).
try {
var _push = history.pushState;
history.pushState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _push.apply(this, arguments); };
var _replace = history.replaceState;
history.replaceState = function () { try { cancelPendingDc('navigation'); } catch (_) {} return _replace.apply(this, arguments); };
window.addEventListener('popstate', function () { cancelPendingDc('navigation'); }, { capture: true });
window.addEventListener('hashchange', function () { cancelPendingDc('navigation'); }, { capture: true });
window.addEventListener('beforeunload', function () { cancelPendingDc('navigation'); }, { capture: true });
} catch (_) {}
// Tier 2 — DOM mutation signals (loading indicators, modals, big additions).
var LOADING_RE = /(?:loading|spinner|skeleton|overlay|modal|drawer|lightbox|sheet|popup|preloader|fetching|progress)/i;
try {
// 2026-06-09 — list re-render threshold. AJAX pagination / faceted
// filters / sort / load-more (LiveSearchFilter, WooCommerce/Shopify AJAX,
// any "load more") swap a GRID of items in place: many small cards
// added/removed at once, none individually >= 300px and no "loading"
// class. The two checks below miss that entirely, so a working filter
// click was logged as a dead click — 100% of these were dismissed by
// operators across 6 sites. Counting added+removed element nodes catches
// the re-render: a truly dead click mutates nothing, a working list
// control mutates a page of items. Threshold is well above incidental
// background churn (a rotating carousel swaps 1-3 slides).
var RERENDER_NODE_THRESHOLD = 8;
var dcSignalObs = new MutationObserver(function (muts) {
// Also evaluate during an in-flight click — the candidate may not
// be scheduled yet (see clickInFlight). Window-bounded so the
// observer is not doing getBoundingClientRect (a forced reflow) on
// every mutation for the life of the page.
if (!pendingDeferredDc.length && !clickSignalWindowOpen()) return;
var changed = 0;
for (var mi = 0; mi < muts.length; mi++) {
var added = muts[mi].addedNodes;
for (var ni = 0; ni < added.length; ni++) {
var n = added[ni];
if (!n || n.nodeType !== 1) continue;
changed++;
// Loading-indicator class name match
var cls = (n.className && typeof n.className === 'string') ? n.className : '';
if (cls && LOADING_RE.test(cls)) {
cancelPendingDc('loading_indicator');
return;
}
// Significant element addition (>= 300px tall = likely modal/page)
try {
var rect = n.getBoundingClientRect && n.getBoundingClientRect();
if (rect && rect.height >= 300 && rect.width >= 200) {
cancelPendingDc('large_dom_addition');
return;
}
} catch (_) {}
}
// Removed element nodes count toward a re-render too — a list swap
// takes the old items out as it puts the new ones in.
var removed = muts[mi].removedNodes;
for (var ri = 0; ri < removed.length; ri++) {
if (removed[ri] && removed[ri].nodeType === 1) changed++;
}
}
// The click swapped in/out a page of nodes (pagination/filter/sort/
// load-more). It did something — not a dead click. Emitted as `ac`
// (async-click) so operators keep the telemetry without the FP.
if (changed >= RERENDER_NODE_THRESHOLD) {
cancelPendingDc('dom_rerender');
}
});
dcSignalObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
} catch (_) {}
// Tier 3 — network signal: any fetch/XHR call starting within the window
// is a strong indicator the click triggered async work. We expose a hook
// here that the existing nf-emit path calls.
window.__harvvNetActivity = function () {
// Unconditional: a fetch dispatched inside a click handler fires this
// BEFORE the deferred dc is scheduled, so a `pendingDeferredDc.length`
// guard would drop the signal. cancelPendingDc also marks the
// in-flight click, which scheduleDeferredDc consults.
cancelPendingDc('network_activity');
};
// Internal-tester flag — T1 (2026-04-23).
// Site owners can mark their own sessions (or their employees') as
// "internal" so events are excluded from detection + calibration. Any
// one of these signals turns the flag on:
// 1. window.__harvv_internal === true (set in inline <script> before pixel loads)
// 2. localStorage.harvv_internal === '1'
// 3. cookie harvv_internal=1
// Once any is set, EVERY event in the session gets data.ih=1.
// Server-side detection queries exclude ih=1 events, keeping the
// dogfood + QA numbers clean. See receiver.js detection queries.
var isInternal = false;
try {
if (window.__harvv_internal === true) isInternal = true;
if (!isInternal && localStorage.getItem('harvv_internal') === '1') isInternal = true;
if (!isInternal && getCookie('harvv_internal') === '1') isInternal = true;
} catch (_) {}
function push(eventType, data) {
var d = data || {};
// Include current page path on ALL events for page-level tracking.
// Without this, issues and logs show null page_url which makes debugging impossible.
if (!d.p && eventType !== 'ss' && eventType !== 'ul') {
d.p = location.pathname;
}
// 2026-05-19 — `pp` always carries the real page path, even on events
// whose `p` field is overloaded. The `sd` scroll-depth event passes
// `{ p: <0|25|50|75|100> }`, so the `!d.p` guard above skips it and the
// event ships with no real path — the server then stored "25"/"50" (or
// NULL) as page_url. `pp` is unconditional, so the receiver can always
// recover the true page path. Cheap: a short string that gzips away
// against the identical `p`/`pp` values across a batch.
d.pp = location.pathname;
// Include cached zoom level on friction events
if (eventType === 'dc' || eventType === 'rc') {
d.zm = cachedZoom;
}
// Internal-tester tag. Single bit; the server uses it to exclude from
// detection + calibration. No PII — just "this session is one of ours."
if (isInternal) d.ih = 1;
// Pixel version stamped on every event so the server can track which
// pixel build produced the data — essential for diagnosing field-coverage
// regressions after a rollout. Read from window.__pxv which the
// receiver injects into the pixel bootstrap script.
var pxv = (typeof window !== 'undefined' && window.__pxv) || undefined;
// T12 (2026-04-24): event deduplication UID. Random 8-hex per event.
// Server keeps an LRU of recent uids; duplicates within 5s are dropped.
// Guards against event-bubbling double-counts and keep-alive retries
// that flush the same event twice. Cost: 8 chars (~10 bytes) per event.
var uid = ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, '0');
queue.push({ v: vid, s: sid, e: eventType, t: ts(), d: d, pxv: pxv, uid: uid });
touch();
}
// 2026-05-26 — install-mode self-check. If our script tag is sync (no
// async/defer attribute), it's render-blocking — which delays page
// paint AND increases the chance our IIFE interleaves badly with the
// host page's own inline scripts on heavy CMS pages (Punchmark, certain
// Shopify themes). We can't upgrade ourselves retroactively, but we
// can fire an `iw` (install-warning) event so the customer sees this
// surface in the dashboard with a one-click fix banner.
// Fires once per pageload; bounded by the queue cap above so a noisy
// edge case can't spam.
try {
var _ownScript = scripts.length && scripts[scripts.length - 1];
if (_ownScript && !_ownScript.async && !_ownScript.defer) {
push('iw', { kind: 'sync_install', src: (_ownScript.src || '').slice(0, 80) });
}
} catch (_) { /* never block on the self-check */ }
// T2.2 (2026-04-30): localStorage retry queue. When sendBeacon AND fetch
// both fail (offline, blocked CSP, ad-blocker partially through, captive
// portal, etc.) we used to silently drop the events. Now we persist the
// failed payload to localStorage and try to flush it on next page load.
// Cap: 50KB total / 24h TTL, so we never balloon a visitor's storage and
// never resurrect stale/PII-heavy events.
var RETRY_KEY = 'harvv_retry_q';
// 2026-05-19 — bumped caps after a Railway-wide outage exposed the
// 50 KB / 24 h limit: high-traffic visitors and visitors who didn't
// return within a day lost events. With the edge spillover (above)
// most outages no longer reach this layer, but it stays as belt-and-
// suspenders for the case where even the fallback can't be reached
// (offline, blocked CSP that catches both hostnames, captive portal).
var RETRY_MAX_BYTES = 500000; // 500 KB — enough for a busy session
var RETRY_TTL_MS = 72 * 60 * 60 * 1000; // 72 h — covers a long weekend
function persistFailedSend(payload) {
try {
var entry = { ts: Date.now(), p: payload };
var existing = localStorage.getItem(RETRY_KEY);
var arr = existing ? JSON.parse(existing) : [];
if (!Array.isArray(arr)) arr = [];
arr.push(entry);
// Trim by total size (FIFO drop oldest until under cap)
var serialized = JSON.stringify(arr);
while (serialized.length > RETRY_MAX_BYTES && arr.length > 1) {
arr.shift();
serialized = JSON.stringify(arr);
}
localStorage.setItem(RETRY_KEY, serialized);
} catch (_) {}
}
// 2026-05-19 — fallback-then-persist. When the primary endpoint fails
// (non-2xx or network error), try the edge spillover before giving up to
// localStorage. The spillover stores the same body in Cloudflare KV; the
// receiver drains it back into PG when it's healthy. Independent of
// Railway, so a Railway outage doesn't reach the retry queue at all.
function tryBackupThenPersist(payload, blob) {
if (!BACKUP_ENDPOINT) { persistFailedSend(payload); return; }
try {
fetch(BACKUP_ENDPOINT, { method: 'POST', body: blob, keepalive: true, headers: { 'Content-Type': 'application/json' } })
.then(function (r) { if (!r || !r.ok) persistFailedSend(payload); })
.catch(function () { persistFailedSend(payload); });
} catch (_) { persistFailedSend(payload); }
}
function drainRetryQueue() {
try {
var raw = localStorage.getItem(RETRY_KEY);
if (!raw) return;
var arr = JSON.parse(raw);
if (!Array.isArray(arr) || !arr.length) { localStorage.removeItem(RETRY_KEY); return; }
var now = Date.now();
var fresh = arr.filter(function (e) { return e && e.ts && (now - e.ts) < RETRY_TTL_MS && e.p; });
if (!fresh.length) { localStorage.removeItem(RETRY_KEY); return; }
// Try to send each persisted payload. On success, remove from queue;
// on failure, leave in queue for next attempt. Use sendBeacon when
// available; fetch otherwise.
var remaining = [];
for (var i = 0; i < fresh.length; i++) {
var blob = new Blob([fresh[i].p], { type: "application/json" });
var sent = false;
// Try primary via sendBeacon → primary via fetch → backup via beacon
// → backup via fetch. Anything that accepts wins; only if ALL four
// refuse do we leave the entry in the retry queue for next pageload.
if (navigator.sendBeacon) {
try { sent = navigator.sendBeacon(ENDPOINT, blob); } catch (_) { sent = false; }
}
if (!sent) {
try { fetch(ENDPOINT, { method: "POST", body: fresh[i].p, headers: { 'Content-Type': 'application/json' }, keepalive: true }); sent = true; } catch (_) {}
}
if (!sent && BACKUP_ENDPOINT && navigator.sendBeacon) {
try { sent = navigator.sendBeacon(BACKUP_ENDPOINT, blob); } catch (_) { sent = false; }
}
if (!sent && BACKUP_ENDPOINT) {
try { fetch(BACKUP_ENDPOINT, { method: "POST", body: fresh[i].p, headers: { 'Content-Type': 'application/json' }, keepalive: true }); sent = true; } catch (_) {}
}
if (!sent) remaining.push(fresh[i]);
}
if (remaining.length) localStorage.setItem(RETRY_KEY, JSON.stringify(remaining));
else localStorage.removeItem(RETRY_KEY);
} catch (_) {}
}
// T141.11 (2026-05-12) — Laravel-context attachment.
// When the harvv/laravel composer package's HarvvContext middleware is
// active on the host site, it injects a <meta name="harvv-laravel"
// content="<base64-url-json>"> tag during HTML response render. The blob
// contains: site_key, route, hashed user_id, request_id, ts, HMAC
// signature. We read it once at boot, cache, and attach it to event[0]
// of every outbound batch as `lc`. Older receivers ignore unknown event
// properties (graceful rollout window — Step 0 ships pixel; Step 3 will
// teach the receiver to read `events[0].lc`). Cached for the page
// lifetime — Laravel's server side guarantees fresh context per request,
// so re-reading on every flush would only add cost without changing
// anything within a single SPA pageview.
var laravelCtxBlob = null;
try {
var lcMeta = document.querySelector && document.querySelector('meta[name="harvv-laravel"]');
if (lcMeta) {
var c = lcMeta.getAttribute && lcMeta.getAttribute('content');
if (typeof c === 'string' && c.length > 0 && c.length < 2048) {
laravelCtxBlob = c; // pre-encoded base64-url; receiver decodes server-side
}
}
} catch (_) { /* DOM unavailable; non-fatal */ }
function flush(forceFetch) {
if (!queue.length) return;
// Build the wire batch. If Laravel context is present, attach to the
// FIRST event in the batch only — receiver dedupes from there. Avoids
// n× duplication for batches >1 event.
var batch;
if (laravelCtxBlob) {
batch = queue.slice();
batch[0] = Object.assign({}, batch[0], { lc: laravelCtxBlob });
} else {
batch = queue;
}
var payload = JSON.stringify(batch);
queue = [];
var blob = new Blob([payload], { type: "application/json" });
// T141.5h (2026-05-11) — HTTP-status-aware retry.
// Pre-fix the flush used sendBeacon-first which fire-and-forgets — it
// returns TRUE if the browser accepted the request for background
// delivery, but NEVER indicates whether the server actually responded
// 200. So a 503 from the receiver during a load-shed was silently
// lost on the wire. T2.2 retry-queue only caught network errors
// (DNS fail, offline, ad-blocker blocked the request entirely).
//
// Fix: in normal "page is alive" mode (forceFetch falsy AND document
// is visible), use fetch + check response.ok. On non-2xx OR a
// network error, push to retry queue. sendBeacon stays in the
// pagehide / beforeunload path (`forceFetch === false` means
// "regular flush"; sendBeacon is ONLY used for the explicit unload
// calls below where we have no chance to inspect the response).
if (forceFetch === 'unload' && navigator.sendBeacon) {
// Unload: fire-and-forget via sendBeacon. We've already lost the
// chance to retry — the page is going away. Send to primary AND
// backup (receiver-side uid dedup handles any duplicate that lands
// in both), then persist to the retry queue as final belt-and-
// suspenders for the case both beacons silently fail.
try { navigator.sendBeacon(ENDPOINT, blob); } catch (_) {}
if (BACKUP_ENDPOINT) { try { navigator.sendBeacon(BACKUP_ENDPOINT, blob); } catch (_) {} }
persistFailedSend(payload);
return;
}
try {
fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true })
.then(function (resp) {
// 2xx = success; non-2xx or network error → try the edge
// spillover before falling back to localStorage. 4xx usually
// means "we shouldn't have sent this" (signed URL expired, site
// disabled, etc) — both retry paths are safe because the
// receiver / spillover both dedup on the per-event uid.
if (!resp || !resp.ok) tryBackupThenPersist(payload, blob);
})
.catch(function () { tryBackupThenPersist(payload, blob); });
return;
} catch (_) {
tryBackupThenPersist(payload, blob);
}
}
// Drain any persisted-from-prior-load payload on startup.
drainRetryQueue();
// Flush every 10 seconds
setInterval(flush, FLUSH_INTERVAL);
// --- Session Start: Referrer + UTMs ---
(function captureSessionContext() {
var params = new URLSearchParams(location.search);
var utms = {};
["source", "medium", "campaign", "term", "content"].forEach(function (k) {
var v = params.get("utm_" + k);
if (v) utms[k] = v;
});
// 2026-05-05 — paid-traffic click IDs. Captured ONLY from URL params at
// session start (entry-time attribute). Same lifecycle as utms:
// populated once, never overwritten. Truncated to 200 chars defensively.
var clickIds = {};
["gclid", "fbclid", "msclkid", "ttclid", "li_fat_id", "epik"].forEach(function (k) {
var v = params.get(k);
if (v) clickIds[k] = String(v).slice(0, 200);
});
// 2026-05-05 — locale + timezone. Cheap geo proxies that don't need IP
// geolocation. VPNs lie about both, but VPN IPs lie about geo too — same
// trust level. Used for cohort segmentation (US vs CA users) and as a
// sanity cross-check against MaxMind country lookup server-side.
var tz, lg;
try { tz = (Intl && Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone) || undefined; } catch (_) {}
try { lg = (navigator.language || (navigator.languages && navigator.languages[0])) || undefined; } catch (_) {}
if (tz) tz = String(tz).slice(0, 40);
if (lg) lg = String(lg).slice(0, 20);
var touch = 'ontouchstart' in window;
var dt = touch && screen.width < 768 ? 'm' : touch && screen.width < 1024 ? 't' : 'd';
var ua = navigator.userAgent || '';
var br = ua.indexOf('Firefox') > -1 ? 'ff' : ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1 ? 'sf' : ua.indexOf('Edg') > -1 ? 'eg' : ua.indexOf('Chrome') > -1 ? 'ch' : '?';
// T9 (2026-04-24): bot fingerprinting — multi-signal detection for
// automation that passes UA checks. Each bit flipped contributes to a
// composite score the server uses alongside the UA regex. Small JS
// footprint (<200 bytes minified), no external dependencies.
//
// bit 0 (1): navigator.webdriver === true
// bit 1 (2): no plugins (headless Chrome signature)
// bit 2 (4): no navigator.languages (bot default)
// bit 3 (8): UA contains "Headless" / "Phantom" / "HeadlessChrome"
// bit 4 (16): window.chrome missing runtime on Chrome UA (headless)
// bit 5 (32): window.outerHeight === 0 (automation window)
//
// Any non-zero value is suspicious; ≥2 bits set is near-certain bot.
var bf = 0;
try {
if (navigator.webdriver) bf |= 1;
if (!navigator.plugins || navigator.plugins.length === 0) bf |= 2;
if (!navigator.languages || navigator.languages.length === 0) bf |= 4;
if (/Headless|PhantomJS|Electron\/[\d.]+$/i.test(ua)) bf |= 8;
if (window.chrome && !window.chrome.runtime && /Chrome/i.test(ua) && !/Firefox/i.test(ua)) bf |= 16;
if (window.outerHeight === 0) bf |= 32;
} catch (_) {}
// 2026-05-06 (Tier 1) — added: hardware concurrency (hc), connection
// downlink/rtt/saveData (dl/rt/sd), a11y media-query prefs (mq), Global
// Privacy Control (gpc), cookie-consent library state (ccl). All signals
// are zero-PII (browser-stated capability/preference flags) and add
// ~40 bytes per session.
var hc, dl, rt, sd, gpc, mq, ccl;
try { hc = navigator.hardwareConcurrency || undefined; } catch (_) {}
try {
var c = navigator.connection;
if (c) { dl = c.downlink || undefined; rt = c.rtt || undefined; sd = c.saveData ? 1 : undefined; }
} catch (_) {}
try {
// Global Privacy Control — explicit opt-out signal recognized under CPRA
// and (increasingly) GDPR. When true we still SEND the session-start
// event but tag it so the server downgrades to anonymous-only mode.
gpc = navigator.globalPrivacyControl ? 1 : undefined;
} catch (_) {}
try {
// Compact bitmask of accessibility/preference media queries.
// bit 0 (1): prefers-reduced-motion
// bit 1 (2): prefers-color-scheme: dark
// bit 2 (4): prefers-contrast: more
// bit 3 (8): forced-colors: active
// bit 4 (16): prefers-reduced-data
var m = 0;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) m |= 1;
if (matchMedia('(prefers-color-scheme: dark)').matches) m |= 2;
if (matchMedia('(prefers-contrast: more)').matches) m |= 4;
if (matchMedia('(forced-colors: active)').matches) m |= 8;
if (matchMedia('(prefers-reduced-data: reduce)').matches) m |= 16;
if (m) mq = m;
} catch (_) {}
try {
// Detect common cookie-consent library state. Zero PII, just whether
// analytics consent has been given by the visitor on the customer's
// banner. Each integration is wrapped so a single broken library
// doesn't break the rest.
// Returns: 'a' = analytics consent given; 'n' = denied; 'p' = pending;
// 'g' = GPC auto-decline; undefined = no consent library detected.
if (gpc) ccl = 'g';
else if (window.OneTrust && typeof window.OneTrust.GetDomainData === 'function') {
try {
var groups = (document.cookie.match(/OptanonConsent=[^;]*groups=([^&;]+)/) || [])[1] || '';
ccl = /C0002:1/.test(decodeURIComponent(groups)) ? 'a' : 'n';
} catch (_) {}
}
else if (window.Cookiebot && typeof window.Cookiebot.consent === 'object') {
ccl = window.Cookiebot.consent.statistics ? 'a' : 'n';
}
else if (window.cookieconsent && window.cookieconsent.status) {
ccl = window.cookieconsent.status === 'allow' ? 'a' : 'n';
}
else if (window.CookieConsent && typeof window.CookieConsent.acceptedCategory === 'function') {
ccl = window.CookieConsent.acceptedCategory('analytics') ? 'a' : 'n';
}
else if (window.tarteaucitron && window.tarteaucitron.state && window.tarteaucitron.state.analytics === true) {
ccl = 'a';
}
} catch (_) {}
// 2026-05-06 (Tier 2): customer-provided hashed user ID for cohort
// analysis without us ever seeing PII. Customer SHA-256 hashes their
// internal user id and sets `window.harvv.user.id_hash`. We only
// accept exactly 64 hex chars; anything else is rejected to prevent
// accidental email/phone exposure. Server salts again before storing.
var hu;
try {
var hh = window.harvv && window.harvv.user && window.harvv.user.id_hash;
if (typeof hh === 'string' && /^[a-f0-9]{64}$/i.test(hh)) hu = hh;
} catch (_) {}
// 2026-05-07 — `cu` correlator. Harvv-on-Harvv only: when the pixel
// fires on harvv.com / admin.harvv.com (i.e. our own dashboard) and a
// logged-in customer's id is in localStorage, attach it so we can
// correlate the session back to who's seeing what slowness/friction.
// Per dogfooding policy this NEVER ships on customer sites — that
// would expose JWT presence to every site's pixel install. Read from
// a tiny localStorage key the dashboards write on login (uid only,
// never the JWT itself, never on cross-origin sites).
var cu;
try {
var h = location.hostname;
if (h === 'harvv.com' || h === 'admin.harvv.com' || h === 'www.harvv.com') {
var x = localStorage.getItem('harvv_uid');
if (x && /^[a-f0-9-]{8,40}$/i.test(x)) cu = x;
}
} catch (_) {}
push("ss", {
r: document.referrer || undefined,
u: Object.keys(utms).length ? utms : undefined,
cid: Object.keys(clickIds).length ? clickIds : undefined,
tz: tz,
lg: lg,
p: location.pathname,
vw: window.innerWidth, vh: window.innerHeight,
dt: dt, br: br,
zm: cachedZoom,
dm: navigator.deviceMemory || undefined,
ct: (navigator.connection && navigator.connection.effectiveType) || undefined,
hc: hc, dl: dl, rt: rt, sd: sd,
gpc: gpc, mq: mq, ccl: ccl,
hu: hu,
cu: cu, // dogfood-only — harvv.com hosts only; gated above
wb: navigator.webdriver ? 1 : undefined,
bf: bf || undefined,
// 1.0.7 — GA4 coexistence. Mark every session with whether GA4
// was detected at boot, and whether GA4 Consent Mode v2 has
// declined analytics. Server uses these to dedupe and to flag
// sessions in dashboard.
ga4: GA4_PRESENT ? 1 : undefined,
cd: CONSENT_DENIED ? 1 : undefined,
});
// 2026-05-13 — lazy-load self-detection (Layer 2) + attribution
// (Layer 5, 1.0.6). If an optimization tool (WP Rocket, LiteSpeed,
// NitroPack, etc.) deferred us, LCP already fired before we booted.
// Server flags the session and the dashboard banner names the tool.
try {
var navE = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
var lcpE = performance.getEntriesByType && performance.getEntriesByType('largest-contentful-paint');
var lateMs = navE ? Math.round(performance.now() - navE.domContentLoadedEventEnd) : 0;
var afterLcp = lcpE && lcpE.length > 0;
// Attribution: walk the page for known delay-tool markers. Each
// tool tags its rewritten scripts with a vendor-specific attr —
// this lets us tell the customer "WP Rocket is delaying you"
// rather than just "something is delaying you".
var via;
try {
if (document.querySelector('script[data-rocket-src],script[data-rocket-status]')) via = 'wp_rocket';
else if (document.querySelector('script[type="pmdelayedscript"],script[data-pmdelayedscript]')) via = 'perfmatters';
else if (window.NitroPack || document.querySelector('script[nitro-exclude]')) via = 'nitropack';
else if (document.querySelector('script[data-litespeed-defer]')) via = 'litespeed';
else if (window.RocketLoader || document.querySelector('script[src*="ajax.cloudflare.com/cdn-cgi/scripts/"][src*="rocket-loader"]')) via = 'cloudflare';
} catch (_) {}
if (afterLcp || lateMs > 500 || via) {
push('pf.deferred', {
al: afterLcp ? 1 : 0,
lm: lateMs > 500 ? lateMs : 0,
via: via || undefined,
});
}
} catch (_) {}
})();
// --- User State Inference ---
// Session-scoped categorical tagset — lets the server cross-tab cases by
// "what kind of user saw this" (logged_in/anonymous, has_cart, first_visit).
// Entirely DOM-derived + optional window.harvv.user opt-in. Zero PII.
(function captureUserState() {
try {
var tags = [];
var ck = document.cookie || "";
var logoutQ = 'a[href*="logout"],a[href*="sign-out"],a[href*="/my-account"],a[href*="/account/"]';
var loginQ = 'a[href*="/login"],a[href*="/signin"],a[href*="/register"]';
var loggedIn = !!document.querySelector(logoutQ)
|| ck.indexOf("wordpress_logged_in") !== -1
|| ck.indexOf("_shopify_customer") !== -1
|| ck.indexOf("_secure_customer_sig") !== -1;
tags.push(loggedIn ? "logged_in" : (document.querySelector(loginQ) ? "anonymous" : "auth_unknown"));
if (document.querySelector(".woocommerce-MyAccount-navigation,.woocommerce-account")) tags.push("wc_logged_in");
var bodyTxt = (document.body && document.body.innerText || "").toLowerCase().slice(0, 4000);
if (/cart \(\d+\)|cart:\s*\d+|items in cart/.test(bodyTxt)
|| document.querySelector('[data-cart-count]:not([data-cart-count="0"]),.cart-count:not(:empty)')) tags.push("has_cart");
if (!ck.match(/_pxv=/)) tags.push("first_visit");
// Optional explicit opt-in via window.harvv.user — categorical only.
var explicit = null;
try {
var wh = window.harvv && window.harvv.user;
if (wh && typeof wh === 'object') {
explicit = {};
for (var k in wh) {
var v = wh[k], tp = typeof v;
if (tp === 'string' && v.length < 60) explicit[k] = v.slice(0, 60);
else if ((tp === 'number' && isFinite(v)) || tp === 'boolean') explicit[k] = v;
}
if (!Object.keys(explicit).length) explicit = null;
}
} catch (_) {}
push("us", { tags: tags, explicit: explicit || undefined });
} catch (_) {}
})();
// --- Unified Click Handler ---
// Merges dead click detection, rage click detection, and interaction sequence into one listener.
var DC_NOISE = /^(HTML|BODY|MAIN|SECTION|HEADER|FOOTER|NAV|ARTICLE|ASIDE)$/;
var clicks = [], lastIx = 0;
// T40 (2026-04-24): record last click for post-click notification correlation.
// The MutationObserver below watches for toasts/alerts added within 5s of a
// click so dc issues on product elements can be reframed as inventory when
// "Sold out" / "Out of stock" banners fire.
var lastClickEid = null, lastClickAt = 0;
document.addEventListener("click", function (ev) {
var tgt = ev.target, elId = eid(tgt), cx = ev.clientX, cy = ev.clientY;
lastClickEid = elId; lastClickAt = Date.now();
// 1. Interaction sequence (every click)
var t = ts(), delta = lastIx ? t - lastIx : 0;
lastIx = t;
var ctxState = getContextState();
// T7 (2026-04-23, fixed 2026-04-24): pointerType + cursor speed + hover
// dwell on every click. `resolvePointerType()` handles edge cases where
// pointerdown doesn't set a value (browsers returning "" for mouse, or
// browsers where pointerdown doesn't fire before click). Always returns
// "mouse" / "touch" / "pen".
//
// Audit fix 2026-04-24: also attach x/y to ix events (previously only
// on dc). Lets the server correlate click position between ix + dc
// events to find near-miss clicks that aren't technically dead.
var ptype = resolvePointerType();
var csPx = cursorSpeedPxPerSec() || undefined;
var dwell = getHoverDwell(tgt) || undefined;
push("ix", { el: elId, x: cx, y: cy, dt: delta, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell, sp: curScrollPct || undefined });
// 2. Dead click detection (non-interactive elements)
// Suppress dead-click emission entirely for CMS-editor previews and inside
// third-party embedded widgets — those aren't the host site's UX and the
// agency can't fix them (M-Diamond had 16 false positives from an embedded
// appointment widget's styled-components classes).
if (IN_EDITOR) { /* skip */ }
else if (isInsideThirdPartyWidget(tgt)) { /* skip */ }
else if (!isInteractive(tgt)) {
var tag = tgt.tagName;
if (!(DC_NOISE.test(tag) && !tgt.id && !(tgt.className && typeof tgt.className === "string" && tgt.className.trim()))) {
// Check if clicked inside a container/wrapper class — these wrap interactive
// children and clicks on them are near-misses, not real dead clicks.
// Example: div.product__info-wrapper on Shopify product pages wraps the
// product title link — clicking the wrapper area navigates correctly.
var cls = (typeof tgt.className === "string" ? tgt.className : "").toLowerCase();
var isWrapper = cls && /(?:wrapper|container|grid|row|group|items|actions|controls|card|overlay|inner|content|block)/.test(cls);
if (!isWrapper) {
var sec = getSection(tgt);
// T7 additions: ptype, csp (cursor speed px/sec), dwell (ms hovered
// before this click). Same fields as the ix event above.
var data = { el: elId, x: cx, y: cy, sec: sec || undefined, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell };
// 2026-05-14 — Host-shielded element flag (`bs` = blind-spot bit).
// True when the click target is inside something whose internal
// state our host-page pixel cannot observe: cross-origin iframes,
// <video>/<canvas>, custom-element hosts (which may have closed
// shadow roots), or any element with an open shadowRoot. The
// canonical case is Shopify Inbox (`inbox-online-store-chat` is a
// custom element wrapping an iframe) — every chat interaction
// emits a wrapper click that LOOKS dead because we can't see the
// DOM change inside. Server-side detection filters bs:1 events
// out of the dc aggregation. See:
// docs/runbooks/incident-2026-05-14-shadow-dom-chat-fp.md
if (isHostShielded(tgt)) data.bs = 1;
var nearest = tgt.parentElement, depth = 5;
var insideInteractive = false;
while (nearest && depth--) {
if (isInteractive(nearest)) {
var r = nearest.getBoundingClientRect();
var dist = (Math.sqrt(Math.pow(Math.max(r.left - cx, cx - r.right, 0), 2) + Math.pow(Math.max(r.top - cy, cy - r.bottom, 0), 2)) + 0.5) | 0;
if (dist === 0) {
// Click landed inside an interactive ancestor's bounding box.
// This is NOT a dead click — the ancestor handles the event.
insideInteractive = true;
} else {
data.near = eid(nearest);
data.dist = dist;
}
break;
}
nearest = nearest.parentElement;
}
// T3 (2026-04-23): throttle dc emission so one element can emit at
// most DC_MAX events per DC_WINDOW ms. One frustrated user clicking
// the same broken element 50 times should produce a single signal
// with suppressed_count=47, not 50 separate detection candidates.
//
// 2026-05-14 — DEFER the dc emit by 1.2 seconds (Axel Off-Road
// learning: Clarity's same-class detector flagged ~3-4× more
// dead clicks than actually existed because modern e-commerce
// clicks trigger 1-2s async responses — variant fetch, image
// lazy-load, pre-order check — all of which Clarity treated as
// "no DOM change" → dead). Instead, we now buffer the dc
// candidate and watch for signals that the click DID work:
// - fetch / XHR fired (we already track via `nf`)
// - URL changed (SPA navigation)
// - Significant DOM mutation (modal, overlay, loading spinner)
// - Loading indicator class names appeared
// If any of those happen within 1.2s, we cancel the dc emit and
// emit a lower-severity `ac` (async-click) event instead so we
// retain telemetry without polluting the dead-click bucket.
if (!insideInteractive) {
if (throttleDc(elId)) {
if (dcSuppressedCount[elId]) {
data.sup = dcSuppressedCount[elId];
dcSuppressedCount[elId] = 0;
}
// Defer 1.2s and cancel on any async-response signal.
scheduleDeferredDc(data, tgt);
}
}
}
}
}
// 3. Rage click detection (3+ clicks within 800ms in 50px radius)
var now = Date.now();
clicks.push({ x: cx, y: cy, t: now });
// Cap array to prevent unbounded growth + filter old clicks
if (clicks.length > 50) clicks = clicks.slice(-30);
clicks = clicks.filter(function (c) { return now - c.t < RAGE_WINDOW; });
if (clicks.length >= RAGE_THRESHOLD) {
var ref = clicks[0], clustered = clicks.every(function (c) {
return Math.hypot(c.x - ref.x, c.y - ref.y) < RAGE_RADIUS;
});
if (clustered) {
// Same `bs` blindness flag as dc — rage on a host-shielded element
// (chat widget, iframe, video player) is the same false-positive
// class: every click LOOKS unhandled because we can't see what's
// happening inside the iframe/shadow root.
var rcData = { el: elId, n: clicks.length, sec: getSection(tgt) || undefined };
if (isHostShielded(tgt)) rcData.bs = 1;
push("rc", rcData);
snapViewport("rage");
clicks = [];
}
}
});
// --- Commerce signals (1.0.6) ----------------------------------------
// Three high-value conversion signals consolidated into a single event
// type `cm` to save bytes. Each fires only when its specific trigger
// matches, so steady-state cost is zero on non-commerce pages.
// k=ca — cart-add: click on element matching the cart-add selector
// k=co — coupon attempt: focus into an input named coupon/discount/promo
// k=ei — exit intent: mouseleave from top of viewport on a cart/checkout URL
//
// Why one event with `k` discriminator (not three event types): each is
// rare enough that splitting would add three event-type strings to the
// wire format; sharing the type tag is ~30 bytes cheaper gzip-wise.
var CART_SEL = '[data-add-to-cart],.add_to_cart_button,.single_add_to_cart_button,button[name="add"]';
var COUPON_SEL = 'input[name*="coupon" i],input[name*="discount" i],input[name*="promo" i]';
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) return;
var hit = t.closest(CART_SEL);
if (hit) emitCm('dom', 'ca', { el: eid(hit), sp: curScrollPct || undefined });
}, true);
document.addEventListener('focusin', function (e) {
var t = e.target;
if (t && t.matches && t.matches(COUPON_SEL)) emitCm('dom', 'co', { el: eid(t) });
}, true);
// Exit intent only on commerce URLs (no GA4 equivalent). One emit/pageload.
if (/\/(cart|checkout|basket|payment)\b/i.test(location.pathname)) {
var eiSent = false;
document.addEventListener('mouseout', function (e) {
if (eiSent) return;
if (!e.relatedTarget && e.clientY < 10) {
eiSent = true;
emitCm('dom', 'ei', { p: location.pathname.slice(0, 80) });
}
});
}
// --- Scroll Depth Milestones ---
// Fire once per session per threshold (25/50/75/100%).
// Throttled — only checks every 100ms via rAF.
// 2026-05-06 (Tier 2): also tracks scroll velocity + direction reversals
// (scrolling down then up = "I missed something / lost my place").
// One sv event emitted at end of session via beforeunload.
var firedScrolls = {};
var scrollCheckPending = false;
var scrollLastY = 0, scrollLastT = 0, scrollLastDir = 0;
var scrollReversals = 0, scrollVelocityMaxPxs = 0;
// 1.0.6 — current scroll-percentage cache. Click handler reads this
// so every `ix` event can carry the scroll-depth at click time
// (`sp`). Lets the server answer "is this CTA being clicked from
// the top of the page or after the user scrolled past it?" without
// re-computing on every click.
var curScrollPct = 0;
// 2026-06-02 — exact peak scroll depth (continuous %, not the 25% milestone
// bucket). Milestones [10,25,50,75,100] only tell us "reached >=N%", so
// anything 0-9% recorded as max_scroll=0 and we couldn't tell a bounce from a
// 9% scroll. curScrollPctMax tracks the true high-water mark and ships on the
// unload `ul` event as `sm`, so the server can store the exact peak.
var curScrollPctMax = 0;
window.addEventListener("scroll", function () {
if (scrollCheckPending) return;
scrollCheckPending = true;
requestAnimationFrame(function () {
scrollCheckPending = false;
var scrollTop = window.scrollY || window.pageYOffset || 0;
var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
curScrollPct = scrollHeight > 0 ? Math.round((scrollTop / scrollHeight) * 100) : 0;
if (curScrollPct > curScrollPctMax) curScrollPctMax = curScrollPct > 100 ? 100 : curScrollPct;
// Velocity + reversal tracking (independent of milestone check)
var nowT = Date.now();
if (scrollLastT && nowT - scrollLastT < 1000) {
var dy = scrollTop - scrollLastY;
var dt = nowT - scrollLastT;
if (dt > 0) {
var pxs = Math.abs(dy) / dt * 1000;
if (pxs > scrollVelocityMaxPxs) scrollVelocityMaxPxs = pxs;
var dir = dy > 0 ? 1 : dy < 0 ? -1 : 0;
if (dir && scrollLastDir && dir !== scrollLastDir) scrollReversals++;
if (dir) scrollLastDir = dir;
}
}
scrollLastY = scrollTop;
scrollLastT = nowT;
// Milestone fire
if (scrollHeight <= 0) return;
var pct = Math.round((scrollTop / scrollHeight) * 100);
SCROLL_MILESTONES.forEach(function (m) {
if (pct >= m && !firedScrolls[m]) {
firedScrolls[m] = 1;
push("sd", { p: m });
snapViewport("sd" + m);
}
});
});
}, { passive: true });
// Emit final scroll-velocity summary on unload-ish events.
function emitScrollSummary() {
if (scrollReversals > 0 || scrollVelocityMaxPxs > 100) {
push("sv", {
rev: scrollReversals,
vp: Math.round(scrollVelocityMaxPxs),
});
}
}
addEventListener('pagehide', emitScrollSummary, { passive: true });
addEventListener('beforeunload', emitScrollSummary, { passive: true });
// --- Engagement Time ---
// Actual visible time. Pauses when tab is hidden. Sent on unload.
var engageStart = document.hidden ? 0 : Date.now();
var totalEngage = 0;
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
// Tab hidden — bank the time
if (engageStart) totalEngage += Date.now() - engageStart;
engageStart = 0;
push("vc", { h: 1 }); // visibility change: hidden
} else {
// Tab visible — restart timer
engageStart = Date.now();
push("vc", { h: 0 }); // visibility change: visible
}
});
// --- Hover Intent ---
// 500ms+ hover on interactive elements. Signals consideration.
// Uses mouseenter/mouseleave with per-element timers to avoid race conditions.
var hoverTimers = new Map();
document.addEventListener("mouseenter", function (ev) {
// T7: stamp dwell start on any element (not just interactive) so we can
// compute hover duration for any subsequent click — including dead clicks.
try { hoverEnterTime.set(ev.target, Date.now()); } catch (_) {}
if (!isInteractive(ev.target)) return;
var target = ev.target;
var timer = setTimeout(function () {
// T7: include dwell_ms so the server can tell long-hesitation hovers
// ("considered it, clicked slowly") from quick ones ("passed through").
push("hi", { el: eid(target), dwell: getHoverDwell(target) || undefined });
hoverTimers.delete(target);
}, HOVER_DELAY);
hoverTimers.set(target, timer);
}, { passive: true, capture: true });
document.addEventListener("mouseleave", function (ev) {
var timer = hoverTimers.get(ev.target);
if (timer) { clearTimeout(timer); hoverTimers.delete(ev.target); }
try { hoverEnterTime.delete(ev.target); } catch (_) {}
}, { passive: true, capture: true });
// --- Viewport Snapshot ---
// Captures which interactive elements are visible at key moments.
// IntersectionObserver maintains a running Set — snapshot reads it instantly, no DOM scan.
// MutationObserver catches dynamically added elements (Shopify, AJAX, SPAs).
var visibleSet = new Set();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
entry.isIntersecting ? visibleSet.add(entry.target) : visibleSet.delete(entry.target);
});
});
// Observe all interactive elements under a root
function observeInteractive(root) {
(root || document).querySelectorAll("a,button,input,select,textarea,[role=button],[tabindex]").forEach(function (el) {
if (!el._pxo) { el._pxo = 1; io.observe(el); }
});
}
// MutationObserver for dynamic pages — debounced to 500ms to avoid thrashing
var mutDebounce = null;
function initObservers() {
observeInteractive();
// Watch for dynamically added interactive elements (SPAs, AJAX, Shopify)
try {
var mo = new MutationObserver(function () {
clearTimeout(mutDebounce);
mutDebounce = setTimeout(function () { observeInteractive(); }, 500);
});
mo.observe(document.body, { childList: true, subtree: true });
} catch (_) {}
}
// Take a viewport snapshot — reads the running Set, caps at 20 elements
function snapViewport(trigger) {
var els = [];
visibleSet.forEach(function (el) {
if (els.length < 20) els.push(eid(el));
});
push("vs", { tr: trigger, el: els });
}
// Widget overlap probe — catches visually colliding third-party widgets
// (chat + rewards, cookie banner + intercom). Containers live in our document
// even when the widget renders in an iframe, so we compare bounding rects.
function widgetOverlapProbe() {
try {
var cands = [], W = window.innerWidth, H = window.innerHeight;
var q = document.querySelectorAll('iframe,[class*="widget"],[id*="widget"],[class*="chat"],[id*="chat"],[class*="reward"],[id*="reward"],[class*="loyalty"],[class*="popup"],[data-hook*="chat"],[data-hook*="wallet"],[data-hook*="reward"],[data-testid*="chat"],[data-testid*="wallet"],[id^="intercom-"],[id^="drift-"],[id^="gorgias-"],[id^="tidio-"],[id^="tawk-"],[id^="zopim"],[id*="smile"],[id*="wix-"],[id*="rivo"],[class*="rivo"],[class*="ba-loy"],[class*="ba_loy"],[class*="launcher"],[class*="nudgify"],[id*="judgeme"],[class*="judgeme"],[class*="yotpo"],[id*="yotpo"],[class*="loyaltylion"],[class*="stamped"],[class*="growave"]');
for (var i = 0; i < q.length && cands.length < 12; i++) {
var n = q[i], cs = getComputedStyle(n);
if (!/fixed|sticky|absolute/.test(cs.position)) continue;
if (cs.display === "none" || cs.visibility === "hidden") continue;
// 2026-06-09 — invisible elements can't visually overlap anything.
// PayPal & co. stack opacity:0 / pointer-events:none staging frames
// on top of each other; counting those produced 100%-overlap wo
// pairs (721 distinct pairs on Tiger Friday) that are pure noise.
if (parseFloat(cs.opacity) === 0 || cs.pointerEvents === 'none') continue;
var r = n.getBoundingClientRect();
if (r.width < 30 || r.height < 30) continue;
if (r.width > W * 0.7 && r.height > H * 0.7) continue; // full-page overlay
cands.push({ el: eid(n), r: r });
}
var hits = [];
for (var a = 0; a < cands.length && hits.length < 3; a++) {
for (var b = a + 1; b < cands.length && hits.length < 3; b++) {
var ra = cands[a].r, rb = cands[b].r;
var ix = Math.max(0, Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left));
var iy = Math.max(0, Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top));
var area = ix * iy;
if (area < 400) continue;
var pct = Math.round((area / Math.min(ra.width * ra.height, rb.width * rb.height)) * 100);
if (pct >= 20) hits.push({ a: cands[a].el, b: cands[b].el, pct: pct });
}
}
if (hits.length) push("wo", { vw: W, hits: hits });
} catch (_) {}
}
// Layout probe — detect nav wrapping, overflow, content offset. Pure structural data, zero PII.
function layoutProbe() {
try {
// Single selector instead of 8 separate querySelectorAll calls
var nav = document.querySelector('header nav, nav, [role="navigation"], .w-nav-menu, .header__inline-menu, .main-navigation, .navPages, .gh-head-menu');
var hdr = document.querySelector('header, [role="banner"], #SITE_HEADER, .site-header');
var main = document.querySelector('main, [role="main"], #MainContent, #content, .site-content');
var d = { vw: window.innerWidth, vh: window.innerHeight };
if (hdr) d.hh = hdr.offsetHeight;
if (nav) {
d.nh = nav.offsetHeight;
// Check if nav items wrap — compare first and last child offsetTop
var items = nav.querySelectorAll(':scope > ul > li, :scope > div > a, :scope > a');
if (items.length > 1) {
var firstTop = items[0].offsetTop, wraps = 0;
for (var i = 1; i < items.length; i++) { if (items[i].offsetTop > firstTop + 5) { wraps++; break; } }
if (wraps) d.nw = 1; // nav wrapped
}
}
if (main) d.cy = Math.round(main.getBoundingClientRect().top); // content Y position
// Horizontal overflow: capture actual delta (scrollWidth - innerWidth) so the
// server can distinguish 6px rounding noise from a 300px layout bug. Only set
// ox=1 when the delta is >10px (below that is typically a scrollbar-width
// artifact on Windows/Linux Firefox, not a real overflow).
var oxd = document.documentElement.scrollWidth - window.innerWidth;
if (oxd > 10) {
d.ox = 1; d.oxd = oxd;
// Walk DOWN from <body> to find the deepest element that's still
// extending past the viewport. This is usually the true culprit —
// the parent overflows because its child does. We cap depth at 20
// and elements examined at 200 so we never hang on pathological DOMs.
try {
var cur = document.body, depth = 20, overflowEl = null;
while (cur && depth-- > 0) {
var offendingChild = null, offendingWidth = 0;
var kids = cur.children;
for (var i = 0; i < kids.length && i < 50; i++) {
var c = kids[i];
var rc = c.getBoundingClientRect();
// right edge past viewport + non-zero width + non-decorative
if (rc.right > window.innerWidth + 5 && rc.width > offendingWidth) {
offendingChild = c; offendingWidth = rc.width;
}
}
if (!offendingChild) break;
overflowEl = offendingChild;
cur = offendingChild;
}
if (overflowEl) {
d.oxel = eid(overflowEl); // selector of offending element
d.oxw = Math.round(overflowEl.getBoundingClientRect().width); // its rendered width
}
} catch (_) {}
}
push("lp", d);
} catch (e) {}
}
// Initial snapshot on DOM ready (small delay for IntersectionObserver to fire)
function onReady() {
initObservers();
setTimeout(function () { snapViewport("init"); }, 100);
setTimeout(layoutProbe, 200); // layout probe after render settles
setTimeout(widgetOverlapProbe, 3000); // widget overlap: wait 3s for async chat/reward widgets to load
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
// --- Text Selection Tracking ---
// Captures element where user selected text + char count. Never captures text content.
var selTimer = null;
document.addEventListener("selectionchange", function () {
clearTimeout(selTimer);
selTimer = setTimeout(function () {
var sel = window.getSelection();
if (!sel || !sel.rangeCount || sel.isCollapsed) return;
var node = sel.anchorNode;
var parent = node && node.nodeType === 3 ? node.parentElement : node;
if (!parent) return;
var range = sel.getRangeAt(0);
var len = range.toString().length;
if (len > 0) push("ts", { el: eid(parent), n: len });
}, 1000);
});
document.addEventListener("copy", function (ev) {
var sel = window.getSelection();
var node = sel && sel.anchorNode;
var parent = node && node.nodeType === 3 ? node.parentElement : node;
if (parent) push("cp", { el: eid(parent), n: (sel.toString() || "").length });
});
// --- Keyboard Signal Categories ---
// Only captures Tab and Escape — never alphanumeric. Tracks keyboard rage (rapid Escape).
var escTimes = [];
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
var now = Date.now();
escTimes.push(now);
escTimes = escTimes.filter(function (t) { return now - t < 2000; });
push("kb", { k: "esc", r: escTimes.length });
} else if (ev.key === "Tab") {
push("kb", { k: "tab" });
}
});
// --- Navigation Patterns ---
// Track all page changes: popstate (back/forward), pushState/replaceState (SPA navigation)
var lastPath = location.pathname;
var cvFired = false; // only fire conversion once per session
var CV_PATTERNS = /\/(thank|order.?confirm|checkout.?complete|purchase.?success|receipt|confirmation)/i;
function onPageChange(trigger) {
var newPath = location.pathname;
if (newPath !== lastPath) {
lastPath = newPath;
push("nb", { p: newPath, tr: trigger });
// Auto-detect conversion pages
if (!cvFired && CV_PATTERNS.test(newPath)) {
push("cv", { p: newPath });
cvFired = true;
}
}
}
window.addEventListener("popstate", function () { onPageChange("pop"); });
// Intercept pushState/replaceState for SPA route changes — return original result
var origPush = history.pushState, origReplace = history.replaceState;
history.pushState = function () { var ret = origPush.apply(this, arguments); onPageChange("push"); return ret; };
history.replaceState = function () { var ret = origReplace.apply(this, arguments); onPageChange("replace"); return ret; };
// 2026-06-11 (#47260) — fire conversion on the INITIAL load too. onPageChange
// only runs on SPA route changes (popstate/pushState/replaceState), but
// Shopify and traditional order-confirmation pages (/thank_you, /thank-you,
// order-confirmation, receipt) load as FULL page navigations, so the cv
// signal never fired for them. Result: zero conversion events across every
// site for 90 days. Checking the landing path on init counts a shopper who
// arrives directly on a conversion page (the common ecommerce case).
if (!cvFired && CV_PATTERNS.test(location.pathname)) {
push("cv", { p: location.pathname, init: 1 });
cvFired = true;
}
document.addEventListener("auxclick", function (ev) {
if (ev.button === 1) { // Middle click
var el = ev.target.closest ? ev.target.closest("a") : ev.target;
if (el) push("ax", { el: eid(el) });
}
});
// --- Time to First Interaction (TTFI) ---
// Measures ms from page load to first user interaction. Fires once, self-destructs.
var ttfiStart = performance.now();
function onFirstInteraction() {
push("tf", { tf: Math.round(performance.now() - ttfiStart) });
document.removeEventListener("click", onFirstInteraction, true);
document.removeEventListener("keydown", onFirstInteraction, true);
window.removeEventListener("scroll", onFirstInteraction);
}
document.addEventListener("click", onFirstInteraction, { once: true, capture: true, passive: true });
document.addEventListener("keydown", onFirstInteraction, { once: true, capture: true, passive: true });
window.addEventListener("scroll", onFirstInteraction, { once: true, passive: true });
// --- Scroll Kinematics ---
// Tracks scroll velocity, direction reversals, and pauses.
// Phase 2 (2026-05-06) — velocity-bucket distances added for Nina patterns
// (content:scroll_speed_skim, content:shallow_read, content:drop_cliff).
// Pure kinematics — no DOM text. PII-safe by construction.
var prevScrollY = window.scrollY, prevScrollT = performance.now();
var scrollDir = 0, reversals = 0, totalDist = 0;
var minVel = Infinity, maxVel = 0;
var velSamples = 0, velSum = 0; // for avg
var skimDist = 0, readDist = 0; // distance buckets in px (px/s thresholds)
var pauses = [], lastScrollTime = 0, scrollPauseTimer = null;
var rafPending = false;
window.addEventListener("scroll", function () {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
var y = window.scrollY, t = performance.now();
var dy = y - prevScrollY, dt = t - prevScrollT;
if (dt < 50) return; // Throttle to ~20fps
var vel = Math.abs(dy / dt); // px/ms
if (vel > 0.01 && vel < 100) { // Filter noise
if (vel < minVel) minVel = vel;
if (vel > maxVel) maxVel = vel;
velSamples++; velSum += vel;
// Bucket by px/s (vel * 1000 = px/s). >200 px/s = skim; 50-200 = active read.
var pxs = vel * 1000;
if (pxs > 200) skimDist += Math.abs(dy);
else if (pxs >= 50) readDist += Math.abs(dy);
}
totalDist += Math.abs(dy);
// Direction reversal detection
var dir = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
if (dir !== 0 && scrollDir !== 0 && dir !== scrollDir) reversals++;
if (dir !== 0) scrollDir = dir;
prevScrollY = y; prevScrollT = t;
lastScrollTime = t;
// Pause detection: if no scroll for 2s after scrolling
clearTimeout(scrollPauseTimer);
scrollPauseTimer = setTimeout(function () {
var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight > 0) {
pauses.push(Math.round((window.scrollY / scrollHeight) * 100));
}
}, 2000);
});
}, { passive: true });
// --- Form-field abandonment (Tier 2 #4, 2026-05-05) ---
// Track which form field the user last interacted with. On
// navigation/unload, if the user touched a form but didn't submit,
// emit `fa` (form abandonment) with the last-touched field. Tells
// us "85% of bails happened on field 4 of 7" — the killer signal
// for one-button-flow products like HR-tech.
//
// Privacy:
// - We capture the field's identifier (id, name, type, position),
// NEVER the value the user typed. Pixel's PII guard would scrub
// textContent anyway, and we don't read .value at all.
// - Honors data-harvv-private="true" on the form (skip).
// - Skips password/credit-card inputs entirely (autocomplete sniff).
(function trackFormAbandonment() {
var lastField = null; // most recent focused/typed field
var formSubmitted = false; // any form submitted in this session?
var fiEmitted = {}; // per-field: emitted `fi` already?
var faEmitted = false; // already emitted `fa` once on this page?
// Per-form distinct-field touch counts. A single global counter overcounted
// badly (a one-field newsletter popup showed avg n=47.6 because EVERY
// focusin anywhere on the page incremented it). Scope is keyed by the
// field's <form>; inputs with no form scope to themselves, so a standalone
// input counts as its own 1-field form.
var formScopes = []; // [{ form: <el>, keys: {}, count: 0 }]
// Build a stable field identifier without reading any value.
function fieldKey(el) {
if (!el) return null;
// Skip password / sensitive types
var t = String(el.type || '').toLowerCase();
if (t === 'password' || t === 'hidden' || t === 'cc-number' || t === 'credit-card') return null;
// Honor private container
var p = el;
for (var i = 0; i < 20 && p; i++) {
if (p.getAttribute && (p.getAttribute('data-harvv-private') === 'true')) return null;
p = p.parentElement;
}
// Build identifier: tag.name OR tag#id OR tag[type=...]
var tag = (el.tagName || '').toLowerCase();
if (!tag) return null;
var id = el.id ? ('#' + String(el.id).slice(0, 40)) : '';
var nm = (!id && el.name) ? ('[name=' + String(el.name).slice(0, 40) + ']') : '';
var ty = (!id && !nm && t) ? ('[type=' + t + ']') : '';
return (tag + id + nm + ty).slice(0, 80);
}
// Find the field's position within its containing form (1-indexed).
function fieldPosition(el) {
try {
var form = el.form;
if (!form) return null;
var fields = form.querySelectorAll('input, textarea, select');
for (var i = 0; i < fields.length; i++) {
if (fields[i] === el) return i + 1;
}
} catch (_) {}
return null;
}
function isFormField(el) {
if (!el || !el.tagName) return false;
var t = el.tagName.toUpperCase();
return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT';
}
// Distinct-field touch count scoped to the field's form (one entry per
// form; a standalone input scopes to itself). ES5: plain array + linear
// scan, no Map/WeakMap.
function scopeFor(el) {
var form = el.form || el;
for (var i = 0; i < formScopes.length; i++) {
if (formScopes[i].form === form) return formScopes[i];
}
var s = { form: form, keys: {}, count: 0 };
formScopes.push(s);
return s;
}
document.addEventListener('focusin', function (e) {
if (!isFormField(e.target)) return;
var key = fieldKey(e.target);
if (!key) return;
var sc = scopeFor(e.target);
if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
lastField = { key: key, pos: fieldPosition(e.target), scope: sc };
// First-touch event per field — server uses for has_form_interaction.
// `fp` (field position) is a SEPARATE key from the page-path `p` that
// push() injects on every event, so it is never clobbered by the path.
if (!fiEmitted[key]) {
fiEmitted[key] = true;
push("fi", { f: key, fp: lastField.pos || undefined });
}
}, true);
document.addEventListener('input', function (e) {
if (!isFormField(e.target)) return;
var key = fieldKey(e.target);
if (!key) return;
// Count fields touched via typing/selection too, not only focusin — some
// controls (radios, selects) fire `input` without a preceding focusin, so
// without this the field they abandon on counts as 0.
var sc = scopeFor(e.target);
if (!sc.keys[key]) { sc.keys[key] = 1; sc.count++; }
lastField = { key: key, pos: fieldPosition(e.target), scope: sc };
}, true);
document.addEventListener('submit', function () {
formSubmitted = true;
}, true);
function emitAbandonment() {
if (faEmitted) return;
if (formSubmitted) return;
if (!lastField) return;
faEmitted = true;
// n = distinct fields touched in THIS form (scoped), not page-wide.
var n = (lastField.scope && lastField.scope.count) || 0;
push("fa", { f: lastField.key, fp: lastField.pos || undefined, n: n });
}
// Fire on tab close + on hashchange (SPA navigation)
window.addEventListener('pagehide', emitAbandonment);
window.addEventListener('beforeunload', emitAbandonment);
window.addEventListener('hashchange', emitAbandonment);
})();
// --- JS Errors + Performance + Network ---
var ec = 0;
window.addEventListener("error", function (e) {
if (ec++ < 3) push("er", { m: (e.message || "").slice(0, 80), f: (e.filename || "").replace(/.*\//, "").slice(0, 20), l: e.lineno });
});
window.addEventListener("unhandledrejection", function (e) {
if (ec++ < 3) push("er", { m: ("P:" + (e.reason&&e.reason.message||"")).slice(0,80) });
});
// --- User-Visible Notifications (T40, 2026-04-24) ---
// When a click produces a toast/alert/inline error, capture the message text
// + inventory/error classification. The receiver uses `un:inv=1` events to
// reframe dead-click issues on product elements as inventory restock issues
// rather than generic "broken CTA". Example: Shopify "Add to Cart" → "Sold
// out — notify me when available" is an inventory signal, not a UX bug.
//
// Gates (cheap → expensive): (a) 5s post-click window, (b) selector match,
// (c) inventory OR error keyword in text. Skips cookie banners, promo
// modals, and any node inside data-harvv-private. Emails + long digit
// sequences are scrubbed. Limit 8/session.
var unc = 0;
var UN_SEL = '[role="alert"],[role="status"],[aria-live="polite"],[aria-live="assertive"],.toast,.toaster,.notification,.notice,.flash,.flash-message,.snackbar,.alert-danger,.alert-warning,.error-message,.cart-error,.form-error,.product-form__error-message,.price-item--sold-out,.badge--sold-out';
var UN_INV = /out[\s-]?of[\s-]?stock|sold[\s-]?out|unavailable|back[\s-]?in[\s-]?stock|restock|notify[\s\w-]{0,12}available|no[\s-]?longer[\s-]?available|only\s+\d+\s+(?:left|in\s+stock)/i;
var UN_ERR = /\b(error|failed?|problem|try\s+again|invalid|required|incorrect)\b/i;
try {
var unObs = new MutationObserver(function (muts) {
if (unc >= 8) return;
var now = Date.now();
if (now - lastClickAt > 5000) return; // only post-click
for (var mi = 0; mi < muts.length && unc < 8; mi++) {
var added = muts[mi].addedNodes;
for (var ni = 0; ni < added.length && unc < 8; ni++) {
var n = added[ni];
if (!n || n.nodeType !== 1) continue;
if (n.closest && n.closest('[data-harvv-private="true"]')) continue;
var el = (n.matches && n.matches(UN_SEL)) ? n : (n.querySelector && n.querySelector(UN_SEL));
if (!el) continue;
var txt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
if (!txt || txt.length < 4) continue;
txt = txt.slice(0, 140).replace(/\S+@\S+\.\S+/g, "[email]").replace(/\b\d{10,}\b/g, "[num]");
var isInv = UN_INV.test(txt) ? 1 : undefined;
var isErr = UN_ERR.test(txt) ? 1 : undefined;
if (!isInv && !isErr) continue; // skip promo/cookie/decorative toasts
push("un", {
t: txt,
c: (el.className && typeof el.className === "string") ? el.className.slice(0, 40) : undefined,
r: (el.getAttribute && (el.getAttribute("role") || el.getAttribute("aria-live"))) || undefined,
src: lastClickEid || undefined,
d: Math.round(now - lastClickAt),
inv: isInv,
err: isErr
});
unc++;
}
}
});
unObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
} catch (_) {}
try { var lcp = 0, cls = 0, fcp = 0, tbt = 0;
// Phase 2 (2026-05-06) — track LCP element type so detection can
// distinguish image-LCP failures (Nina deliverable: image optimization)
// from JS/text-LCP failures. PII-safe: tag name only + URL-presence bool.
// Never sends the URL value itself.
var lcpEl = '', lcpHasUrl = 0;
new PerformanceObserver(function (l) {
var e = l.getEntries();
if (e.length) {
var last = e[e.length - 1];
lcp = last.startTime | 0;
try {
lcpEl = (last.element && last.element.tagName ? String(last.element.tagName).toLowerCase() : '').slice(0, 8);
lcpHasUrl = last.url ? 1 : 0;
} catch (_) {}
}
}).observe({ type: "largest-contentful-paint", buffered: true });
// Phase 2.5 (2026-05-06) — CLS source attribution. layout-shift entries
// include a `sources[]` array of nodes that shifted. We capture short
// selectors for the top contributors so detection can name the culprit
// ("hero image is what shifted at 1.2s"), not just "your CLS is 0.18".
// PII-safe: selector strings only, no DOM text.
var clsSources = [];
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (e.hadRecentInput) return;
cls += e.value;
if (clsSources.length >= 8) return;
try {
var srcs = e.sources || [];
for (var si = 0; si < srcs.length && clsSources.length < 8; si++) {
var n = srcs[si].node;
if (!n || !n.tagName) continue;
var sel = n.tagName.toLowerCase();
if (n.id) sel += '#' + String(n.id).slice(0, 24);
else if (n.className && typeof n.className === 'string') {
var c = n.className.split(/\s+/)[0];
if (c) sel += '.' + c.slice(0, 24);
}
clsSources.push({ s: sel.slice(0, 60), v: +e.value.toFixed(3) });
}
} catch (_) {}
});
}).observe({ type: "layout-shift", buffered: true });
// FCP (First Contentful Paint) — widely supported paint API entry.
try {
var paints = performance.getEntriesByType("paint") || [];
for (var pi = 0; pi < paints.length; pi++) {
if (paints[pi].name === "first-contentful-paint") fcp = paints[pi].startTime | 0;
}
} catch (_) {}
// TBT (Total Blocking Time) — sum of (longtask_duration - 50ms) over all long tasks.
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) { tbt += Math.max(0, (e.duration | 0) - 50); });
}).observe({ type: "longtask", buffered: true });
} catch (_) {}
setTimeout(function () {
var n = performance.getEntriesByType("navigation")[0];
var tti = n ? Math.round(n.domInteractive || 0) : 0;
var plt = n ? Math.round(n.loadEventEnd || n.domContentLoadedEventEnd || 0) : 0;
// Re-read FCP in case it arrived after init (paint entries aren't observer-streamed here)
if (!fcp) {
try {
var p2 = performance.getEntriesByType("paint") || [];
for (var pj = 0; pj < p2.length; pj++) {
if (p2[pj].name === "first-contentful-paint") fcp = p2[pj].startTime | 0;
}
} catch (_) {}
}
push("pf", {
ttfb: n ? n.responseStart | 0 : 0,
fcp: fcp || undefined,
lcp: lcp,
cls: (cls * 1e3 | 0) / 1e3,
tbt: tbt || undefined,
tti: tti,
plt: plt,
inp: worstInp > 0 ? Math.round(worstInp) : undefined,
// T7 (2026-04-23): network type for attribution. Lets us separate
// "slow network → dead click" from "broken CTA → dead click".
net: (navigator.connection && navigator.connection.effectiveType) || undefined,
// Phase 2 (2026-05-06) — LCP element discriminator. PII-safe:
// tag name only + URL-presence boolean. Detection layer uses
// lcp_el='img' + lcp>2500 to fire content:image_lcp_fail.
lcp_el: lcpEl || undefined,
lcp_url: lcpHasUrl || undefined,
// Phase 2.5 (2026-05-06) — CLS source attribution (top 5
// contributors). Detection layer uses this to fire
// layout:cls_culprit naming the element that shifted.
cls_src: clsSources.length ? clsSources.slice(0, 5) : undefined,
});
}, 5e3);
} catch (_) {}
// --- Long Tasks (main thread blocking >50ms) ---
// Correlates with rage clicks and unresponsive UI. Chromium-only (~86% of traffic).
var ltc = 0;
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (ltc++ < 5) push("lt", { d: Math.round(e.duration), n: (e.attribution && e.attribution[0] && e.attribution[0].name || "").slice(0, 40) });
});
}).observe({ type: "longtask", buffered: true });
} catch (_) {}
// --- INP (Interaction to Next Paint) — Core Web Vital ---
// Measures responsiveness: delay between user input and visual update.
// Supported in Chrome 96+, Safari 26.2+, Firefox 122+.
// 2026-05-06 (Tier 1): emit per-event INP for slow interactions so the
// server can answer "WHICH button is slow?" today only the page-aggregated
// INP is captured in pf.
var worstInp = 0;
var inpEventCount = 0;
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (e.duration > worstInp) worstInp = e.duration;
// Per-interaction INP: slow events (>200ms) get their own emit, capped
// at 5/page so we don't flood the wire on a janky page.
if (e.duration > 200 && inpEventCount++ < 5) {
var tgt = e.target || (e.attribution && e.attribution[0]);
push("ie", {
d: Math.round(e.duration),
n: e.name,
el: tgt ? eid(tgt) : undefined,
});
}
});
}).observe({ type: "event", buffered: true, durationThreshold: 100 });
} catch (_) {}
// 2026-05-06 (Tier 1) — Soft-navigation Performance Observer.
// Modern SPAs (Hydrogen, Next.js) emit `soft-navigation` entries when
// client-side route changes happen without a full page reload. Gives us
// accurate timing for SPA route transitions. ~30 bytes when supported.
try {
if (PerformanceObserver.supportedEntryTypes && PerformanceObserver.supportedEntryTypes.indexOf('soft-navigation') !== -1) {
var softNavCount = 0;
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (softNavCount++ < 10) {
push("sn", {
p: e.name && e.name.replace(/^https?:\/\/[^/]+/, '') || location.pathname,
ms: Math.round(e.duration),
});
}
});
}).observe({ type: 'soft-navigation', buffered: true });
}
} catch (_) {}
// 2026-05-06 (Tier 1) — Print intent. Strong "user wants to keep this
// page" signal — frequently triggered on receipts/confirmations/specs
// when the email isn't sufficient. ~20 bytes.
try {
addEventListener('beforeprint', function () {
push("pi", { p: location.pathname });
}, { passive: true, once: true });
} catch (_) {}
// 2026-05-06 (Tier 1) — Idle time within session. Emits `id` events when
// ≥30s passes between meaningful interactions. Distinct from `vc`
// (visibility change) which fires when the tab is hidden — idle is "tab
// active but user not interacting." Useful for funnel-stall analysis.
// Capped at 8/session so a long idle background tab doesn't generate spam.
(function trackIdle() {
var lastActive = Date.now();
var idleEmits = 0;
var IDLE_THRESHOLD_MS = 30000;
// 2026-06-09 — ceiling on the gap. lastActive only updates on
// interaction ticks, so a laptop sleep / frozen mobile tab / bfcache
// restore / clock jump made the first post-wake interaction emit a
// "gap" of hours or DAYS as one id event (prod max: 2,530,469,429 ms
// = 29 days; two events exceeded int4 max and crashed any ::int cast
// server-side). A gap above 30 minutes is suspension, not idling —
// drop it entirely rather than clamp (a clamped value would still
// be a lie about visible idle time).
var IDLE_CEILING_MS = 1800000;
function tick() {
try {
var now = Date.now();
var gap = now - lastActive;
if (gap >= IDLE_THRESHOLD_MS && gap <= IDLE_CEILING_MS && document.visibilityState === 'visible' && idleEmits++ < 8) {
push("id", { ms: Math.round(gap) });
}
lastActive = now;
} catch (_) {}
}
// Reset the anchor when the page hides/shows or returns from bfcache,
// so suspended wall-clock time never masquerades as visible idle.
function resetAnchor() { try { lastActive = Date.now(); } catch (_) {} }
addEventListener('click', tick, { passive: true, capture: true });
addEventListener('keydown', tick, { passive: true, capture: true });
addEventListener('scroll', tick, { passive: true, capture: true });
document.addEventListener('visibilitychange', resetAnchor, { passive: true });
addEventListener('pageshow', resetAnchor, { passive: true });
})();
// 2026-05-06 (Tier 1) — Multi-tab presence via BroadcastChannel.
// When a visitor opens >1 tab of the customer's site, each tab announces
// itself; we count peer responses. Useful for distinguishing "research
// mode" (multiple tabs) from "single-tab purchase intent." ~80 bytes.
// Tab-local — never leaves the browser; respects all privacy guarantees.
try {
if (typeof BroadcastChannel === 'function') {
var bc = new BroadcastChannel('harvv:tabs');
var peers = 0;
bc.onmessage = function (e) {
if (e.data === 'present?') bc.postMessage('here');
else if (e.data === 'here') peers++;
};
bc.postMessage('present?');
setTimeout(function () {
if (peers > 0) push("mt", { n: peers });
}, 1500);
}
} catch (_) {}
// 2026-05-06 (Tier 2) — Resource timing for top-3 slowest resources >500ms.
// Answers: "which images / scripts / CSS files are dragging down LCP?"
// URL is scrubbed via the existing scrubQuery() before emission.
// Capped at 3/page so a junk-asset page can't generate spam.
try {
var rsCount = 0;
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (r) {
if (rsCount >= 3) return;
if (r.duration < 500) return;
if (!r.name || /\b(harvv\.com|cloudflare|googleads|facebook|datadog)\b/.test(r.name)) return;
var path = '';
try { path = scrubQuery((new URL(r.name)).pathname.slice(0, 100)); } catch (_) { path = (r.name || '').slice(0, 100); }
rsCount++;
push("rs", {
ms: Math.round(r.duration),
sz: r.transferSize || undefined,
t: (r.initiatorType || 'other').slice(0, 8),
u: path,
});
});
}).observe({ type: 'resource', buffered: true });
} catch (_) {}
// 2026-05-06 (Tier 2) — Filter / search refinement chain.
// Fires when user clicks a filter button or refines a search input.
// Emits sequence-counter and query LENGTH only (not text content).
// Customer signals: data-filter attribute, role=button inside .filter-*,
// form submit to a search-like input. ~120 bytes.
(function trackFilterAndSearch() {
var filterCount = 0, searchRefineCount = 0, lastQLen = 0;
addEventListener('click', function (e) {
try {
var t = e.target;
if (!t || !t.closest) return;
var fc = t.closest('[data-filter], [data-facet], .filter-btn, [role="button"][aria-pressed]');
if (fc && filterCount++ < 20) {
push("fl", { kind: 'f', n: filterCount, el: eid(fc) });
}
} catch (_) {}
}, { passive: true, capture: true });
addEventListener('input', function (e) {
try {
var t = e.target;
if (!t || t.tagName !== 'INPUT') return;
var ty = (t.type || '').toLowerCase();
var name = (t.name || t.id || '').toLowerCase();
if (ty !== 'search' && !/search|query|q\b/.test(name)) return;
var len = (t.value || '').length;
if (len === 0) return;
if (Math.abs(len - lastQLen) > 2 && searchRefineCount++ < 10) {
push("fl", { kind: 's', qlen: len, n: searchRefineCount });
}
lastQLen = len;
} catch (_) {}
}, { passive: true, capture: true });
})();
// 2026-05-06 (Tier 2) — Pinch zoom + pull-to-refresh detection (mobile).
// Pinch zoom = "text too small / image not legible" frustration signal.
// Pull-to-refresh = "I think this page is broken or stale."
// Both fire as `pz` events. Mobile-only (gated on touchstart support).
// Capped at 3/page.
try {
if ('ontouchstart' in window) {
var pzCount = 0;
var pinchStart = 0, lastTouchStartY = 0;
addEventListener('touchstart', function (e) {
try {
if (e.touches.length === 2) {
var t1 = e.touches[0], t2 = e.touches[1];
pinchStart = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
} else if (e.touches.length === 1) {
lastTouchStartY = e.touches[0].clientY;
}
} catch (_) {}
}, { passive: true });
addEventListener('touchmove', function (e) {
try {
if (e.touches.length === 2 && pinchStart > 0) {
var t1 = e.touches[0], t2 = e.touches[1];
var d = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
if (Math.abs(d - pinchStart) > 60 && pzCount++ < 3) {
push("pz", { kind: 'pinch' });
pinchStart = 0;
}
} else if (e.touches.length === 1 && lastTouchStartY > 0 && (window.scrollY || 0) === 0) {
var pull = e.touches[0].clientY - lastTouchStartY;
if (pull > 120 && pzCount++ < 3) {
push("pz", { kind: 'ptr' });
lastTouchStartY = 0;
}
}
} catch (_) {}
}, { passive: true });
}
} catch (_) {}
// 2026-05-06 (Tier 3) — UA Client Hints. Modern replacement for parsing
// navigator.userAgent. getHighEntropyValues is async; we attach the brand
// array (always available sync) + the deferred model/platformVersion in
// a separate `uah` event when the promise resolves. Chromium-only — Safari
// and Firefox don't expose userAgentData. ~120 bytes.
try {
if (navigator.userAgentData && typeof navigator.userAgentData.getHighEntropyValues === 'function') {
var brand = (navigator.userAgentData.brands || []).filter(function (b) {
return b.brand && !/Not.?A.?Brand/i.test(b.brand);
})[0];
navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'mobile', 'model']).then(function (h) {
push("uah", {
b: brand && brand.brand && brand.brand.slice(0, 20),
m: h.mobile ? 1 : undefined,
pf: (h.platform || '').slice(0, 16),
pv: (h.platformVersion || '').slice(0, 16),
md: (h.model || '').slice(0, 24),
});
}).catch(function () {});
}
} catch (_) {}
// 2026-05-06 (Tier 3) — Service worker state. One bit: is the customer's
// site currently controlled by a service worker? Diagnostic value for
// "why is my page serving stale content?" / "why does it work offline?"
// questions. ~30 bytes.
try {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
push("sw", { c: 1 });
}
} catch (_) {}
// --- ReportingObserver (deprecation + browser intervention signals) ---
// Catches deprecated API usage and browser interventions (e.g. blocked heavy ads).
// Supported in Chrome 69+, Edge 79+, Firefox 115+, Safari 16.4+.
try {
if (window.ReportingObserver) {
var roc = 0;
new ReportingObserver(function (reports) {
reports.forEach(function (r) {
if (roc++ < 3) push("ro", { t: r.type, m: (r.body.message || "").slice(0, 80), src: (r.body.sourceFile || "").replace(/.*\//, "").slice(0, 30) });
});
}, { types: ["deprecation", "intervention"], buffered: true }).observe();
}
} catch (_) {}
// --- Network Monitoring (fetch + resource) ---
// Captures full URL, initiator script, request type, timing — like Chrome DevTools Network tab
// Scrub query strings on paths known to carry user-generated content.
// Keys with these names are replaced with `[scrubbed]` so a GET /api/search?q=my+private+question
// becomes /api/search?q=[scrubbed]. POST bodies are never captured so those are safe.
var SENSITIVE_QS_KEYS = /^(q|query|search|s|email|password|token|code|secret|message|text|content|chat|prompt|question)$/i;
function scrubQuery(pathWithQuery) {
var qIdx = pathWithQuery.indexOf("?");
if (qIdx === -1) return pathWithQuery;
var path = pathWithQuery.slice(0, qIdx);
var qs = pathWithQuery.slice(qIdx + 1);
var parts = qs.split("&").map(function (pair) {
var eq = pair.indexOf("=");
if (eq === -1) return pair;
var k = pair.slice(0, eq);
return SENSITIVE_QS_KEYS.test(k) ? k + "=[scrubbed]" : pair;
});
return path + "?" + parts.join("&");
}
// 2026-05-23 — GA4 Measurement Protocol observer. If a customer has GA4
// (gtag.js) installed, every page-view + event fires an HTTP call to
// `/g/collect` (or `/r/collect`, `/j/collect` for legacy). The query
// string carries the event name, tracking ID, GA's client/session IDs,
// and any numeric event parameters (value, items_qty, etc.). This
// helper extracts a SAFE subset (no string event params, no user
// properties — those can carry PII) and emits a `ga` event. Two
// capabilities unlock from this:
// (1) Discrepancy / data-quality cases. We now know which GA4 events
// fired per session and can compare to our own detections to
// surface "your GA4 isn't catching this conversion" findings.
// (2) GA's session/client ID joins to ours, so server-side we can
// cross-reference our visitor_id with GA's perspective.
// Returns null when the URL is not a collect call, or an object
// suitable for push("ga", ...). Capped at 30 events per session to
// avoid floods on event-chatty sites.
var _gaEvCount = 0;
function _parseGACollect(rawUrl) {
if (_gaEvCount >= 30) return null;
// Cheap rejects first — most fetches aren't GA.
if (!rawUrl || rawUrl.indexOf("/g/collect") === -1 && rawUrl.indexOf("/r/collect") === -1 && rawUrl.indexOf("/j/collect") === -1) return null;
// Must be a known GA endpoint host. Don't match accidentally on a
// first-party path like /collect — only Google Analytics ones.
if (rawUrl.indexOf("analytics.google.com") === -1 &&
rawUrl.indexOf("google-analytics.com") === -1 &&
rawUrl.indexOf("www.google.com/g/collect") === -1) return null;
var qIdx = rawUrl.indexOf("?");
if (qIdx === -1) return null;
var qs = rawUrl.slice(qIdx + 1);
// Lightweight parse — avoid URL/URLSearchParams to keep this fast on
// event-chatty pages.
var en = "", tid = "", cid = "", sid = "", value = null;
var pairs = qs.split("&");
for (var i = 0; i < pairs.length; i++) {
var eq = pairs[i].indexOf("=");
if (eq === -1) continue;
var k = pairs[i].slice(0, eq);
var v = pairs[i].slice(eq + 1);
if (k === "en" && !en) en = decodeURIComponent(v).slice(0, 40);
else if (k === "tid" && !tid) tid = decodeURIComponent(v).slice(0, 32);
else if (k === "cid" && !cid) cid = v.slice(0, 32); // GA client ID — already opaque
else if (k === "sid" && !sid) sid = v.slice(0, 20);
else if (k === "epn.value" && value === null) {
var n = parseFloat(decodeURIComponent(v));
if (!isNaN(n) && isFinite(n)) value = n;
}
}
if (!en && !tid) return null;
_gaEvCount++;
var payload = { en: en || undefined, tid: tid || undefined };
if (cid) payload.cid = cid;
if (sid) payload.sid = sid;
if (value !== null) payload.v = value;
return payload;
}
// 2026-05-27 — Defensive wrap install. Punchmark + Quantum Qarat
// hit "Maximum call stack size exceeded" because:
// 1. We wrapped XMLHttpRequest.prototype.open and window.fetch in
// TWO places each, with no idempotency guard.
// 2. A third-party tag with the (common) anti-pattern of calling
// `XMLHttpRequest.prototype.open.apply(this, args)` instead of
// saving its own reference creates a self-referential loop
// with our wrapper after both run.
// Fix has four layers matching the Posthog/Hotjar/Segment standard:
// (a) Single wrap point per transport. The fs (form-submission)
// inference logic is folded into the same wrap that does nf
// (network-failure) detection. Below the form-submit listener
// there are no more transport wraps.
// (b) Idempotency marker on the prototype/fn so we never wrap twice
// (c) try/catch around the wrap installation so if anything fails
// to assign, we exit silently — the host page is never broken
// (d) try/catch INSIDE the wrapper so internal logic bugs cannot
// block the original call. We always reach `apply(this, args)`.
// Symptom on Quantum Qarat: 1,586 XHR failures across acsbapp,
// affirm, klaviyo, gtag, and the site's own /api/async.php.
// Shared helpers used by both transport wraps for fs (form-submit)
// inference. Moved to function scope so the wrap blocks below can
// reference them — previously they lived inside a later try block
// which forced a second wrap site that ran after these.
var SUBMIT_PATH_HINTS = /\/(?:api|graphql|submit|checkout|order|payment|signup|sign-up|register|login|sign-in|subscribe|contact|lead|kyc|onboard|outcome|track)\b/i;
// 2026-06-15 — beacons/pollers that match SUBMIT_PATH_HINTS but are NOT form
// submits: analytics collectors and the Shopify Storefront GraphQL the theme
// polls. /api and /graphql in the hints catch these, so Tiger Friday's
// /api/collect (~30k/day) and /api/<ver>/graphql.json polling were inflating
// fs. Excluding them keeps real submit signal (checkout/login/lead) while
// killing the polling floods at the source. Mirrors the 5xx detector's
// NON_ACTIONABLE path list.
var FS_PATH_EXCLUDE = /(?:\/collect|monorail|\/produce|beacon|telemetry|\/gtm|\/gtag|analytics|\/wpm|\/__|wc-analytics|cdn-cgi|\.well-known)|\/api\/\d{4}-\d{2}\/graphql/i;
// 2026-06-09 — per-session cap on TRANSPORT-inferred fs (fetch/xhr only;
// declarative <form> submits stay uncapped). SUBMIT_PATH_HINTS matches
// /api and /track, so a theme that POLLS a same-origin API emits fs on
// every poll: Tiger Friday's theme started polling Shopify's Storefront
// GraphQL on 2026-06-03 and fs jumped to 1.2-1.6M/day (36% of their whole
// event volume; p95 332 fs/session, max 3,512). Every sibling transport
// event is capped (nf 5+5/page, ga 30/session, rs 3/page) — fs was the
// only one with none. 20/session keeps real form/checkout signal (p50 on
// healthy sites is < 5) while flooring polling floods.
var fsTransportCount = 0;
var FS_TRANSPORT_CAP = 20;
function pushFsTransport(d) {
if (fsTransportCount >= FS_TRANSPORT_CAP) return;
fsTransportCount++;
push('fs', d);
}
function templatize(pathname) {
return String(pathname || '/').replace(/\/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi, '/:uuid')
.replace(/\/\d{5,}/g, '/:id')
.substring(0, 120);
}
var slowCount = 0, errCount = 0;
try { if (window.fetch && !window.fetch.__harvv_wrapped) {
var oF = window.fetch;
// Confirm we're saving a real function reference, not a hostile
// proxy that already re-references the live window.fetch.
if (typeof oF !== 'function') throw new Error('fetch_not_fn');
window.fetch.__harvv_wrapped = 1;
// 2026-05-19 perf fix — resolve the `initiator` from a stack ONLY when
// an `nf` event is actually about to be pushed. Previously this wrapper
// formatted `new Error().stack` and ran a regex scan on EVERY fetch —
// pure overhead on the 99% of requests that are fast + OK, since `nf`
// only fires for failed/slow requests (and is capped at 5 each).
// `Error.stack` formatting is one of the most expensive per-call ops in
// JS; on a dashboard that fires many API calls it was a measurable tax.
function _initiatorFrom(errObj) {
try {
var lines = ((errObj && errObj.stack) || "").split("\n");
for (var li = 1; li < lines.length && li < 8; li++) {
var line = lines[li];
if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
if (fileMatch) return fileMatch[1].replace(/.*\//, "").substring(0, 60);
var pathMatch = line.match(/([^\/()\s]+\.js[:\d]*)/);
if (pathMatch) return pathMatch[1].substring(0, 60);
}
}
} catch (_) {}
return undefined;
}
window.fetch = function (input, init) {
// 2026-05-14 — signal post-click async response so any pending
// deferred dead-click can be cancelled (Axel Off-Road learning).
try { if (window.__harvvNetActivity) window.__harvvNetActivity(); } catch (_) {}
var t0 = performance.now();
var url = (typeof input === "string") ? input : (input && input.url) || "";
// 2026-05-23 — GA4 collect observer. Inspect the raw URL BEFORE
// it gets scrubbed below (the scrub strips epn.value etc.).
try { var _gaPayload = _parseGACollect(url); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
var fullUrl = "";
var path = "";
try {
var u = new URL(url, location.origin);
var scrubbedSearch = scrubQuery(u.pathname + u.search).slice(u.pathname.length);
fullUrl = u.origin !== location.origin ? (u.origin + scrubQuery(u.pathname + u.search)).substring(0, 200) : (u.pathname + scrubbedSearch).substring(0, 80);
path = u.pathname.substring(0, 80);
} catch (_) {
fullUrl = scrubQuery(url).substring(0, 200);
path = url.split("?")[0].substring(0, 80);
}
// Classify request type from extension/path (no PII)
var ext = path.split(".").pop().toLowerCase();
var rt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg" || ext === "gif" || ext === "ico") ? "asset"
: (path.indexOf("/api/") > -1 || path.indexOf("/graphql") > -1 || path.indexOf("/cart") > -1) ? "api" : "page";
// Detect method
var method = (init && init.method) ? init.method.toUpperCase() : "GET";
// Capture a bare Error at call time so the initiator stays accurate
// (the stack inside a .then() microtask would be wrong). `.stack` is
// NOT accessed here — only formatted later in the rare nf branch.
// Once both nf caps are exhausted, skip the capture entirely.
var callerErr = (errCount < 5 || slowCount < 5) ? new Error() : null;
// 2026-05-27 — fs (form-submission) inference, folded in from
// the formerly-separate second wrap block. Mutating same-origin
// requests to submission-like paths get an 'fs' event in the
// same .then() callback below.
var _fsMutating = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
var _fsSameOrigin = false, _fsPathHint = false, _fsPath = null, _fsUrlObj = null;
try {
_fsUrlObj = new URL(url, location.origin);
_fsSameOrigin = _fsUrlObj.origin === location.origin;
_fsPathHint = SUBMIT_PATH_HINTS.test(_fsUrlObj.pathname) && !FS_PATH_EXCLUDE.test(_fsUrlObj.pathname);
if (_fsMutating && _fsSameOrigin && _fsPathHint) _fsPath = templatize(_fsUrlObj.pathname);
} catch (_) {}
// 2026-05-26 — non-chaining observer pattern.
// Previously: `return oF.apply(...).then(observer).catch(observer)`.
// That added two microtasks of latency to the caller's promise chain
// — small but real. Third-party apps with a timing-sensitive consumer
// of the fetch result (notably SearchPie's collection-injection logic
// on Shopify, which races setTimeout-based cache restore against the
// fetch resolution) could see "fired twice" / "failed to clean up"
// symptoms when our wrapper's delay flipped the order.
//
// The fix: get the original promise, attach observer with
// `.then(success, error)` (does NOT return a chained promise the
// caller would see), and return the ORIGINAL promise to the caller.
// From the caller's perspective our wrapper is byte-identical to no
// wrapper at all — observer fires independently in its own task.
var p = oF.apply(this, arguments);
p.then(function (r) {
var ms = Math.round(performance.now() - t0);
// nf: network-failure or slow-request signal
if (r.status >= 400 && errCount++ < 5) push("nf", { s: r.status, ms: ms, u: fullUrl, rt: rt, e: 1, mt: method, ini: _initiatorFrom(callerErr) });
else if (ms > 2000 && slowCount++ < 5) push("nf", { s: r.status, ms: ms, slow: 1, u: fullUrl, rt: rt, mt: method, ini: _initiatorFrom(callerErr) });
// fs: form-submission inference (folded in 2026-05-27)
if (_fsPath) { try { pushFsTransport({ p: _fsPath, m: method, s: r.status | 0, t: 'fetch' }); } catch (_) {} }
}, function (err) {
var ms = Math.round(performance.now() - t0);
if (errCount++ < 5) push("nf", { s: 0, ms: ms, u: fullUrl, rt: rt, e: 1, to: ms > 30000 ? 1 : 0, m: (err.message || "").substring(0, 40), mt: method, ini: _initiatorFrom(callerErr) });
if (_fsPath) { try { pushFsTransport({ p: _fsPath, m: method, s: 0, t: 'fetch', err: 1 }); } catch (_) {} }
});
return p;
};
// Mark the new wrapper too so the second-block check below sees it.
try { window.fetch.__harvv_wrapped = 1; } catch (_) {}
} } catch (_) { /* fetch wrap install failed; do not break host page */ }
// Track XHR (XMLHttpRequest) — many sites still use it (jQuery, legacy code)
try { if (window.XMLHttpRequest && !XMLHttpRequest.prototype.__harvv_xhr_wrapped) {
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
if (typeof origOpen !== 'function' || typeof origSend !== 'function') throw new Error('xhr_not_fn');
XMLHttpRequest.prototype.__harvv_xhr_wrapped = 1;
XMLHttpRequest.prototype.open = function (method, url) {
this._pxMethod = (method || "GET").toUpperCase();
this._pxUrl = "";
this._pxPath = "";
// 2026-05-23 — GA4 collect observer (XHR path). Some older GA tags
// use XHR, and ad-trackers built on top of dataLayer occasionally
// proxy through XHR. Inspect the raw URL before scrubbing.
try { var _gaPayload = _parseGACollect(String(url)); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
try {
var u = new URL(url, location.origin);
var scrubbed = scrubQuery(u.pathname + u.search);
this._pxUrl = u.origin !== location.origin ? (u.origin + scrubbed).substring(0, 200) : scrubbed.substring(0, 80);
this._pxPath = u.pathname.substring(0, 80);
} catch (_) {
this._pxUrl = scrubQuery(String(url)).substring(0, 200);
this._pxPath = String(url).split("?")[0].substring(0, 80);
}
var ext = this._pxPath.split(".").pop().toLowerCase();
this._pxRt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg") ? "asset"
: (this._pxPath.indexOf("/api/") > -1 || this._pxPath.indexOf("/graphql") > -1 || this._pxPath.indexOf("/cart") > -1) ? "api" : "page";
// 2026-05-27 — fs (form-submission) inference, folded in from
// the formerly-separate second wrap block. Stash metadata here;
// emission happens in send()'s loadend below.
this.__hv_fs = null;
try {
var _u2; try { _u2 = new URL(url, location.origin); } catch (_) { _u2 = null; }
var _fsMethod = this._pxMethod;
var _fsIsMutating = _fsMethod !== 'GET' && _fsMethod !== 'HEAD' && _fsMethod !== 'OPTIONS';
var _fsSameOrigin = _u2 && _u2.origin === location.origin;
var _fsPathHint = _u2 && SUBMIT_PATH_HINTS.test(_u2.pathname) && !FS_PATH_EXCLUDE.test(_u2.pathname);
if (_fsIsMutating && _fsSameOrigin && _fsPathHint) {
this.__hv_fs = { p: templatize(_u2.pathname), m: _fsMethod };
}
} catch (_) {}
// Detect initiator
this._pxIni = "";
try {
var stack = new Error().stack || "";
var lines = stack.split("\n");
for (var li = 1; li < lines.length && li < 8; li++) {
var line = lines[li];
if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
var match = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
if (match) { this._pxIni = match[1].replace(/.*\//, "").substring(0, 60); break; }
var pm = line.match(/([^\/()\s]+\.js[:\d]*)/);
if (pm) { this._pxIni = pm[1].substring(0, 60); break; }
}
}
} catch (_) {}
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
// 2026-05-14 — signal post-click async response (Axel Off-Road
// learning, see fetch hook above). Cancels any pending deferred
// dead-click since this proves the click triggered server work.
try { if (window.__harvvNetActivity) window.__harvvNetActivity(); } catch (_) {}
var xhr = this;
var t0 = performance.now();
xhr.addEventListener("loadend", function () {
var ms = Math.round(performance.now() - t0);
// nf: network failure / slow request
if (xhr.status >= 400 && errCount++ < 5) {
push("nf", { s: xhr.status, ms: ms, u: xhr._pxUrl, rt: xhr._pxRt, e: 1, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
} else if (ms > 2000 && slowCount++ < 5) {
push("nf", { s: xhr.status, ms: ms, slow: 1, u: xhr._pxUrl, rt: xhr._pxRt, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
}
// fs: form-submission inference (folded in 2026-05-27)
if (xhr.__hv_fs) {
try { pushFsTransport({ p: xhr.__hv_fs.p, m: xhr.__hv_fs.m, s: xhr.status | 0, t: 'xhr' }); } catch (_) {}
}
});
return origSend.apply(this, arguments);
};
} } catch (_) { /* xhr wrap install failed; do not break host page */ }
// Track failed resource loads (images, scripts, stylesheets)
window.addEventListener("error", function (e) {
var t = e.target;
if (t && t.tagName && (t.tagName === "IMG" || t.tagName === "SCRIPT" || t.tagName === "LINK")) {
var src = (t.src || t.href || "").substring(0, 200);
if (src && ec++ < 5) push("nf", { s: 0, u: src, rt: "asset", e: 1, tag: t.tagName.toLowerCase() });
}
}, true);
// 2026-05-23 — sendBeacon observer for GA4. ~30% of GA4 measurement
// protocol calls fire via navigator.sendBeacon on pagehide/visibilitychange
// (especially `purchase` and other "user is leaving" events). Wrap as
// a pass-through that inspects the URL and emits a `ga` event when it
// matches /g/collect — never modifies the actual call.
if (navigator.sendBeacon) {
var oSB = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function (url, data) {
try { var _gaPayload = _parseGACollect(String(url)); if (_gaPayload) push("ga", _gaPayload); } catch (_) {}
return oSB(url, data);
};
}
// --- Explicit Conversion API ---
// Sites can call window.__harvv_convert(value) on order confirmation
window.__harvv_convert = function (value) {
if (!cvFired) { push("cv", { p: location.pathname, v: typeof value === "number" ? value : undefined }); cvFired = true; }
};
// ─── Page-Audit Signals (Phase 2 + Dogfooding, 2026-05-06) ─────────
// wc / ma / jl fire ONCE per pageview, not per session.
// PII rules per signal documented inline. Strict design: numbers > text,
// counts > content, schema-keyword constants > raw schema.
//
// Auth-pattern URLs and `data-harvv-private` ancestors short-circuit
// any signal that would touch DOM text. Defense-in-depth scrubbing of
// emails/phones/long digit runs on every text field that ships.
function isPrivatePage() {
try {
if (document.body && document.body.matches && document.body.matches('[data-harvv-private="true"]')) return true;
if (document.body && document.body.closest && document.body.closest('[data-harvv-private="true"]')) return true;
var p = (location.pathname || '').toLowerCase();
if (/^\/(login|signup|sign-?in|sign-?up|account|admin|dashboard|app|billing|settings|profile|my-?account|reset-?password|forgot-?password|checkout|cart|orders?|wp-admin)(\/|$)/.test(p)) return true;
} catch (_) {}
return false;
}
function scrubText(s) {
return String(s || '')
.replace(/[\w._%+-]+@[\w.-]+\.[a-z]{2,}/gi, '[email]')
.replace(/\+?\d[\d\s().-]{8,}\d/g, '[phone]')
.replace(/\b\d{10,}\b/g, '[num]');
}
// wc — word count of the primary content body. Numbers only. Used for
// "expected reading time" baselines (no_consumption pattern). Never sends
// the text itself.
function emitWordCount() {
try {
if (isPrivatePage()) return;
var sel = 'article, [role="article"], main article, main .post, main .entry, .post-content, .entry-content, [itemprop="articleBody"]';
var node = document.querySelector(sel) || document.querySelector('main') || document.body;
if (!node) return;
// textContent is read locally; we only ship the COUNT.
var txt = (node.textContent || '').replace(/\s+/g, ' ').trim();
if (!txt) return;
var n = txt.split(/\s+/).length;
if (n > 10000) n = 10000;
var sl = node.tagName ? node.tagName.toLowerCase() : 'body';
if (node.id) sl += '#' + String(node.id).slice(0, 20);
else if (node.className && typeof node.className === 'string') {
var cls = node.className.split(/\s+/)[0];
if (cls) sl += '.' + cls.slice(0, 20);
}
push('wc', { n: n, sl: sl });
} catch (_) {}
}
// ma — meta audit. Tags in <head> are public-by-spec (sent to crawlers).
// Defense in depth: scrub emails/phones, cap text at 200 chars, skip on
// private/auth pages anyway. Sends both length AND truncated content for
// SEO QA — Nina needs to see WHY a title is wrong, not just that it's wrong.
function emitMetaAudit() {
try {
if (isPrivatePage()) return;
var t = document.title || '';
var dq = document.querySelector('meta[name="description"]');
var d = dq ? (dq.getAttribute('content') || '') : '';
var h1s = document.querySelectorAll('h1');
var h1t = h1s.length ? (h1s[0].textContent || '').trim() : '';
var ogt = document.querySelector('meta[property="og:title"]');
var ogd = document.querySelector('meta[property="og:description"]');
var ogi = document.querySelector('meta[property="og:image"]');
var cn = document.querySelector('link[rel="canonical"]');
var rb = document.querySelector('meta[name="robots"]');
// Count duplicates (the LinkedIn-no-image bug from 2026-05-06 — we shipped
// start.html with TWO og:title and og:description tags. Track here so we
// can detect on customer sites too.)
var ogtN = document.querySelectorAll('meta[property="og:title"]').length;
var ogdN = document.querySelectorAll('meta[property="og:description"]').length;
var ogiN = document.querySelectorAll('meta[property="og:image"]').length;
push('ma', {
tl: t.length,
tt: scrubText(t).slice(0, 200),
dl: d.length,
dt: scrubText(d).slice(0, 200),
h1n: h1s.length,
h1t: scrubText(h1t).slice(0, 200),
ogt: ogt ? 1 : 0,
ogd: ogd ? 1 : 0,
ogi: ogi ? 1 : 0,
ogtN: ogtN > 1 ? ogtN : undefined,
ogdN: ogdN > 1 ? ogdN : undefined,
ogiN: ogiN > 1 ? ogiN : undefined,
cn: cn ? 1 : 0,
ni: rb && /noindex/i.test(rb.getAttribute('content') || '') ? 1 : 0,
});
} catch (_) {}
}
// jl — JSON-LD schema detect. PII-safe: ships only schema.org @type
// keywords ("Article", "Product", etc.) — constants, never user data.
// Never sends the schema content itself.
function emitSchemaDetect() {
try {
var scripts = document.querySelectorAll('script[type="application/ld+json"]');
var types = [];
var errors = 0;
function takeType(item) {
if (!item) return;
if (item['@type']) {
var raw = Array.isArray(item['@type']) ? item['@type'][0] : item['@type'];
var t = String(raw || '').slice(0, 30);
if (t && types.indexOf(t) < 0) types.push(t);
}
if (Array.isArray(item['@graph'])) item['@graph'].forEach(takeType);
}
for (var i = 0; i < scripts.length; i++) {
try {
var data = JSON.parse(scripts[i].textContent || '{}');
var arr = Array.isArray(data) ? data : [data];
for (var j = 0; j < arr.length; j++) takeType(arr[j]);
} catch (_) { errors++; }
}
push('jl', { n: scripts.length, ts: types.slice(0, 10), err: errors || undefined });
} catch (_) {}
}
// ─── Visual-bug signals (Phase 2.5, 2026-05-06) ────────────────────
// The static + viewport-overflow QA misses a class of bug: stuff that
// renders but renders WRONG — text clipped, cards stacked too tight,
// logo squashed, image wrong-aspect. We catch these in the field by
// asking the browser, on real devices.
//
// PII rules: selectors + numeric pixel deltas only. Never DOM text,
// never URLs, never alt text. All gated by isPrivatePage().
// Build a short, safe selector for an element (PII-free).
function _vbSel(el) {
if (!el || !el.tagName) return '';
var t = el.tagName.toLowerCase();
var id = el.id ? '#' + String(el.id).slice(0, 24) : '';
var cls = '';
if (!id && el.className && typeof el.className === 'string') {
var c = el.className.trim().split(/\s+/)[0];
if (c) cls = '.' + c.slice(0, 24);
}
return (t + id + cls).slice(0, 60);
}
// vw — visual warnings. Walks layout-critical selectors and emits any of:
// clip_x — content overflows horizontally (text or grid clipped)
// clip_y — content overflows vertically beyond an overflow:hidden box
// zero_h — element has zero rendered height despite display:block
// tight_gap — adjacent <section> peers separated by <8px (stacked)
// overlap — two siblings have intersecting bounding rects
function emitVisualWarnings() {
try {
if (isPrivatePage()) return;
var checks = [];
var sel = 'section, .testimonial, .audience-card, .customer-logo, .hero-cta, .price-card, .finding, .stat-card, .view-card';
var nodes = document.querySelectorAll(sel);
// 2026-06-02 hardening — a live Puppeteer confirmation pass (Tiger Friday +
// Axel) showed the raw rules were ~95% false positives: carousels/sliders
// (intentional overflow), ellipsis-truncated text, full-bleed background
// covers, and collapsed drawers/modals/popups/app-blocks/dividers. These
// helpers gate them out so a fired check is trustworthy enough to be a case.
var _vbCarousel = function (node) {
var hop = node, n = 0;
while (hop && n < 5) {
var cls = (hop.className && typeof hop.className === 'string') ? hop.className : '';
if (/carousel|slider|swiper|flickity|marquee|instafeed|ticker|scroller|slick|splide/i.test(cls)) return true;
hop = hop.parentElement; n++;
}
return false;
};
var _vbIntentionalClipX = function (node, st) {
// 2026-06-02 (round 2): content is only CUT OFF from the user when the
// box actually clips its horizontal overflow. overflow-x:visible lets the
// content spill but it stays VISIBLE (Axel header icon rows, footer
// localization, and tab panels were all confirmed-clean this way);
// auto/scroll is a scroller. ONLY hidden|clip truly hides content — so
// anything else is not a clip defect and must not fire.
if (st.overflowX !== 'hidden' && st.overflowX !== 'clip') return true;
if (_vbCarousel(node)) return true; // carousel track
if (st.textOverflow === 'ellipsis') return true; // intentional truncation
var idc = (node.id || '') + ' ' + ((node.className && typeof node.className === 'string') ? node.className : '');
if (/ellipsis|truncate|marquee/i.test(idc)) return true;
if (st.backgroundImage && st.backgroundImage !== 'none' && (node.textContent || '').trim().length <= 4) return true; // bg cover
return false;
};
var _ZERO_H_SKIP = /drawer|modal|popup|popover|tooltip|notification|swym|accordion|divider|spacer|sticky|chat|app-block|shopify-block|mobile-nav|search|quick-view|cart|wishlist|country|localization|mailing|overlay|flyout|backdrop/i;
// Per-element clip + zero-height scan
for (var i = 0; i < nodes.length && checks.length < 12; i++) {
var el = nodes[i];
var r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
var cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
// clip_x: content wider than the box, excluding intentional cases.
if (el.scrollWidth > el.clientWidth + 4 && !_vbIntentionalClipX(el, cs)) {
checks.push({ k: 'clip_x', s: _vbSel(el), d: el.scrollWidth - el.clientWidth });
}
// zero_h: collapsed box — only when real content is being clipped
// (scrollHeight present), never a drawer/modal/app-block/divider that is
// simply empty or closed until used.
var _idc = (el.id || '') + ' ' + ((el.className && typeof el.className === 'string') ? el.className : '');
// 2026-06-02 (round 2): a collapsed box only HIDES its content when it
// clips overflow-y. With overflow-y:visible the content spills below and
// still renders (Axel's testimonials/rich_text section wrappers report
// height ~0 yet display perfectly because their children escape the box).
// Require hidden|clip so zero_h means "content present but invisible".
if (r.height < 4 && r.width > 80 && cs.display !== 'inline'
&& (cs.overflowY === 'hidden' || cs.overflowY === 'clip')
&& !_ZERO_H_SKIP.test(_idc) && el.getAttribute('aria-hidden') !== 'true' && !el.hasAttribute('hidden')
&& el.scrollHeight > 8 && ((el.textContent || '').trim().length > 0 || el.querySelector('img,svg,video,picture'))) {
checks.push({ k: 'zero_h', s: _vbSel(el), d: Math.round(r.width) });
}
}
// Section gap: adjacent <section> elements with too-tight spacing.
// Detection 2026-05-06 audit revealed that raw bounding-box gap alone
// is a noisy signal — Shopify (and most CMSes) wrap each section in
// a thin div, and the visible content has its own padding. Two
// sections with 0px outer gap can still have 36px of breathing room
// because of internal padding. Conversely, a 5px outer gap can be a
// real merge if both sections have zero internal padding AND share
// a background.
//
// To make this filterable on the server without re-rendering each
// page, ship along:
// d — outer bounding-box gap (px)
// tp — A's trailing internal pad (A.bottom - last visible child bottom)
// lp — B's leading internal pad (first visible child top - B.top)
// bg — 'm' if backgrounds match, 'd' if differ (visual divider)
// im — 1 if either side contains <img>/<video>/<picture> (full-bleed media)
// Server can then derive visible_separation = d + tp + lp and decide.
function _measureChild(el, which) {
// which='last' → bottom of last visible child relative to el.top
// which='first' → top of first visible child relative to el.top
try {
var elRect = el.getBoundingClientRect();
var nodes = el.querySelectorAll('*');
var bestBottom = null, bestTop = null;
for (var k = 0; k < nodes.length; k++) {
var n = nodes[k];
var nr = n.getBoundingClientRect();
if (nr.width < 1 || nr.height < 1) continue;
if (which === 'last') {
if (bestBottom == null || nr.bottom > bestBottom) bestBottom = nr.bottom;
} else {
if (bestTop == null || nr.top < bestTop) bestTop = nr.top;
}
}
if (which === 'last') return bestBottom != null ? Math.max(0, Math.round(elRect.bottom - bestBottom)) : 0;
return bestTop != null ? Math.max(0, Math.round(bestTop - elRect.top)) : 0;
} catch (_) { return 0; }
}
var sections = document.querySelectorAll('section');
for (var j = 0; j < sections.length - 1 && checks.length < 16; j++) {
var aSec = sections[j], bSec = sections[j+1];
var aBox = aSec.getBoundingClientRect();
var bBox = bSec.getBoundingClientRect();
if (aBox.height < 1 || bBox.height < 1) continue;
var gap = bBox.top - aBox.bottom;
if (gap >= 0 && gap < 12) {
var aCs = window.getComputedStyle(aSec);
var bCs = window.getComputedStyle(bSec);
var bgA = aCs.backgroundColor || '';
var bgB = bCs.backgroundColor || '';
var isTransparent = function (c) { return !c || c === 'transparent' || /rgba\([^)]*,\s*0\)/.test(c); };
var bgMatch = bgA === bgB || (isTransparent(bgA) && isTransparent(bgB));
// 2026-05-06: split "either has media" into per-side flags.
// The discriminating case is "small text bar (no media) bumping
// a content block (with media)" → real bug. Two full-bleed
// media sections touching is design intent. We need both flags
// to tell those apart at aggregation time without AI.
var aHasImg = !!aSec.querySelector('img,picture,video');
var bHasImg = !!bSec.querySelector('img,picture,video');
checks.push({
k: 'tight_gap',
s: _vbSel(aSec) + '>' + _vbSel(bSec),
d: Math.round(gap),
tp: _measureChild(aSec, 'last'),
lp: _measureChild(bSec, 'first'),
bg: bgMatch ? 'm' : 'd',
ia: aHasImg ? 1 : 0,
ib: bHasImg ? 1 : 0,
ah: Math.round(aBox.height), // A height — small bars (<120px) flag as cross-role merges
bh: Math.round(bBox.height), // B height
});
}
}
// 2026-05-15 — content-overflow scan. The selector list above only
// covers marketing-page sections; it missed text overflowing
// interactive UI — chat bubbles, cards, list rows, buttons, table
// cells — a real bug class (a chat message ran past its box on our
// own dashboard). This pass walks common content containers and
// flags clip_x ONLY where the overflow is not an intentional scroll
// region. Reuses the clip_x kind so the server's layout:text_clipped
// detector picks it up with no server-side change. Selector + pixel
// delta only — same PII discipline as the rest of vw.
try {
var coSel = 'button, .btn, li, td, th, h1, h2, h3, dd, figcaption,'
+ ' [class*="card"], [class*="bubble"], [class*="message"],'
+ ' [class*="badge"], [class*="chip"], [class*="tag"], [class*="alert"]';
var coNodes = document.querySelectorAll(coSel);
for (var ci = 0; ci < coNodes.length && ci < 400 && checks.length < 24; ci++) {
var coEl = coNodes[ci];
// Respect the per-element privacy boundary even for structural
// signals — the pixel never scans inside data-harvv-private UI.
if (coEl.closest && coEl.closest('[data-harvv-private="true"]')) continue;
var coR = coEl.getBoundingClientRect();
if (coR.width < 60 || coR.height < 8) continue;
var coCs = window.getComputedStyle(coEl);
if (coCs.display === 'none' || coCs.visibility === 'hidden') continue;
// Intentional overflow — scroller, carousel, ellipsis truncation, or a
// full-bleed background cover. Not a bug. (2026-06-02 hardening.)
if (_vbIntentionalClipX(coEl, coCs)) continue;
// Real overflow: rendered content wider than the box by >4px.
if (coEl.scrollWidth > coEl.clientWidth + 4) {
checks.push({ k: 'clip_x', s: _vbSel(coEl), d: coEl.scrollWidth - coEl.clientWidth });
}
}
} catch (_) {}
// clip_desc — glyph clipping, measured EXACTLY (not a guessed ratio).
// Deterministic rule: a line clips its glyphs when the line box is shorter
// than the ACTUAL painted glyphs of the heading's own text AND the element
// clips overflow (overflow:hidden/clip, or background-clip:text where
// pixels outside the glyph fill show nothing). We read the actual glyph
// box per font + size from Canvas measureText (actualBoundingBoxAscent/
// Descent) of the element's REAL textContent — what is actually painted,
// not the font's design padding. clipPx = glyphBox - lineBox - padRescue.
// 2026-06-15 (#46989): switched from fontBoundingBox of a generic 'Hxgyp'
// probe to actualBoundingBox of the real text. The design box includes the
// font's empty descender padding, so it exceeds a line-height:1.0 on most
// typefaces even when the rendered glyphs fit — that fired a false clip on
// harvv.com's own gradient hero (background-clip:text, no descenders cut).
// Measuring the actual glyphs only fires when something is really clipped.
// Box geometry stays normal so clip_x/clip_y are blind to it. Falls back to
// a line-height/font-size ratio on engines without TextMetrics box metrics
// (IE11). d = clipped px. 2026-06-02 (dogfood).
try {
var _mc = null;
var _glyphBox = function (cssFont, txt) { // px height of the ACTUAL painted glyphs, or -1
try {
if (!_mc) { var cv = document.createElement('canvas'); _mc = (cv && cv.getContext) ? cv.getContext('2d') : null; }
if (!_mc) return -1;
_mc.font = cssFont;
var m = _mc.measureText((txt && txt.length) ? txt.slice(0, 120) : 'Hxgyp');
if (typeof m.actualBoundingBoxAscent !== 'number' || typeof m.actualBoundingBoxDescent !== 'number') return -1;
return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent;
} catch (_e) { return -1; }
};
var heads = document.querySelectorAll('h1, h2');
for (var hi = 0; hi < heads.length && checks.length < 20; hi++) {
var hEl = heads[hi];
var hr = hEl.getBoundingClientRect();
if (hr.width < 1 || hr.height < 1) continue;
var hcs = window.getComputedStyle(hEl);
if (hcs.display === 'none' || hcs.visibility === 'hidden') continue;
var fsz = parseFloat(hcs.fontSize) || 0;
if (fsz < 28) continue; // display text only
// 2026-06-02 hardening (live Puppeteer confirm caught "Testimonials" /
// "SUBSCRIBE TO OUR" firing with nothing actually clipped): a real clip
// needs the text to actually carry descender glyphs (g/y/p/q/j) OR use
// background-clip:text (where any glyph edge clips). And never flag a
// visually-hidden / screen-reader heading (intentionally off-screen).
var _hcls = (hEl.className && typeof hEl.className === 'string') ? hEl.className : '';
if (/visually-hidden|sr-only|screen-reader|visually_hidden/i.test(_hcls)) continue;
if (hcs.position === 'absolute' && hcs.clip && hcs.clip !== 'auto') continue;
var _hbg = (hcs.webkitBackgroundClip === 'text' || hcs.backgroundClip === 'text');
if (!_hbg && !/[gyjpq]/.test(hEl.textContent || '')) continue;
// Only flag elements that actually clip what leaves the line box.
var clips = (_hbg || hcs.overflowY === 'hidden' || hcs.overflowY === 'clip');
if (!clips) continue;
var lh = parseFloat(hcs.lineHeight); // NaN when computed value is 'normal'
if (isNaN(lh) || !lh) continue; // 'normal' gives full leading; safe
var padRescue = Math.max(parseFloat(hcs.paddingBottom) || 0, parseFloat(hcs.paddingTop) || 0);
var fb = _glyphBox(hcs.fontStyle + ' ' + hcs.fontWeight + ' ' + Math.round(fsz) + 'px ' + hcs.fontFamily, hEl.textContent || '');
if (fb > 0) {
var clipPx = fb - lh - padRescue; // ACTUAL glyphs taller than the line box, less any padding rescue
if (clipPx > 1) checks.push({ k: 'clip_desc', s: _vbSel(hEl), d: Math.round(clipPx) });
} else if ((lh / fsz) < 1.0) { // IE11 / no-TextMetrics fallback: only when line-height is genuinely sub-em (tight)
checks.push({ k: 'clip_desc', s: _vbSel(hEl), d: Math.max(1, Math.round(fsz - lh)) });
}
}
} catch (_) {}
if (checks.length) push('vw', { c: checks });
} catch (_) {}
}
// im — image meta. Per important image: natural vs rendered dims, complete
// flag, applied filter (truncated). Detects: stretched, broken, filter-
// wrecked logos. PII-safe: numbers only, never URL or alt text.
function emitImageMeta() {
try {
if (isPrivatePage()) return;
var entries = [];
// Physical-pixel context for this device: an image rendered at R CSS px
// is drawn into R*dpr physical px, so "blurry on large/retina displays"
// is a natural-vs-physical comparison, not natural-vs-CSS. ES5-safe.
var dpr = Math.round((window.devicePixelRatio || 1) * 100) / 100;
var vw = window.innerWidth;
var imgs = document.querySelectorAll('img');
for (var i = 0; i < imgs.length && entries.length < 12; i++) {
var im = imgs[i];
var r = im.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
// Skip tiny/decorative pixels (1x1 trackers, etc.)
if (r.width < 16 && r.height < 16) continue;
var info = {
sl: _vbSel(im),
nw: im.naturalWidth || 0,
nh: im.naturalHeight || 0,
rw: Math.round(r.width),
rh: Math.round(r.height),
c: im.complete ? 1 : 0,
};
// Aspect-ratio mismatch — squashed/stretched
if (im.naturalWidth > 0 && im.naturalHeight > 0 && r.width > 0 && r.height > 0) {
var natRatio = im.naturalWidth / im.naturalHeight;
var renRatio = r.width / r.height;
var dr = Math.abs(natRatio - renRatio) / Math.max(natRatio, 0.1);
if (dr > 0.10) info.ar = +dr.toFixed(2);
}
// Upscale / under-resolution for THIS display: natural px below the
// physical px the device actually renders (rendered CSS px * DPR).
// up = physical-px-needed / natural-px. >1.15 = visibly soft/blurry —
// a 1500px hero stretched across a 32" screen, or a 1x asset on retina.
// Right shape, wrong sharpness — the case `ar` (stretch) never catches.
if (im.naturalWidth > 0 && r.width > 1) {
var up = (r.width * dpr) / im.naturalWidth;
if (up > 1.15) info.up = +up.toFixed(2);
}
// Computed filter — detects "brightness(0) invert(1)" wrecking a
// light-on-dark logo, etc.
try {
var cs = window.getComputedStyle(im);
if (cs.filter && cs.filter !== 'none') info.f = cs.filter.slice(0, 80);
} catch (_) {}
// Broken-load flag — only when the image actually had a chance to
// load and genuinely failed: in the viewport AND not lazy. A
// `loading="lazy"` / below-fold image legitimately reports
// naturalWidth 0 before it scrolls into view; flagging those gave a
// 43%-false-positive rate on lazy-heavy WordPress pages (Prime MD,
// 2026-06-18 — every "broken" attachment-full image returned 200 +
// valid webp on the live page). Genuine failed loads are still caught
// authoritatively by the resource-error ('nf') onerror tracker, so no
// true-broken coverage is lost — this only drops the false positives.
if (im.complete && (im.naturalWidth === 0 || im.naturalHeight === 0)) {
var lazy = ('' + (im.getAttribute('loading') || '')).toLowerCase() === 'lazy';
var inView = r.top < (window.innerHeight || 0) && r.bottom > 0;
if (!lazy && inView) info.broken = 1;
}
entries.push(info);
}
if (entries.length) push('im', { i: entries, vw: vw, dpr: dpr });
} catch (_) {}
}
// ─── Mobile UI signals (Phase 2 dogfood-driven, 2026-05-08) ───────────
// Five signals seeded from a real bug: mobile UI was bad on harvv.com
// itself and the existing pixel surface (wo, vw, pf-CLS, lp) didn't
// catch it. See docs/POSTMORTEM-2026-05-08-mobile-ui-bugs.md.
//
// Bucket per emit: 'm' (mobile <600px), 't' (tablet <1024px), 'd'
// (desktop ≥1024px). Lets detectors file viewport-specific cases
// ("edge bleed on phones only") instead of one global rule.
//
// PII rules: same as the existing visual-bug signals — selectors +
// numeric measurements only. All gated by isPrivatePage().
function _vpBucket(w) { return w < 600 ? 'm' : w < 1024 ? 't' : 'd'; }
// eg — element edge proximity. For an allow-list of important
// elements, emit any whose bounding rect is within 4px of the
// viewport's left or right edge. Catches "brand logo at left:0",
// "CTA bleeding past right edge", etc.
function emitEdgeProximity() {
try {
if (isPrivatePage()) return;
var W = window.innerWidth;
var sel = 'header, nav, h1, h2.headline, .hero, .cta, .hero-cta, .primary-cta, button.primary, a.cta, .brand, [data-cta]';
var nodes = document.querySelectorAll(sel);
var hits = [];
for (var i = 0; i < nodes.length && hits.length < 8; i++) {
var el = nodes[i];
var r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
var l = Math.round(r.left), rt = Math.round(r.right);
// Skip fixed-position floating CTAs (intentionally near edge)
var cs = window.getComputedStyle(el);
if (cs.position === 'fixed' || cs.position === 'absolute') continue;
if (l < 4 || rt > W - 4) {
hits.push({ s: _vbSel(el), l: l, r: rt, w: Math.round(r.width) });
}
}
if (hits.length) push('eg', { vw: W, b: _vpBucket(W), h: hits });
} catch (_) {}
}
// cs — container starvation. The largest content block as a fraction
// of the viewport. Sub-0.7 on phones means a sidebar/modal/aside is
// eating room; sub-0.5 on tablet/desktop is similarly suspicious.
function emitContainerStarvation() {
try {
if (isPrivatePage()) return;
var W = window.innerWidth;
if (W < 1) return;
var main = document.querySelector('main') || document.querySelector('article') || document.querySelector('[role=main]');
if (!main) return;
// Find the widest visible descendant of main, up to depth 3
var best = { w: 0, sel: '' };
function walk(el, depth) {
if (depth > 3) return;
var kids = el.children || [];
for (var i = 0; i < kids.length; i++) {
var k = kids[i];
var kr = k.getBoundingClientRect();
if (kr.height < 40 || kr.width < 40) continue;
if (kr.width > best.w) { best.w = kr.width; best.sel = _vbSel(k); }
if (depth < 3) walk(k, depth + 1);
}
}
walk(main, 1);
if (best.w < 1) return;
var ratio = Math.round((best.w / W) * 100) / 100;
var b = _vpBucket(W);
var threshold = b === 'm' ? 0.70 : 0.50;
if (ratio >= threshold) return;
// Identify the largest non-main rival eating the space
var rival = { w: 0, sel: '' };
var bodyKids = document.body.children || [];
for (var j = 0; j < bodyKids.length; j++) {
var c = bodyKids[j];
if (c === main || c.contains(main)) continue;
var cr = c.getBoundingClientRect();
if (cr.width > rival.w && cr.height > 100) { rival.w = cr.width; rival.sel = _vbSel(c); }
}
push('cs', {
vw: W, b: b,
ratio: ratio,
ms: Math.round(best.w),
rs: rival.sel || undefined,
rw: rival.w ? Math.round(rival.w) : undefined,
});
} catch (_) {}
}
// tt — tap target audit. Mobile + tablet only (desktop has mouse).
// Counts interactive elements below 44×44 (Apple HIG threshold) and
// emits a sample of the worst offenders.
function emitTapTargets() {
try {
if (isPrivatePage()) return;
var W = window.innerWidth;
var b = _vpBucket(W);
if (b === 'd') return;
var nodes = document.querySelectorAll('a, button, [role="button"], input[type="submit"], input[type="button"]');
var smalls = [], total = 0;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var r = n.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
// Skip elements that are visually inline within text (links inside <p>)
var p = n.parentElement;
if (p && (p.tagName === 'P' || p.tagName === 'LI') && r.height < 30) continue;
total++;
if (r.width < 44 || r.height < 44) {
smalls.push({ s: _vbSel(n), w: Math.round(r.width), h: Math.round(r.height) });
}
}
if (smalls.length === 0) return;
smalls.sort(function (a, b) { return (a.w * a.h) - (b.w * b.h); });
push('tt', { vw: W, b: b, sc: smalls.length, tt: total, h: smalls.slice(0, 6) });
} catch (_) {}
}
// rb — readability baseline. Body-text font-size, line-height,
// chars-per-line estimate, and contrast vs background. Emits 1/page.
function emitReadabilityBaseline() {
try {
if (isPrivatePage()) return;
var W = window.innerWidth;
var b = _vpBucket(W);
var ps = document.querySelectorAll('main p, article p, section p');
if (!ps.length) ps = document.querySelectorAll('p');
// Pick the longest paragraph >50 chars (representative body text)
var p = null, maxText = 0;
for (var i = 0; i < ps.length && i < 20; i++) {
var len = (ps[i].textContent || '').length;
if (len > maxText && len > 50) { maxText = len; p = ps[i]; }
}
if (!p) return;
var cs = window.getComputedStyle(p);
var fs = parseFloat(cs.fontSize) || 0;
var lh = parseFloat(cs.lineHeight) || 0;
var color = cs.color, bg = '';
// 2026-06-09 — three measurement fixes (the readability family's
// near-100% dismissal rate traced partly to wrong ct values):
// 1. bgi flag: if any ancestor paints a backgroundImage (hero
// photo, CSS gradient) before an opaque backgroundColor is
// found, the real backdrop is unknowable from computed style.
// Previously we fell through to body's white and reported
// ct=1.0 for white-on-dark-photo heroes (m-diamond 114
// sessions all at ct=1.0). Now: bgi:1 + ct:null so the
// server can exclude these from contrast checks.
// 2. Alpha-aware walker: rgba(255,255,255,0.03) glass panels
// were accepted as the background (old regex only skipped
// alpha exactly 0). Anything under 0.4 alpha is treated as
// see-through and the walk continues.
// 3. Color parsing goes through _colorRGBA (canvas-normalized),
// so lab()/oklch() from Tailwind-4 sites parse correctly
// instead of as digit-soup (3vltn reported ct=1.1 for text
// that actually measures ~6.6:1).
var bgImg = 0;
var walker = p;
while (walker && walker !== document.body) {
var pcs = window.getComputedStyle(walker);
if (pcs.backgroundImage && pcs.backgroundImage !== 'none') { bgImg = 1; break; }
var wrgba = _colorRGBA(pcs.backgroundColor);
if (wrgba && wrgba.a >= 0.4) { bg = pcs.backgroundColor; break; }
walker = walker.parentElement;
}
if (!bgImg && !bg) {
var bcs = window.getComputedStyle(document.body);
if (bcs.backgroundImage && bcs.backgroundImage !== 'none') bgImg = 1;
else bg = bcs.backgroundColor || 'rgb(255,255,255)';
}
var pr = p.getBoundingClientRect();
// Approx chars per line: width / (fontSize * 0.5) — rough but
// useful for "way too long" signal (>80 = readability issue)
var cpl = pr.width > 0 && fs > 0 ? Math.round(pr.width / (fs * 0.5)) : null;
var contrast = bgImg ? null : _contrastRatio(color, bg);
push('rb', {
vw: W, b: b,
fs: Math.round(fs),
lh: Math.round(lh),
cpl: cpl,
ct: contrast ? Math.round(contrast * 10) / 10 : null,
bgi: bgImg || undefined,
});
} catch (_) {}
}
// Normalize ANY CSS color the browser understands (rgb/rgba/hex/named/
// lab()/oklch()/color()) to exact {r,g,b,a} bytes by painting a 1x1
// canvas and reading the pixel back. A digit-regex parser mangles
// modern color spaces: "lab(65.9 -0.8 -8.2)" parsed as r=65 g=0.8 b=8.
var _ccCanvas = null, _ccCtx = null;
function _colorRGBA(s) {
if (!s || s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
try {
if (!_ccCtx) {
_ccCanvas = document.createElement('canvas');
_ccCanvas.width = 1; _ccCanvas.height = 1;
_ccCtx = _ccCanvas.getContext('2d');
}
if (!_ccCtx) throw new Error('no2d');
_ccCtx.clearRect(0, 0, 1, 1);
_ccCtx.fillStyle = '#000';
_ccCtx.fillStyle = String(s); // invalid values keep #000; valid ones normalize
_ccCtx.fillRect(0, 0, 1, 1);
var d = _ccCtx.getImageData(0, 0, 1, 1).data;
return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 };
} catch (_) {
// Canvas unavailable (ancient engine / blocked) — legacy rgb()/rgba()
// digit parse as a fallback; returns null for modern syntaxes.
var m = String(s).match(/\d+(?:\.\d+)?/g);
if (!m || m.length < 3) return null;
return { r: +m[0], g: +m[1], b: +m[2], a: m.length > 3 ? +m[3] : 1 };
}
}
function _contrastRatio(c1, c2) {
function lum(c) {
var ch = [c.r, c.g, c.b].map(function (v) {
v = v / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
}
var a = _colorRGBA(c1), b = _colorRGBA(c2);
if (!a || !b || a.a === 0 || b.a === 0) return null;
var la = lum(a), lb = lum(b);
var hi = Math.max(la, lb), lo = Math.min(la, lb);
return (hi + 0.05) / (lo + 0.05);
}
// fb — fallback / degraded UI. Exposed via window.harvv.reportFallback()
// so the host React app (or any host page) can self-report when a
// known degraded state renders. The pixel can't see "iframe blocked
// and our 3.5s fallback fired" — only the app knows.
function _reportFallback(payload) {
try {
if (isPrivatePage()) return;
if (!payload || typeof payload !== 'object') return;
push('fb', {
k: String(payload.kind || payload.k || 'unknown').slice(0, 32),
cause: payload.cause ? String(payload.cause).slice(0, 32) : undefined,
ms: typeof payload.ms === 'number' ? Math.round(payload.ms) : undefined,
sel: payload.sel ? String(payload.sel).slice(0, 60) : undefined,
vw: window.innerWidth,
b: _vpBucket(window.innerWidth),
});
} catch (_) {}
}
try {
window.harvv = window.harvv || {};
window.harvv.reportFallback = _reportFallback;
} catch (_) {}
function runPageAudits() {
emitWordCount(); emitMetaAudit(); emitSchemaDetect();
// Visual-bug audits run twice: once on load (early/best-effort), once
// 2.2s later (after deferred scripts settle, fonts swap, lazy images
// resolve). The detection layer dedupes by (page_url, k, s).
emitVisualWarnings(); emitImageMeta();
// 2026-05-08 — new mobile UI signals (eg, cs, tt, rb). Run on the
// same cadence as visual warnings so the second pass picks up
// post-font-swap measurements (rb in particular is sensitive).
emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
setTimeout(function () {
emitVisualWarnings(); emitImageMeta();
emitEdgeProximity(); emitContainerStarvation(); emitTapTargets(); emitReadabilityBaseline();
}, 2200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runPageAudits, { once: true });
} else {
setTimeout(runPageAudits, 50);
}
// --- Unload: Final Engagement + Scroll Kinematics + Flush ---
window.addEventListener("pagehide", function () {
if (engageStart) totalEngage += Date.now() - engageStart;
push("ul", { eg: totalEngage, sm: curScrollPctMax }); // unload: total engagement ms + exact peak scroll %
// Flush scroll kinematics on exit
if (totalDist > 0) {
var avgVel = velSamples ? velSum / velSamples : 0;
push("sk", {
v: [Math.round(minVel * 1000) / 1000, Math.round(maxVel * 1000) / 1000], // px/ms
r: reversals,
p: pauses.slice(0, 10), // Cap at 10 pause positions
d: Math.round(totalDist),
// Phase 2 (2026-05-06) — bucketed velocity in px (px/s thresholds).
// mxv/avv = max/avg in px/s. kd = skim distance (>200 px/s),
// rd = active-read distance (50-200 px/s). Used by content:scroll_speed_skim
// and content:shallow_read patterns. PII-safe — pure kinematics.
mxv: Math.round(maxVel * 1000),
avv: Math.round(avgVel * 1000),
kd: Math.round(skimDist) || undefined,
rd: Math.round(readDist) || undefined,
});
}
// T141.5h — pass 'unload' so flush uses sendBeacon (fire-and-forget,
// the only viable transport once the page is being torn down) AND
// mirrors a copy to the localStorage retry queue. drainRetryQueue()
// on next pageload will redeliver if the beacon was dropped.
flush('unload');
});
// ─── 2026-05-21 — Form submission + API success detector ─────────────────
// The "is this friction blocking or just annoying?" signal lives in
// whether the user's data ultimately reached the server. We monitor 3
// signals to know that:
// 1. <form>.submit events — declarative submission
// 2. fetch()/XHR POSTs to same-origin URLs — programmatic submission
// 3. 2xx response status from those POSTs — confirms backend received
//
// Captured event = type 'fs' (form/api submit) with sanitized payload:
// { p: path_template, m: method, s: status, t: type ('form'|'xhr'|'fetch') }
// path_template = the URL pathname with high-cardinality segments collapsed
// to :id (numeric ids, uuids). NEVER includes query string, body, or
// response. NEVER includes auth headers. NEVER captures form field values.
// 2026-05-27 — Single wrap point per transport. The fetch + XHR
// wraps that USED to live here (for fs/form-submission inference)
// have been folded INTO the nf wraps above. This block keeps only
// the declarative <form> submit listener — purely additive, never
// touches XMLHttpRequest.prototype or window.fetch.
try {
// Declarative <form> submit. Catches user-driven submits even when
// the form's action is a server route (full page reload).
document.addEventListener('submit', function (ev) {
try {
var form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
var action = form.getAttribute('action') || location.pathname;
var url; try { url = new URL(action, location.origin); } catch (_) { url = null; }
var path = url ? templatize(url.pathname) : templatize(action);
var method = (form.getAttribute('method') || 'POST').toUpperCase();
push('fs', { p: path, m: method, t: 'form' });
} catch (_) {}
}, true);
} catch (_) { /* form listener install is best-effort; never break host page */ }
// ─── 2026-05-21 — window.harvv.outcome() customer-side API ──────────────
// Customers call this from their app after meaningful state transitions:
// window.harvv.outcome('kyc_submitted', { step: 'individual', success: true });
// window.harvv.outcome('purchase_completed', { value: 99.00, currency: 'USD' });
//
// We emit as event type 'oc'. Properties get strictly sanitized:
// - Strings: max 100 chars
// - Numbers: any (currency values are useful)
// - Booleans: pass through
// - PII detector: reject keys/values that look like email, phone,
// SSN, full credit card. Whole call dropped if any field smells PII.
try {
window.harvv = window.harvv || {};
var PII_KEY = /\b(?:email|phone|ssn|tax_?id|credit_?card|card_?number|cvv|cvc|password|secret|token|api_?key)\b/i;
var PII_VAL_EMAIL = /[\w.+-]{2,}@[\w-]{1,}\.[a-z]{2,}/i;
var PII_VAL_PHONE = /\b\+?\d[\d\s\-().]{8,}\d\b/;
var PII_VAL_CARD = /\b(?:\d[ -]?){12,19}\b/; // 12-19 digit run = likely card/account
function sanitizeOutcomeProps(props) {
if (!props || typeof props !== 'object') return null;
var out = {};
var keys = Object.keys(props).slice(0, 12); // cap at 12 props
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (typeof k !== 'string' || k.length > 40) return null; // suspicious
if (PII_KEY.test(k)) return null; // PII-flagged key — drop whole event
var v = props[k];
if (v == null) continue;
if (typeof v === 'number' && isFinite(v)) { out[k] = v; continue; }
if (typeof v === 'boolean') { out[k] = v; continue; }
if (typeof v === 'string') {
var s = v.substring(0, 100);
if (PII_VAL_EMAIL.test(s) || PII_VAL_PHONE.test(s) || PII_VAL_CARD.test(s)) return null;
out[k] = s;
continue;
}
// Arrays / objects / anything else — drop the prop, keep going
}
return out;
}
window.harvv.outcome = function (name, props) {
try {
if (!name || typeof name !== 'string') return;
var n = name.substring(0, 60).replace(/[^a-z0-9_:.-]+/gi, '_');
if (!n) return;
var safe = props === undefined ? {} : sanitizeOutcomeProps(props);
if (safe === null) return; // PII detected — silently drop
push('oc', { n: n, d: safe });
} catch (_) { /* never throw into customer code */ }
};
// 2026-05-21 — Read accessors so customers can correlate pixel sessions
// with their own server-side data when calling POST /v1/outcomes from
// their backend. session_id is 8-char random (non-PII), visitor_id is
// 8-char random + cookied for ~30 days (also non-PII — it's a device
// signal, not a person signal). Documented in /docs/api.
//
// Pattern: customer reads window.harvv.sessionId on the client, stuffs
// it into a hidden form field or order-create payload, server then
// POSTs to /v1/outcomes with the same session_id. We do the join.
window.harvv.sessionId = sid;
window.harvv.visitorId = vid;
window.harvv.getSessionId = function () { return sid; };
window.harvv.getVisitorId = function () { return vid; };
} catch (_) {}
})();