PageSourceSearch

https://r.evaboot.com/t.js

js evaboot.com collected 2026-09-24 10:27:21 UTC 97,635 bytes, 2,118 lines download raw bytes

1(function () {
2    "use strict";
3
4    // Per-request server replacement targets — see apps/ingest/src/tracker-template.ts.
5    // Leave these lines EXACTLY as-is so applyCookieDomain / applyTrackerOrigins can find them.
6    var COOKIE_DOMAIN = null;
7    var TRACKER_ORIGINS = [];
8
9    // Per-request server replacement target — see apps/ingest/src/tracker-template.ts
10    // (applyIngestOrigin). A string constant survives minification, so even a
11    // self-hosted minified copy whose ?k= was stripped still knows the real
12    // beacon endpoint. Leave this line EXACTLY as-is.
13    var INGEST_ORIGIN = null;
14    var MARKETING_HOST = null;
15
16    function hostMatchesDomain(host, domain) {
17      if (!domain) return false;
18      return host === domain || host.endsWith("." + domain);
19    }
20
21    // Strip a trailing root dot from a hostname (FQDN form, e.g.
22    // "www.mailreach.co." → "www.mailreach.co"). Without this the trailing dot
23    // breaks both the cookie-domain match below AND ClickHouse's domain() on the
24    // sent URL, so those (rare) hits land with no domain and a host-only cookie.
25    function fvStripTrailingDot(h) { return h ? h.replace(/\.+$/, "") : h; }
26    function pageUrl() {
27      try {
28        var u = new URL(location.href);
29        u.hostname = fvStripTrailingDot(u.hostname);
30        return u.href;
31      } catch (_) { return location.href; }
32    }
33
34    // Element discovery chain. Optimizers (WP Rocket Delay-JS, Combine/Minify,
35    // LiteSpeed, Perfmatters) move src→data-rocket-src / data-src and may
36    // self-host a copy under the customer's domain — but the rewritten URL
37    // still contains "/t.js", so the selector matches. currentScript is null
38    // once the script runs deferred from an event handler.
39    var script =
40      document.currentScript ||
41      document.querySelector('script[src*="/t.js"]') ||
42      document.querySelector('script[data-rocket-src*="/t.js"]') ||
43      document.querySelector('script[data-src*="/t.js"]');
44    if (!script) return;
45
46    // Resolve the script URL from whichever attribute carried it.
47    var scriptUrl = "";
48    try {
49      scriptUrl =
50        script.src ||
51        script.getAttribute("data-rocket-src") ||
52        script.getAttribute("data-src") ||
53        "";
54    } catch (_) {}
55
56    // Key precedence: ?k= from the resolved URL → data-key attr → window.__fvKey.
57    // data-key is the load-bearing fallback: optimizers that strip the query
58    // string preserve tag attributes (verified in the otpe.fr incident).
59    var apiKey = null;
60    var keyFromUrl = false;
61    try {
62      if (scriptUrl) {
63        apiKey = new URL(scriptUrl, location.href).searchParams.get("k");
64        if (apiKey) keyFromUrl = true;
65      }
66    } catch (_) {}
67    if (!apiKey) {
68      try { apiKey = script.getAttribute("data-key"); } catch (_) {}
69    }
70    if (!apiKey) {
71      try { if (window.__fvKey) apiKey = window.__fvKey; } catch (_) {}
72    }
73    if (!apiKey) return;
74
75    // Origin precedence:
76    //   - key came from the URL's ?k= → the URL is canonical (CNAME/proxy
77    //     install): use its origin so first-party stays first-party.
78    //   - otherwise the URL is untrustworthy (self-hosted minified copy):
79    //     window.__fvOrigin (explicit override) → serve-time-baked INGEST_ORIGIN
80    //     → hardcoded canonical host. The last hop matters: WP Rocket fetches
81    //     t.js WITHOUT ?k= when self-hosting its minified copy, so INGEST_ORIGIN
82    //     stays null and only data-key survives (rubisvoyages.ch incident,
83    //     2026-09-01). Beaconing a custom-domain workspace to the canonical
84    //     host as a last resort beats dropping the traffic.
85    var origin = null;
86    if (keyFromUrl) {
87      try { origin = scriptUrl.replace(/\/t\.js(\?.*)?$/, ""); } catch (_) {}
88    }
89    if (!origin) {
90      try { origin = window.__fvOrigin || INGEST_ORIGIN || "https://db.fullvision.io"; } catch (_) { origin = INGEST_ORIGIN || "https://db.fullvision.io"; }
91    }
92    if (!origin) return;
93
94    var endpoint = origin + "/v1/i?api_key=" + encodeURIComponent(apiKey);
95
96    function getCookie(name) {
97      var match = document.cookie.match(
98        new RegExp("(^| )" + name + "=([^;]+)")
99      );
100      return match ? match[2] : null;
101    }
102
103    function setCookie(name, value, days) {
104      var expires = new Date(Date.now() + days * 864e5).toUTCString();
105      var domainSegment = hostMatchesDomain(fvStripTrailingDot(location.hostname), COOKIE_DOMAIN)
106        ? "; Domain=" + COOKIE_DOMAIN
107        : "";
108      document.cookie =
109        name + "=" + value +
110        "; expires=" + expires +
111        "; path=/; SameSite=Lax; Secure" +
112        domainSegment;
113    }
114
115    // ─── Opt-out: per-browser internal-traffic exclusion ───
116    // Visiting any tracked page with ?fv_optout=1 stores a first-party
117    // fv_optout=1 cookie (same Domain logic as fv_visitor_id); ?fv_optout=0
118    // clears it. While the cookie is present t.js sends NOTHING — no pageview,
119    // events, engagement, replay or experiment config/exposure — and leaves a
120    // no-op window.fv so customer code calling fv.variant()/fv.formSuccess()
121    // does not throw. Complements the workspace IP exclusion list (ingest).
122    var OPTOUT_COOKIE = "fv_optout";
123
124    // Pure decision kernel (unit-tested via extraction in
125    // tests/unit/tracker-optout.test.ts): the ?fv_optout= query param wins
126    // over the stored cookie. Returns {write: "1"|"0"|null, optedOut: bool}.
127    function __fv_optOutState(search, cookieValue) {
128      var m = /[?&]fv_optout=([^&#]*)/.exec(search || "");
129      if (m && m[1] === "1") return { write: "1", optedOut: true };
130      if (m && m[1] === "0") return { write: "0", optedOut: false };
131      return { write: null, optedOut: cookieValue === "1" };
132    }
133
134    function __fv_clearOptOutCookie() {
135      var past = "; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax; Secure";
136      // Clear both the host-only and the Domain-scoped variant: the cookie may
137      // have been written before/after cookie_domain was configured.
138      document.cookie = OPTOUT_COOKIE + "=" + past;
139      if (hostMatchesDomain(fvStripTrailingDot(location.hostname), COOKIE_DOMAIN)) {
140        document.cookie = OPTOUT_COOKIE + "=" + past + "; Domain=" + COOKIE_DOMAIN;
141      }
142    }
143
144    var __fv_optout = { write: null, optedOut: false };
145    try {
146      __fv_optout = __fv_optOutState(location.search, getCookie(OPTOUT_COOKIE));
147      if (__fv_optout.write === "1") {
148        // 400 days = the Chrome/Safari ceiling for cookie lifetimes.
149        setCookie(OPTOUT_COOKIE, "1", 400);
150        try { console.info("[FullVision] This browser is now excluded from tracking. Visit with ?fv_optout=0 to undo."); } catch (_) {}
151      } else if (__fv_optout.write === "0") {
152        __fv_clearOptOutCookie();
153        try { console.info("[FullVision] Tracking re-enabled for this browser."); } catch (_) {}
154      }
155    } catch (_) {}
156    if (__fv_optout.optedOut) {
157      window.fv = window.fv || {};
158      window.fv.variant = function () { return 'control'; };
159      window.fv.formSuccess = function () {};
160      window.fv.optedOut = true;
161      return;
162    }
163
164    var COOKIE_NAME = "fv_visitor_id";
165    var GADS_COOKIE = "fv_gads";
166
167    function fvGadsShouldReplace(p) {
168      var hasClick = !!(p.gclid || p.gbraid || p.wbraid);
169      return hasClick || !!(p.gad_source && p.gad_campaignid);
170    }
171
172    function fvGadsCompact(fields) {
173      function put(obj, key, value) {
174        if (value) obj[key] = value;
175      }
176      function build(keepUa, keepGad) {
177        var obj = {};
178        put(obj, "gclid", fields.gclid);
179        put(obj, "gbraid", fields.gbraid);
180        put(obj, "wbraid", fields.wbraid);
181        if (keepGad) {
182          put(obj, "gad_source", fields.gad_source);
183          put(obj, "gad_campaignid", fields.gad_campaignid);
184        }
185        if (keepUa) put(obj, "ua", fields.ua);
186        obj.captured_at = fields.captured_at;
187        return JSON.stringify(obj);
188      }
189      var json = build(true, true);
190      if (encodeURIComponent(json).length > 2048) json = build(false, true);
191      if (encodeURIComponent(json).length > 2048) json = build(false, false);
192      return json;
193    }
194
195    function fvGadsParams() {
196      try {
197        var sp = new URL(location.href).searchParams;
198        function clean(v) {
199          if (v == null) return "";
200          v = String(v).trim();
201          return v;
202        }
203        return {
204          gclid: clean(sp.get("gclid")),
205          gbraid: clean(sp.get("gbraid")),
206          wbraid: clean(sp.get("wbraid")),
207          gad_source: clean(sp.get("gad_source")),
208          gad_campaignid: clean(sp.get("gad_campaignid"))
209        };
210      } catch (_) {
211        return {};
212      }
213    }
214
215    function maybeWriteGadsCookie() {
216      var p = fvGadsParams();
217      if (!fvGadsShouldReplace(p)) return;
218      var json = fvGadsCompact({
219        gclid: p.gclid,
220        gbraid: p.gbraid,
221        wbraid: p.wbraid,
222        gad_source: p.gad_source,
223        gad_campaignid: p.gad_campaignid,
224        ua: navigator.userAgent,
225        captured_at: Date.now()
226      });
227      setCookie(GADS_COOKIE, encodeURIComponent(json), 90);
228    }
229
230    function readGadsPersist() {
231      var raw = getCookie(GADS_COOKIE);
232      if (!raw) return null;
233      try {
234        return JSON.parse(decodeURIComponent(raw));
235      } catch (_) {
236        return null;
237      }
238    }
239
240    var LS_KEY = "fv_visitor_id";
241
242    function readLocal() {
243      try { return window.localStorage.getItem(LS_KEY); } catch (_) { return null; }
244    }
245    function writeLocal(v) {
246      try { window.localStorage.setItem(LS_KEY, v); } catch (_) {}
247    }
248
249    function uuidv4() {
250      if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
251      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
252        var r = Math.random() * 16 | 0;
253        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
254      });
255    }
256
257    function isUuid(v) {
258      return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
259    }
260
261    function fvHash(str) {
262      var h = 5381, s = String(str == null ? '' : str);
263      for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) | 0; }
264      return (h >>> 0).toString(16);
265    }
266
267    var visitorId = getCookie(COOKIE_NAME) || readLocal();
268    if (!visitorId) {
269      visitorId =
270        "fv_" +
271        Math.random().toString(36).substring(2) +
272        Math.random().toString(36).substring(2);
273      setCookie(COOKIE_NAME, visitorId, 365);
274      writeLocal(visitorId);
275    }
276
277    // ─── T24: cross-domain inbound adoption (spec §E) ───
278    // Read fv_vid from location.hash (NOT searchParams — fragments never reach
279    // origin servers / Referer headers / 3rd-party analytics). Must run BEFORE
280    // the initial pageview send so the first hit on the destination domain
281    // upserts against the same visitor row as the source domain.
282    try {
283      var __fv_hash = (location.hash || '').replace(/^#/, '');
284      var __fv_m = __fv_hash.match(/(?:^|&)fv_vid=([^&]+)/);
285      var __fv_inboundVid = __fv_m ? decodeURIComponent(__fv_m[1]) : null;
286      if (__fv_inboundVid && /^fv_[a-z0-9]{10,}$/.test(__fv_inboundVid)) {
287        visitorId = __fv_inboundVid;
288        setCookie(COOKIE_NAME, visitorId, 365);
289        writeLocal(visitorId);
290        var __fv_cleaned = __fv_hash.replace(/(?:^|&)fv_vid=[^&]+/, '').replace(/^&/, '');
291        history.replaceState(history.state, '', location.pathname + location.search + (__fv_cleaned ? '#' + __fv_cleaned : ''));
292      }
293    } catch (_) {}
294
295    var __fv_pageview_id = uuidv4();
296
297    // ─── A/B experiments: config fetch + deterministic assignment (spec §5) ───
298    // Fail-open: any error → no experiments → everyone sees control.
299    var __fv_assignments = {};   // {experimentKey: variantKey} — stamped on every event
300    var __fv_exposed = {};       // {experimentKey + '|' + pageviewId: true} — once-per-pageview guard
301
302    function __fv_fnv1a32(str) {
303      var h = 0x811c9dc5, s = String(str == null ? '' : str);
304      for (var i = 0; i < s.length; i++) {
305        h ^= s.charCodeAt(i);
306        h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
307      }
308      return h >>> 0;
309    }
310
311    function __fv_bucket(hashAttr, key, variations) {
312      var h = __fv_fnv1a32(hashAttr + '|' + key);
313      var u = (h % 10000) / 10000, cum = 0;
314      for (var i = 0; i < variations.length; i++) {
315        cum += variations[i].weight;
316        if (u < cum) return variations[i].key;
317      }
318      return variations[variations.length - 1].key;
319    }
320
321    // Split-URL support (spec 2026-07-09 §3.4). ES5 inline of the tested
322    // reference helpers in lib/experiments/page-match.ts — keep in sync.
323    var EXP_CACHE_KEY = 'fv_exp_config';
324    var __fv_suppress_pv = false;     // sync redirect issued → skip the original page's $pageview
325    var __fv_redirect_decided = false;
326
327    // Preview mode (spec 2026-07-09 §4): ?__fv_variant=<experimentId>:<variantKey>
328    // force-applies that arm's transforms on the matching page — no exposure,
329    // no assignment stamping, no cache write, overrides bucketing. Parse
330    // failure → null → normal bucketing proceeds.
331    var __fv_preview = null;
332    var __fv_dom_ready_scheduled = {};
333    try {
334      var __fv_pm = (location.search || '').match(/[?&]__fv_variant=([^&]+)/);
335      if (__fv_pm) {
336        var __fv_pv = decodeURIComponent(__fv_pm[1]);
337        var __fv_ci = __fv_pv.indexOf(':');
338        if (__fv_ci > 0 && __fv_ci < __fv_pv.length - 1) {
339          __fv_preview = { expId: __fv_pv.slice(0, __fv_ci), variantKey: __fv_pv.slice(__fv_ci + 1) };
340        }
341      }
342    } catch (_) {}
343
344    function __fv_normPath(p) {
345      if (p && p.length > 1 && p.charAt(p.length - 1) === '/') return p.slice(0, -1);
346      return p || '/';
347    }
348    function __fv_matchesPage(pageUrl, currentHref) {
349      try {
350        var a = new URL(pageUrl), b = new URL(currentHref);
351        return a.hostname.toLowerCase() === b.hostname.toLowerCase() &&
352               __fv_normPath(a.pathname) === __fv_normPath(b.pathname);
353      } catch (_) { return false; }
354    }
355    function __fv_variantRedirectUrl(variantUrl, currentHref) {
356      var target = new URL(variantUrl), current = new URL(currentHref);
357      // Snapshot the variant's own keys first: a live has() check would flip
358      // true after the first value of a repeated key, dropping the rest.
359      var targetKeys = {};
360      target.searchParams.forEach(function (v, k) { targetKeys[k] = 1; });
361      current.searchParams.forEach(function (value, key) {
362        if (!targetKeys[key]) target.searchParams.append(key, value);
363      });
364      return target.toString();
365    }
366    function __fv_readExpCache() {
367      try {
368        var raw = localStorage.getItem(EXP_CACHE_KEY);
369        if (!raw) return null;
370        var parsed = JSON.parse(raw);
371        return (parsed && parsed.experiments) ? parsed.experiments : null;
372      } catch (_) { return null; }
373    }
374    function __fv_applyAssignments(experiments) {
375      for (var i = 0; i < experiments.length; i++) {
376        var exp = experiments[i];
377        if (!exp || !exp.key || !exp.variations) continue;
378        if (__fv_preview && exp.id === __fv_preview.expId) continue; // preview: no assignment stamping
379        // hash_attribute is fv_visitor_id in v1 (only supported attr).
380        var variant = __fv_bucket(visitorId, exp.key, exp.variations);
381        __fv_assignments[exp.key] = variant;
382        // data-variant-<key> on <html> for flicker-free CSS variants (Path B).
383        try { document.documentElement.setAttribute('data-variant-' + exp.key, variant); } catch (_) {}
384      }
385    }
386    // Exposure for the redirect path rides sendPayload (keepalive fetch,
387    // beacon fallback) — the same transport as every other event. keepalive
388    // survives the immediate location.replace by spec, and beacon-FIRST is a
389    // trap: privacy extensions eat sendBeacon while returning true (verified
390    // live 2026-07-09), which defeats any fallback and silently loses the
391    // exposure. Same once-per-pageview guard as fv.variant().
392    function __fv_sendExposureBeacon(key, variant) {
393      var guard = key + '|' + __fv_pageview_id;
394      if (__fv_exposed[guard]) return;
395      __fv_exposed[guard] = true;
396      sendPayload(JSON.stringify({
397        kind: 'event',
398        visitor_id: visitorId,
399        event_name: 'Experiment Exposed',
400        properties: {
401          pageview_id: __fv_pageview_id,
402          session_id: getSessionId(),
403          experiment: key,
404          variant: variant,
405          experiments: __fv_assignments,
406        },
407      }));
408    }
409
410    // DOM-patch apply (spec 2026-07-09 §4). ES5 inline of the tested
411    // reference helpers in lib/experiments/apply-transforms.ts — keep in sync.
412    // Fail-open per transform: a selector matching nothing is a silent no-op
413    // (exposure already sent — honest denominator). Every change type tags
414    // its elements and tagged elements are skipped — the tag is both the
415    // double-apply guard and the MutationObserver loop guard (textContent/
416    // innerHTML writes fire childList mutations; without the skip, the
417    // observer would re-trigger its own re-apply forever).
418    function __fv_applyTransforms(expKey, transforms) {
419      for (var i = 0; i < transforms.length; i++) {
420        try {
421          var t = transforms[i];
422          if (!t || !t.selector) continue;
423          var tag = 'data-fv-patched-' + expKey.replace(/[^a-zA-Z0-9_-]/g, '_') + '-' + i;
424          var els = document.querySelectorAll(t.selector);
425          for (var j = 0; j < els.length; j++) {
426            var el = els[j];
427            if (el.hasAttribute(tag)) continue;
428            if (t.text != null) el.textContent = t.text;
429            else if (t.html != null) el.innerHTML = t.html;
430            else if (t.css != null) el.style.cssText += (el.style.cssText ? '; ' : '') + t.css;
431            el.setAttribute(tag, '1');
432          }
433        } catch (_) {}
434      }
435    }
436    // Apply now; if the async script is running mid-parse (later elements not
437    // yet in the DOM), stream-apply: a scoped MutationObserver re-applies as
438    // elements land during parsing (GrowthBook dom-mutator pattern), so
439    // below-the-script elements are patched at insertion time instead of
440    // flashing original content until DOMContentLoaded. Final apply +
441    // disconnect on DOMContentLoaded — the observer never outlives parsing.
442    function __fv_applyPatchArm(expKey, arm) {
443      __fv_applyTransforms(expKey, arm.transforms);
444      if (document.readyState === 'loading' && !__fv_dom_ready_scheduled[expKey]) {
445        __fv_dom_ready_scheduled[expKey] = true;
446        var __fv_obs = null;
447        try {
448          __fv_obs = new MutationObserver(function () {
449            __fv_applyTransforms(expKey, arm.transforms);
450          });
451          __fv_obs.observe(document.documentElement, { childList: true, subtree: true });
452        } catch (_) {}
453        document.addEventListener('DOMContentLoaded', function () {
454          try { if (__fv_obs) __fv_obs.disconnect(); } catch (_) {}
455          __fv_applyTransforms(expKey, arm.transforms);
456        });
457      }
458    }
459
460    // Page-experiment runner (initial page load only — no SPA route-change
461    // handling in v1). Experiments with page_url are auto-exposed here,
462    // control included (honest denominator); code-controlled ones keep
463    // requiring window.fv.variant(). url arm → redirect flow; transforms arm
464    // → DOM patches (tag-guarded idempotent, so the cached pass, the refetch
465    // pass, and the parse-time observer may all re-apply). Preview mode is exclusive:
466    // only the previewed experiment acts — no exposure, no redirects — so a
467    // QA session can't be redirected away or pollute assignment data.
468    function __fv_runPageExperiment(experiments, isSync) {
469      try {
470        if (__fv_preview) {
471          for (var p = 0; p < experiments.length; p++) {
472            var pexp = experiments[p];
473            if (!pexp || pexp.id !== __fv_preview.expId || !pexp.page_url || !pexp.variations) continue;
474            if (!__fv_matchesPage(pexp.page_url, location.href)) return;
475            for (var q = 0; q < pexp.variations.length; q++) {
476              var parm = pexp.variations[q];
477              if (parm && parm.key === __fv_preview.variantKey && parm.transforms && parm.transforms.length) {
478                __fv_applyPatchArm(pexp.key, parm);
479              }
480            }
481            return;
482          }
483          return;
484        }
485        for (var i = 0; i < experiments.length; i++) {
486          var exp = experiments[i];
487          if (!exp || !exp.page_url || !exp.key || !exp.variations) continue;
488          if (!__fv_matchesPage(exp.page_url, location.href)) continue;
489          var variant = __fv_assignments[exp.key] || __fv_bucket(visitorId, exp.key, exp.variations);
490          __fv_sendExposureBeacon(exp.key, variant);
491          if (exp.execution === 'edge') continue; // edge: Worker served the page; only record exposure
492          var arm = null;
493          for (var j = 0; j < exp.variations.length; j++) {
494            if (exp.variations[j].key === variant) { arm = exp.variations[j]; break; }
495          }
496          if (!arm) return;
497          if (arm.transforms && arm.transforms.length) {
498            __fv_applyPatchArm(exp.key, arm);
499            return; // first matching experiment only (start guard makes >1 impossible)
500          }
501          if (__fv_redirect_decided) return; // redirect already decided on the cached pass
502          __fv_redirect_decided = true;
503          if (!arm.url) return;                                    // control → page shows normally
504          if (__fv_matchesPage(arm.url, location.href)) return;    // already on the variant page
505          if (isSync) __fv_suppress_pv = true; // variant page's pageview is the real one (no double count)
506          // Hide during the hop; 1.5s failsafe un-hide if the redirect never lands.
507          try {
508            document.documentElement.style.opacity = '0';
509            setTimeout(function () { try { document.documentElement.style.opacity = ''; } catch (_) {} }, 1500);
510          } catch (_) {}
511          location.replace(__fv_variantRedirectUrl(arm.url, location.href));
512          return;
513        }
514      } catch (_) {}
515    }
516
517    // Config cache: returning visitors get a SYNCHRONOUS redirect decision
518    // from localStorage (any age); the network refetch always runs and
519    // refreshes the cache + assignments + data-variant attributes. A stopped
520    // experiment therefore stops redirecting at most one pageview late.
521    // debt: first-visit redirect decides async (~100-300ms flash of the original page), move to serve-time/edge config if the flash measurably hurts conversion
522    function __fv_bootstrapExperiments() {
523      var cached = __fv_readExpCache();
524      if (cached) {
525        __fv_applyAssignments(cached);
526        __fv_runPageExperiment(cached, true);
527      }
528      try {
529        var __fv_cfg_url = origin + '/v1/experiments/active?api_key=' + encodeURIComponent(apiKey);
530        if (__fv_preview) __fv_cfg_url += '&preview_exp=' + encodeURIComponent(__fv_preview.expId);
531        fetch(__fv_cfg_url, {
532          method: 'GET', credentials: 'omit', mode: 'cors',
533        }).then(function (r) { return r.json().catch(function () { return null; }); })
534          .then(function (data) {
535            if (!data || !data.experiments) return;
536            if (!__fv_preview) {
537              // Preview responses can contain drafts — never cache them.
538              try {
539                localStorage.setItem(EXP_CACHE_KEY, JSON.stringify({ fetchedAt: Date.now(), experiments: data.experiments }));
540              } catch (_) {}
541            }
542            __fv_applyAssignments(data.experiments);
543            __fv_runPageExperiment(data.experiments, false);
544          }).catch(function () {});
545      } catch (_) {}
546    }
547    __fv_bootstrapExperiments();
548
549    // T21: session helpers — UUIDv4 minted on first event, rotated after 30
550    // min inactivity OR 24h hard cap (Amplitude parity). Persisted in
551    // sessionStorage; the rotation check runs before every event send.
552    var SESSION_KEY = "fv_session";
553    var SESSION_INACTIVITY_MS = 30 * 60 * 1000;
554    var SESSION_HARD_CAP_MS = 24 * 60 * 60 * 1000;
555    function getSessionId() {
556      try {
557        var raw = sessionStorage.getItem(SESSION_KEY);
558        var now = Date.now();
559        if (raw) {
560          var s = JSON.parse(raw);
561          var lastSeen = (s && s.last) || 0;
562          var startedAt = (s && s.started) || lastSeen;
563          var inactive = (now - lastSeen) >= SESSION_INACTIVITY_MS;
564          var capExpired = (now - startedAt) >= SESSION_HARD_CAP_MS;
565          if (s && s.id && !inactive && !capExpired) {
566            s.last = now;
567            sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));
568            return s.id;
569          }
570        }
571        var id = uuidv4();
572        sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id: id, last: now, started: now }));
573        return id;
574      } catch (_) { return uuidv4(); }
575    }
576
577    function fvBareHost(h) {
578      h = fvStripTrailingDot(h);
579      if (!h) return h;
580      if (h.indexOf('www.') === 0) h = h.slice(4);
581      return h;
582    }
583    function sessionInSample(id) {
584      var hex = ('00000000' + fvHash(id)).slice(-8);
585      return parseInt(hex, 16) / 4294967295 < 0.10;
586    }
587    function maybeStartReplay() {
588      try {
589        if (!MARKETING_HOST) return;
590        if (fvBareHost(location.hostname) === MARKETING_HOST) return;
591        var sid = getSessionId();
592        if (!sessionInSample(sid)) return;
593        if (window.__fvReplayStarted) return;
594        window.__fvReplayStarted = true;
595        window.__fvReplay = {
596          session_id: sid,
597          visitor_id: visitorId,
598          origin: origin,
599          api_key: apiKey,
600          host: fvStripTrailingDot(location.hostname)
601        };
602        var el = document.createElement('script');
603        el.src = origin + '/replay.js';
604        el.async = true;
605        (document.head || document.documentElement).appendChild(el);
606      } catch (_) {}
607    }
608    maybeStartReplay();
609
610    function sendPayload(payload) {
611      try {
612        if (typeof fetch === 'function') {
613          fetch(endpoint, {
614            method: 'POST',
615            body: payload,
616            keepalive: true,
617            credentials: 'include',
618            mode: 'cors',
619          }).then(function (r) {
620            return r.json().catch(function () { return null; });
621          }).then(function (data) {
622            if (data && typeof data.visitor_id === 'string' && data.visitor_id) {
623              visitorId = data.visitor_id;
624              writeLocal(visitorId);
625            }
626          }).catch(function () {
627            if (navigator.sendBeacon) navigator.sendBeacon(endpoint, payload);
628          });
629          return;
630        }
631      } catch (_) {}
632      if (navigator.sendBeacon) navigator.sendBeacon(endpoint, payload);
633    }
634
635    var __FV_SUBMIT_WINDOW = 3000;   // ms — suppress identical re-fires (echo window)
636    var __fv_recent_submits = {};    // content signature -> last-send ms
637
638    // Compact, dependency-free string hash (djb2). Browsers may lack crypto.subtle
639    // in non-secure contexts, so do not use it here.
640    function __fv_hashStr(s) {
641      var h = 5381, i = s.length;
642      while (i) h = (h * 33) ^ s.charCodeAt(--i);
643      return (h >>> 0).toString(36);
644    }
645
646    // Signature over form identity + captured values. Stable across an AJAX
647    // re-render of the same form with the same values.
648    function __fv_submitSig(p) {
649      var fd = '';
650      if (p.form_data) {
651        var keys = Object.keys(p.form_data).sort();
652        for (var i = 0; i < keys.length; i++) fd += keys[i] + '=' + p.form_data[keys[i]] + '&';
653      }
654      return __fv_hashStr([
655        p.form_id || '', p.form_email || '', p.form_phone || '',
656        p.form_name_value || '', fd, (p.field_names || []).join(',')
657      ].join('|'));
658    }
659
660    // ─── T22: Core Web Vitals (LCP/INP/CLS) via web-vitals npm package. ───
661    // The iife bundle is injected by tracker-template.ts:applyWebVitals at
662    // serve time — leave the placeholder line EXACTLY as-is.
663    var webVitals=function(e){"use strict";var n,t,r,i,o,a=-1,c=function(e){addEventListener("pageshow",(function(n){n.persisted&&(a=n.timeStamp,e(n))}),!0)},u=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},s=function(){var e=u();return e&&e.activationStart||0},f=function(e,n){var t=u(),r="navigate";a>=0?r="back-forward-cache":t&&(document.prerendering||s()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-")));return{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},d=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},l=function(e,n,t,r){var i,o;return function(a){n.value>
vendor: 1,083 bytes, line 663
663=0&&(a||r)&&((o=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=o,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},p=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},v=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},m=function(e){var n=!1;return function(){n||(e(),n=!0)}},h=-1,g=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},T=function(e){"hidden"===document.visibilityState&&h>-1&&(h="visibilitychange"===e.type?e.timeStamp:0,E())},y=function(){addEventListener("visibilitychange",T,!0),addEventListener("prerenderingchange",T,!0)},E=function(){removeEventListener("visibilitychange",T,!0),removeEventListener("prerenderingchange",T,!0)},C=function(){return h<0&&(h=g(),y(),c((function(){setTimeout((function(){h=g(),y()}),0)}))),{get firstHiddenTime(){return h}}},L=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()}
663,S=[1800,3e3],b=function(e,n){n=n||{},L((function(){var t,r=C(),i=f("FCP"),o=d("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-s(),0),i.entries.push(e),t(!0)))}))}));o&&(t=l(e,i,S,n.reportAllChanges),c((function(r){i=f("FCP"),t=l(e,i,S,n.reportAllChanges),p((function(){i.value=performance.now()-r.timeStamp,t(!0)}))})))}))},w=[.1,.25],I=0,P=1/0,A=0,F=function(e){e.forEach((function(e){e.interactionId&&(P=Math.min(P,e.interactionId),A=Math.max(A,e.interactionId),I=A?(A-P)/7+1:0)}))},M=function(){return n?I:performance.interactionCount||0},k=function(){"interactionCount"in performance||n||(n=d("event",F,{type:"event",buffered:!0,durationThreshold:0}))},D=[],B=new Map,R=0,x=function(){var e=Math.min(D.length-1,Math.floor((M()-R)/50));return D[e]},H=[],N=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=B.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};B.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return B.delete(e.id)}))}}},q=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=m(e),"hidden"===document.visibilityState?e():(t=n(e),v(e)),t},O=[200,500],j=[2500,4e3],V={},_=[800,1800],z=function e(n){document.prerendering?L((function(){return e(n)})):"complete"!==document.readyState?addEventListener("load",(function(){return e(n)}),!0):setTimeout(n,0)},G={passive:!0,capture:!0},J=new Date,K=function(e,n){t||(t=n,r=e,i=new Date,W(removeEventListener),Q())},Q=function(){if(r>=0&&r<i-J){var e={entryType:"first-input",name:t.type,target:t.target,cancelable:t.cancelable,startTime:t.timeStamp,processingStart:t.timeStamp+r};o.forEach((function(n){n(e)})),o=[]}},U=function(e){if(e.cancelable){var n=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){K(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,G),removeEventListener("pointercancel",r,G)};addEventListener("pointerup",t,G),addEventListener("pointercancel",r,G)}(n,e):K(n,e)}},W=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,U,G)}))},X=[100,300];return e.CLSThresholds=w,e.FCPThresholds=S,e.FIDThresholds=X,e.INPThresholds=O,e.LCPThresholds=j,e.TTFBThresholds=_,e.onCLS=function(e,n){
663n=n||{},b(m((function(){var t,r=f("CLS",0),i=0,o=[],a=function(e){e.forEach((function(e){if(!e.hadRecentInput){var n=o[0],t=o[o.length-1];i&&e.startTime-t.startTime<1e3&&e.startTime-n.startTime<5e3?(i+=e.value,o.push(e)):(i=e.value,o=[e])}})),i>r.value&&(r.value=i,r.entries=o,t())},u=d("layout-shift",a);u&&(t=l(e,r,w,n.reportAllChanges),v((function(){a(u.takeRecords()),t(!0)})),c((function(){i=0,r=f("CLS",0),t=l(e,r,w,n.reportAllChanges),p((function(){return t()}))})),setTimeout(t,0))})))},e.onFCP=b,e.onFID=function(e,n){n=n||{},L((function(){var i,a=C(),u=f("FID"),s=function(e){e.startTime<a.firstHiddenTime&&(u.value=e.processingStart-e.startTime,u.entries.push(e),i(!0))},p=function(e){e.forEach(s)},h=d("first-input",p);i=l(e,u,X,n.reportAllChanges),h&&(v(m((function(){p(h.takeRecords()),h.disconnect()}))),c((function(){var a;u=f("FID"),i=l(e,u,X,n.reportAllChanges),o=[],r=-1,t=null,W(addEventListener),a=s,o.push(a),Q()})))}))},e.onINP=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},L((function(){var t;k();var r,i=f("INP"),o=function(e){q((function(){e.forEach(N);var n=x();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},a=d("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=l(e,i,O,n.reportAllChanges),a&&(a.observe({type:"first-input",buffered:!0}),v((function(){o(a.takeRecords()),r(!0)})),c((function(){R=M(),D.length=0,B.clear(),i=f("INP"),r=l(e,i,O,n.reportAllChanges)})))})))}
663,e.onLCP=function(e,n){n=n||{},L((function(){var t,r=C(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-s(),0),i.entries=[e],t())}))},a=d("largest-contentful-paint",o);if(a){t=l(e,i,j,n.reportAllChanges);var u=m((function(){V[i.id]||(o(a.takeRecords()),a.disconnect(),V[i.id]=!0,t(!0))}));["keydown","click"].forEach((function(e){addEventListener(e,(function(){return q(u)}),{once:!0,capture:!0})})),v(u),c((function(r){i=f("LCP"),t=l(e,i,j,n.reportAllChanges),p((function(){i.value=performance.now()-r.timeStamp,V[i.id]=!0,t(!0)}))}))}}))},e.onTTFB=function(e,n){n=n||{};var t=f("TTFB"),r=l(e,t,_,n.reportAllChanges);z((function(){var i=u();i&&(t.value=Math.max(i.responseStart-s(),0),t.entries=[i],r(!0),c((function(){t=f("TTFB",0),(r=l(e,t,_,n.reportAllChanges))(!0)})))}))},e}({});
664
665    window.__fv_cwv = window.__fv_cwv || {};
666    try {
667      if (typeof webVitals !== 'undefined') {
668        webVitals.onLCP(function (v) { window.__fv_cwv.lcp = v.value; });
669        webVitals.onINP(function (v) { window.__fv_cwv.inp = v.value; });
670        webVitals.onCLS(function (v) { window.__fv_cwv.cls = v.value; });
671      }
672    } catch (_) {}
673
674    // ─── T22: engagement bookkeeping ───
675    var __fv_engage = {
676      open_at: Date.now(),
677      max_scroll: 0,
678      clicks: 0,
679      first_int: null,
680      visible_ms: 0,
681      hidden_ms: 0,
682      last_vis_change: Date.now(),
683    };
684
685    function throttle(fn, ms) {
686      var last = 0, timer = null;
687      return function () {
688        var now = Date.now();
689        if (now - last >= ms) { last = now; fn(); }
690        else { if (timer) clearTimeout(timer); timer = setTimeout(function () { last = Date.now(); fn(); }, ms - (now - last)); }
691      };
692    }
693
694    // Scroll-depth milestones, two complementary signals fired once per mark
695    // crossed (page-load scoped; reset on SPA route change). sendEvent is hoisted.
696    //   $scroll_depth — percent of total document height (25/50/75/100). Cheap
697    //     read of "how far through the page", but degenerate on very long pages:
698    //     a 40k-px article needs ~15 screens of scroll to reach the 25% mark, so
699    //     engaged readers who scan the top never trip a milestone.
700    //   $scroll_reach — ABSOLUTE depth in viewport-heights scrolled (1/2/3/5/8).
701    //     Independent of page length, so long-form engagement stays measurable.
702    var __fv_scroll_fired = {};
703    var __fv_reach_fired = {};
704    var __FV_SCROLL_MARKS = [25, 50, 75, 100];
705    var __FV_REACH_MARKS = [1, 2, 3, 5, 8];
706    window.addEventListener('scroll', throttle(function () {
707      var seen = window.scrollY + window.innerHeight;
708      var pct = Math.round((seen / document.documentElement.scrollHeight) * 100);
709      if (pct > 100) pct = 100;
710      if (pct > __fv_engage.max_scroll) __fv_engage.max_scroll = pct;
711      for (var i = 0; i < __FV_SCROLL_MARKS.length; i++) {
712        var m = __FV_SCROLL_MARKS[i];
713        if (pct >= m && !__fv_scroll_fired[m]) {
714          __fv_scroll_fired[m] = true;
715          sendEvent('$scroll_depth', {
716            pageview_id: __fv_pageview_id,
717            depth: m,
718            time_to_depth_ms: Date.now() - __fv_engage.open_at,
719            page_url: pageUrl(),
720          });
721        }
722      }
723      var vh = window.innerHeight > 0 ? seen / window.innerHeight : 0;
724      for (var j = 0; j < __FV_REACH_MARKS.length; j++) {
725        var r = __FV_REACH_MARKS[j];
726        if (vh >= r && !__fv_reach_fired[r]) {
727          __fv_reach_fired[r] = true;
728          sendEvent('$scroll_reach', {
729            pageview_id: __fv_pageview_id,
730            viewports: r,
731            pixels: Math.round(seen),
732            time_to_depth_ms: Date.now() - __fv_engage.open_at,
733            page_url: pageUrl(),
734          });
735        }
736      }
737    }, 250), { passive: true });
738
739    ['click', 'keydown', 'pointerdown'].forEach(function (ev) {
740      window.addEventListener(ev, function () {
741        if (ev === 'click') __fv_engage.clicks++;
742        if (__fv_engage.first_int === null) __fv_engage.first_int = Date.now() - __fv_engage.open_at;
743      }, { passive: true });
744    });
745
746    document.addEventListener('visibilitychange', function () {
747      var now = Date.now();
748      var delta = now - __fv_engage.last_vis_change;
749      if (document.visibilityState === 'hidden') __fv_engage.visible_ms += delta;
750      else __fv_engage.hidden_ms += delta;
751      __fv_engage.last_vis_change = now;
752      if (document.visibilityState === 'hidden') flushEngagement();
753    });
754
755    window.addEventListener('pagehide', flushEngagement);
756    window.addEventListener('pageshow', function (e) { window.__fv_bfcache = e.persisted; });
757
758    function flushEngagement() {
759      if (!__fv_pageview_id) return;
760      var nav = (performance.getEntriesByType && performance.getEntriesByType('navigation')[0]) || {};
761      var payload = JSON.stringify({
762        kind: 'engagement',
763        visitor_id: visitorId,
764        session_id: getSessionId(),
765        pageview_id: __fv_pageview_id,
766        page_url: location.href,
767        time_on_page_ms: Date.now() - __fv_engage.open_at,
768        max_scroll_pct: __fv_engage.max_scroll,
769        click_count: __fv_engage.clicks,
770        first_interaction_ms: __fv_engage.first_int,
771        navigation_type: nav.type || null,
772        load_ms: nav.loadEventEnd | 0,
773        dcl_ms: nav.domContentLoadedEventEnd | 0,
774        ttfb_ms: nav.responseStart | 0,
775        bfcache_restore: !!window.__fv_bfcache,
776        visible_ms: __fv_engage.visible_ms,
777        hidden_ms: __fv_engage.hidden_ms,
778        lcp_ms: (window.__fv_cwv && window.__fv_cwv.lcp) || null,
779        inp_ms: (window.__fv_cwv && window.__fv_cwv.inp) || null,
780        cls: (window.__fv_cwv && typeof window.__fv_cwv.cls === 'number') ? window.__fv_cwv.cls : null,
781      });
782      sendPayload(payload);
783    }
784
785    // §B stable signals — emitted on every $pageview; server UPDATEs `visitor`
786    // (latest wins). Per-pageview signals (viewport/connection) computed at
787    // build-payload time so resize/roaming is captured.
788    function readStableSignals() {
789      var out = {};
790      try {
791        out.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
792      } catch (_) { out.timezone = null; }
793      out.locale = (navigator.language || null);
794      out.languages = (navigator.languages && Array.prototype.slice.call(navigator.languages)) || null;
795      out.platform = navigator.platform || null;
796      out.hw_concurrency = typeof navigator.hardwareConcurrency === "number" ? navigator.hardwareConcurrency : null;
797      out.device_memory = typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null;
798      out.max_touch_points = typeof navigator.maxTouchPoints === "number" ? navigator.maxTouchPoints : null;
799      out.screen_w = (window.screen && window.screen.width) || null;
800      out.screen_h = (window.screen && window.screen.height) || null;
801      out.color_depth = (window.screen && window.screen.colorDepth) || null;
802      out.device_pixel_ratio = window.devicePixelRatio || null;
803      try {
804        out.prefers_color_scheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
805        out.prefers_reduced_motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
806        out.prefers_contrast_more = window.matchMedia('(prefers-contrast: more)').matches;
807        out.has_hover = window.matchMedia('(hover: hover)').matches;
808        out.pointer_fine = window.matchMedia('(pointer: fine)').matches;
809      } catch (_) {}
810      try {
811        out.ua_brands = (navigator.userAgentData && navigator.userAgentData.brands) || null;
812      } catch (_) { out.ua_brands = null; }
813      // navigator.webdriver === true is high-confidence automation when true;
814      // its absence proves nothing (cloud/stealth agents remove it via CDP).
815      out.webdriver = navigator.webdriver === true;
816      // High-entropy stable signals (cookieless fallback identity). Each in its
817      // own try/catch — null is acceptable when the browser blocks the API.
818      try {
819        var cv = document.createElement('canvas');
820        var ctx = cv.getContext('2d');
821        if (ctx) {
822          ctx.textBaseline = 'top';
823          ctx.font = "14px 'Arial'";
824          ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20);
825          ctx.fillStyle = '#069'; ctx.fillText('Fullvision_fp_v1', 2, 15);
826          out.canvas_hash = fvHash(cv.toDataURL());
827        } else { out.canvas_hash = null; }
828      } catch (_) { out.canvas_hash = null; }
829      try {
830        var gl = (document.createElement('canvas')).getContext('webgl') ||
831                 (document.createElement('canvas')).getContext('experimental-webgl');
832        if (gl) {
833          var dbg = gl.getExtension('WEBGL_debug_renderer_info');
834          out.webgl_vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null;
835          out.webgl_renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null;
836          out.webgl_hash = fvHash([
837            gl.getParameter(gl.VERSION),
838            gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
839            (gl.getSupportedExtensions() || []).join(',')
840          ].join('|'));
841        } else { out.webgl_vendor = null; out.webgl_renderer = null; out.webgl_hash = null; }
842      } catch (_) { out.webgl_vendor = null; out.webgl_renderer = null; out.webgl_hash = null; }
843      try {
844        var AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
845        if (AC) {
846          var actx = new AC(1, 4096, 44100);
847          var osc = actx.createOscillator();
848          osc.type = 'triangle'; osc.frequency.value = 10000;
849          var comp = actx.createDynamicsCompressor();
850          osc.connect(comp); comp.connect(actx.destination);
851          osc.start(0); actx.startRendering();
852          actx.oncomplete = function (e) {
853            try {
854              var buf = e.renderedBuffer.getChannelData(0);
855              var sum = 0; for (var i = 0; i < buf.length; i++) sum += Math.abs(buf[i]);
856              out.audio_hash = fvHash(String(sum));
857            } catch (_) {}
858            // Audio resolved (a tick after load). Flush the initial pageview now
859            // so its beacon carries audio_hash instead of waiting out the timer.
860            if (typeof __fv_flushOnAudio === 'function') { try { __fv_flushOnAudio(); } catch (_) {} }
861          };
862        }
863      } catch (_) { out.audio_hash = out.audio_hash || null; }
864      return out;
865    }
866
867    function readPerPageviewSignals() {
868      var out = {
869        viewport_w: window.innerWidth || null,
870        viewport_h: window.innerHeight || null,
871      };
872      try {
873        var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
874        if (c) {
875          out.connection_type = c.effectiveType || null;
876          out.connection_downlink = typeof c.downlink === "number" ? c.downlink : null;
877          out.connection_rtt = typeof c.rtt === "number" ? c.rtt : null;
878          out.connection_save_data = !!c.saveData;
879        }
880      } catch (_) {}
881      return out;
882    }
883
884    // Stable (device-constant) signals are collected ONCE and cached by reference.
885    // audio_hash is computed in an OfflineAudioContext.oncomplete callback that
886    // fires a tick later than the first beacon — but it writes into this same
887    // cached object, so every beacon after the first carries it and ingest
888    // COALESCEs it onto the visitor row. (Re-reading per pageview would orphan
889    // each callback's write into a discarded object — the original bug.)
890    var __fv_stable = null;
891    function getStableSignals() {
892      if (__fv_stable === null) __fv_stable = readStableSignals();
893      return __fv_stable;
894    }
895    // Set by the initial-pageview block; called from the audio oncomplete so the
896    // first beacon flushes the instant audio_hash is ready (see below).
897    var __fv_flushOnAudio = null;
898
899    function buildPageviewPayload() {
900      maybeWriteGadsCookie();
901      var stable = getStableSignals();
902      var perpv = readPerPageviewSignals();
903      var payload = {
904        kind: "pageview",
905        visitor_id: visitorId,
906        pageview_id: __fv_pageview_id,
907        session_id: getSessionId(),
908        url: pageUrl(),
909        title: document.title,
910        referrer: document.referrer,
911        // _fbp browser cookie set by the customer's Meta Pixel; null when no pixel.
912        // Stored in event properties → CH events.fbp → person.first_touch_fbp (Meta CAPI).
913        fbp: getCookie("_fbp"),
914        // {key: variant} assigned map — server copies it into properties.experiments
915        // (journey display: "assigned", distinct from the Experiment Exposed event).
916        experiments: __fv_assignments,
917      };
918      // Merge stable + per-pageview client signals
919      for (var k in stable) { if (Object.prototype.hasOwnProperty.call(stable, k)) payload[k] = stable[k]; }
920      for (var k2 in perpv) { if (Object.prototype.hasOwnProperty.call(perpv, k2)) payload[k2] = perpv[k2]; }
921      var persist = readGadsPersist();
922      if (persist) payload.gads_persist = persist;
923      return JSON.stringify(payload);
924    }
925
926    function send() {
927      var url = location.href;
928      var now = Date.now();
929      if (window.__fullvision_lastUrl === url) return;
930      if (
931        typeof window.__fullvision_lastSentAt === "number" &&
932        now - window.__fullvision_lastSentAt < 250
933      ) {
934        return;
935      }
936      window.__fullvision_lastUrl = url;
937      window.__fullvision_lastSentAt = now;
938      // Mint a fresh pageview_id for SPA route changes (the IIFE only runs
939      // once; subsequent pageviews via pushState/popstate need a new id).
940      __fv_pageview_id = uuidv4();
941      // New virtual page → allow one Experiment Exposed per experiment again.
942      __fv_exposed = {};
943      // SPA route change = a fresh virtual page: reset engagement + scroll-depth
944      // bookkeeping so $scroll_depth fires again and $pageleave is scoped to the
945      // new route instead of carrying max_scroll/clicks over from the previous one.
946      __fv_engage.open_at = now;
947      __fv_engage.max_scroll = 0;
948      __fv_engage.clicks = 0;
949      __fv_engage.first_int = null;
950      __fv_engage.visible_ms = 0;
951      __fv_engage.hidden_ms = 0;
952      __fv_engage.last_vis_change = now;
953      __fv_scroll_fired = {};
954      __fv_reach_fired = {};
955      sendPayload(buildPageviewPayload());
956    }
957
958    function patchHistory(method) {
959      var original = history[method];
960      history[method] = function () {
961        var result = original.apply(this, arguments);
962        send();
963        return result;
964      };
965    }
966    patchHistory("pushState");
967    patchHistory("replaceState");
968
969    window.addEventListener("popstate", send);
970
971    // Initial pageview — bypass the URL-equality guard (lastUrl undefined here).
972    // Defer the send up to AUDIO_WAIT_MS so the async audio fingerprint (computed
973    // in OfflineAudioContext.oncomplete a few ms after load) rides this beacon —
974    // on a full page load it's usually the ONLY pageview, so the cache alone
975    // wouldn't deliver audio. The audio callback flushes early the moment it
976    // resolves; hide/unload force-flushes so a fast bounce never drops the
977    // pageview. Sends exactly once.
978    window.__fullvision_lastUrl = location.href;
979    window.__fullvision_lastSentAt = Date.now();
980    var AUDIO_WAIT_MS = 250;
981    var __fv_pvSent = false;
982    var __fv_pvTimer = null;
983    function flushInitialPageview() {
984      if (__fv_suppress_pv) return; // sync split-URL redirect issued — the variant page owns the pageview
985      if (__fv_pvSent) return;
986      __fv_pvSent = true;
987      if (__fv_pvTimer) { clearTimeout(__fv_pvTimer); __fv_pvTimer = null; }
988      sendPayload(buildPageviewPayload());
989    }
990    __fv_flushOnAudio = flushInitialPageview;
991    getStableSignals(); // kick the audio render now so it can resolve before the timer
992    __fv_pvTimer = setTimeout(flushInitialPageview, AUDIO_WAIT_MS);
993    document.addEventListener('visibilitychange', function () {
994      if (document.visibilityState === 'hidden') flushInitialPageview();
995    });
996    window.addEventListener('pagehide', flushInitialPageview);
997
998    // ─── Public API: variant consumption (spec §5) ───
999    // window.fv.variant(key) → assigned variant (default 'control' if unknown),
1000    // firing `Experiment Exposed` once per experiment per pageview on first call.
1001    window.fv = window.fv || {};
1002    window.fv.variant = function (key) {
1003      var variant = __fv_assignments[key];
1004      if (variant == null) return 'control'; // unknown/inactive → control (fail-open)
1005      var guard = key + '|' + __fv_pageview_id;
1006      if (!__fv_exposed[guard]) {
1007        __fv_exposed[guard] = true;
1008        sendEvent('Experiment Exposed', {
1009          pageview_id: __fv_pageview_id,
1010          experiment: key,
1011          variant: variant,
1012        });
1013      }
1014      return variant;
1015    };
1016
1017    // ─── T23: $identify autocapture (spec §D) ───
1018    //
1019    // Form-email blur, mailto click, meta/global/dataLayer scrape. Per-page
1020    // dedupe via __fv_identified_emails so the same email blurred twice fires
1021    // once.
1022    function sendIdentify(email, source) {
1023      if (!email) return;
1024      window.__fv_identified_emails = window.__fv_identified_emails || {};
1025      if (window.__fv_identified_emails[email]) return;
1026      window.__fv_identified_emails[email] = true;
1027      var payload = JSON.stringify({
1028        kind: 'identify',
1029        visitor_id: visitorId,
1030        session_id: getSessionId(),
1031        email: email,
1032        source: source,
1033      });
1034      sendPayload(payload);
1035    }
1036
1037    // Centralized skip — also reused by T25 click/form autocapture.
1038    var __fv_SENSITIVE_RX = /\b(password|cc|card|cvv|ssn|social[-_]?security)\b/i;
1039    function shouldSkipElement(el) {
1040      if (!el) return true;
1041      var node = el;
1042      while (node && node.tagName) {
1043        if (node.classList && node.classList.contains('fv-no-track')) return true;
1044        if (node.hasAttribute && node.hasAttribute('data-fv-private')) return true;
1045        node = node.parentElement;
1046      }
1047      if (el.tagName === 'INPUT' && /^(password|hidden)$/i.test(el.type || '')) return true;
1048      if (el.name && __fv_SENSITIVE_RX.test(el.name)) return true;
1049      if (el.id && __fv_SENSITIVE_RX.test(el.id)) return true;
1050      if (el.autocomplete && /^(cc-|current-password|new-password)/i.test(el.autocomplete)) return true;
1051      return false;
1052    }
1053
1054    // Email field blur → $identify. Requires the input to live inside a <form>
1055    // (free-standing email inputs are usually search/filter widgets, not signups).
1056    document.addEventListener('change', function (e) {
1057      var t = e.target;
1058      if (!t || t.type !== 'email' || !t.value || !t.closest || !t.closest('form')) return;
1059      if (shouldSkipElement(t)) return;
1060      var v = t.value.trim();
1061      if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) sendIdentify(v, 'form_email');
1062    }, true);
1063
1064    // mailto: link click → $identify with the address.
1065    document.addEventListener('click', function (e) {
1066      var a = e.target && e.target.closest && e.target.closest('a[href^="mailto:"]');
1067      if (a) sendIdentify(a.href.replace(/^mailto:/, '').split('?')[0], 'mailto');
1068    }, true);
1069
1070    // Scrape meta tag + common globals + GTM dataLayer.
1071    try {
1072      var metaEmail = document.querySelector('meta[name="user-email"]');
1073      if (metaEmail) sendIdentify(metaEmail.content, 'meta_tag');
1074      ['__USER__', 'user', 'currentUser'].forEach(function (k) {
1075        if (window[k] && window[k].email) sendIdentify(window[k].email, 'global');
1076      });
1077      if (window.intercomSettings && window.intercomSettings.email) {
1078        sendIdentify(window.intercomSettings.email, 'global');
1079      }
1080      if (Array.isArray(window.dataLayer)) {
1081        window.dataLayer.forEach(function (entry) {
1082          if (entry && entry.email) sendIdentify(entry.email, 'datalayer');
1083          if (entry && entry.user && entry.user.email) sendIdentify(entry.user.email, 'datalayer');
1084        });
1085      }
1086    } catch (_) {}
1087
1088    // ─── T24: cross-domain outbound link decoration (spec §E) — DEACTIVATED 2026-08-28 ───
1089    // Kept for a future customer whose site and app live on different
1090    // registrable domains (acme.com → app.acme.io), where no cookie can
1091    // carry the visitor id. Every current workspace lists only subdomains of
1092    // its cookie_domain (Evaboot: www/dash under evaboot.com), so the shared
1093    // Domain= cookie already stitches the hop: 104 of 108 www→dash hops
1094    // measured 2026-07-20..08-28 were stitched by the cookie alone, the other
1095    // 4 had no source pageview at all. Decorating URLs with a visitor id
1096    // bought nothing and put ids in the address bar, so the listener below is
1097    // commented out. Inbound adoption (~line 226) stays active and harmless.
1098    // Before re-enabling: skip targets sharing cookie_domain, gate behind a
1099    // per-workspace opt-in, add a short expiry (GA4 _gl uses ~2 min). See
1100    // docs/handoffs/handoff-fv-vid-cross-domain-handoff.md.
1101    //
1102    // For each outbound click on `<a href>` whose host is in TRACKER_ORIGINS,
1103    // append `fv_vid=<visitorId>` to the URL FRAGMENT (#) — not the query
1104    // string. Fragments never reach origin servers, Referer headers, or any
1105    // 3rd-party analytics on the destination site. Same idea as PostHog's
1106    // #posthog=ID.
1107    // document.addEventListener('click', function (e) {
1108    //   var a = e.target && e.target.closest && e.target.closest('a[href]');
1109    //   if (!a || !a.href) return;
1110    //   try {
1111    //     var u = new URL(a.href, location.href);
1112    //     if (TRACKER_ORIGINS.indexOf(u.hostname) === -1) return;
1113    //     // Same-domain navigation already carries the visitor via the cookie —
1114    //     // only decorate genuine cross-domain hops, never pollute same-host URLs.
1115    //     if (fvStripTrailingDot(u.hostname) === fvStripTrailingDot(location.hostname)) return;
1116    //     if (u.hash && u.hash.indexOf('fv_vid=') !== -1) return;
1117    //     u.hash = (u.hash ? u.hash + '&' : '') + 'fv_vid=' + encodeURIComponent(visitorId);
1118    //     a.href = u.toString();
1119    //   } catch (_) {}
1120    // }, true);
1121
1122    // ─── T25: $click + $form_submit + $rageclick/$dead_click/$exception (spec §F) ───
1123    var __fv_click_count = 0;
1124    var __fv_last_click_at_selector = {};
1125    var __fv_rage_cluster = {};
1126    // Allowlist for data-* attributes — never blanket-capture `dataset`
1127    // (customer apps stash emails/order-ids/customer-ids in arbitrary
1128    // data-* keys). Values containing `@` or matching __fv_SENSITIVE_RX
1129    // are dropped even if the key is allowed.
1130    var __fv_DATA_ATTR_ALLOW_RX = /^(testid|test|track|variant|category|kind|type|role|page|section|state|popup(type)?)$/i;
1131
1132    function __fv_resolveInteractiveAncestor(node) {
1133      var depth = 0;
1134      while (node && depth < 32) {
1135        if (node.tagName) {
1136          var tag = node.tagName.toLowerCase();
1137          if (tag === 'body' || tag === 'html') return null;
1138          if (tag === 'button' || tag === 'a' || tag === 'label' || tag === 'summary' || tag === 'select' || tag === 'textarea' || tag === 'input') return node;
1139          if (node.getAttribute && node.getAttribute('role') === 'button') return node;
1140          if (node.onclick) return node;
1141          try {
1142            if (typeof getComputedStyle === 'function' && getComputedStyle(node).cursor === 'pointer') return node;
1143          } catch (_) {}
1144        }
1145        if (node.parentElement) node = node.parentElement;
1146        else if (node.getRootNode) {
1147          var root = node.getRootNode();
1148          node = (root && root.host) || null;
1149        } else node = null;
1150        depth++;
1151      }
1152      return null;
1153    }
1154
1155    function __fv_computeSelector(el) {
1156      var parts = []; var depth = 0; var node = el;
1157      while (node && depth < 4 && node.tagName) {
1158        if (node.id && !__fv_isUnstableFormId(node.id)) { parts.unshift('#' + node.id); break; }
1159        var seg = node.tagName.toLowerCase();
1160        if (node.className && typeof node.className === 'string') {
1161          seg += '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.');
1162        }
1163        parts.unshift(seg);
1164        node = node.parentElement; depth++;
1165      }
1166      return parts.join(' > ');
1167    }
1168
1169    function __fv_readDataAttrs(el) {
1170      var out = {};
1171      if (!el.dataset) return out;
1172      for (var k in el.dataset) {
1173        if (k.indexOf('fv') === 0) continue;
1174        if (!__fv_DATA_ATTR_ALLOW_RX.test(k)) continue;
1175        var v = el.dataset[k];
1176        if (typeof v === 'string' && (__fv_SENSITIVE_RX.test(v) || /@/.test(v))) continue;
1177        out[k] = v;
1178      }
1179      return out;
1180    }
1181
1182    function sendEvent(name, props) {
1183      props.session_id = getSessionId();
1184      // Assigned experiments map on every event, inside properties — the CH
1185      // events.experiments column materializes from properties.experiments.
1186      props.experiments = __fv_assignments;
1187      var payload = JSON.stringify({
1188        kind: 'event',
1189        visitor_id: visitorId,
1190        event_name: name,
1191        properties: props,
1192      });
1193      sendPayload(payload);
1194    }
1195
1196    // $dead_click liveness (PostHog-shaped). Shared timestamps so we do not
1197    // leak a MutationObserver per click. Skip <a> — browser nav is unobservable.
1198    var __fv_dead_last = { mutation: 0, scroll: 0, selection: 0, visibility: 0, focus: 0 };
1199
1200    function __fv_skipDeadClick(node) {
1201      while (node) {
1202        if (node.tagName && String(node.tagName).toLowerCase() === 'a') return true;
1203        node = node.parentElement;
1204      }
1205      return false;
1206    }
1207
1208    function __fv_deadClickIsAlive(last, clickAt) {
1209      function after(t, maxMs) {
1210        return t && t >= clickAt && (t - clickAt) < maxMs;
1211      }
1212      function around(t, maxMs) {
1213        if (!t) return false;
1214        var d = t - clickAt;
1215        if (d < 0) d = -d;
1216        return d < maxMs;
1217      }
1218      return after(last.mutation, 2500)
1219        || after(last.scroll, 100)
1220        || after(last.selection, 100)
1221        || around(last.visibility, 1000)
1222        || around(last.focus, 1000);
1223    }
1224
1225    window.addEventListener('scroll', function () { __fv_dead_last.scroll = Date.now(); }, true);
1226    document.addEventListener('selectionchange', function () { __fv_dead_last.selection = Date.now(); });
1227    document.addEventListener('visibilitychange', function () { __fv_dead_last.visibility = Date.now(); });
1228    window.addEventListener('focus', function () { __fv_dead_last.focus = Date.now(); });
1229    window.addEventListener('blur', function () { __fv_dead_last.focus = Date.now(); });
1230    function __fv_attachDeadMutationObserver() {
1231      try {
1232        if (typeof MutationObserver !== 'function' || !document.body) return;
1233        var deadMo = new MutationObserver(function () { __fv_dead_last.mutation = Date.now(); });
1234        deadMo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
1235      } catch (_) {}
1236    }
1237    if (document.body) __fv_attachDeadMutationObserver();
1238    else document.addEventListener('DOMContentLoaded', __fv_attachDeadMutationObserver);
1239
1240    // $click + $rageclick + $dead_click — all hang off the same capture-phase listener.
1241    document.addEventListener('click', function (e) {
1242      if (__fv_click_count >= 200) return;
1243      var rawTarget = e.target;
1244      try {
1245        if (e.composedPath) {
1246          var composed = e.composedPath();
1247          if (composed && composed[0]) rawTarget = composed[0];
1248        }
1249      } catch (_) {}
1250      if (!rawTarget || shouldSkipElement(rawTarget)) return;
1251      var el = __fv_resolveInteractiveAncestor(rawTarget);
1252      if (!el) return;
1253      var selector = __fv_computeSelector(el);
1254      var now = Date.now();
1255
1256      // $rageclick — sliding window per selector, independent of the $click
1257      // 500ms dedupe so 200–300ms bursts can still reach ≥3 / 1s.
1258      var cluster = (__fv_rage_cluster[selector] || []).filter(function (t) { return now - t < 1000; });
1259      cluster.push(now);
1260      __fv_rage_cluster[selector] = cluster;
1261      if (cluster.length >= 3) {
1262        sendEvent('$rageclick', {
1263          pageview_id: __fv_pageview_id,
1264          selector: selector,
1265          click_count: cluster.length,
1266          first_click_ms: cluster[0],
1267          last_click_ms: cluster[cluster.length - 1],
1268          page_url: pageUrl(),
1269        });
1270        __fv_rage_cluster[selector] = [];
1271      }
1272
1273      if (__fv_last_click_at_selector[selector] && now - __fv_last_click_at_selector[selector] < 500) return;
1274      __fv_last_click_at_selector[selector] = now;
1275      __fv_click_count++;
1276
1277      var classes = [];
1278      if (el.className && typeof el.className === 'string') {
1279        classes = el.className.split(/\s+/).filter(Boolean);
1280      }
1281      var formEl = el.closest && el.closest('form');
1282      // Outbound flag — http(s) link whose host differs from the current host.
1283      // app.evaboot.com from evaboot.com counts as outbound (host inequality),
1284      // which is what we want for marketing→app / Stripe / Calendly clicks.
1285      var isOutbound = false, outboundHost = null;
1286      if (el.href) {
1287        try {
1288          var ou = new URL(el.href, location.href);
1289          if (/^https?:$/.test(ou.protocol) && ou.hostname && fvStripTrailingDot(ou.hostname) !== fvStripTrailingDot(location.hostname)) {
1290            isOutbound = true;
1291            outboundHost = fvStripTrailingDot(ou.hostname);
1292          }
1293        } catch (_) {}
1294      }
1295      var modalEl = MODAL_AUTOCAPTURE ? __fv_acceptedModalAncestor(el) : null;
1296      if (modalEl) modalEl.__fv_modal_interacted = true;
1297      sendEvent('$click', {
1298        pageview_id: __fv_pageview_id,
1299        tag: el.tagName.toLowerCase(),
1300        text: (el.textContent || '').trim().slice(0, 200),
1301        selector: selector,
1302        id: el.id || null,
1303        classes: classes,
1304        href: el.href || null,
1305        is_outbound: isOutbound,
1306        outbound_host: outboundHost,
1307        target: el.target || null,
1308        data_attrs: __fv_readDataAttrs(el),
1309        form_id: formEl ? __fv_formIdOf(formEl, true) : null,
1310        action: __fv_actionOf(el),
1311        inner_tag: rawTarget !== el && rawTarget.tagName ? rawTarget.tagName.toLowerCase() : null,
1312        page_url: pageUrl(),
1313        modal_id: modalEl ? __fv_modalId(modalEl) : null,
1314        modal_selector: modalEl ? __fv_computeSelector(modalEl) : null,
1315      });
1316
1317      // $dead_click — alive if mutation < 2500ms, scroll/selection < 100ms,
1318      // or visibility/focus/blur within 1000ms. Anchors skip (nav is silent).
1319      try {
1320        if (!__fv_skipDeadClick(rawTarget)) {
1321          var deadClickAt = now;
1322          setTimeout(function () {
1323            if (!__fv_deadClickIsAlive(__fv_dead_last, deadClickAt)) {
1324              sendEvent('$dead_click', {
1325                pageview_id: __fv_pageview_id,
1326                selector: selector,
1327                page_url: pageUrl(),
1328              });
1329            }
1330          }, 2500);
1331        }
1332      } catch (_) {}
1333    }, true);
1334
1335    // ─── T25b: form-value capture (global) ───
1336    // Global kill-switch. Flip to false + redeploy ingest to disable value
1337    // capture everywhere (falls back to names-only $form_submit).
1338    var CAPTURE_FORM_VALUES = true;
1339
1340    var __FV_EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1341    var __FV_PHONE_VAL_RX = /^[+()\d][\d\s().-]{6,}$/;
1342    var __FV_EMAIL_HINT_RX = /e-?mail/i;
1343    var __FV_PHONE_HINT_RX = /phone|tel|mobile|portable|t[eé]l[eé]phone/i;
1344    var __FV_NAME_HINT_RX = /name|nom|pr[eé]nom|first|last|full.?name/i;
1345    var __FV_USERNAME_RX = /user-?name/i;
1346
1347    // Per-value caps (stay well under the 64KB ingest body ceiling).
1348    var __FV_MAX_VALUE = 512;
1349    var __FV_MAX_MESSAGE = 2000;
1350    var __FV_MAX_EMAIL = 320;
1351    var __FV_MAX_PHONE = 64;
1352    var __FV_MAX_NAME = 256;
1353    var __FV_MAX_FORM_DATA_FIELDS = 50;
1354
1355    function __fv_attr(el, name) {
1356      return (el && el.getAttribute && el.getAttribute(name)) || '';
1357    }
1358
1359    // Form identity: data-fv-form wins; then a stable id; then the selector.
1360    // Alpine runtime ids (_x_marker, _x_*) and serialized component state are
1361    // not stable handles — drop them so dash grouping is not per-render.
1362    function __fv_isUnstableFormId(id) {
1363      if (!id) return true;
1364      if (id === '_x_marker' || id.indexOf('_x_') === 0) return true;
1365      var c = id.charAt(0);
1366      return c === '{' || c === '[';
1367    }
1368
1369    function __fv_formIdOf(el, isForm) {
1370      var explicit = __fv_attr(el, 'data-fv-form');
1371      if (explicit) return explicit.slice(0, 200);
1372      if (isForm) {
1373        var sid = __fv_safeElId(el.id);
1374        return sid || __fv_computeSelector(el);
1375      }
1376      return __fv_computeSelector(el);
1377    }
1378
1379    function __fv_safeElId(id) {
1380      return (typeof id === 'string' && id.length >= 1 && id.length <= 200 &&
1381        id.charAt(0) !== '{' && id.charAt(0) !== '[' &&
1382        !__fv_isUnstableFormId(id)) ? id : '';
1383    }
1384
1385    function __fv_actionOf(node) {
1386      var n = node;
1387      var depth = 0;
1388      while (n && n.getAttribute && depth < 8) {
1389        var a = n.getAttribute('data-fv-action');
1390        if (a) return String(a).slice(0, 200);
1391        n = n.parentElement;
1392        depth++;
1393      }
1394      return null;
1395    }
1396
1397    // Read a field's submitted value; join multi-value controls with ", ".
1398    function __fv_fieldValue(el) {
1399      var tag = el.tagName ? el.tagName.toLowerCase() : '';
1400      if (tag === 'select' && el.multiple && el.options) {
1401        var vals = [];
1402        for (var i = 0; i < el.options.length; i++) {
1403          if (el.options[i].selected) vals.push(el.options[i].value);
1404        }
1405        return vals.join(', ');
1406      }
1407      if (el.type === 'checkbox' || el.type === 'radio') {
1408        return el.checked ? (el.value || 'on') : '';
1409      }
1410      return el.value == null ? '' : String(el.value);
1411    }
1412
1413    // Classify by first match: email > phone > name > other.
1414    function __fv_classify(el, val) {
1415      var type = (el.type || '').toLowerCase();
1416      var name = el.name || '';
1417      var id = el.id || '';
1418      var ac = el.autocomplete || '';
1419      var doi = __fv_attr(el, 'data-original_id');
1420      var placeholder = __fv_attr(el, 'placeholder');
1421
1422      // Lead email: type / autocomplete / name only. Id or "value looks like
1423      // an email" would promote tool widgets (ev-email, a pasted address) to
1424      // leads. Those still land in form_data via the name||id key.
1425      if (type === 'email' || __FV_EMAIL_HINT_RX.test(ac) ||
1426          __FV_EMAIL_HINT_RX.test(name)) {
1427        return 'email';
1428      }
1429      if (type === 'tel' || /tel/i.test(ac) ||
1430          __FV_PHONE_HINT_RX.test(name) || __FV_PHONE_HINT_RX.test(id) ||
1431          __FV_PHONE_HINT_RX.test(placeholder) || __FV_PHONE_VAL_RX.test(val)) {
1432        return 'phone';
1433      }
1434      if (!__FV_USERNAME_RX.test(name) && !__FV_USERNAME_RX.test(id)) {
1435        if (ac === 'name' || ac === 'given-name' || ac === 'family-name' ||
1436            __FV_NAME_HINT_RX.test(name) || __FV_NAME_HINT_RX.test(id) ||
1437            __FV_NAME_HINT_RX.test(doi) || __FV_NAME_HINT_RX.test(placeholder)) {
1438          return 'name';
1439        }
1440      }
1441      return 'other';
1442    }
1443
1444    // Collect fillable, non-sensitive fields from a <form> or a formless group.
1445    // Name is preferred; id covers CMS/Astro widgets that omit name.
1446    function __fv_collectFields(container) {
1447      var raw;
1448      if (container.tagName === 'FORM' && container.elements) {
1449        raw = Array.prototype.slice.call(container.elements);
1450      } else {
1451        raw = Array.prototype.slice.call(
1452          container.querySelectorAll('input, textarea, select'));
1453      }
1454      return raw.filter(function (i) { return (i.name || i.id) && !shouldSkipElement(i); });
1455    }
1456
1457    var __FV_SEARCH_NAMES = { q: 1, s: 1, search: 1, query: 1, keyword: 1 };
1458
1459    function __fv_isSearchForm(form) {
1460      if (!form || form.tagName !== 'FORM') return false;
1461      if (String(form.method || 'get').toLowerCase() !== 'get') return false;
1462      try {
1463        var a = document.createElement('a');
1464        a.href = form.action;
1465        if (a.origin !== location.origin) return false;
1466      } catch (_) { return false; }
1467      var fields = __fv_collectFields(form);
1468      var n = 0;
1469      for (var i = 0; i < fields.length; i++) {
1470        if (!__fv_isFillableField(fields[i])) continue;
1471        var key = String(fields[i].name || fields[i].id || '').toLowerCase();
1472        if (!__FV_SEARCH_NAMES[key]) return false;
1473        n++;
1474      }
1475      return n > 0;
1476    }
1477
1478    function __fv_searchTerm(form) {
1479      var fields = __fv_collectFields(form);
1480      for (var i = 0; i < fields.length; i++) {
1481        if (!__fv_isFillableField(fields[i])) continue;
1482        var v = '';
1483        try { v = __fv_fieldValue(fields[i]); } catch (_) {}
1484        v = (v == null ? '' : String(v)).trim();
1485        if (v) return v.slice(0, 200);
1486      }
1487      return '';
1488    }
1489
1490    function __fv_fieldBreadcrumbs(container) {
1491      var fields = __fv_collectFields(container);
1492      var last = null, filled = 0;
1493      for (var i = 0; i < fields.length; i++) {
1494        var el = fields[i];
1495        if (!__fv_isFillableField(el)) continue;
1496        var v = '';
1497        try { v = __fv_fieldValue(el); } catch (_) {}
1498        v = (v == null ? '' : String(v)).trim();
1499        if (!v) continue;
1500        filled++;
1501        last = el.name || el.id || null;
1502      }
1503      return { last_field: last, filled_count: filled };
1504    }
1505
1506    function __fv_emitStart(container, isForm, firstField) {
1507      if (!container || container.__fv_form_started || shouldSkipElement(container)) return;
1508      if (isForm && __fv_isSearchForm(container)) return;
1509      container.__fv_form_started = true;
1510      sendEvent('$form_start', {
1511        pageview_id: __fv_pageview_id,
1512        form_id: __fv_formIdOf(container, isForm),
1513        form_name: isForm ? (container.name || null) : null,
1514        form_action: isForm ? (container.action || null) : null,
1515        page_url: pageUrl(),
1516        first_field: firstField || null,
1517      });
1518    }
1519
1520    // One submit path for both native submit and capture-phase click. `container`
1521    // is a <form> or a formless field-group. Deduped per-container within 1.5s.
1522    function captureFormSubmit(container, submitBtn) {
1523      if (!container || shouldSkipElement(container)) return;
1524      var isForm = container.tagName === 'FORM';
1525
1526      if (isForm && __fv_isSearchForm(container)) {
1527        sendEvent('$search', {
1528          pageview_id: __fv_pageview_id,
1529          form_id: __fv_formIdOf(container, true),
1530          page_url: pageUrl(),
1531          search_term: __fv_searchTerm(container),
1532        });
1533        return;
1534      }
1535
1536      var crumbs = __fv_fieldBreadcrumbs(container);
1537      container.__fv_last_field = crumbs.last_field;
1538      container.__fv_filled_count = crumbs.filled_count;
1539      if (!container.__fv_form_started) {
1540        __fv_emitStart(container, isForm, null); // first_field: null
1541      }
1542
1543      var fields = __fv_collectFields(container);
1544      if (!submitBtn && container.querySelector) {
1545        submitBtn = container.querySelector('[type=submit]');
1546      }
1547
1548      var formEmail = '', formPhone = '';
1549      var givenName = '', familyName = '', firstName = '';
1550      var formData = {}, formDataCount = 0;
1551
1552      if (CAPTURE_FORM_VALUES) {
1553        for (var i = 0; i < fields.length; i++) {
1554          var el = fields[i];
1555          if (!__fv_isFillableField(el)) continue;
1556          var val;
1557          try { val = __fv_fieldValue(el); } catch (_) { val = ''; }
1558          val = (val == null ? '' : String(val)).trim();
1559          if (!val) continue;
1560          var cls = __fv_classify(el, val);
1561          if (cls === 'email') {
1562            if (!formEmail) formEmail = val.toLowerCase().slice(0, __FV_MAX_EMAIL);
1563          } else if (cls === 'phone') {
1564            if (!formPhone) formPhone = val.slice(0, __FV_MAX_PHONE);
1565          } else if (cls === 'name') {
1566            var ac = el.autocomplete || '';
1567            if (ac === 'given-name') { if (!givenName) givenName = val; }
1568            else if (ac === 'family-name') { if (!familyName) familyName = val; }
1569            else if (!firstName) firstName = val;
1570          } else {
1571            if (formDataCount >= __FV_MAX_FORM_DATA_FIELDS) continue;
1572            var tag = el.tagName ? el.tagName.toLowerCase() : '';
1573            var cap = (tag === 'textarea' ||
1574                       /message/i.test(__fv_attr(el, 'data-field_type')))
1575              ? __FV_MAX_MESSAGE : __FV_MAX_VALUE;
1576            formData[el.name || el.id] = val.slice(0, cap);
1577            formDataCount++;
1578          }
1579        }
1580      }
1581
1582      var formName = (givenName || familyName)
1583        ? (givenName + (givenName && familyName ? ' ' : '') + familyName)
1584        : firstName;
1585      formName = formName.slice(0, __FV_MAX_NAME);
1586
1587      var modalEl = MODAL_AUTOCAPTURE ? __fv_acceptedModalAncestor(container) : null;
1588      var props = {
1589        pageview_id: __fv_pageview_id,
1590        form_id: __fv_formIdOf(container, isForm),
1591        form_name: isForm ? (container.name || null) : null,
1592        form_action: isForm ? (container.action || null) : null,
1593        form_method: isForm ? (container.method || null) : null,
1594        field_names: fields.map(function (i) { return i.name || i.id; }),
1595        field_count: fields.length,
1596        submit_text: submitBtn
1597          ? (submitBtn.textContent || submitBtn.value || '').trim().slice(0, 200)
1598          : null,
1599        page_url: pageUrl(),
1600        modal_id: modalEl ? __fv_modalId(modalEl) : null,
1601        modal_selector: modalEl ? __fv_computeSelector(modalEl) : null,
1602      };
1603      if (CAPTURE_FORM_VALUES) {
1604        if (formEmail) props.form_email = formEmail;
1605        if (formPhone) props.form_phone = formPhone;
1606        if (formName) props.form_name_value = formName;
1607        if (formDataCount > 0) props.form_data = formData;
1608      }
1609      props.last_field = crumbs.last_field;
1610      props.filled_count = crumbs.filled_count;
1611      var __fv_now = Date.now();
1612      for (var __fv_k in __fv_recent_submits) {
1613        if (__fv_now - __fv_recent_submits[__fv_k] > __FV_SUBMIT_WINDOW) delete __fv_recent_submits[__fv_k];
1614      }
1615      var __fv_sig = __fv_submitSig(props);
1616      if (__fv_recent_submits[__fv_sig] && __fv_now - __fv_recent_submits[__fv_sig] < __FV_SUBMIT_WINDOW) return;
1617      __fv_recent_submits[__fv_sig] = __fv_now;
1618
1619      sendEvent('$form_submit', props);
1620
1621      if (CAPTURE_FORM_VALUES && formEmail && __FV_EMAIL_RX.test(formEmail)) {
1622        sendIdentify(formEmail, 'form_field');
1623      }
1624    }
1625
1626    // $form_submit — native submit path delegates to the shared capture fn.
1627    document.addEventListener('submit', function (e) {
1628      var form = e.target;
1629      if (!form || form.tagName !== 'FORM') return;
1630      captureFormSubmit(form, null);
1631      if (form.querySelector && form.querySelector('input[type="password"]')) return;
1632      if (typeof __fv_isSearchForm === 'function' && __fv_isSearchForm(form)) return;
1633      setTimeout(function () {
1634        if (e.defaultPrevented) return;
1635        form.__fv_success_candidate = true;
1636      }, 0); // setTimeout(0)
1637    }, true);
1638
1639    // Capture-phase click on a submit control — covers AJAX forms (Divi,
1640    // CF7) that never fire a native submit. Resolves the form (or formless
1641    // group) and reads values BEFORE the framework clears/removes the form.
1642    // The content-signature dedupe in captureFormSubmit collapses the
1643    // native-submit + click double-fire on plain HTML forms.
1644    var __FV_SUBMIT_SELECTOR =
1645      'input[type=submit],button[type=submit],.et_pb_contact_submit,.wpcf7-submit';
1646    document.addEventListener('click', function (e) {
1647      var t = e.target;
1648      if (!t || !t.closest) return;
1649      var ctrl = t.closest(__FV_SUBMIT_SELECTOR);
1650      if (!ctrl) {
1651        // Type-less <button> inside a form, or a role=button submit control.
1652        var btn = t.closest('button,[role=button]');
1653        if (btn && btn.closest('form')) {
1654          var bt = btn.getAttribute('type');
1655          if (!bt || /submit/i.test(bt)) ctrl = btn;
1656        }
1657      }
1658      if (!ctrl || shouldSkipElement(ctrl)) return;
1659      var form = ctrl.closest && ctrl.closest('form');
1660      if (form) {
1661        if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
1662        captureFormSubmit(form, ctrl);
1663        return;
1664      }
1665      var group = __fv_findFieldGroup(ctrl);
1666      if (group) captureFormSubmit(group, ctrl);
1667    }, true);
1668
1669    // A field a user can type/pick into (excludes buttons/hidden).
1670    function __fv_isFillableField(el) {
1671      if (!el || !el.tagName) return false;
1672      var tg = el.tagName.toLowerCase();
1673      return tg === 'select' || tg === 'textarea' ||
1674        (tg === 'input' && !/^(submit|button|reset|hidden)$/i.test(el.type || ''));
1675    }
1676
1677    // Formless fallback: walk up from a focused field to the nearest ancestor
1678    // holding >=2 fillable fields, and treat that container as a logical form.
1679    // The >=2 rule keeps lone search/newsletter boxes from counting as forms.
1680    function __fv_findFieldGroup(el) {
1681      var node = el.parentElement, depth = 0;
1682      while (node && node !== document.body && depth < 8) {
1683        var fields = node.querySelectorAll('input, textarea, select');
1684        var count = 0;
1685        for (var i = 0; i < fields.length; i++) {
1686          if (__fv_isFillableField(fields[i]) && ++count >= 2) return node;
1687        }
1688        node = node.parentElement;
1689        depth++;
1690      }
1691      return null;
1692    }
1693
1694    // $form_start — first non-empty fill of a fillable field. Fires once per form
1695    // (marker on the element); pairs with $form_submit to measure conversion/abandonment.
1696    // input+change (not focusin) so empty focus is not a start. When there is no
1697    // <form> wrapper (e.g. Bubble), fall back to the nearest >=2-field container,
1698    // keyed by its CSS selector as form_id.
1699    function __fv_onStartCandidate(e) {
1700      var t = e.target;
1701      if (!t || !t.tagName || !t.closest) return;
1702      if (!__fv_isFillableField(t) || shouldSkipElement(t)) return;
1703      var val = '';
1704      try { val = __fv_fieldValue(t); } catch (_) {}
1705      if (!(val == null ? '' : String(val)).trim()) return;
1706      var firstField = t.name || t.id || null;
1707      var form = t.closest('form');
1708      if (form) { __fv_emitStart(form, true, firstField); return; }
1709      var group = __fv_findFieldGroup(t);
1710      if (group) __fv_emitStart(group, false, firstField);
1711    }
1712    document.addEventListener('input', __fv_onStartCandidate, true);
1713    document.addEventListener('change', __fv_onStartCandidate, true);
1714
1715    var __fv_success_emitted = {};
1716    var __fv_success_ignore = [];
1717
1718    function __fv_isShown(el) {
1719      if (!el) return false;
1720      try {
1721        var st = window.getComputedStyle(el);
1722        if (!st || st.display === 'none' || st.visibility === 'hidden') return false;
1723        if (parseFloat(st.opacity) === 0) return false;
1724        var r = el.getBoundingClientRect();
1725        return r.width > 0 && r.height > 0;
1726      } catch (_) { return false; }
1727    }
1728
1729    function __fv_successFormIdFromNode(el) {
1730      var v = __fv_attr(el, 'data-fv-success');
1731      if (v) return v.slice(0, 200);
1732      var form = el.closest && el.closest('form');
1733      if (form) {
1734        var id = __fv_formIdOf(form, true);
1735        if (id) return id;
1736      }
1737      var wrap = el.closest && el.closest('[data-fv-form]');
1738      if (wrap) {
1739        var ex = __fv_attr(wrap, 'data-fv-form');
1740        if (ex) return ex.slice(0, 200);
1741      }
1742      return null;
1743    }
1744
1745    function __fv_emitFormSuccess(formId, source, extras) {
1746      if (typeof formId !== 'string') return;
1747      formId = formId.replace(/^\s+|\s+$/g, '').slice(0, 200);
1748      if (!formId) return;
1749      var key = String(__fv_pageview_id || '') + '\0' + formId;
1750      if (__fv_success_emitted[key]) return;
1751      __fv_success_emitted[key] = 1;
1752      extras = extras || {};
1753      sendEvent('$form_success', {
1754        pageview_id: __fv_pageview_id,
1755        form_id: formId,
1756        form_name: extras.form_name || null,
1757        form_action: extras.form_action || null,
1758        page_url: pageUrl(),
1759        last_field: extras.last_field || null,
1760        filled_count: extras.filled_count || 0,
1761        source: source,
1762      });
1763    }
1764
1765    window.fv = window.fv || {};
1766    window.fv.formSuccess = function (formId) {
1767      if (typeof formId !== 'string') return;
1768      __fv_emitFormSuccess(formId, 'explicit', {});
1769    };
1770
1771    function __fv_considerSuccessNode(el) {
1772      if (!el || !el.getAttribute) return;
1773      if (!el.hasAttribute || !el.hasAttribute('data-fv-success')) return;
1774      if (__fv_success_ignore.indexOf(el) !== -1) return;
1775      if (!__fv_isShown(el)) return;
1776      var fid = __fv_successFormIdFromNode(el);
1777      if (!fid) return;
1778      __fv_emitFormSuccess(fid, 'explicit', {});
1779    }
1780
1781    try {
1782      var existing = document.querySelectorAll('[data-fv-success]');
1783      for (var i = 0; i < existing.length; i++) {
1784        if (__fv_isShown(existing[i])) __fv_success_ignore.push(existing[i]);
1785      }
1786      if (typeof MutationObserver === 'function' && document.documentElement) {
1787        var smo = new MutationObserver(function (muts) {
1788          for (var m = 0; m < muts.length; m++) {
1789            var mu = muts[m];
1790            if (mu.type === 'attributes' && mu.target && mu.target.getAttribute) {
1791              __fv_considerSuccessNode(mu.target);
1792            }
1793            var nodes = mu.addedNodes || [];
1794            for (var n = 0; n < nodes.length; n++) {
1795              var node = nodes[n];
1796              if (!node || node.nodeType !== 1) continue;
1797              if (node.hasAttribute && node.hasAttribute('data-fv-success')) {
1798                __fv_considerSuccessNode(node);
1799              }
1800              if (node.querySelectorAll) {
1801                var inner = node.querySelectorAll('[data-fv-success]');
1802                for (var k = 0; k < inner.length; k++) __fv_considerSuccessNode(inner[k]);
1803              }
1804            }
1805          }
1806        });
1807        smo.observe(document.documentElement, {
1808          subtree: true,
1809          childList: true,
1810          attributes: true,
1811          attributeFilter: ['data-fv-success', 'class', 'style', 'hidden'],
1812        });
1813      }
1814    } catch (_) {}
1815
1816    document.addEventListener('reset', function (e) {
1817      var form = e.target;
1818      if (!form || !form.__fv_success_candidate) return;
1819      form.__fv_success_candidate = false;
1820      __fv_emitFormSuccess(__fv_formIdOf(form, true), 'reset', {
1821        form_name: form.name || null,
1822        form_action: form.action || null,
1823        last_field: form.__fv_last_field || null,
1824        filled_count: form.__fv_filled_count || 0,
1825      });
1826    }, true);
1827
1828    window.addEventListener('pagehide', function () {
1829      var forms = document.getElementsByTagName('form');
1830      for (var i = 0; i < forms.length; i++) {
1831        var f = forms[i];
1832        if (!f.__fv_success_candidate) continue;
1833        f.__fv_success_candidate = false;
1834        __fv_emitFormSuccess(__fv_formIdOf(f, true), 'unload', {
1835          form_name: f.name || null,
1836          form_action: f.action || null,
1837          last_field: f.__fv_last_field || null,
1838          filled_count: f.__fv_filled_count || 0,
1839        });
1840      }
1841    });
1842
1843    // $exception — window.error + unhandledrejection. Truncate message/stack
1844    // so we don't ship megabyte payloads from runaway frameworks.
1845    //
1846    // Drop errors thrown by the visitor's own browser extensions — they're the
1847    // extension's bug, not ours, and were ~65% of all $exception volume. We test
1848    // the originating (top) stack frame, so genuine app errors that merely pass
1849    // through an extension-patched API deeper in the stack are still kept.
1850    var __FV_EXT_URL = /(?:chrome|moz|ms-browser|safari(?:-web)?)-extension:\/\//;
1851    function __fv_isExtensionError(stack, filename) {
1852      if (filename && __FV_EXT_URL.test(filename)) return true;
1853      if (!stack) return false;
1854      var lines = stack.split('\n');
1855      for (var i = 0; i < lines.length; i++) {
1856        if (/^\s*at\b/.test(lines[i])) return __FV_EXT_URL.test(lines[i]); // first frame only
1857      }
1858      return false;
1859    }
1860
1861    window.addEventListener('error', function (ev) {
1862      try {
1863        var msg = (ev.message || (ev.error && ev.error.message) || '').toString().slice(0, 500);
1864        var stack = (ev.error && ev.error.stack ? ev.error.stack.toString() : '').slice(0, 2000);
1865        if (__fv_isExtensionError(stack, ev.filename)) return;
1866        sendEvent('$exception', {
1867          pageview_id: __fv_pageview_id,
1868          message: msg,
1869          stack: stack,
1870          filename: ev.filename || null,
1871          lineno: typeof ev.lineno === 'number' ? ev.lineno : null,
1872          colno: typeof ev.colno === 'number' ? ev.colno : null,
1873          page_url: pageUrl(),
1874        });
1875      } catch (_) {}
1876    });
1877
1878    window.addEventListener('unhandledrejection', function (ev) {
1879      try {
1880        var reason = ev.reason;
1881        var msg = '';
1882        var stack = '';
1883        if (reason) {
1884          msg = (reason.message || (typeof reason === 'string' ? reason : '') || '').toString().slice(0, 500);
1885          stack = (reason.stack ? reason.stack.toString() : '').slice(0, 2000);
1886        }
1887        if (__fv_isExtensionError(stack, null)) return;
1888        sendEvent('$exception', {
1889          pageview_id: __fv_pageview_id,
1890          message: msg,
1891          stack: stack,
1892          filename: null,
1893          lineno: null,
1894          colno: null,
1895          page_url: pageUrl(),
1896        });
1897      } catch (_) {}
1898    });
1899
1900    // ─── T26: modal/popup autocapture (docs/specs/2026-06-10-modal-autocapture-design.md) ───
1901    // Global kill-switch. Flip to false + redeploy ingest to disable modal
1902    // autocapture everywhere (also nulls modal stamping on $click/$form_submit).
1903    var MODAL_AUTOCAPTURE = true;
1904
1905    // ARIA-strict: only elements that self-declare as dialogs are candidates.
1906    // Sites whose popups carry no dialog ARIA get no modal events — no noise
1907    // over coverage.
1908    var __FV_MODAL_CANDIDATE_SEL = 'dialog, [role=dialog], [aria-modal="true"]';
1909    // Cookie banners, toasts, dropdowns — dialog-ish roles we never count.
1910    var __FV_MODAL_ROLE_DENY_RX = /^(alertdialog|alert|status|tooltip|menu|listbox)$/i;
1911
1912    function __fv_isAcceptedModal(el) {
1913      if (!el || !el.matches) return false;
1914      try { if (!el.matches(__FV_MODAL_CANDIDATE_SEL)) return false; } catch (_) { return false; }
1915      var role = el.getAttribute && el.getAttribute('role');
1916      if (role && __FV_MODAL_ROLE_DENY_RX.test(role)) return false;
1917      if (shouldSkipElement(el)) return false;
1918      return true;
1919    }
1920
1921    // Shared accept helper — the observer and the $click/$form_submit stamping
1922    // both resolve modals through this, so detection and stamping can never
1923    // disagree on what counts as a modal. Visibility is deliberately NOT
1924    // checked here: stamping callers just interacted with the element, so it
1925    // is visible by definition; the observer adds its own visibility check.
1926    function __fv_acceptedModalAncestor(el) {
1927      var node = el;
1928      while (node && node.tagName) {
1929        if (__fv_isAcceptedModal(node)) return node;
1930        node = node.parentElement;
1931      }
1932      return null;
1933    }
1934
1935    function __fv_modalVisible(el) {
1936      if (!el.isConnected) return false;
1937      var cs;
1938      try { cs = getComputedStyle(el); } catch (_) { return false; }
1939      if (cs.display === 'none' || cs.visibility === 'hidden') return false;
1940      if (!(parseFloat(cs.opacity) > 0.1)) return false;
1941      var r = el.getBoundingClientRect();
1942      return r.width > 0 && r.height > 0;
1943    }
1944
1945    function __fv_modalRole(el) {
1946      if (el.tagName && el.tagName.toLowerCase() === 'dialog') return 'native-dialog';
1947      if (el.getAttribute && el.getAttribute('role') === 'dialog') return 'dialog';
1948      return 'aria-modal';
1949    }
1950
1951    function __fv_modalId(el) {
1952      return el.id || __fv_computeSelector(el);
1953    }
1954
1955    // Registry of every candidate we have ever evaluated — needed to re-check
1956    // tracked modals when an ANCESTOR mutates (wrapper-driven hides) or the
1957    // element leaves the DOM.
1958    var __fv_modal_registry = [];
1959
1960    // Per-pageview impression cap (same spirit as the 200-click cap). The
1961    // counter lazily resets when __fv_pageview_id rotates (SPA route change).
1962    var __fv_modal_shown_count = 0;
1963    var __fv_modal_count_pv = null;
1964    function __fv_modalCapOk() {
1965      if (__fv_modal_count_pv !== __fv_pageview_id) {
1966        __fv_modal_count_pv = __fv_pageview_id;
1967        __fv_modal_shown_count = 0;
1968      }
1969      return __fv_modal_shown_count < 20;
1970    }
1971
1972    function __fv_fireModalShown(el) {
1973      var now = Date.now();
1974      // pageview_id/page_url are snapshotted here and reused by the matching
1975      // $modal_hidden, so a shown/hidden pair always attributes to the same
1976      // pageview even across an SPA route change.
1977      el.__fv_modal_pageview_id = __fv_pageview_id;
1978      el.__fv_modal_page_url = pageUrl();
1979      el.__fv_modal_shown_at = now;
1980      el.__fv_modal_interacted = false; // re-opens don't inherit prior interaction
1981      el.__fv_modal_last_fire_at = now;
1982      el.__fv_modal_shown_fired = true;
1983      __fv_modal_shown_count++;
1984      sendEvent('$modal_shown', {
1985        pageview_id: el.__fv_modal_pageview_id,
1986        modal_id: __fv_modalId(el),
1987        modal_selector: __fv_computeSelector(el),
1988        modal_role: __fv_modalRole(el),
1989        text_snippet: (el.textContent || '').trim().slice(0, 120),
1990        data_attrs: __fv_readDataAttrs(el),
1991        page_url: el.__fv_modal_page_url,
1992      });
1993    }
1994
1995    function __fv_fireModalHidden(el) {
1996      sendEvent('$modal_hidden', {
1997        pageview_id: el.__fv_modal_pageview_id,
1998        modal_id: __fv_modalId(el),
1999        modal_selector: __fv_computeSelector(el),
2000        modal_role: __fv_modalRole(el),
2001        visible_ms: Date.now() - el.__fv_modal_shown_at,
2002        interacted: !!el.__fv_modal_interacted,
2003        page_url: el.__fv_modal_page_url,
2004      });
2005    }
2006
2007    // Transition-only state machine. Steady states are no-ops. $modal_hidden
2008    // only fires when the current visible period actually emitted a
2009    // $modal_shown (__fv_modal_shown_fired) — pairs stay paired even when the
2010    // cap or the refire guard suppressed the shown.
2011    function __fv_checkModal(el) {
2012      if (!__fv_isAcceptedModal(el)) return;
2013      if (!el.__fv_modal_tracked) {
2014        el.__fv_modal_tracked = true;
2015        __fv_modal_registry.push(el);
2016      }
2017      var vis = __fv_modalVisible(el);
2018      var was = !!el.__fv_modal_visible;
2019      if (vis === was) return;
2020      el.__fv_modal_visible = vis;
2021      if (vis) {
2022        var now = Date.now();
2023        // 1s per-element refire guard absorbs animation flapping.
2024        if (el.__fv_modal_last_fire_at && now - el.__fv_modal_last_fire_at < 1000) {
2025          el.__fv_modal_shown_fired = false;
2026          return;
2027        }
2028        if (!__fv_modalCapOk()) {
2029          el.__fv_modal_shown_fired = false;
2030          return;
2031        }
2032        __fv_fireModalShown(el);
2033      } else if (el.__fv_modal_shown_fired) {
2034        el.__fv_modal_shown_fired = false;
2035        __fv_fireModalHidden(el);
2036      }
2037    }
2038
2039    // Mutation batches are coalesced via a ~100ms debounce timer, then turned
2040    // into a unique candidate set: attribute targets + their candidate
2041    // descendants, childList added/removed nodes + their candidate
2042    // descendants, and already-tracked modals whose ancestor chain contains a
2043    // mutated target (wrapper-driven hides) or that left the DOM.
2044    var __fv_modal_mutation_queue = [];
2045    var __fv_modal_debounce = null;
2046
2047    function __fv_processModalMutations() {
2048      __fv_modal_debounce = null;
2049      var batch = __fv_modal_mutation_queue;
2050      __fv_modal_mutation_queue = [];
2051      var seen = [];
2052      function add(el) {
2053        if (!el || el.nodeType !== 1) return;
2054        if (seen.indexOf(el) === -1) seen.push(el);
2055      }
2056      function addWithDescendants(el) {
2057        if (!el || el.nodeType !== 1) return;
2058        try {
2059          if (el.matches && el.matches(__FV_MODAL_CANDIDATE_SEL)) add(el);
2060          if (el.querySelectorAll) {
2061            var ds = el.querySelectorAll(__FV_MODAL_CANDIDATE_SEL);
2062            for (var i = 0; i < ds.length; i++) add(ds[i]);
2063          }
2064        } catch (_) {}
2065      }
2066      var mutatedTargets = [];
2067      for (var b = 0; b < batch.length; b++) {
2068        var m = batch[b];
2069        if (m.target && mutatedTargets.indexOf(m.target) === -1) mutatedTargets.push(m.target);
2070        if (m.type === 'attributes') {
2071          addWithDescendants(m.target);
2072        } else if (m.type === 'childList') {
2073          for (var a = 0; a < m.addedNodes.length; a++) addWithDescendants(m.addedNodes[a]);
2074          for (var rm = 0; rm < m.removedNodes.length; rm++) addWithDescendants(m.removedNodes[rm]);
2075        }
2076      }
2077      for (var r = 0; r < __fv_modal_registry.length; r++) {
2078        var el = __fv_modal_registry[r];
2079        if (seen.indexOf(el) !== -1) continue;
2080        if (!el.isConnected) { add(el); continue; } // removal → hidden transition
2081        for (var t = 0; t < mutatedTargets.length; t++) {
2082          var mt = mutatedTargets[t];
2083          if (mt && mt !== el && mt.contains && mt.contains(el)) { add(el); break; }
2084        }
2085      }
2086      for (var c = 0; c < seen.length; c++) {
2087        try { __fv_checkModal(seen[c]); } catch (_) {}
2088      }
2089    }
2090
2091    function __fv_attachModalObserver() {
2092      if (typeof MutationObserver !== 'function' || !document.body) return;
2093      var mo = new MutationObserver(function (muts) {
2094        for (var i = 0; i < muts.length; i++) __fv_modal_mutation_queue.push(muts[i]);
2095        if (!__fv_modal_debounce) {
2096          __fv_modal_debounce = setTimeout(__fv_processModalMutations, 100);
2097        }
2098      });
2099      mo.observe(document.body, {
2100        childList: true,
2101        subtree: true,
2102        attributes: true,
2103        attributeFilter: ['open', 'aria-hidden', 'style', 'class', 'hidden'],
2104      });
2105      // Init scan — fire $modal_shown for modals already visible at attach.
2106      var initial = document.body.querySelectorAll(__FV_MODAL_CANDIDATE_SEL);
2107      for (var i = 0; i < initial.length; i++) {
2108        try { __fv_checkModal(initial[i]); } catch (_) {}
2109      }
2110    }
2111
2112    if (MODAL_AUTOCAPTURE) {
2113      // Body-ready guard: the tracker can execute from <head> before <body>
2114      // exists — defer attach + init scan to DOMContentLoaded in that case.
2115      if (document.body) __fv_attachModalObserver();
2116      else document.addEventListener('DOMContentLoaded', __fv_attachModalObserver);
2117    }
2118  })();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.