Last updated: March 29, 2026
This is the complete, unminified source code of the Harvv behavioral analytics pixel. Every site running Harvv loads a minified version of exactly this code.
Size: 109,240 bytes unminified · ~3,363 bytes gzipped · 18 behavioral signals · Zero PII
/**
* TheReallyFastPixel — Behavioral analytics in under 2KB gzipped.
* Drop-in, zero-config, first-party behavioral analytics.
* Captures the behavioral story of every session as a first-class data structure.
*
* Configure: optionally set window.__px = "https://yourdomain.com/px" before this script loads.
* Or just load the script — it auto-detects its own origin and POSTs back to /px on it.
*/
(function () {
// --- Consent gate (Harvv-properties only) ---
// 2026-05-06 — when running on harvv.com properties (harvv.com, *.harvv.com,
// including admin.harvv.com / flow.harvv.com / pk2 etc.) the pixel must
// honor the cookie banner's analytics choice. Customer sites are exempt:
// their consent banner is THEIR responsibility, our pixel just runs as the
// customer configured. We listen for the harvv:consent event so the pixel
// can wake up if the user later flips analytics back on without a reload.
try {
var hostIsHarvv = /(?:^|\.)harvv\.com$/i.test(location.hostname);
if (hostIsHarvv) {
var consent = (typeof window.__harvvConsent === 'function') ? window.__harvvConsent() : null;
if (consent && consent.choices && consent.choices.analytics === false) {
// User explicitly declined analytics on this harvv property. Bail.
// If they later accept, the cookie banner fires the harvv:consent event;
// we re-bootstrap the pixel by reloading itself.
window.addEventListener('harvv:consent', function (ev) {
if (ev && ev.detail && ev.detail.choices && ev.detail.choices.analytics === true) {
try {
var s = document.createElement('script');
s.async = true;
s.src = (document.currentScript && document.currentScript.src) ||
document.querySelector('script[src*="/pixel.js"]')?.src ||
'/px/5abaa759db95fcf4/pixel.js';
document.head.appendChild(s);
} catch (_) {}
}
});
return;
}
}
} catch (_) { /* never block the pixel on consent-check errors */ }
// --- Configuration ---
// Auto-detect endpoint from script src so a single <script src="..."> just works.
// document.currentScript is null for defer scripts, so find ourselves by src attribute.
var scripts = document.querySelectorAll('script[src*="/pixel.js"]');
var scriptSrc = scripts.length && scripts[scripts.length - 1].src;
var ENDPOINT = window.__px || (scriptSrc ? scriptSrc.replace(/\/[^/]*$/, "/px") : "/px");
var SESSION_TIMEOUT = 1800000; // 30 minutes in ms
var FLUSH_INTERVAL = 10000; // 10 seconds
var RAGE_THRESHOLD = 3; // clicks within window
var RAGE_WINDOW = 800; // ms
var RAGE_RADIUS = 50; // px
var HOVER_DELAY = 500; // ms to qualify as hover intent
var SCROLL_MILESTONES = [25, 50, 75, 100];
var TEXT_LIMIT = 20; // max chars of innerText in element ID
// --- Utilities ---
// Generate a random ID for session_id + visitor_id.
//
// T70 (2026-04-28): expanded from 8 → 16 hex chars after a real production
// collision was observed: session_id `ce51b784` appeared on two distinct
// sites (51 events on one, 14 on the other). 8 hex chars = 4B-ish space,
// and at Tiger Friday's 35M-events/month scale the birthday-paradox math
// makes inter-site collisions inevitable.
//
// 16 hex chars = 64-bit space (~1.8e19), making collisions effectively
// impossible at any realistic scale. Two Math.random calls produce ~64
// effective bits (Math.random itself caps at ~53 bits each but the
// concatenation comfortably saturates the 64-bit hex output).
//
// Cost: +8 bytes per event payload, +~50 bytes to the pixel. Trivial
// vs the data-integrity gain. Backwards-compatible — receiver treats
// ID as a string, and existing visitors keep their old 8-char IDs from
// cookies/localStorage until those expire naturally.
function rid() {
return ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0") +
((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, "0");
}
// Read a cookie by name
function getCookie(n) {
var m = document.cookie.match(new RegExp("(?:^|; )" + n + "=([^;]*)"));
return m ? m[1] : null;
}
// Set a first-party cookie, 2-year expiry for visitor, session for session.
// Domain set to registrable domain so subdomains share the same visitor/session.
//
// [PC-019] The naive two-label extraction (`parts.slice(-2).join('.')`) produced
// a public suffix for ccTLDs like .co.uk, .com.br, .com.au — cookies set on a
// public suffix are rejected by browsers, silently killing visitor tracking.
// This was a live bug affecting oxfordcityfalafel.co.uk (703 sessions, all
// visitors undercounted as new). Fix: compressed Public Suffix List of common
// two-label ccTLD suffixes. The list covers ~98% of real-world ccTLD traffic;
// exotic suffixes fall back to the two-label heuristic, which is correct for
// non-ccTLD domains.
var PSL_2LABELS = "co.uk,org.uk,me.uk,ac.uk,gov.uk,net.uk,com.au,net.au,org.au,edu.au,co.nz,net.nz,org.nz,co.jp,or.jp,ne.jp,ac.jp,com.br,net.br,org.br,com.mx,com.ar,com.cn,com.sg,com.my,com.ph,com.hk,com.tw,com.tr,com.ua,com.co,com.pe,com.ve,co.in,net.in,org.in,co.za,org.za,co.kr,or.kr,co.il,org.il,co.th,or.th,com.pk,net.pk,org.pk,co.id,or.id,com.ng,com.eg,com.bd,co.ke,co.tz,com.do,com.gt,com.sv,com.hn,com.ni,com.py,com.bo,com.ec,com.uy";
var PSL_SET = {};
PSL_2LABELS.split(",").forEach(function(s) { PSL_SET[s] = 1; });
var rootDomain = (function() {
var parts = location.hostname.split(".");
if (parts.length <= 2) return location.hostname; // example.com → example.com
// Check if the last 2 labels form a known public suffix (ccTLD)
var last2 = parts.slice(-2).join(".");
if (PSL_SET[last2] && parts.length >= 3) {
// ccTLD: www.example.co.uk → example.co.uk (take last 3 labels)
return parts.slice(-3).join(".");
}
// Standard TLD: www.example.com → example.com (take last 2 labels)
return last2;
})();
// [2026-04-21] Dropped the RFC 2109 leading dot from `domain=.example.com`. Modern
// browsers normalize it, but some privacy tools + ad blockers strip cookies with
// the leading-dot syntax, which was eating session continuity on same-origin
// navigations (e.g. "clicked 30-day exchange button → new page, new session").
// Skip the domain attribute entirely on localhost / single-label hosts (browser
// defaults to the current host, which is what we want).
function setCookie(n, v, days) {
var e = days ? ";expires=" + new Date(Date.now() + days * 864e5).toUTCString() : "";
var dom = (rootDomain && rootDomain.indexOf(".") !== -1) ? (";domain=" + rootDomain) : "";
document.cookie = n + "=" + v + e + ";path=/;SameSite=Lax" + dom;
}
// LocalStorage backup for session continuity. Cookies can be stripped by
// ad-blockers, strict privacy modes, or host policies, which would orphan
// events across navigations. localStorage is more durable; we write BOTH
// on every update and read from either on init (cookie wins if both present).
function lsSet(k, v) { try { localStorage.setItem(k, String(v)); } catch (_) {} }
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
// --- PII Scrubbing ---
// An element is "private" (text-scrubbed) if it OR any ancestor up to the
// document root:
// • has data-harvv-private="true"
// • is contenteditable (a chat composer, comment editor, etc.)
// • is a TEXTAREA
// • matches a role that typically contains user-generated content
// (role=log, role=textbox, role=feed, role=listitem inside role=log)
// Private elements still get identified by tag/id/class (for detection) but
// their textContent is NEVER captured — the `:"..."` suffix is dropped.
//
// Sites can opt IN more aggressively by setting data-harvv-private="true" on
// any container (e.g. wrap your chat bubbles in <div data-harvv-private>...)
var PRIVATE_ROLE = /^(log|textbox|feed)$/;
function isPrivateElement(el) {
var p = el, depth = 20;
while (p && depth--) {
if (p.getAttribute) {
var priv = p.getAttribute("data-harvv-private");
if (priv === "true" || priv === "" || priv === "1") return true;
if (p.isContentEditable) return true;
var tag = p.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
// Password, credit card, hidden, email inputs — never capture surrounding text
var t = (p.type || "").toLowerCase();
if (t === "password" || t === "hidden" || t === "email" || t === "tel" || t === "number") return true;
}
var role = p.getAttribute("role");
if (role && PRIVATE_ROLE.test(role)) return true;
}
p = p.parentElement;
}
return false;
}
// Build a minimal element identifier.
// Format (public): tag#id:"text" or tag.class:"text"
// e.g. btn#add-cart:"Add to C" — a human reads the session story.
// Format (private): tag#id or tag.class (no text — PII guard)
// e.g. div.chat-bubble, div#response-34
// For INPUT elements: input[type]#id or input[type].name (never value)
function eid(el) {
if (!el || !el.tagName) return null;
var tag = el.tagName.toLowerCase();
// For inputs, include type attribute and prefer name for the class slot
if (tag === "input") {
var type = el.type || "text";
var id = el.id ? "#" + el.id : "";
var name = !id && el.name ? "." + el.name : "";
return "input[" + type + "]" + id + name;
}
var id2 = el.id ? "#" + el.id : "";
var cls = !id2 && el.className && typeof el.className === "string"
? "." + el.className.trim().split(/\s+/)[0] : "";
// PII guard — if this element is inside a private zone, skip text content
// entirely. We still report the tag/id/class so detection (dead clicks,
// blind spots, etc.) works, but the user-typed/generated text never leaks.
if (isPrivateElement(el)) {
return tag + id2 + cls;
}
// Use textContent (fast) instead of innerText (triggers layout/style calc).
// Sanitize: strip control chars (tab/newline/CR) and collapse whitespace so
// titles like `Engaging New Mexico\t` don't surface the escape char to users.
// Also reject text that contains "<" — if textContent starts with an HTML-like
// fragment, something rendered raw markup as a text child; capturing that as
// a "title" produces broken strings like `"<img width="150" hei`.
var rawTxt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
var txt = rawTxt.slice(0, TEXT_LIMIT);
if (txt.indexOf("<") !== -1) txt = "";
txt = txt ? ':"' + txt + '"' : "";
return tag + id2 + cls + txt;
}
// Check if an element is interactive (clickable by nature)
function isInteractive(el) {
if (!el || !el.tagName) return false;
var tag = el.tagName;
// Interactive tags
if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|DETAILS|SUMMARY)$/.test(tag)) return true;
// ARIA roles (button, radio, checkbox, option, menuitem, tab, switch)
var role = el.getAttribute && el.getAttribute("role");
if (role && /^(button|radio|checkbox|option|menuitem|tab|switch)$/.test(role)) return true;
// Tabindex = explicitly interactive
if (el.getAttribute && el.getAttribute("tabindex")) return true;
// Label-for-input (clicks on labels natively toggle their associated input)
if (tag === "LABEL") {
var f = el.htmlFor || (el.getAttribute && el.getAttribute("for"));
if (f && document.getElementById(f)) return true;
}
// Data attributes indicating JS-handled interactive elements (swatches, variant selectors)
if (el.hasAttribute && (el.hasAttribute("data-value") || el.hasAttribute("data-option-value") || el.hasAttribute("data-variant-id") || el.hasAttribute("data-action"))) return true;
// Walk up to see if inside an anchor, button, label[for], ARIA button,
// tabindex-enabled element, or custom element (max 8 levels). Shopify themes
// nest deeply: div.product__info-wrapper > div > div > span > a.product-link.
// 5 levels was too shallow. Elementor/React/Vue use role="button" tabindex="0"
// on divs instead of <button> — previously we fired false-positive dead clicks
// on child icons inside those (e.g. <i class="menu-toggle__icon--open"> inside
// <div role="button" tabindex="0">). Covers M-Diamond's mobile menu icon
// (68 sessions of false positives verified against actual ix events).
var p = el.parentElement, i = 8;
while (p && i--) {
if (/^(A|BUTTON)$/.test(p.tagName)) return true;
if (p.tagName === "LABEL" && p.htmlFor && document.getElementById(p.htmlFor)) return true;
// ARIA button / interactive role pattern (Elementor, React, Tailwind, etc.)
if (p.getAttribute) {
var pRole = p.getAttribute("role");
if (pRole && /^(button|radio|checkbox|option|menuitem|tab|switch|link)$/.test(pRole)) return true;
// tabindex=0/1/... explicitly makes the element keyboard-interactive
var pTab = p.getAttribute("tabindex");
if (pTab !== null && pTab !== "-1") return true;
// Event-handler hints from common frameworks
if (p.hasAttribute("onclick") || p.hasAttribute("@click") || p.hasAttribute("v-on:click") || p.hasAttribute("ng-click")) return true;
}
// Shopify custom elements for variant selectors + product cards
if (/^(VARIANT-SELECTS|VARIANT-RADIOS|PRODUCT-FORM)$/.test(p.tagName)) return true;
// Shopify product cards: clicking product title text inside a card-wrapper that
// contains a link should NOT be a dead click — the click navigates via the link.
if (p.hasAttribute && p.hasAttribute("data-product-id")) return true;
p = p.parentElement;
}
return false;
}
// --- Third-party widget / editor-preview detection ---
// Clicks inside embedded widgets (Setmore, Calendly, Acuity, Intercom, etc.)
// or inside CMS-editor previews are not the host site's UX — agencies can't
// fix them. Suppress dc events for elements whose class matches hashed
// styled-components (`Foo-sc-a1b2c3d4-0`) or known widget containers.
var WIDGET_CLASS_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|livechat|fc-|__intercom|hubspot)-|^sc-[a-z0-9]{6,}|^[A-Z][\w]*__[A-Z][\w]*-sc-[a-z0-9]{6,}/;
var WIDGET_ATTR_RX = /^(setmore|calendly|acuity|intercom|drift|zendesk|tawk|hs)-/;
function isInsideThirdPartyWidget(el) {
var p = el, depth = 10;
while (p && depth--) {
if (p.getAttribute) {
var cls = typeof p.className === "string" ? p.className : (p.className && p.className.baseVal) || "";
if (cls) {
var classList = cls.split(/\s+/);
for (var ci = 0; ci < classList.length; ci++) {
if (WIDGET_CLASS_RX.test(classList[ci])) return true;
}
}
var id = p.id || "";
if (id && WIDGET_ATTR_RX.test(id)) return true;
}
p = p.parentElement;
}
return false;
}
// Detect CMS editor previews (Elementor preview, WP admin, Shopify theme editor,
// Wix editor, Webflow designer, Framer preview, etc.). Those sessions are not
// real visitor traffic and shouldn't contribute dead clicks or cases.
function isInCmsEditor() {
try {
var s = location.search || "";
var p = location.pathname || "";
if (/(elementor-preview|preview=1|preview_id=|ver=preview|theme-preview|shopify-preview|wix-editor|framer-preview)/i.test(s)) return true;
if (/(^|\/)(wp-admin|wp-login|admin|editor|designer)(\/|$)/i.test(p)) return true;
// Running inside an iframe from the host CMS editor
if (window.self !== window.top) {
try {
var ref = document.referrer || "";
if (/(wp-admin|editor\.shopify|my\.shopify|editor\.wix|webflow\.com|framer\.com)/i.test(ref)) return true;
} catch (_) {}
}
} catch (_) {}
return false;
}
var IN_EDITOR = isInCmsEditor();
// Detect visible modal/drawer/overlay UI state at click time. Critical for
// case framing — "add to cart" inside an open cart drawer is a drawer bug,
// not a button bug. Emits `cs` tags on dc+ix events. Compact: single selector
// + single classifier regex keeps the footprint minimal (~250 bytes gzip).
var CS_SEL = '[aria-modal="true"],[role="dialog"],[data-state="open"],[class*="drawer"][class*="open"],[class*="modal"][class*="show"],[class*="modal"][class*="open"],[class*="offcanvas"][class*="show"],[class*="active"][class*="cart"],.fancybox-container,.pswp--open';
function csType(node) {
var hay = ((typeof node.className === "string" ? node.className : (node.className && node.className.baseVal) || "") + " " + (node.id || "")).toLowerCase();
return /cart/.test(hay) ? "cart_drawer"
: /search/.test(hay) ? "search_modal"
: /filter|facet|refine/.test(hay) ? "filter_panel"
: /(menu|nav).*(mobile|drawer)|(mobile|drawer).*(menu|nav)/.test(hay) ? "mobile_menu"
: /lightbox|gallery|photoswipe|pswp/.test(hay) ? "gallery_lightbox"
: /login|signin|register|account/.test(hay) ? "auth_modal"
: /checkout/.test(hay) ? "checkout_modal"
: /consent|cookie|gdpr/.test(hay) ? "consent_banner"
: /newsletter|popup|exit/.test(hay) ? "popup"
: "modal";
}
function getContextState() {
try {
var nodes = document.querySelectorAll(CS_SEL);
if (!nodes.length) return null;
var seen = {}, out = [];
for (var i = 0; i < nodes.length && out.length < 4; i++) {
var n = nodes[i], r = n.getBoundingClientRect();
if (r.width < 40 || r.height < 40) continue;
if (n.offsetParent === null && n.getAttribute && n.getAttribute("aria-hidden") === "true") continue;
var t = csType(n);
if (!seen[t]) { seen[t] = 1; out.push(t); }
}
return out.length ? out : null;
} catch (_) { return null; }
}
// --- Identity ---
// Cookies are primary; localStorage is the fallback so we survive
// cookie-stripping ad blockers + strict privacy modes across navigations.
// Visitor: persistent, generated once, 2-year cookie + localStorage mirror.
var vid = getCookie("_pxv") || lsGet("_pxv") || rid();
setCookie("_pxv", vid, 730);
lsSet("_pxv", vid);
// Session: check for timeout, rotate if stale.
var now = Date.now();
var sessionStart = parseInt(getCookie("_pxss") || lsGet("_pxss")) || now;
var lastActivity = parseInt(getCookie("_pxla") || lsGet("_pxla")) || now;
var sid;
if (now - lastActivity > SESSION_TIMEOUT) {
// Session expired — start fresh.
sid = rid();
sessionStart = now;
} else {
sid = getCookie("_pxs") || lsGet("_pxs") || rid();
}
setCookie("_pxs", sid);
setCookie("_pxss", sessionStart);
lsSet("_pxs", sid);
lsSet("_pxss", sessionStart);
function touch() {
lastActivity = Date.now();
setCookie("_pxla", lastActivity);
lsSet("_pxla", lastActivity);
}
touch();
// Relative timestamp: ms since session start
function ts() {
return Date.now() - sessionStart;
}
// --- Cached values (avoid recalc on every event) ---
var cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
window.addEventListener("resize", function () {
cachedZoom = Math.round((window.outerWidth / window.innerWidth) * 100) || 100;
});
// --- Section Detection ---
// Walk up from an element to find the nearest section/landmark and its heading.
// Returns "Page Name / Section Name" like "Homepage / Hero Section"
function getSection(el) {
if (!el) return null;
var page = location.pathname;
// Map common paths to human names
var pageName = page === '/' ? 'Homepage' : page.replace(/^\//, '').replace(/[-_]/g, ' ').replace(/\/$/, '');
if (pageName.length > 30) pageName = pageName.substring(0, 30);
// Walk up to find nearest section, article, or landmark
var node = el, depth = 15, sectionName = '';
while (node && depth--) {
var tag = node.tagName;
if (!tag) { node = node.parentElement; continue; }
// Check for section-like containers
if (/^(SECTION|ARTICLE|ASIDE|MAIN|HEADER|FOOTER|NAV)$/.test(tag)) {
// Try to find a heading inside this section
var heading = node.querySelector('h1, h2, h3');
if (heading) {
sectionName = (heading.textContent || '').trim().substring(0, 40);
} else if (node.id) {
sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
} else if (node.getAttribute && node.getAttribute('aria-label')) {
sectionName = node.getAttribute('aria-label').substring(0, 30);
} else {
sectionName = tag.charAt(0) + tag.slice(1).toLowerCase(); // "Section", "Header", etc.
}
break;
}
// Check for div/container with meaningful id or aria-label
if (tag === 'DIV' && (node.id || (node.getAttribute && node.getAttribute('aria-label')))) {
var label = node.getAttribute && node.getAttribute('aria-label');
if (label) { sectionName = label.substring(0, 30); break; }
if (node.id && !/^(root|app|__next|content|wrapper|container)$/i.test(node.id)) {
sectionName = node.id.replace(/[-_]/g, ' ').substring(0, 30);
break;
}
}
node = node.parentElement;
}
if (!sectionName) return pageName;
return pageName + ' / ' + sectionName;
}
// --- Event Queue ---
var queue = [];
// T7 (2026-04-23): pointer type + cursor speed + hover dwell tracking.
// These 4 fields distinguish touch users, stalled cursors, and genuine
// hesitation from false-positive signals on click events.
//
// ptype → "mouse" | "touch" | "pen" — mobile vs desktop vs stylus
// cs → cursor speed over last 200ms in pixels/second (0 = stalled)
// dwell → ms the element was hovered before click (user hesitation)
//
// BUG FIX (2026-04-24): relying on PointerEvent.pointerType alone misses
// desktop mouse clicks in browsers that return "" for mouse pointerType,
// or where pointerdown doesn't fire reliably before click. We now layer 4
// signals and pick the best one at click time:
// 1. pointerdown → ev.pointerType (if non-empty)
// 2. touchstart → "touch"
// 3. mousedown → "mouse" (if no touch seen recently)
// 4. fallback at click: navigator.maxTouchPoints > 0 + no touchstart = "mouse"
var lastPointerType = null;
var lastTouchAt = 0;
var hasTouchCapability = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
var cursorTrack = []; // [{x, y, t}] last 10 mousemoves
try {
// PointerEvent — modern path. Sets ptype if non-empty string.
document.addEventListener('pointerdown', function (ev) {
if (ev.pointerType && typeof ev.pointerType === 'string' && ev.pointerType.length > 0) {
lastPointerType = ev.pointerType;
}
}, { capture: true, passive: true });
// touchstart — fires reliably on every touch device regardless of PointerEvent support.
document.addEventListener('touchstart', function () {
lastPointerType = 'touch';
lastTouchAt = Date.now();
}, { capture: true, passive: true });
// mousedown — fires on desktop even when pointerdown reports "". Only
// sets "mouse" if we haven't seen a touch in the last 500ms (to avoid
// synthesized mouse events that touch devices fire after a tap).
document.addEventListener('mousedown', function () {
if (Date.now() - lastTouchAt > 500) lastPointerType = 'mouse';
}, { capture: true, passive: true });
document.addEventListener('mousemove', function (ev) {
cursorTrack.push({ x: ev.clientX, y: ev.clientY, t: Date.now() });
if (cursorTrack.length > 10) cursorTrack.shift();
}, { passive: true });
} catch (_) {}
// Read-time fallback: if lastPointerType is still null at click, infer
// from device capability. No touch capability at all = must be mouse.
function resolvePointerType() {
if (lastPointerType) return lastPointerType;
return hasTouchCapability ? 'touch' : 'mouse';
}
function cursorSpeedPxPerSec() {
try {
var now = Date.now();
var recent = cursorTrack.filter(function (p) { return now - p.t < 200; });
if (recent.length < 2) return 0;
var first = recent[0], last = recent[recent.length - 1];
var dx = last.x - first.x, dy = last.y - first.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var dt = (last.t - first.t) / 1000;
return dt > 0 ? Math.round(dist / dt) : 0;
} catch (_) { return 0; }
}
// Hover dwell tracking — when the pointer enters an element, stamp the
// time; on click or leave, compute elapsed. Separate from the existing
// hover-intent listener so dwell is available even for short hovers.
var hoverEnterTime = new WeakMap();
function getHoverDwell(el) {
try {
var t = hoverEnterTime.get(el);
return t ? Math.round(Date.now() - t) : 0;
} catch (_) { return 0; }
}
// Dead-click throttle — T3 (2026-04-23).
// Rate-limits the `dc` emission per element so a frustrated user clicking
// the same broken element 50 times produces one detection signal rather
// than 50 separate ones. Uses a sliding window: at most DC_MAX per element
// per DC_WINDOW ms. Suppressed clicks are NOT lost — they'll still count
// toward rage detection (which runs on raw clicks) and the next flushed
// dc event embeds a suppressed-count so the server can see the true volume.
var DC_WINDOW = 1000; // 1-second window
var DC_MAX = 3; // at most 3 dc per element per second
var dcTimes = {}; // elId → [timestamps]
var dcSuppressedCount = {}; // elId → count suppressed since last emit
function throttleDc(elId) {
var now = Date.now();
var arr = dcTimes[elId] || [];
// drop timestamps older than the window
arr = arr.filter(function (t) { return now - t < DC_WINDOW; });
if (arr.length >= DC_MAX) {
dcSuppressedCount[elId] = (dcSuppressedCount[elId] || 0) + 1;
dcTimes[elId] = arr;
return false; // suppress
}
arr.push(now);
dcTimes[elId] = arr;
return true; // allow
}
// Internal-tester flag — T1 (2026-04-23).
// Site owners can mark their own sessions (or their employees') as
// "internal" so events are excluded from detection + calibration. Any
// one of these signals turns the flag on:
// 1. window.__harvv_internal === true (set in inline <script> before pixel loads)
// 2. localStorage.harvv_internal === '1'
// 3. cookie harvv_internal=1
// Once any is set, EVERY event in the session gets data.ih=1.
// Server-side detection queries exclude ih=1 events, keeping the
// dogfood + QA numbers clean. See receiver.js detection queries.
var isInternal = false;
try {
if (window.__harvv_internal === true) isInternal = true;
if (!isInternal && localStorage.getItem('harvv_internal') === '1') isInternal = true;
if (!isInternal && getCookie('harvv_internal') === '1') isInternal = true;
} catch (_) {}
function push(eventType, data) {
var d = data || {};
// Include current page path on ALL events for page-level tracking.
// Without this, issues and logs show null page_url which makes debugging impossible.
if (!d.p && eventType !== 'ss' && eventType !== 'ul') {
d.p = location.pathname;
}
// Include cached zoom level on friction events
if (eventType === 'dc' || eventType === 'rc') {
d.zm = cachedZoom;
}
// Internal-tester tag. Single bit; the server uses it to exclude from
// detection + calibration. No PII — just "this session is one of ours."
if (isInternal) d.ih = 1;
// Pixel version stamped on every event so the server can track which
// pixel build produced the data — essential for diagnosing field-coverage
// regressions after a rollout. Read from window.__pxv which the
// receiver injects into the pixel bootstrap script.
var pxv = (typeof window !== 'undefined' && window.__pxv) || undefined;
// T12 (2026-04-24): event deduplication UID. Random 8-hex per event.
// Server keeps an LRU of recent uids; duplicates within 5s are dropped.
// Guards against event-bubbling double-counts and keep-alive retries
// that flush the same event twice. Cost: 8 chars (~10 bytes) per event.
var uid = ((Math.random() * 0xffffffff) >>> 0).toString(16).padStart(8, '0');
queue.push({ v: vid, s: sid, e: eventType, t: ts(), d: d, pxv: pxv, uid: uid });
touch();
}
// T2.2 (2026-04-30): localStorage retry queue. When sendBeacon AND fetch
// both fail (offline, blocked CSP, ad-blocker partially through, captive
// portal, etc.) we used to silently drop the events. Now we persist the
// failed payload to localStorage and try to flush it on next page load.
// Cap: 50KB total / 24h TTL, so we never balloon a visitor's storage and
// never resurrect stale/PII-heavy events.
var RETRY_KEY = 'harvv_retry_q';
var RETRY_MAX_BYTES = 50000;
var RETRY_TTL_MS = 24 * 60 * 60 * 1000;
function persistFailedSend(payload) {
try {
var entry = { ts: Date.now(), p: payload };
var existing = localStorage.getItem(RETRY_KEY);
var arr = existing ? JSON.parse(existing) : [];
if (!Array.isArray(arr)) arr = [];
arr.push(entry);
// Trim by total size (FIFO drop oldest until under cap)
var serialized = JSON.stringify(arr);
while (serialized.length > RETRY_MAX_BYTES && arr.length > 1) {
arr.shift();
serialized = JSON.stringify(arr);
}
localStorage.setItem(RETRY_KEY, serialized);
} catch (_) {}
}
function drainRetryQueue() {
try {
var raw = localStorage.getItem(RETRY_KEY);
if (!raw) return;
var arr = JSON.parse(raw);
if (!Array.isArray(arr) || !arr.length) { localStorage.removeItem(RETRY_KEY); return; }
var now = Date.now();
var fresh = arr.filter(function (e) { return e && e.ts && (now - e.ts) < RETRY_TTL_MS && e.p; });
if (!fresh.length) { localStorage.removeItem(RETRY_KEY); return; }
// Try to send each persisted payload. On success, remove from queue;
// on failure, leave in queue for next attempt. Use sendBeacon when
// available; fetch otherwise.
var remaining = [];
for (var i = 0; i < fresh.length; i++) {
var blob = new Blob([fresh[i].p], { type: "application/json" });
var sent = false;
if (navigator.sendBeacon) {
try { sent = navigator.sendBeacon(ENDPOINT, blob); } catch (_) { sent = false; }
}
if (!sent) {
try { fetch(ENDPOINT, { method: "POST", body: fresh[i].p, headers: { 'Content-Type': 'application/json' }, keepalive: true }); sent = true; } catch (_) {}
}
if (!sent) remaining.push(fresh[i]);
}
if (remaining.length) localStorage.setItem(RETRY_KEY, JSON.stringify(remaining));
else localStorage.removeItem(RETRY_KEY);
} catch (_) {}
}
function flush() {
if (!queue.length) return;
var payload = JSON.stringify(queue);
queue = [];
var blob = new Blob([payload], { type: "application/json" });
// sendBeacon first (works on unload), fetch+keepalive fallback, retry-queue if both fail
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
try {
fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true })
.catch(function () { persistFailedSend(payload); });
return;
} catch (_) {
persistFailedSend(payload);
}
}
// Drain any persisted-from-prior-load payload on startup.
drainRetryQueue();
// Flush every 10 seconds
setInterval(flush, FLUSH_INTERVAL);
// --- Session Start: Referrer + UTMs ---
(function captureSessionContext() {
var params = new URLSearchParams(location.search);
var utms = {};
["source", "medium", "campaign", "term", "content"].forEach(function (k) {
var v = params.get("utm_" + k);
if (v) utms[k] = v;
});
// 2026-05-05 — paid-traffic click IDs. Captured ONLY from URL params at
// session start (entry-time attribute). Same lifecycle as utms:
// populated once, never overwritten. Truncated to 200 chars defensively.
var clickIds = {};
["gclid", "fbclid", "msclkid", "ttclid", "li_fat_id", "epik"].forEach(function (k) {
var v = params.get(k);
if (v) clickIds[k] = String(v).slice(0, 200);
});
// 2026-05-05 — locale + timezone. Cheap geo proxies that don't need IP
// geolocation. VPNs lie about both, but VPN IPs lie about geo too — same
// trust level. Used for cohort segmentation (US vs CA users) and as a
// sanity cross-check against MaxMind country lookup server-side.
var tz, lg;
try { tz = (Intl && Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone) || undefined; } catch (_) {}
try { lg = (navigator.language || (navigator.languages && navigator.languages[0])) || undefined; } catch (_) {}
if (tz) tz = String(tz).slice(0, 40);
if (lg) lg = String(lg).slice(0, 20);
var touch = 'ontouchstart' in window;
var dt = touch && screen.width < 768 ? 'm' : touch && screen.width < 1024 ? 't' : 'd';
var ua = navigator.userAgent || '';
var br = ua.indexOf('Firefox') > -1 ? 'ff' : ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1 ? 'sf' : ua.indexOf('Edg') > -1 ? 'eg' : ua.indexOf('Chrome') > -1 ? 'ch' : '?';
// T9 (2026-04-24): bot fingerprinting — multi-signal detection for
// automation that passes UA checks. Each bit flipped contributes to a
// composite score the server uses alongside the UA regex. Small JS
// footprint (<200 bytes minified), no external dependencies.
//
// bit 0 (1): navigator.webdriver === true
// bit 1 (2): no plugins (headless Chrome signature)
// bit 2 (4): no navigator.languages (bot default)
// bit 3 (8): UA contains "Headless" / "Phantom" / "HeadlessChrome"
// bit 4 (16): window.chrome missing runtime on Chrome UA (headless)
// bit 5 (32): window.outerHeight === 0 (automation window)
//
// Any non-zero value is suspicious; ≥2 bits set is near-certain bot.
var bf = 0;
try {
if (navigator.webdriver) bf |= 1;
if (!navigator.plugins || navigator.plugins.length === 0) bf |= 2;
if (!navigator.languages || navigator.languages.length === 0) bf |= 4;
if (/Headless|PhantomJS|Electron\/[\d.]+$/i.test(ua)) bf |= 8;
if (window.chrome && !window.chrome.runtime && /Chrome/i.test(ua) && !/Firefox/i.test(ua)) bf |= 16;
if (window.outerHeight === 0) bf |= 32;
} catch (_) {}
// 2026-05-06 (Tier 1) — added: hardware concurrency (hc), connection
// downlink/rtt/saveData (dl/rt/sd), a11y media-query prefs (mq), Global
// Privacy Control (gpc), cookie-consent library state (ccl). All signals
// are zero-PII (browser-stated capability/preference flags) and add
// ~40 bytes per session.
var hc, dl, rt, sd, gpc, mq, ccl;
try { hc = navigator.hardwareConcurrency || undefined; } catch (_) {}
try {
var c = navigator.connection;
if (c) { dl = c.downlink || undefined; rt = c.rtt || undefined; sd = c.saveData ? 1 : undefined; }
} catch (_) {}
try {
// Global Privacy Control — explicit opt-out signal recognized under CPRA
// and (increasingly) GDPR. When true we still SEND the session-start
// event but tag it so the server downgrades to anonymous-only mode.
gpc = navigator.globalPrivacyControl ? 1 : undefined;
} catch (_) {}
try {
// Compact bitmask of accessibility/preference media queries.
// bit 0 (1): prefers-reduced-motion
// bit 1 (2): prefers-color-scheme: dark
// bit 2 (4): prefers-contrast: more
// bit 3 (8): forced-colors: active
// bit 4 (16): prefers-reduced-data
var m = 0;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) m |= 1;
if (matchMedia('(prefers-color-scheme: dark)').matches) m |= 2;
if (matchMedia('(prefers-contrast: more)').matches) m |= 4;
if (matchMedia('(forced-colors: active)').matches) m |= 8;
if (matchMedia('(prefers-reduced-data: reduce)').matches) m |= 16;
if (m) mq = m;
} catch (_) {}
try {
// Detect common cookie-consent library state. Zero PII, just whether
// analytics consent has been given by the visitor on the customer's
// banner. Each integration is wrapped so a single broken library
// doesn't break the rest.
// Returns: 'a' = analytics consent given; 'n' = denied; 'p' = pending;
// 'g' = GPC auto-decline; undefined = no consent library detected.
if (gpc) ccl = 'g';
else if (window.OneTrust && typeof window.OneTrust.GetDomainData === 'function') {
try {
var groups = (document.cookie.match(/OptanonConsent=[^;]*groups=([^&;]+)/) || [])[1] || '';
ccl = /C0002:1/.test(decodeURIComponent(groups)) ? 'a' : 'n';
} catch (_) {}
}
else if (window.Cookiebot && typeof window.Cookiebot.consent === 'object') {
ccl = window.Cookiebot.consent.statistics ? 'a' : 'n';
}
else if (window.cookieconsent && window.cookieconsent.status) {
ccl = window.cookieconsent.status === 'allow' ? 'a' : 'n';
}
else if (window.CookieConsent && typeof window.CookieConsent.acceptedCategory === 'function') {
ccl = window.CookieConsent.acceptedCategory('analytics') ? 'a' : 'n';
}
else if (window.tarteaucitron && window.tarteaucitron.state && window.tarteaucitron.state.analytics === true) {
ccl = 'a';
}
} catch (_) {}
// 2026-05-06 (Tier 2): customer-provided hashed user ID for cohort
// analysis without us ever seeing PII. Customer SHA-256 hashes their
// internal user id and sets `window.harvv.user.id_hash`. We only
// accept exactly 64 hex chars; anything else is rejected to prevent
// accidental email/phone exposure. Server salts again before storing.
var hu;
try {
var hh = window.harvv && window.harvv.user && window.harvv.user.id_hash;
if (typeof hh === 'string' && /^[a-f0-9]{64}$/i.test(hh)) hu = hh;
} catch (_) {}
push("ss", {
r: document.referrer || undefined,
u: Object.keys(utms).length ? utms : undefined,
cid: Object.keys(clickIds).length ? clickIds : undefined,
tz: tz,
lg: lg,
p: location.pathname,
vw: window.innerWidth, vh: window.innerHeight,
dt: dt, br: br,
zm: cachedZoom,
dm: navigator.deviceMemory || undefined,
ct: (navigator.connection && navigator.connection.effectiveType) || undefined,
hc: hc, dl: dl, rt: rt, sd: sd,
gpc: gpc, mq: mq, ccl: ccl,
hu: hu,
wb: navigator.webdriver ? 1 : undefined,
bf: bf || undefined
});
})();
// --- User State Inference ---
// Session-scoped categorical tagset — lets the server cross-tab cases by
// "what kind of user saw this" (logged_in/anonymous, has_cart, first_visit).
// Entirely DOM-derived + optional window.harvv.user opt-in. Zero PII.
(function captureUserState() {
try {
var tags = [];
var ck = document.cookie || "";
var logoutQ = 'a[href*="logout"],a[href*="sign-out"],a[href*="/my-account"],a[href*="/account/"]';
var loginQ = 'a[href*="/login"],a[href*="/signin"],a[href*="/register"]';
var loggedIn = !!document.querySelector(logoutQ)
|| ck.indexOf("wordpress_logged_in") !== -1
|| ck.indexOf("_shopify_customer") !== -1
|| ck.indexOf("_secure_customer_sig") !== -1;
tags.push(loggedIn ? "logged_in" : (document.querySelector(loginQ) ? "anonymous" : "auth_unknown"));
if (document.querySelector(".woocommerce-MyAccount-navigation,.woocommerce-account")) tags.push("wc_logged_in");
var bodyTxt = (document.body && document.body.innerText || "").toLowerCase().slice(0, 4000);
if (/cart \(\d+\)|cart:\s*\d+|items in cart/.test(bodyTxt)
|| document.querySelector('[data-cart-count]:not([data-cart-count="0"]),.cart-count:not(:empty)')) tags.push("has_cart");
if (!ck.match(/_pxv=/)) tags.push("first_visit");
// Optional explicit opt-in via window.harvv.user — categorical only.
var explicit = null;
try {
var wh = window.harvv && window.harvv.user;
if (wh && typeof wh === 'object') {
explicit = {};
for (var k in wh) {
var v = wh[k], tp = typeof v;
if (tp === 'string' && v.length < 60) explicit[k] = v.slice(0, 60);
else if ((tp === 'number' && isFinite(v)) || tp === 'boolean') explicit[k] = v;
}
if (!Object.keys(explicit).length) explicit = null;
}
} catch (_) {}
push("us", { tags: tags, explicit: explicit || undefined });
} catch (_) {}
})();
// --- Unified Click Handler ---
// Merges dead click detection, rage click detection, and interaction sequence into one listener.
var DC_NOISE = /^(HTML|BODY|MAIN|SECTION|HEADER|FOOTER|NAV|ARTICLE|ASIDE)$/;
var clicks = [], lastIx = 0;
// T40 (2026-04-24): record last click for post-click notification correlation.
// The MutationObserver below watches for toasts/alerts added within 5s of a
// click so dc issues on product elements can be reframed as inventory when
// "Sold out" / "Out of stock" banners fire.
var lastClickEid = null, lastClickAt = 0;
document.addEventListener("click", function (ev) {
var tgt = ev.target, elId = eid(tgt), cx = ev.clientX, cy = ev.clientY;
lastClickEid = elId; lastClickAt = Date.now();
// 1. Interaction sequence (every click)
var t = ts(), delta = lastIx ? t - lastIx : 0;
lastIx = t;
var ctxState = getContextState();
// T7 (2026-04-23, fixed 2026-04-24): pointerType + cursor speed + hover
// dwell on every click. `resolvePointerType()` handles edge cases where
// pointerdown doesn't set a value (browsers returning "" for mouse, or
// browsers where pointerdown doesn't fire before click). Always returns
// "mouse" / "touch" / "pen".
//
// Audit fix 2026-04-24: also attach x/y to ix events (previously only
// on dc). Lets the server correlate click position between ix + dc
// events to find near-miss clicks that aren't technically dead.
var ptype = resolvePointerType();
var csPx = cursorSpeedPxPerSec() || undefined;
var dwell = getHoverDwell(tgt) || undefined;
push("ix", { el: elId, x: cx, y: cy, dt: delta, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell });
// 2. Dead click detection (non-interactive elements)
// Suppress dead-click emission entirely for CMS-editor previews and inside
// third-party embedded widgets — those aren't the host site's UX and the
// agency can't fix them (M-Diamond had 16 false positives from an embedded
// appointment widget's styled-components classes).
if (IN_EDITOR) { /* skip */ }
else if (isInsideThirdPartyWidget(tgt)) { /* skip */ }
else if (!isInteractive(tgt)) {
var tag = tgt.tagName;
if (!(DC_NOISE.test(tag) && !tgt.id && !(tgt.className && typeof tgt.className === "string" && tgt.className.trim()))) {
// Check if clicked inside a container/wrapper class — these wrap interactive
// children and clicks on them are near-misses, not real dead clicks.
// Example: div.product__info-wrapper on Shopify product pages wraps the
// product title link — clicking the wrapper area navigates correctly.
var cls = (typeof tgt.className === "string" ? tgt.className : "").toLowerCase();
var isWrapper = cls && /(?:wrapper|container|grid|row|group|items|actions|controls|card|overlay|inner|content|block)/.test(cls);
if (!isWrapper) {
var sec = getSection(tgt);
// T7 additions: ptype, csp (cursor speed px/sec), dwell (ms hovered
// before this click). Same fields as the ix event above.
var data = { el: elId, x: cx, y: cy, sec: sec || undefined, cs: ctxState || undefined, ptype: ptype, csp: csPx, dwell: dwell };
var nearest = tgt.parentElement, depth = 5;
var insideInteractive = false;
while (nearest && depth--) {
if (isInteractive(nearest)) {
var r = nearest.getBoundingClientRect();
var dist = (Math.sqrt(Math.pow(Math.max(r.left - cx, cx - r.right, 0), 2) + Math.pow(Math.max(r.top - cy, cy - r.bottom, 0), 2)) + 0.5) | 0;
if (dist === 0) {
// Click landed inside an interactive ancestor's bounding box.
// This is NOT a dead click — the ancestor handles the event.
insideInteractive = true;
} else {
data.near = eid(nearest);
data.dist = dist;
}
break;
}
nearest = nearest.parentElement;
}
// T3 (2026-04-23): throttle dc emission so one element can emit at
// most DC_MAX events per DC_WINDOW ms. One frustrated user clicking
// the same broken element 50 times should produce a single signal
// with suppressed_count=47, not 50 separate detection candidates.
if (!insideInteractive) {
if (throttleDc(elId)) {
// Attach the count we suppressed since the last successful emit.
if (dcSuppressedCount[elId]) {
data.sup = dcSuppressedCount[elId];
dcSuppressedCount[elId] = 0;
}
push("dc", data);
}
}
}
}
}
// 3. Rage click detection (3+ clicks within 800ms in 50px radius)
var now = Date.now();
clicks.push({ x: cx, y: cy, t: now });
// Cap array to prevent unbounded growth + filter old clicks
if (clicks.length > 50) clicks = clicks.slice(-30);
clicks = clicks.filter(function (c) { return now - c.t < RAGE_WINDOW; });
if (clicks.length >= RAGE_THRESHOLD) {
var ref = clicks[0], clustered = clicks.every(function (c) {
return Math.hypot(c.x - ref.x, c.y - ref.y) < RAGE_RADIUS;
});
if (clustered) {
push("rc", { el: elId, n: clicks.length, sec: getSection(tgt) || undefined });
snapViewport("rage");
clicks = [];
}
}
});
// --- Scroll Depth Milestones ---
// Fire once per session per threshold (25/50/75/100%).
// Throttled — only checks every 100ms via rAF.
// 2026-05-06 (Tier 2): also tracks scroll velocity + direction reversals
// (scrolling down then up = "I missed something / lost my place").
// One sv event emitted at end of session via beforeunload.
var firedScrolls = {};
var scrollCheckPending = false;
var scrollLastY = 0, scrollLastT = 0, scrollLastDir = 0;
var scrollReversals = 0, scrollVelocityMaxPxs = 0;
window.addEventListener("scroll", function () {
if (scrollCheckPending) return;
scrollCheckPending = true;
requestAnimationFrame(function () {
scrollCheckPending = false;
var scrollTop = window.scrollY || window.pageYOffset || 0;
var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
// Velocity + reversal tracking (independent of milestone check)
var nowT = Date.now();
if (scrollLastT && nowT - scrollLastT < 1000) {
var dy = scrollTop - scrollLastY;
var dt = nowT - scrollLastT;
if (dt > 0) {
var pxs = Math.abs(dy) / dt * 1000;
if (pxs > scrollVelocityMaxPxs) scrollVelocityMaxPxs = pxs;
var dir = dy > 0 ? 1 : dy < 0 ? -1 : 0;
if (dir && scrollLastDir && dir !== scrollLastDir) scrollReversals++;
if (dir) scrollLastDir = dir;
}
}
scrollLastY = scrollTop;
scrollLastT = nowT;
// Milestone fire
if (scrollHeight <= 0) return;
var pct = Math.round((scrollTop / scrollHeight) * 100);
SCROLL_MILESTONES.forEach(function (m) {
if (pct >= m && !firedScrolls[m]) {
firedScrolls[m] = 1;
push("sd", { p: m });
snapViewport("sd" + m);
}
});
});
}, { passive: true });
// Emit final scroll-velocity summary on unload-ish events.
function emitScrollSummary() {
if (scrollReversals > 0 || scrollVelocityMaxPxs > 100) {
push("sv", {
rev: scrollReversals,
vp: Math.round(scrollVelocityMaxPxs),
});
}
}
addEventListener('pagehide', emitScrollSummary, { passive: true });
addEventListener('beforeunload', emitScrollSummary, { passive: true });
// --- Engagement Time ---
// Actual visible time. Pauses when tab is hidden. Sent on unload.
var engageStart = document.hidden ? 0 : Date.now();
var totalEngage = 0;
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
// Tab hidden — bank the time
if (engageStart) totalEngage += Date.now() - engageStart;
engageStart = 0;
push("vc", { h: 1 }); // visibility change: hidden
} else {
// Tab visible — restart timer
engageStart = Date.now();
push("vc", { h: 0 }); // visibility change: visible
}
});
// --- Hover Intent ---
// 500ms+ hover on interactive elements. Signals consideration.
// Uses mouseenter/mouseleave with per-element timers to avoid race conditions.
var hoverTimers = new Map();
document.addEventListener("mouseenter", function (ev) {
// T7: stamp dwell start on any element (not just interactive) so we can
// compute hover duration for any subsequent click — including dead clicks.
try { hoverEnterTime.set(ev.target, Date.now()); } catch (_) {}
if (!isInteractive(ev.target)) return;
var target = ev.target;
var timer = setTimeout(function () {
// T7: include dwell_ms so the server can tell long-hesitation hovers
// ("considered it, clicked slowly") from quick ones ("passed through").
push("hi", { el: eid(target), dwell: getHoverDwell(target) || undefined });
hoverTimers.delete(target);
}, HOVER_DELAY);
hoverTimers.set(target, timer);
}, { passive: true, capture: true });
document.addEventListener("mouseleave", function (ev) {
var timer = hoverTimers.get(ev.target);
if (timer) { clearTimeout(timer); hoverTimers.delete(ev.target); }
try { hoverEnterTime.delete(ev.target); } catch (_) {}
}, { passive: true, capture: true });
// --- Viewport Snapshot ---
// Captures which interactive elements are visible at key moments.
// IntersectionObserver maintains a running Set — snapshot reads it instantly, no DOM scan.
// MutationObserver catches dynamically added elements (Shopify, AJAX, SPAs).
var visibleSet = new Set();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
entry.isIntersecting ? visibleSet.add(entry.target) : visibleSet.delete(entry.target);
});
});
// Observe all interactive elements under a root
function observeInteractive(root) {
(root || document).querySelectorAll("a,button,input,select,textarea,[role=button],[tabindex]").forEach(function (el) {
if (!el._pxo) { el._pxo = 1; io.observe(el); }
});
}
// MutationObserver for dynamic pages — debounced to 500ms to avoid thrashing
var mutDebounce = null;
function initObservers() {
observeInteractive();
// Watch for dynamically added interactive elements (SPAs, AJAX, Shopify)
try {
var mo = new MutationObserver(function () {
clearTimeout(mutDebounce);
mutDebounce = setTimeout(function () { observeInteractive(); }, 500);
});
mo.observe(document.body, { childList: true, subtree: true });
} catch (_) {}
}
// Take a viewport snapshot — reads the running Set, caps at 20 elements
function snapViewport(trigger) {
var els = [];
visibleSet.forEach(function (el) {
if (els.length < 20) els.push(eid(el));
});
push("vs", { tr: trigger, el: els });
}
// Widget overlap probe — catches visually colliding third-party widgets
// (chat + rewards, cookie banner + intercom). Containers live in our document
// even when the widget renders in an iframe, so we compare bounding rects.
function widgetOverlapProbe() {
try {
var cands = [], W = window.innerWidth, H = window.innerHeight;
var q = document.querySelectorAll('iframe,[class*="widget"],[id*="widget"],[class*="chat"],[id*="chat"],[class*="reward"],[id*="reward"],[class*="loyalty"],[class*="popup"],[data-hook*="chat"],[data-hook*="wallet"],[data-hook*="reward"],[data-testid*="chat"],[data-testid*="wallet"],[id^="intercom-"],[id^="drift-"],[id^="gorgias-"],[id^="tidio-"],[id^="tawk-"],[id^="zopim"],[id*="smile"],[id*="wix-"],[id*="rivo"],[class*="rivo"],[class*="ba-loy"],[class*="ba_loy"],[class*="launcher"],[class*="nudgify"],[id*="judgeme"],[class*="judgeme"],[class*="yotpo"],[id*="yotpo"],[class*="loyaltylion"],[class*="stamped"],[class*="growave"]');
for (var i = 0; i < q.length && cands.length < 12; i++) {
var n = q[i], cs = getComputedStyle(n);
if (!/fixed|sticky|absolute/.test(cs.position)) continue;
if (cs.display === "none" || cs.visibility === "hidden") continue;
var r = n.getBoundingClientRect();
if (r.width < 30 || r.height < 30) continue;
if (r.width > W * 0.7 && r.height > H * 0.7) continue; // full-page overlay
cands.push({ el: eid(n), r: r });
}
var hits = [];
for (var a = 0; a < cands.length && hits.length < 3; a++) {
for (var b = a + 1; b < cands.length && hits.length < 3; b++) {
var ra = cands[a].r, rb = cands[b].r;
var ix = Math.max(0, Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left));
var iy = Math.max(0, Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top));
var area = ix * iy;
if (area < 400) continue;
var pct = Math.round((area / Math.min(ra.width * ra.height, rb.width * rb.height)) * 100);
if (pct >= 20) hits.push({ a: cands[a].el, b: cands[b].el, pct: pct });
}
}
if (hits.length) push("wo", { vw: W, hits: hits });
} catch (_) {}
}
// Layout probe — detect nav wrapping, overflow, content offset. Pure structural data, zero PII.
function layoutProbe() {
try {
// Single selector instead of 8 separate querySelectorAll calls
var nav = document.querySelector('header nav, nav, [role="navigation"], .w-nav-menu, .header__inline-menu, .main-navigation, .navPages, .gh-head-menu');
var hdr = document.querySelector('header, [role="banner"], #SITE_HEADER, .site-header');
var main = document.querySelector('main, [role="main"], #MainContent, #content, .site-content');
var d = { vw: window.innerWidth, vh: window.innerHeight };
if (hdr) d.hh = hdr.offsetHeight;
if (nav) {
d.nh = nav.offsetHeight;
// Check if nav items wrap — compare first and last child offsetTop
var items = nav.querySelectorAll(':scope > ul > li, :scope > div > a, :scope > a');
if (items.length > 1) {
var firstTop = items[0].offsetTop, wraps = 0;
for (var i = 1; i < items.length; i++) { if (items[i].offsetTop > firstTop + 5) { wraps++; break; } }
if (wraps) d.nw = 1; // nav wrapped
}
}
if (main) d.cy = Math.round(main.getBoundingClientRect().top); // content Y position
// Horizontal overflow: capture actual delta (scrollWidth - innerWidth) so the
// server can distinguish 6px rounding noise from a 300px layout bug. Only set
// ox=1 when the delta is >10px (below that is typically a scrollbar-width
// artifact on Windows/Linux Firefox, not a real overflow).
var oxd = document.documentElement.scrollWidth - window.innerWidth;
if (oxd > 10) {
d.ox = 1; d.oxd = oxd;
// Walk DOWN from <body> to find the deepest element that's still
// extending past the viewport. This is usually the true culprit —
// the parent overflows because its child does. We cap depth at 20
// and elements examined at 200 so we never hang on pathological DOMs.
try {
var cur = document.body, depth = 20, overflowEl = null;
while (cur && depth-- > 0) {
var offendingChild = null, offendingWidth = 0;
var kids = cur.children;
for (var i = 0; i < kids.length && i < 50; i++) {
var c = kids[i];
var rc = c.getBoundingClientRect();
// right edge past viewport + non-zero width + non-decorative
if (rc.right > window.innerWidth + 5 && rc.width > offendingWidth) {
offendingChild = c; offendingWidth = rc.width;
}
}
if (!offendingChild) break;
overflowEl = offendingChild;
cur = offendingChild;
}
if (overflowEl) {
d.oxel = eid(overflowEl); // selector of offending element
d.oxw = Math.round(overflowEl.getBoundingClientRect().width); // its rendered width
}
} catch (_) {}
}
push("lp", d);
} catch (e) {}
}
// Initial snapshot on DOM ready (small delay for IntersectionObserver to fire)
function onReady() {
initObservers();
setTimeout(function () { snapViewport("init"); }, 100);
setTimeout(layoutProbe, 200); // layout probe after render settles
setTimeout(widgetOverlapProbe, 3000); // widget overlap: wait 3s for async chat/reward widgets to load
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
// --- Text Selection Tracking ---
// Captures element where user selected text + char count. Never captures text content.
var selTimer = null;
document.addEventListener("selectionchange", function () {
clearTimeout(selTimer);
selTimer = setTimeout(function () {
var sel = window.getSelection();
if (!sel || !sel.rangeCount || sel.isCollapsed) return;
var node = sel.anchorNode;
var parent = node && node.nodeType === 3 ? node.parentElement : node;
if (!parent) return;
var range = sel.getRangeAt(0);
var len = range.toString().length;
if (len > 0) push("ts", { el: eid(parent), n: len });
}, 1000);
});
document.addEventListener("copy", function (ev) {
var sel = window.getSelection();
var node = sel && sel.anchorNode;
var parent = node && node.nodeType === 3 ? node.parentElement : node;
if (parent) push("cp", { el: eid(parent), n: (sel.toString() || "").length });
});
// --- Keyboard Signal Categories ---
// Only captures Tab and Escape — never alphanumeric. Tracks keyboard rage (rapid Escape).
var escTimes = [];
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
var now = Date.now();
escTimes.push(now);
escTimes = escTimes.filter(function (t) { return now - t < 2000; });
push("kb", { k: "esc", r: escTimes.length });
} else if (ev.key === "Tab") {
push("kb", { k: "tab" });
}
});
// --- Navigation Patterns ---
// Track all page changes: popstate (back/forward), pushState/replaceState (SPA navigation)
var lastPath = location.pathname;
var cvFired = false; // only fire conversion once per session
var CV_PATTERNS = /\/(thank|order.?confirm|checkout.?complete|purchase.?success|receipt|confirmation)/i;
function onPageChange(trigger) {
var newPath = location.pathname;
if (newPath !== lastPath) {
lastPath = newPath;
push("nb", { p: newPath, tr: trigger });
// Auto-detect conversion pages
if (!cvFired && CV_PATTERNS.test(newPath)) {
push("cv", { p: newPath });
cvFired = true;
}
}
}
window.addEventListener("popstate", function () { onPageChange("pop"); });
// Intercept pushState/replaceState for SPA route changes — return original result
var origPush = history.pushState, origReplace = history.replaceState;
history.pushState = function () { var ret = origPush.apply(this, arguments); onPageChange("push"); return ret; };
history.replaceState = function () { var ret = origReplace.apply(this, arguments); onPageChange("replace"); return ret; };
document.addEventListener("auxclick", function (ev) {
if (ev.button === 1) { // Middle click
var el = ev.target.closest ? ev.target.closest("a") : ev.target;
if (el) push("ax", { el: eid(el) });
}
});
// --- Time to First Interaction (TTFI) ---
// Measures ms from page load to first user interaction. Fires once, self-destructs.
var ttfiStart = performance.now();
function onFirstInteraction() {
push("tf", { tf: Math.round(performance.now() - ttfiStart) });
document.removeEventListener("click", onFirstInteraction, true);
document.removeEventListener("keydown", onFirstInteraction, true);
window.removeEventListener("scroll", onFirstInteraction);
}
document.addEventListener("click", onFirstInteraction, { once: true, capture: true, passive: true });
document.addEventListener("keydown", onFirstInteraction, { once: true, capture: true, passive: true });
window.addEventListener("scroll", onFirstInteraction, { once: true, passive: true });
// --- Scroll Kinematics ---
// Tracks scroll velocity, direction reversals, and pauses.
// Phase 2 (2026-05-06) — velocity-bucket distances added for Nina patterns
// (content:scroll_speed_skim, content:shallow_read, content:drop_cliff).
// Pure kinematics — no DOM text. PII-safe by construction.
var prevScrollY = window.scrollY, prevScrollT = performance.now();
var scrollDir = 0, reversals = 0, totalDist = 0;
var minVel = Infinity, maxVel = 0;
var velSamples = 0, velSum = 0; // for avg
var skimDist = 0, readDist = 0; // distance buckets in px (px/s thresholds)
var pauses = [], lastScrollTime = 0, scrollPauseTimer = null;
var rafPending = false;
window.addEventListener("scroll", function () {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
var y = window.scrollY, t = performance.now();
var dy = y - prevScrollY, dt = t - prevScrollT;
if (dt < 50) return; // Throttle to ~20fps
var vel = Math.abs(dy / dt); // px/ms
if (vel > 0.01 && vel < 100) { // Filter noise
if (vel < minVel) minVel = vel;
if (vel > maxVel) maxVel = vel;
velSamples++; velSum += vel;
// Bucket by px/s (vel * 1000 = px/s). >200 px/s = skim; 50-200 = active read.
var pxs = vel * 1000;
if (pxs > 200) skimDist += Math.abs(dy);
else if (pxs >= 50) readDist += Math.abs(dy);
}
totalDist += Math.abs(dy);
// Direction reversal detection
var dir = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
if (dir !== 0 && scrollDir !== 0 && dir !== scrollDir) reversals++;
if (dir !== 0) scrollDir = dir;
prevScrollY = y; prevScrollT = t;
lastScrollTime = t;
// Pause detection: if no scroll for 2s after scrolling
clearTimeout(scrollPauseTimer);
scrollPauseTimer = setTimeout(function () {
var scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight > 0) {
pauses.push(Math.round((window.scrollY / scrollHeight) * 100));
}
}, 2000);
});
}, { passive: true });
// --- Form-field abandonment (Tier 2 #4, 2026-05-05) ---
// Track which form field the user last interacted with. On
// navigation/unload, if the user touched a form but didn't submit,
// emit `fa` (form abandonment) with the last-touched field. Tells
// us "85% of bails happened on field 4 of 7" — the killer signal
// for one-button-flow products like HR-tech.
//
// Privacy:
// - We capture the field's identifier (id, name, type, position),
// NEVER the value the user typed. Pixel's PII guard would scrub
// textContent anyway, and we don't read .value at all.
// - Honors data-harvv-private="true" on the form (skip).
// - Skips password/credit-card inputs entirely (autocomplete sniff).
(function trackFormAbandonment() {
var lastField = null; // most recent focused/typed field
var formSubmitted = false; // any form submitted in this session?
var fiEmitted = {}; // per-field: emitted `fi` already?
var faEmitted = false; // already emitted `fa` once on this page?
var fieldsTouched = 0;
// Build a stable field identifier without reading any value.
function fieldKey(el) {
if (!el) return null;
// Skip password / sensitive types
var t = String(el.type || '').toLowerCase();
if (t === 'password' || t === 'hidden' || t === 'cc-number' || t === 'credit-card') return null;
// Honor private container
var p = el;
for (var i = 0; i < 20 && p; i++) {
if (p.getAttribute && (p.getAttribute('data-harvv-private') === 'true')) return null;
p = p.parentElement;
}
// Build identifier: tag.name OR tag#id OR tag[type=...]
var tag = (el.tagName || '').toLowerCase();
if (!tag) return null;
var id = el.id ? ('#' + String(el.id).slice(0, 40)) : '';
var nm = (!id && el.name) ? ('[name=' + String(el.name).slice(0, 40) + ']') : '';
var ty = (!id && !nm && t) ? ('[type=' + t + ']') : '';
return (tag + id + nm + ty).slice(0, 80);
}
// Find the field's position within its containing form (1-indexed).
function fieldPosition(el) {
try {
var form = el.form;
if (!form) return null;
var fields = form.querySelectorAll('input, textarea, select');
for (var i = 0; i < fields.length; i++) {
if (fields[i] === el) return i + 1;
}
} catch (_) {}
return null;
}
function isFormField(el) {
if (!el || !el.tagName) return false;
var t = el.tagName.toUpperCase();
return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT';
}
document.addEventListener('focusin', function (e) {
if (!isFormField(e.target)) return;
var key = fieldKey(e.target);
if (!key) return;
lastField = { key: key, pos: fieldPosition(e.target) };
fieldsTouched++;
// First-touch event per field — server uses for has_form_interaction.
if (!fiEmitted[key]) {
fiEmitted[key] = true;
push("fi", { f: key, p: lastField.pos || undefined });
}
}, true);
document.addEventListener('input', function (e) {
if (!isFormField(e.target)) return;
var key = fieldKey(e.target);
if (!key) return;
lastField = { key: key, pos: fieldPosition(e.target) };
}, true);
document.addEventListener('submit', function () {
formSubmitted = true;
}, true);
function emitAbandonment() {
if (faEmitted) return;
if (formSubmitted) return;
if (!lastField) return;
faEmitted = true;
push("fa", { f: lastField.key, p: lastField.pos || undefined, n: fieldsTouched });
}
// Fire on tab close + on hashchange (SPA navigation)
window.addEventListener('pagehide', emitAbandonment);
window.addEventListener('beforeunload', emitAbandonment);
window.addEventListener('hashchange', emitAbandonment);
})();
// --- JS Errors + Performance + Network ---
var ec = 0;
window.addEventListener("error", function (e) {
if (ec++ < 3) push("er", { m: (e.message || "").slice(0, 80), f: (e.filename || "").replace(/.*\//, "").slice(0, 20), l: e.lineno });
});
window.addEventListener("unhandledrejection", function (e) {
if (ec++ < 3) push("er", { m: ("P:" + (e.reason&&e.reason.message||"")).slice(0,80) });
});
// --- User-Visible Notifications (T40, 2026-04-24) ---
// When a click produces a toast/alert/inline error, capture the message text
// + inventory/error classification. The receiver uses `un:inv=1` events to
// reframe dead-click issues on product elements as inventory restock issues
// rather than generic "broken CTA". Example: Shopify "Add to Cart" → "Sold
// out — notify me when available" is an inventory signal, not a UX bug.
//
// Gates (cheap → expensive): (a) 5s post-click window, (b) selector match,
// (c) inventory OR error keyword in text. Skips cookie banners, promo
// modals, and any node inside data-harvv-private. Emails + long digit
// sequences are scrubbed. Limit 8/session.
var unc = 0;
var UN_SEL = '[role="alert"],[role="status"],[aria-live="polite"],[aria-live="assertive"],.toast,.toaster,.notification,.notice,.flash,.flash-message,.snackbar,.alert-danger,.alert-warning,.error-message,.cart-error,.form-error,.product-form__error-message,.price-item--sold-out,.badge--sold-out';
var UN_INV = /out[\s-]?of[\s-]?stock|sold[\s-]?out|unavailable|back[\s-]?in[\s-]?stock|restock|notify[\s\w-]{0,12}available|no[\s-]?longer[\s-]?available|only\s+\d+\s+(?:left|in\s+stock)/i;
var UN_ERR = /\b(error|failed?|problem|try\s+again|invalid|required|incorrect)\b/i;
try {
var unObs = new MutationObserver(function (muts) {
if (unc >= 8) return;
var now = Date.now();
if (now - lastClickAt > 5000) return; // only post-click
for (var mi = 0; mi < muts.length && unc < 8; mi++) {
var added = muts[mi].addedNodes;
for (var ni = 0; ni < added.length && unc < 8; ni++) {
var n = added[ni];
if (!n || n.nodeType !== 1) continue;
if (n.closest && n.closest('[data-harvv-private="true"]')) continue;
var el = (n.matches && n.matches(UN_SEL)) ? n : (n.querySelector && n.querySelector(UN_SEL));
if (!el) continue;
var txt = (el.textContent || "").replace(/[\t\n\r\s]+/g, " ").trim();
if (!txt || txt.length < 4) continue;
txt = txt.slice(0, 140).replace(/\S+@\S+\.\S+/g, "[email]").replace(/\b\d{10,}\b/g, "[num]");
var isInv = UN_INV.test(txt) ? 1 : undefined;
var isErr = UN_ERR.test(txt) ? 1 : undefined;
if (!isInv && !isErr) continue; // skip promo/cookie/decorative toasts
push("un", {
t: txt,
c: (el.className && typeof el.className === "string") ? el.className.slice(0, 40) : undefined,
r: (el.getAttribute && (el.getAttribute("role") || el.getAttribute("aria-live"))) || undefined,
src: lastClickEid || undefined,
d: Math.round(now - lastClickAt),
inv: isInv,
err: isErr
});
unc++;
}
}
});
unObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
} catch (_) {}
try { var lcp = 0, cls = 0, fcp = 0, tbt = 0;
// Phase 2 (2026-05-06) — track LCP element type so detection can
// distinguish image-LCP failures (Nina deliverable: image optimization)
// from JS/text-LCP failures. PII-safe: tag name only + URL-presence bool.
// Never sends the URL value itself.
var lcpEl = '', lcpHasUrl = 0;
new PerformanceObserver(function (l) {
var e = l.getEntries();
if (e.length) {
var last = e[e.length - 1];
lcp = last.startTime | 0;
try {
lcpEl = (last.element && last.element.tagName ? String(last.element.tagName).toLowerCase() : '').slice(0, 8);
lcpHasUrl = last.url ? 1 : 0;
} catch (_) {}
}
}).observe({ type: "largest-contentful-paint", buffered: true });
// Phase 2.5 (2026-05-06) — CLS source attribution. layout-shift entries
// include a `sources[]` array of nodes that shifted. We capture short
// selectors for the top contributors so detection can name the culprit
// ("hero image is what shifted at 1.2s"), not just "your CLS is 0.18".
// PII-safe: selector strings only, no DOM text.
var clsSources = [];
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (e.hadRecentInput) return;
cls += e.value;
if (clsSources.length >= 8) return;
try {
var srcs = e.sources || [];
for (var si = 0; si < srcs.length && clsSources.length < 8; si++) {
var n = srcs[si].node;
if (!n || !n.tagName) continue;
var sel = n.tagName.toLowerCase();
if (n.id) sel += '#' + String(n.id).slice(0, 24);
else if (n.className && typeof n.className === 'string') {
var c = n.className.split(/\s+/)[0];
if (c) sel += '.' + c.slice(0, 24);
}
clsSources.push({ s: sel.slice(0, 60), v: +e.value.toFixed(3) });
}
} catch (_) {}
});
}).observe({ type: "layout-shift", buffered: true });
// FCP (First Contentful Paint) — widely supported paint API entry.
try {
var paints = performance.getEntriesByType("paint") || [];
for (var pi = 0; pi < paints.length; pi++) {
if (paints[pi].name === "first-contentful-paint") fcp = paints[pi].startTime | 0;
}
} catch (_) {}
// TBT (Total Blocking Time) — sum of (longtask_duration - 50ms) over all long tasks.
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) { tbt += Math.max(0, (e.duration | 0) - 50); });
}).observe({ type: "longtask", buffered: true });
} catch (_) {}
setTimeout(function () {
var n = performance.getEntriesByType("navigation")[0];
var tti = n ? Math.round(n.domInteractive || 0) : 0;
var plt = n ? Math.round(n.loadEventEnd || n.domContentLoadedEventEnd || 0) : 0;
// Re-read FCP in case it arrived after init (paint entries aren't observer-streamed here)
if (!fcp) {
try {
var p2 = performance.getEntriesByType("paint") || [];
for (var pj = 0; pj < p2.length; pj++) {
if (p2[pj].name === "first-contentful-paint") fcp = p2[pj].startTime | 0;
}
} catch (_) {}
}
push("pf", {
ttfb: n ? n.responseStart | 0 : 0,
fcp: fcp || undefined,
lcp: lcp,
cls: (cls * 1e3 | 0) / 1e3,
tbt: tbt || undefined,
tti: tti,
plt: plt,
inp: worstInp > 0 ? Math.round(worstInp) : undefined,
// T7 (2026-04-23): network type for attribution. Lets us separate
// "slow network → dead click" from "broken CTA → dead click".
net: (navigator.connection && navigator.connection.effectiveType) || undefined,
// Phase 2 (2026-05-06) — LCP element discriminator. PII-safe:
// tag name only + URL-presence boolean. Detection layer uses
// lcp_el='img' + lcp>2500 to fire content:image_lcp_fail.
lcp_el: lcpEl || undefined,
lcp_url: lcpHasUrl || undefined,
// Phase 2.5 (2026-05-06) — CLS source attribution (top 5
// contributors). Detection layer uses this to fire
// layout:cls_culprit naming the element that shifted.
cls_src: clsSources.length ? clsSources.slice(0, 5) : undefined,
});
}, 5e3);
} catch (_) {}
// --- Long Tasks (main thread blocking >50ms) ---
// Correlates with rage clicks and unresponsive UI. Chromium-only (~86% of traffic).
var ltc = 0;
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (ltc++ < 5) push("lt", { d: Math.round(e.duration), n: (e.attribution && e.attribution[0] && e.attribution[0].name || "").slice(0, 40) });
});
}).observe({ type: "longtask", buffered: true });
} catch (_) {}
// --- INP (Interaction to Next Paint) — Core Web Vital ---
// Measures responsiveness: delay between user input and visual update.
// Supported in Chrome 96+, Safari 26.2+, Firefox 122+.
// 2026-05-06 (Tier 1): emit per-event INP for slow interactions so the
// server can answer "WHICH button is slow?" today only the page-aggregated
// INP is captured in pf.
var worstInp = 0;
var inpEventCount = 0;
try {
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (e.duration > worstInp) worstInp = e.duration;
// Per-interaction INP: slow events (>200ms) get their own emit, capped
// at 5/page so we don't flood the wire on a janky page.
if (e.duration > 200 && inpEventCount++ < 5) {
var tgt = e.target || (e.attribution && e.attribution[0]);
push("ie", {
d: Math.round(e.duration),
n: e.name,
el: tgt ? eid(tgt) : undefined,
});
}
});
}).observe({ type: "event", buffered: true, durationThreshold: 100 });
} catch (_) {}
// 2026-05-06 (Tier 1) — Soft-navigation Performance Observer.
// Modern SPAs (Hydrogen, Next.js) emit `soft-navigation` entries when
// client-side route changes happen without a full page reload. Gives us
// accurate timing for SPA route transitions. ~30 bytes when supported.
try {
if (PerformanceObserver.supportedEntryTypes && PerformanceObserver.supportedEntryTypes.indexOf('soft-navigation') !== -1) {
var softNavCount = 0;
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (e) {
if (softNavCount++ < 10) {
push("sn", {
p: e.name && e.name.replace(/^https?:\/\/[^/]+/, '') || location.pathname,
ms: Math.round(e.duration),
});
}
});
}).observe({ type: 'soft-navigation', buffered: true });
}
} catch (_) {}
// 2026-05-06 (Tier 1) — Print intent. Strong "user wants to keep this
// page" signal — frequently triggered on receipts/confirmations/specs
// when the email isn't sufficient. ~20 bytes.
try {
addEventListener('beforeprint', function () {
push("pi", { p: location.pathname });
}, { passive: true, once: true });
} catch (_) {}
// 2026-05-06 (Tier 1) — Idle time within session. Emits `id` events when
// ≥30s passes between meaningful interactions. Distinct from `vc`
// (visibility change) which fires when the tab is hidden — idle is "tab
// active but user not interacting." Useful for funnel-stall analysis.
// Capped at 8/session so a long idle background tab doesn't generate spam.
(function trackIdle() {
var lastActive = Date.now();
var idleEmits = 0;
var IDLE_THRESHOLD_MS = 30000;
function tick() {
try {
var now = Date.now();
var gap = now - lastActive;
if (gap >= IDLE_THRESHOLD_MS && document.visibilityState === 'visible' && idleEmits++ < 8) {
push("id", { ms: Math.round(gap) });
}
lastActive = now;
} catch (_) {}
}
addEventListener('click', tick, { passive: true, capture: true });
addEventListener('keydown', tick, { passive: true, capture: true });
addEventListener('scroll', tick, { passive: true, capture: true });
})();
// 2026-05-06 (Tier 1) — Multi-tab presence via BroadcastChannel.
// When a visitor opens >1 tab of the customer's site, each tab announces
// itself; we count peer responses. Useful for distinguishing "research
// mode" (multiple tabs) from "single-tab purchase intent." ~80 bytes.
// Tab-local — never leaves the browser; respects all privacy guarantees.
try {
if (typeof BroadcastChannel === 'function') {
var bc = new BroadcastChannel('harvv:tabs');
var peers = 0;
bc.onmessage = function (e) {
if (e.data === 'present?') bc.postMessage('here');
else if (e.data === 'here') peers++;
};
bc.postMessage('present?');
setTimeout(function () {
if (peers > 0) push("mt", { n: peers });
}, 1500);
}
} catch (_) {}
// 2026-05-06 (Tier 2) — Resource timing for top-3 slowest resources >500ms.
// Answers: "which images / scripts / CSS files are dragging down LCP?"
// URL is scrubbed via the existing scrubQuery() before emission.
// Capped at 3/page so a junk-asset page can't generate spam.
try {
var rsCount = 0;
new PerformanceObserver(function (l) {
l.getEntries().forEach(function (r) {
if (rsCount >= 3) return;
if (r.duration < 500) return;
if (!r.name || /\b(harvv\.com|cloudflare|googleads|facebook|datadog)\b/.test(r.name)) return;
var path = '';
try { path = scrubQuery((new URL(r.name)).pathname.slice(0, 100)); } catch (_) { path = (r.name || '').slice(0, 100); }
rsCount++;
push("rs", {
ms: Math.round(r.duration),
sz: r.transferSize || undefined,
t: (r.initiatorType || 'other').slice(0, 8),
u: path,
});
});
}).observe({ type: 'resource', buffered: true });
} catch (_) {}
// 2026-05-06 (Tier 2) — Filter / search refinement chain.
// Fires when user clicks a filter button or refines a search input.
// Emits sequence-counter and query LENGTH only (not text content).
// Customer signals: data-filter attribute, role=button inside .filter-*,
// form submit to a search-like input. ~120 bytes.
(function trackFilterAndSearch() {
var filterCount = 0, searchRefineCount = 0, lastQLen = 0;
addEventListener('click', function (e) {
try {
var t = e.target;
if (!t || !t.closest) return;
var fc = t.closest('[data-filter], [data-facet], .filter-btn, [role="button"][aria-pressed]');
if (fc && filterCount++ < 20) {
push("fl", { kind: 'f', n: filterCount, el: eid(fc) });
}
} catch (_) {}
}, { passive: true, capture: true });
addEventListener('input', function (e) {
try {
var t = e.target;
if (!t || t.tagName !== 'INPUT') return;
var ty = (t.type || '').toLowerCase();
var name = (t.name || t.id || '').toLowerCase();
if (ty !== 'search' && !/search|query|q\b/.test(name)) return;
var len = (t.value || '').length;
if (len === 0) return;
if (Math.abs(len - lastQLen) > 2 && searchRefineCount++ < 10) {
push("fl", { kind: 's', qlen: len, n: searchRefineCount });
}
lastQLen = len;
} catch (_) {}
}, { passive: true, capture: true });
})();
// 2026-05-06 (Tier 2) — Pinch zoom + pull-to-refresh detection (mobile).
// Pinch zoom = "text too small / image not legible" frustration signal.
// Pull-to-refresh = "I think this page is broken or stale."
// Both fire as `pz` events. Mobile-only (gated on touchstart support).
// Capped at 3/page.
try {
if ('ontouchstart' in window) {
var pzCount = 0;
var pinchStart = 0, lastTouchStartY = 0;
addEventListener('touchstart', function (e) {
try {
if (e.touches.length === 2) {
var t1 = e.touches[0], t2 = e.touches[1];
pinchStart = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
} else if (e.touches.length === 1) {
lastTouchStartY = e.touches[0].clientY;
}
} catch (_) {}
}, { passive: true });
addEventListener('touchmove', function (e) {
try {
if (e.touches.length === 2 && pinchStart > 0) {
var t1 = e.touches[0], t2 = e.touches[1];
var d = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
if (Math.abs(d - pinchStart) > 60 && pzCount++ < 3) {
push("pz", { kind: 'pinch' });
pinchStart = 0;
}
} else if (e.touches.length === 1 && lastTouchStartY > 0 && (window.scrollY || 0) === 0) {
var pull = e.touches[0].clientY - lastTouchStartY;
if (pull > 120 && pzCount++ < 3) {
push("pz", { kind: 'ptr' });
lastTouchStartY = 0;
}
}
} catch (_) {}
}, { passive: true });
}
} catch (_) {}
// 2026-05-06 (Tier 3) — UA Client Hints. Modern replacement for parsing
// navigator.userAgent. getHighEntropyValues is async; we attach the brand
// array (always available sync) + the deferred model/platformVersion in
// a separate `uah` event when the promise resolves. Chromium-only — Safari
// and Firefox don't expose userAgentData. ~120 bytes.
try {
if (navigator.userAgentData && typeof navigator.userAgentData.getHighEntropyValues === 'function') {
var brand = (navigator.userAgentData.brands || []).filter(function (b) {
return b.brand && !/Not.?A.?Brand/i.test(b.brand);
})[0];
navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'mobile', 'model']).then(function (h) {
push("uah", {
b: brand && brand.brand && brand.brand.slice(0, 20),
m: h.mobile ? 1 : undefined,
pf: (h.platform || '').slice(0, 16),
pv: (h.platformVersion || '').slice(0, 16),
md: (h.model || '').slice(0, 24),
});
}).catch(function () {});
}
} catch (_) {}
// 2026-05-06 (Tier 3) — Service worker state. One bit: is the customer's
// site currently controlled by a service worker? Diagnostic value for
// "why is my page serving stale content?" / "why does it work offline?"
// questions. ~30 bytes.
try {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
push("sw", { c: 1 });
}
} catch (_) {}
// --- ReportingObserver (deprecation + browser intervention signals) ---
// Catches deprecated API usage and browser interventions (e.g. blocked heavy ads).
// Supported in Chrome 69+, Edge 79+, Firefox 115+, Safari 16.4+.
try {
if (window.ReportingObserver) {
var roc = 0;
new ReportingObserver(function (reports) {
reports.forEach(function (r) {
if (roc++ < 3) push("ro", { t: r.type, m: (r.body.message || "").slice(0, 80), src: (r.body.sourceFile || "").replace(/.*\//, "").slice(0, 30) });
});
}, { types: ["deprecation", "intervention"], buffered: true }).observe();
}
} catch (_) {}
// --- Network Monitoring (fetch + resource) ---
// Captures full URL, initiator script, request type, timing — like Chrome DevTools Network tab
// Scrub query strings on paths known to carry user-generated content.
// Keys with these names are replaced with `[scrubbed]` so a GET /api/search?q=my+private+question
// becomes /api/search?q=[scrubbed]. POST bodies are never captured so those are safe.
var SENSITIVE_QS_KEYS = /^(q|query|search|s|email|password|token|code|secret|message|text|content|chat|prompt|question)$/i;
function scrubQuery(pathWithQuery) {
var qIdx = pathWithQuery.indexOf("?");
if (qIdx === -1) return pathWithQuery;
var path = pathWithQuery.slice(0, qIdx);
var qs = pathWithQuery.slice(qIdx + 1);
var parts = qs.split("&").map(function (pair) {
var eq = pair.indexOf("=");
if (eq === -1) return pair;
var k = pair.slice(0, eq);
return SENSITIVE_QS_KEYS.test(k) ? k + "=[scrubbed]" : pair;
});
return path + "?" + parts.join("&");
}
if (window.fetch) {
var oF = window.fetch, slowCount = 0, errCount = 0;
window.fetch = function (input, init) {
var t0 = performance.now();
var url = (typeof input === "string") ? input : (input && input.url) || "";
var fullUrl = "";
var path = "";
try {
var u = new URL(url, location.origin);
var scrubbedSearch = scrubQuery(u.pathname + u.search).slice(u.pathname.length);
fullUrl = u.origin !== location.origin ? (u.origin + scrubQuery(u.pathname + u.search)).substring(0, 200) : (u.pathname + scrubbedSearch).substring(0, 80);
path = u.pathname.substring(0, 80);
} catch (_) {
fullUrl = scrubQuery(url).substring(0, 200);
path = url.split("?")[0].substring(0, 80);
}
// Classify request type from extension/path (no PII)
var ext = path.split(".").pop().toLowerCase();
var rt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg" || ext === "gif" || ext === "ico") ? "asset"
: (path.indexOf("/api/") > -1 || path.indexOf("/graphql") > -1 || path.indexOf("/cart") > -1) ? "api" : "page";
// Detect method
var method = (init && init.method) ? init.method.toUpperCase() : "GET";
// Detect initiator from call stack
var initiator = "";
try {
var stack = new Error().stack || "";
var lines = stack.split("\n");
// Find first line that isn't this pixel file
for (var li = 1; li < lines.length && li < 8; li++) {
var line = lines[li];
if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
// Extract filename:line from stack frame
var fileMatch = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
if (fileMatch) {
initiator = fileMatch[1].replace(/.*\//, "").substring(0, 60);
break;
}
// Fallback: just grab last path segment
var pathMatch = line.match(/([^\/()\s]+\.js[:\d]*)/);
if (pathMatch) {
initiator = pathMatch[1].substring(0, 60);
break;
}
}
}
} catch (_) {}
return oF.apply(this, arguments).then(function (r) {
var ms = Math.round(performance.now() - t0);
if (r.status >= 400 && errCount++ < 5) push("nf", { s: r.status, ms: ms, u: fullUrl, rt: rt, e: 1, mt: method, ini: initiator || undefined });
else if (ms > 2000 && slowCount++ < 5) push("nf", { s: r.status, ms: ms, slow: 1, u: fullUrl, rt: rt, mt: method, ini: initiator || undefined });
return r;
}).catch(function (err) {
var ms = Math.round(performance.now() - t0);
if (errCount++ < 5) push("nf", { s: 0, ms: ms, u: fullUrl, rt: rt, e: 1, to: ms > 30000 ? 1 : 0, m: (err.message || "").substring(0, 40), mt: method, ini: initiator || undefined });
throw err;
});
};
}
// Track XHR (XMLHttpRequest) — many sites still use it (jQuery, legacy code)
if (window.XMLHttpRequest) {
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._pxMethod = (method || "GET").toUpperCase();
this._pxUrl = "";
this._pxPath = "";
try {
var u = new URL(url, location.origin);
var scrubbed = scrubQuery(u.pathname + u.search);
this._pxUrl = u.origin !== location.origin ? (u.origin + scrubbed).substring(0, 200) : scrubbed.substring(0, 80);
this._pxPath = u.pathname.substring(0, 80);
} catch (_) {
this._pxUrl = scrubQuery(String(url)).substring(0, 200);
this._pxPath = String(url).split("?")[0].substring(0, 80);
}
var ext = this._pxPath.split(".").pop().toLowerCase();
this._pxRt = (ext === "js" || ext === "css" || ext === "woff2" || ext === "png" || ext === "jpg" || ext === "svg") ? "asset"
: (this._pxPath.indexOf("/api/") > -1 || this._pxPath.indexOf("/graphql") > -1 || this._pxPath.indexOf("/cart") > -1) ? "api" : "page";
// Detect initiator
this._pxIni = "";
try {
var stack = new Error().stack || "";
var lines = stack.split("\n");
for (var li = 1; li < lines.length && li < 8; li++) {
var line = lines[li];
if (line.indexOf("pixel.js") === -1 && line.indexOf("pixel.min.js") === -1) {
var match = line.match(/(?:at\s+.*?\(|@)(.*?:\d+)/);
if (match) { this._pxIni = match[1].replace(/.*\//, "").substring(0, 60); break; }
var pm = line.match(/([^\/()\s]+\.js[:\d]*)/);
if (pm) { this._pxIni = pm[1].substring(0, 60); break; }
}
}
} catch (_) {}
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
var t0 = performance.now();
xhr.addEventListener("loadend", function () {
var ms = Math.round(performance.now() - t0);
if (xhr.status >= 400 && errCount++ < 5) {
push("nf", { s: xhr.status, ms: ms, u: xhr._pxUrl, rt: xhr._pxRt, e: 1, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
} else if (ms > 2000 && slowCount++ < 5) {
push("nf", { s: xhr.status, ms: ms, slow: 1, u: xhr._pxUrl, rt: xhr._pxRt, mt: xhr._pxMethod, ini: xhr._pxIni || undefined, tp: "xhr" });
}
});
return origSend.apply(this, arguments);
};
}
// Track failed resource loads (images, scripts, stylesheets)
window.addEventListener("error", function (e) {
var t = e.target;
if (t && t.tagName && (t.tagName === "IMG" || t.tagName === "SCRIPT" || t.tagName === "LINK")) {
var src = (t.src || t.href || "").substring(0, 200);
if (src && ec++ < 5) push("nf", { s: 0, u: src, rt: "asset", e: 1, tag: t.tagName.toLowerCase() });
}
}, true);
// --- Explicit Conversion API ---
// Sites can call window.__harvv_convert(value) on order confirmation
window.__harvv_convert = function (value) {
if (!cvFired) { push("cv", { p: location.pathname, v: typeof value === "number" ? value : undefined }); cvFired = true; }
};
// ─── Page-Audit Signals (Phase 2 + Dogfooding, 2026-05-06) ─────────
// wc / ma / jl fire ONCE per pageview, not per session.
// PII rules per signal documented inline. Strict design: numbers > text,
// counts > content, schema-keyword constants > raw schema.
//
// Auth-pattern URLs and `data-harvv-private` ancestors short-circuit
// any signal that would touch DOM text. Defense-in-depth scrubbing of
// emails/phones/long digit runs on every text field that ships.
function isPrivatePage() {
try {
if (document.body && document.body.matches && document.body.matches('[data-harvv-private="true"]')) return true;
if (document.body && document.body.closest && document.body.closest('[data-harvv-private="true"]')) return true;
var p = (location.pathname || '').toLowerCase();
if (/^\/(login|signup|sign-?in|sign-?up|account|admin|dashboard|app|billing|settings|profile|my-?account|reset-?password|forgot-?password|checkout|cart|orders?|wp-admin)(\/|$)/.test(p)) return true;
} catch (_) {}
return false;
}
function scrubText(s) {
return String(s || '')
.replace(/[\w._%+-]+@[\w.-]+\.[a-z]{2,}/gi, '[email]')
.replace(/\+?\d[\d\s().-]{8,}\d/g, '[phone]')
.replace(/\b\d{10,}\b/g, '[num]');
}
// wc — word count of the primary content body. Numbers only. Used for
// "expected reading time" baselines (no_consumption pattern). Never sends
// the text itself.
function emitWordCount() {
try {
if (isPrivatePage()) return;
var sel = 'article, [role="article"], main article, main .post, main .entry, .post-content, .entry-content, [itemprop="articleBody"]';
var node = document.querySelector(sel) || document.querySelector('main') || document.body;
if (!node) return;
// textContent is read locally; we only ship the COUNT.
var txt = (node.textContent || '').replace(/\s+/g, ' ').trim();
if (!txt) return;
var n = txt.split(/\s+/).length;
if (n > 10000) n = 10000;
var sl = node.tagName ? node.tagName.toLowerCase() : 'body';
if (node.id) sl += '#' + String(node.id).slice(0, 20);
else if (node.className && typeof node.className === 'string') {
var cls = node.className.split(/\s+/)[0];
if (cls) sl += '.' + cls.slice(0, 20);
}
push('wc', { n: n, sl: sl });
} catch (_) {}
}
// ma — meta audit. Tags in <head> are public-by-spec (sent to crawlers).
// Defense in depth: scrub emails/phones, cap text at 200 chars, skip on
// private/auth pages anyway. Sends both length AND truncated content for
// SEO QA — Nina needs to see WHY a title is wrong, not just that it's wrong.
function emitMetaAudit() {
try {
if (isPrivatePage()) return;
var t = document.title || '';
var dq = document.querySelector('meta[name="description"]');
var d = dq ? (dq.getAttribute('content') || '') : '';
var h1s = document.querySelectorAll('h1');
var h1t = h1s.length ? (h1s[0].textContent || '').trim() : '';
var ogt = document.querySelector('meta[property="og:title"]');
var ogd = document.querySelector('meta[property="og:description"]');
var ogi = document.querySelector('meta[property="og:image"]');
var cn = document.querySelector('link[rel="canonical"]');
var rb = document.querySelector('meta[name="robots"]');
// Count duplicates (the LinkedIn-no-image bug from 2026-05-06 — we shipped
// start.html with TWO og:title and og:description tags. Track here so we
// can detect on customer sites too.)
var ogtN = document.querySelectorAll('meta[property="og:title"]').length;
var ogdN = document.querySelectorAll('meta[property="og:description"]').length;
var ogiN = document.querySelectorAll('meta[property="og:image"]').length;
push('ma', {
tl: t.length,
tt: scrubText(t).slice(0, 200),
dl: d.length,
dt: scrubText(d).slice(0, 200),
h1n: h1s.length,
h1t: scrubText(h1t).slice(0, 200),
ogt: ogt ? 1 : 0,
ogd: ogd ? 1 : 0,
ogi: ogi ? 1 : 0,
ogtN: ogtN > 1 ? ogtN : undefined,
ogdN: ogdN > 1 ? ogdN : undefined,
ogiN: ogiN > 1 ? ogiN : undefined,
cn: cn ? 1 : 0,
ni: rb && /noindex/i.test(rb.getAttribute('content') || '') ? 1 : 0,
});
} catch (_) {}
}
// jl — JSON-LD schema detect. PII-safe: ships only schema.org @type
// keywords ("Article", "Product", etc.) — constants, never user data.
// Never sends the schema content itself.
function emitSchemaDetect() {
try {
var scripts = document.querySelectorAll('script[type="application/ld+json"]');
var types = [];
var errors = 0;
function takeType(item) {
if (!item) return;
if (item['@type']) {
var raw = Array.isArray(item['@type']) ? item['@type'][0] : item['@type'];
var t = String(raw || '').slice(0, 30);
if (t && types.indexOf(t) < 0) types.push(t);
}
if (Array.isArray(item['@graph'])) item['@graph'].forEach(takeType);
}
for (var i = 0; i < scripts.length; i++) {
try {
var data = JSON.parse(scripts[i].textContent || '{}');
var arr = Array.isArray(data) ? data : [data];
for (var j = 0; j < arr.length; j++) takeType(arr[j]);
} catch (_) { errors++; }
}
push('jl', { n: scripts.length, ts: types.slice(0, 10), err: errors || undefined });
} catch (_) {}
}
// ─── Visual-bug signals (Phase 2.5, 2026-05-06) ────────────────────
// The static + viewport-overflow QA misses a class of bug: stuff that
// renders but renders WRONG — text clipped, cards stacked too tight,
// logo squashed, image wrong-aspect. We catch these in the field by
// asking the browser, on real devices.
//
// PII rules: selectors + numeric pixel deltas only. Never DOM text,
// never URLs, never alt text. All gated by isPrivatePage().
// Build a short, safe selector for an element (PII-free).
function _vbSel(el) {
if (!el || !el.tagName) return '';
var t = el.tagName.toLowerCase();
var id = el.id ? '#' + String(el.id).slice(0, 24) : '';
var cls = '';
if (!id && el.className && typeof el.className === 'string') {
var c = el.className.trim().split(/\s+/)[0];
if (c) cls = '.' + c.slice(0, 24);
}
return (t + id + cls).slice(0, 60);
}
// vw — visual warnings. Walks layout-critical selectors and emits any of:
// clip_x — content overflows horizontally (text or grid clipped)
// clip_y — content overflows vertically beyond an overflow:hidden box
// zero_h — element has zero rendered height despite display:block
// tight_gap — adjacent <section> peers separated by <8px (stacked)
// overlap — two siblings have intersecting bounding rects
function emitVisualWarnings() {
try {
if (isPrivatePage()) return;
var checks = [];
var sel = 'section, .testimonial, .audience-card, .customer-logo, .hero-cta, .price-card, .finding, .stat-card, .view-card';
var nodes = document.querySelectorAll(sel);
// Per-element clip + zero-height scan
for (var i = 0; i < nodes.length && checks.length < 12; i++) {
var el = nodes[i];
var r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
var cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
// clip_x: scrollWidth exceeds clientWidth (text or grid cut off)
if (el.scrollWidth > el.clientWidth + 2) {
checks.push({ k: 'clip_x', s: _vbSel(el), d: el.scrollWidth - el.clientWidth });
}
// zero_h: zero rendered height but display says it should have content
if (r.height < 4 && r.width > 80 && cs.display !== 'inline') {
checks.push({ k: 'zero_h', s: _vbSel(el), d: Math.round(r.width) });
}
}
// Section gap: adjacent <section> elements with too-tight spacing.
// Detection 2026-05-06 audit revealed that raw bounding-box gap alone
// is a noisy signal — Shopify (and most CMSes) wrap each section in
// a thin div, and the visible content has its own padding. Two
// sections with 0px outer gap can still have 36px of breathing room
// because of internal padding. Conversely, a 5px outer gap can be a
// real merge if both sections have zero internal padding AND share
// a background.
//
// To make this filterable on the server without re-rendering each
// page, ship along:
// d — outer bounding-box gap (px)
// tp — A's trailing internal pad (A.bottom - last visible child bottom)
// lp — B's leading internal pad (first visible child top - B.top)
// bg — 'm' if backgrounds match, 'd' if differ (visual divider)
// im — 1 if either side contains <img>/<video>/<picture> (full-bleed media)
// Server can then derive visible_separation = d + tp + lp and decide.
function _measureChild(el, which) {
// which='last' → bottom of last visible child relative to el.top
// which='first' → top of first visible child relative to el.top
try {
var elRect = el.getBoundingClientRect();
var nodes = el.querySelectorAll('*');
var bestBottom = null, bestTop = null;
for (var k = 0; k < nodes.length; k++) {
var n = nodes[k];
var nr = n.getBoundingClientRect();
if (nr.width < 1 || nr.height < 1) continue;
if (which === 'last') {
if (bestBottom == null || nr.bottom > bestBottom) bestBottom = nr.bottom;
} else {
if (bestTop == null || nr.top < bestTop) bestTop = nr.top;
}
}
if (which === 'last') return bestBottom != null ? Math.max(0, Math.round(elRect.bottom - bestBottom)) : 0;
return bestTop != null ? Math.max(0, Math.round(bestTop - elRect.top)) : 0;
} catch (_) { return 0; }
}
var sections = document.querySelectorAll('section');
for (var j = 0; j < sections.length - 1 && checks.length < 16; j++) {
var aSec = sections[j], bSec = sections[j+1];
var aBox = aSec.getBoundingClientRect();
var bBox = bSec.getBoundingClientRect();
if (aBox.height < 1 || bBox.height < 1) continue;
var gap = bBox.top - aBox.bottom;
if (gap >= 0 && gap < 12) {
var aCs = window.getComputedStyle(aSec);
var bCs = window.getComputedStyle(bSec);
var bgA = aCs.backgroundColor || '';
var bgB = bCs.backgroundColor || '';
var isTransparent = function (c) { return !c || c === 'transparent' || /rgba\([^)]*,\s*0\)/.test(c); };
var bgMatch = bgA === bgB || (isTransparent(bgA) && isTransparent(bgB));
// 2026-05-06: split "either has media" into per-side flags.
// The discriminating case is "small text bar (no media) bumping
// a content block (with media)" → real bug. Two full-bleed
// media sections touching is design intent. We need both flags
// to tell those apart at aggregation time without AI.
var aHasImg = !!aSec.querySelector('img,picture,video');
var bHasImg = !!bSec.querySelector('img,picture,video');
checks.push({
k: 'tight_gap',
s: _vbSel(aSec) + '>' + _vbSel(bSec),
d: Math.round(gap),
tp: _measureChild(aSec, 'last'),
lp: _measureChild(bSec, 'first'),
bg: bgMatch ? 'm' : 'd',
ia: aHasImg ? 1 : 0,
ib: bHasImg ? 1 : 0,
ah: Math.round(aBox.height), // A height — small bars (<120px) flag as cross-role merges
bh: Math.round(bBox.height), // B height
});
}
}
if (checks.length) push('vw', { c: checks });
} catch (_) {}
}
// im — image meta. Per important image: natural vs rendered dims, complete
// flag, applied filter (truncated). Detects: stretched, broken, filter-
// wrecked logos. PII-safe: numbers only, never URL or alt text.
function emitImageMeta() {
try {
if (isPrivatePage()) return;
var entries = [];
var imgs = document.querySelectorAll('img');
for (var i = 0; i < imgs.length && entries.length < 12; i++) {
var im = imgs[i];
var r = im.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
// Skip tiny/decorative pixels (1x1 trackers, etc.)
if (r.width < 16 && r.height < 16) continue;
var info = {
sl: _vbSel(im),
nw: im.naturalWidth || 0,
nh: im.naturalHeight || 0,
rw: Math.round(r.width),
rh: Math.round(r.height),
c: im.complete ? 1 : 0,
};
// Aspect-ratio mismatch — squashed/stretched
if (im.naturalWidth > 0 && im.naturalHeight > 0 && r.width > 0 && r.height > 0) {
var natRatio = im.naturalWidth / im.naturalHeight;
var renRatio = r.width / r.height;
var dr = Math.abs(natRatio - renRatio) / Math.max(natRatio, 0.1);
if (dr > 0.10) info.ar = +dr.toFixed(2);
}
// Computed filter — detects "brightness(0) invert(1)" wrecking a
// light-on-dark logo, etc.
try {
var cs = window.getComputedStyle(im);
if (cs.filter && cs.filter !== 'none') info.f = cs.filter.slice(0, 80);
} catch (_) {}
// Broken-load flag
if (im.complete && (im.naturalWidth === 0 || im.naturalHeight === 0)) info.broken = 1;
entries.push(info);
}
if (entries.length) push('im', { i: entries });
} catch (_) {}
}
function runPageAudits() {
emitWordCount(); emitMetaAudit(); emitSchemaDetect();
// Visual-bug audits run twice: once on load (early/best-effort), once
// 2.2s later (after deferred scripts settle, fonts swap, lazy images
// resolve). The detection layer dedupes by (page_url, k, s).
emitVisualWarnings(); emitImageMeta();
setTimeout(function () { emitVisualWarnings(); emitImageMeta(); }, 2200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runPageAudits, { once: true });
} else {
setTimeout(runPageAudits, 50);
}
// --- Unload: Final Engagement + Scroll Kinematics + Flush ---
window.addEventListener("pagehide", function () {
if (engageStart) totalEngage += Date.now() - engageStart;
push("ul", { eg: totalEngage }); // unload with total engagement ms
// Flush scroll kinematics on exit
if (totalDist > 0) {
var avgVel = velSamples ? velSum / velSamples : 0;
push("sk", {
v: [Math.round(minVel * 1000) / 1000, Math.round(maxVel * 1000) / 1000], // px/ms
r: reversals,
p: pauses.slice(0, 10), // Cap at 10 pause positions
d: Math.round(totalDist),
// Phase 2 (2026-05-06) — bucketed velocity in px (px/s thresholds).
// mxv/avv = max/avg in px/s. kd = skim distance (>200 px/s),
// rd = active-read distance (50-200 px/s). Used by content:scroll_speed_skim
// and content:shallow_read patterns. PII-safe — pure kinematics.
mxv: Math.round(maxVel * 1000),
avv: Math.round(avgVel * 1000),
kd: Math.round(skimDist) || undefined,
rd: Math.round(readDist) || undefined,
});
}
flush();
});
})();