cryptique-sdk 1.2.17 → 1.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cjs/index.js CHANGED
@@ -5519,104 +5519,157 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5519
5519
 
5520
5520
  /**
5521
5521
  * ElementVisibilityTracker - Emits element_view auto events when elements
5522
- * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5523
- * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5524
- * vs just didn't click them completely different problems, opposite fixes.
5522
+ * enter the viewport for ≥ 1 second, capturing the actual dwell duration.
5523
+ *
5524
+ * Covers interactive elements (buttons, links, inputs, headings, images) and
5525
+ * any element with an id or data-cq-track attribute. Each viewport entry is
5526
+ * tracked independently — if a user scrolls away and back, both dwells count.
5527
+ * This gives accurate attention data for the heatmap attention overlay.
5525
5528
  */
5526
5529
  startElementVisibilityTracking() {
5527
5530
  try {
5528
5531
  if (typeof IntersectionObserver === 'undefined') return;
5529
5532
 
5530
- // Only observe elements with an id or data-cq-track attribute
5533
+ // Observe interactive/structural elements in addition to id/data-cq-track
5534
+ const TRACKABLE_SELECTOR = [
5535
+ '[id]:not([id=""])', '[data-cq-track]',
5536
+ 'button', 'a[href]', 'input:not([type=hidden])', 'select', 'textarea',
5537
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img[alt]',
5538
+ '[role="button"]', '[role="tab"]', '[role="menuitem"]', '[role="link"]'
5539
+ ].join(', ');
5540
+
5531
5541
  const getTrackableElements = () =>
5532
- document.querySelectorAll('[id]:not([id=""]), [data-cq-track]');
5542
+ Array.from(document.querySelectorAll(TRACKABLE_SELECTOR)).filter(el => {
5543
+ try { return el.getBoundingClientRect().height >= 20; } catch (_) { return true; }
5544
+ });
5533
5545
 
5534
- const viewTimers = new Map(); // elementKey setTimeout handle
5535
- const reported = new Set(); // elementKey → already fired once per page load
5546
+ // enterTimes: key { enterTime, el } tracks elements currently in viewport
5547
+ const enterTimes = new Map();
5548
+ const observedEls = new WeakSet(); // prevents double-observing same DOM node
5549
+
5550
+ function elementKey(el) {
5551
+ return `${el.tagName}|${el.id || ''}|${el.getAttribute('data-cq-track') || ''}|${(el.textContent || '').trim().slice(0, 30)}`;
5552
+ }
5553
+
5554
+ function getElementData(el) {
5555
+ return {
5556
+ element_id: el.id || '',
5557
+ element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5558
+ element_tag_name: el.tagName || '',
5559
+ element_type: el.type || el.getAttribute('type') || '',
5560
+ element_class: el.className || '',
5561
+ element_text: (el.textContent || '').trim().slice(0, 100)
5562
+ };
5563
+ }
5536
5564
 
5537
5565
  const observer = new IntersectionObserver((entries) => {
5538
5566
  entries.forEach((entry) => {
5539
- const el = entry.target;
5540
- const key = el.id || el.getAttribute('data-cq-track') || el.className;
5541
- if (!key || reported.has(key)) return;
5567
+ const el = entry.target;
5568
+ const key = elementKey(el);
5542
5569
 
5543
5570
  if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5544
- // Element entered viewport — start 1s timer
5545
- if (!viewTimers.has(key)) {
5546
- const enterTime = Date.now();
5547
- const timer = setTimeout(() => {
5548
- if (reported.has(key)) return;
5549
- reported.add(key);
5550
- EventsManager.trackAutoEvent('element_view', {
5551
- time_in_viewport_ms: Date.now() - enterTime,
5552
- viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5553
- }, {
5554
- element_id: el.id || '',
5555
- element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5556
- element_tag_name: el.tagName || '',
5557
- element_type: el.type || el.getAttribute('type') || '',
5558
- element_class: el.className || '',
5559
- element_text: (el.textContent || '').trim().slice(0, 100)
5560
- });
5561
- viewTimers.delete(key);
5562
- }, 1000);
5563
- viewTimers.set(key, timer);
5571
+ // Element entered viewport — record entry time
5572
+ if (!enterTimes.has(key)) {
5573
+ enterTimes.set(key, { enterTime: Date.now(), el });
5564
5574
  }
5565
5575
  } else {
5566
- // Element left viewport before 1s cancel timer
5567
- if (viewTimers.has(key)) {
5568
- clearTimeout(viewTimers.get(key));
5569
- viewTimers.delete(key);
5576
+ // Element left viewport emit actual dwell if ≥ 1s
5577
+ const record = enterTimes.get(key);
5578
+ if (record) {
5579
+ const dwell = Date.now() - record.enterTime;
5580
+ enterTimes.delete(key);
5581
+ if (dwell >= 1000) {
5582
+ EventsManager.trackAutoEvent('element_view', {
5583
+ time_in_viewport_ms: dwell,
5584
+ viewport_percent_visible: Math.round(entry.intersectionRatio * 100),
5585
+ scroll_y_at_view: window.scrollY
5586
+ }, getElementData(el));
5587
+ }
5570
5588
  }
5571
5589
  }
5572
5590
  });
5573
- }, { threshold: 0.5 });
5591
+ }, { threshold: [0.5] });
5592
+
5593
+ const observe = (el) => {
5594
+ if (!observedEls.has(el)) { observedEls.add(el); observer.observe(el); }
5595
+ };
5574
5596
 
5575
- // Observe existing elements
5576
- getTrackableElements().forEach(el => observer.observe(el));
5597
+ getTrackableElements().forEach(observe);
5577
5598
 
5578
5599
  // Observe elements added to DOM later (SPAs)
5579
5600
  if (typeof MutationObserver !== 'undefined') {
5580
5601
  new MutationObserver(() => {
5581
- getTrackableElements().forEach(el => {
5582
- if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5583
- observer.observe(el);
5584
- }
5585
- });
5602
+ getTrackableElements().forEach(observe);
5586
5603
  }).observe(document.body, { childList: true, subtree: true });
5587
5604
  }
5588
- } catch (error) {
5589
- }
5605
+
5606
+ // On page unload, flush elements still in viewport
5607
+ window.addEventListener('pagehide', () => {
5608
+ enterTimes.forEach(({ enterTime, el }) => {
5609
+ const dwell = Date.now() - enterTime;
5610
+ if (dwell >= 1000) {
5611
+ EventsManager.trackAutoEvent('element_view', {
5612
+ time_in_viewport_ms: dwell,
5613
+ viewport_percent_visible: 100,
5614
+ scroll_y_at_view: window.scrollY
5615
+ }, getElementData(el));
5616
+ }
5617
+ });
5618
+ });
5619
+ } catch (error) {}
5590
5620
  },
5591
5621
 
5592
5622
  /**
5593
5623
  * PageSummaryTracker — collects high-frequency signals in-memory and flushes
5594
5624
  * them as a single 'page_summary' auto-event on page unload.
5595
5625
  *
5596
- * This keeps event counts low (1 per page visit) while capturing:
5597
- * - Mouse movement grid (move_grid)
5598
- * - Hover dwell grid (hover_grid)
5626
+ * High-volume/continuous data lives here (grids, counts) to keep event volume low.
5627
+ * Low-frequency, high-signal moments (exit_intent) are emitted as separate auto-events
5628
+ * but their counters are also included here for correlation.
5629
+ *
5630
+ * Captures:
5631
+ * - Mouse movement grid (move_grid) + hover dwell grid (hover_grid)
5632
+ * - Element-level hover dwells for named/interactive elements (element_dwells)
5599
5633
  * - Tab-switch count and hidden time
5600
- * - Scroll reversal count
5634
+ * - Scroll reversal count + depths at each reversal (scroll_reversal_depths)
5601
5635
  * - Per-field focus dwell times
5636
+ * - Exit intent count + scroll depth at last exit intent
5637
+ * - First interaction time (how long before user engaged)
5638
+ * - Performance: CLS score, long task count/max, INP
5602
5639
  */
5603
5640
  startPageSummaryTracking() {
5604
5641
  try {
5605
5642
  const GRID_SIZE = 40;
5606
- // In-memory accumulators — never sent to the server individually
5607
- const moveGrid = new Map(); // cellKey → move count
5608
- const hoverGrid = new Map(); // cellKey → total dwell_ms
5609
- const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5610
-
5611
- let tabSwitches = 0;
5612
- let totalTabHiddenMs = 0;
5613
- let tabHiddenTime = null;
5614
- let scrollReversals = 0;
5615
5643
 
5616
- // Track last significant scroll position for reversal detection
5644
+ // In-memory accumulators never sent to the server individually
5645
+ const moveGrid = new Map(); // cellKey → move count
5646
+ const hoverGrid = new Map(); // cellKey → total dwell_ms
5647
+ const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5648
+ const elementDwellsMap = new Map(); // elemKey → {tag, id, text, dwell_ms}
5649
+ const scrollReversalDepths = []; // scroll depth % at each reversal
5650
+
5651
+ let tabSwitches = 0;
5652
+ let totalTabHiddenMs = 0;
5653
+ let tabHiddenTime = null;
5654
+ let scrollReversals = 0;
5655
+ let exitIntentCount = 0;
5656
+ let exitIntentLastScrollDepth = 0;
5657
+ let firstInteractionMs = null;
5658
+ let clsScore = 0;
5659
+ let longTasksCount = 0;
5660
+ let maxLongTaskMs = 0;
5661
+ let inpMs = 0;
5662
+
5663
+ const pageLoadTime = Date.now();
5617
5664
  let lastSigScrollY = window.scrollY;
5618
5665
  let lastSigDir = 0; // 1=down, -1=up
5619
5666
 
5667
+ // ── Helpers
5668
+ function getScrollDepth() {
5669
+ const maxScroll = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight;
5670
+ return maxScroll <= 0 ? 100 : Math.round((window.scrollY / maxScroll) * 100);
5671
+ }
5672
+
5620
5673
  function getGridCell(pageX, pageY) {
5621
5674
  const docW = Math.max(1, document.documentElement.scrollWidth || document.body.scrollWidth || window.innerWidth);
5622
5675
  const docH = Math.max(1, document.documentElement.scrollHeight || document.body.scrollHeight || window.innerHeight);
@@ -5625,6 +5678,39 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5625
5678
  return col * GRID_SIZE + row;
5626
5679
  }
5627
5680
 
5681
+ // ── PerformanceObserver: CLS, Long Tasks, INP (best-effort, not all browsers)
5682
+ try {
5683
+ new PerformanceObserver(list => {
5684
+ for (const e of list.getEntries()) {
5685
+ if (!e.hadRecentInput) clsScore = Math.round((clsScore + e.value) * 1000) / 1000;
5686
+ }
5687
+ }).observe({ type: 'layout-shift', buffered: true });
5688
+ } catch (_) {}
5689
+ try {
5690
+ new PerformanceObserver(list => {
5691
+ for (const e of list.getEntries()) {
5692
+ longTasksCount++;
5693
+ if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
5694
+ }
5695
+ }).observe({ type: 'longtask', buffered: true });
5696
+ } catch (_) {}
5697
+ try {
5698
+ new PerformanceObserver(list => {
5699
+ for (const e of list.getEntries()) {
5700
+ if (e.duration > inpMs) inpMs = Math.round(e.duration);
5701
+ }
5702
+ }).observe({ type: 'event', durationThreshold: 40, buffered: true });
5703
+ } catch (_) {}
5704
+
5705
+ // ── First interaction: time from page load to first meaningful engagement
5706
+ const markFirstInteraction = () => {
5707
+ if (firstInteractionMs === null) firstInteractionMs = Date.now() - pageLoadTime;
5708
+ };
5709
+ document.addEventListener('click', markFirstInteraction, { once: true, passive: true });
5710
+ document.addEventListener('keydown', markFirstInteraction, { once: true, passive: true });
5711
+ document.addEventListener('touchstart', markFirstInteraction, { once: true, passive: true });
5712
+ window.addEventListener('scroll', markFirstInteraction, { once: true, passive: true });
5713
+
5628
5714
  // ── Mouse movement → move_grid (throttled to max 1 sample / 100 ms)
5629
5715
  let moveThrottle = false;
5630
5716
  document.addEventListener('mousemove', (e) => {
@@ -5635,7 +5721,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5635
5721
  moveGrid.set(key, (moveGrid.get(key) || 0) + 1);
5636
5722
  }, { passive: true });
5637
5723
 
5638
- // ── Hover dwell → hover_grid (track all elements, filter < 100 ms)
5724
+ // ── Hover dwell → hover_grid + element_dwells for named/interactive elements
5725
+ const ELEMENT_DWELL_TAGS = new Set(['BUTTON', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'INPUT', 'SELECT', 'TEXTAREA', 'IMG', 'LABEL']);
5639
5726
  const hoverStarts = new Map(); // element → enterTime
5640
5727
  document.addEventListener('mouseover', (e) => {
5641
5728
  if (e.target && e.target !== document.documentElement) {
@@ -5654,6 +5741,22 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5654
5741
  const cy = rect.top + rect.height / 2 + window.scrollY;
5655
5742
  const key = getGridCell(cx, cy);
5656
5743
  hoverGrid.set(key, (hoverGrid.get(key) || 0) + dwell);
5744
+
5745
+ // Element-level dwell: track named/interactive elements hovered ≥ 300ms
5746
+ if (dwell >= 300 && (target.id || ELEMENT_DWELL_TAGS.has(target.tagName))) {
5747
+ const elemKey = `${target.tagName}|${target.id || ''}|${(target.textContent || '').trim().slice(0, 30)}`;
5748
+ const existing = elementDwellsMap.get(elemKey);
5749
+ if (existing) {
5750
+ existing.dwell_ms += dwell;
5751
+ } else {
5752
+ elementDwellsMap.set(elemKey, {
5753
+ tag: target.tagName.toLowerCase(),
5754
+ id: target.id || '',
5755
+ text: (target.textContent || '').trim().slice(0, 50),
5756
+ dwell_ms: dwell
5757
+ });
5758
+ }
5759
+ }
5657
5760
  } catch (_) {}
5658
5761
  }, { passive: true });
5659
5762
 
@@ -5668,14 +5771,15 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5668
5771
  }
5669
5772
  });
5670
5773
 
5671
- // ── Scroll reversal detection (direction change > 200 px)
5774
+ // ── Scroll reversal detection (direction change 200 px) + depth recording
5672
5775
  window.addEventListener('scroll', () => {
5673
5776
  const curr = window.scrollY;
5674
5777
  const diff = curr - lastSigScrollY;
5675
- if (Math.abs(diff) < 50) return; // ignore tiny movements
5676
- const dir = diff > 0 ? 1 : -1;
5778
+ if (Math.abs(diff) < 50) return;
5779
+ const dir = diff > 0 ? 1 : -1;
5677
5780
  if (lastSigDir !== 0 && dir !== lastSigDir && Math.abs(curr - lastSigScrollY) >= 200) {
5678
5781
  scrollReversals++;
5782
+ scrollReversalDepths.push(getScrollDepth());
5679
5783
  }
5680
5784
  if (Math.abs(diff) >= 50) { lastSigDir = dir; lastSigScrollY = curr; }
5681
5785
  }, { passive: true });
@@ -5693,7 +5797,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5693
5797
  if (!t || !fieldFocusTimes.has(t)) return;
5694
5798
  const dwell = Date.now() - fieldFocusTimes.get(t);
5695
5799
  fieldFocusTimes.delete(t);
5696
- if (dwell < 100) return; // skip accidental focus
5800
+ if (dwell < 100) return;
5697
5801
  const label = t.id
5698
5802
  ? (document.querySelector(`label[for="${CSS.escape(t.id)}"]`)?.textContent?.trim() || t.placeholder || t.name || t.id)
5699
5803
  : (t.placeholder || t.name || t.type || '');
@@ -5706,6 +5810,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5706
5810
  });
5707
5811
  }, true);
5708
5812
 
5813
+ // ── Exit intent: mouse leaving viewport toward the top (user about to navigate away)
5814
+ // Also fires as a standalone auto-event for direct querying.
5815
+ let lastExitIntentTime = 0;
5816
+ document.addEventListener('mouseleave', (e) => {
5817
+ if (e.clientY > 0 || e.relatedTarget !== null) return; // only top-edge exits
5818
+ const now = Date.now();
5819
+ if (now - lastExitIntentTime < 2000) return; // debounce: max once per 2s
5820
+ lastExitIntentTime = now;
5821
+ exitIntentCount++;
5822
+ exitIntentLastScrollDepth = getScrollDepth();
5823
+ EventsManager.trackAutoEvent('exit_intent', {
5824
+ scroll_depth: exitIntentLastScrollDepth,
5825
+ time_on_page_ms: now - pageLoadTime,
5826
+ scroll_y: window.scrollY,
5827
+ document_height: Math.min(
5828
+ document.documentElement.scrollHeight || document.body.scrollHeight, 25000
5829
+ )
5830
+ }).catch(() => {});
5831
+ });
5832
+
5709
5833
  // ── Serialise grid Map to [[col, row, value], ...] sparse array
5710
5834
  function serializeGrid(grid) {
5711
5835
  const out = [];
@@ -5722,17 +5846,34 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5722
5846
  let summarySent = false;
5723
5847
  function firePageSummary() {
5724
5848
  if (summarySent) return; // only fire once per page lifecycle
5725
- if (moveGrid.size === 0 && hoverGrid.size === 0 && fieldDwells.length === 0
5726
- && tabSwitches === 0 && scrollReversals === 0) return;
5849
+ const hasData = moveGrid.size > 0 || hoverGrid.size > 0 || fieldDwells.length > 0
5850
+ || tabSwitches > 0 || scrollReversals > 0 || exitIntentCount > 0
5851
+ || firstInteractionMs !== null || clsScore > 0 || longTasksCount > 0;
5852
+ if (!hasData) return;
5727
5853
  summarySent = true;
5728
5854
  try {
5855
+ // Serialize element_dwells: filter ≥ 300ms, sort desc, cap at 20
5856
+ const elementDwells = Array.from(elementDwellsMap.values())
5857
+ .filter(e => e.dwell_ms >= 300)
5858
+ .sort((a, b) => b.dwell_ms - a.dwell_ms)
5859
+ .slice(0, 20);
5860
+
5729
5861
  EventsManager.trackAutoEvent('page_summary', {
5730
- scroll_reversals: scrollReversals,
5731
- tab_switches: tabSwitches,
5732
- total_tab_hidden_ms: totalTabHiddenMs,
5733
- move_grid: serializeGrid(moveGrid),
5734
- hover_grid: serializeGrid(hoverGrid),
5735
- field_dwells: fieldDwells,
5862
+ scroll_reversals: scrollReversals,
5863
+ scroll_reversal_depths: scrollReversalDepths,
5864
+ tab_switches: tabSwitches,
5865
+ total_tab_hidden_ms: totalTabHiddenMs,
5866
+ move_grid: serializeGrid(moveGrid),
5867
+ hover_grid: serializeGrid(hoverGrid),
5868
+ field_dwells: fieldDwells,
5869
+ element_dwells: elementDwells,
5870
+ first_interaction_ms: firstInteractionMs,
5871
+ exit_intent_count: exitIntentCount,
5872
+ exit_intent_last_scroll_depth: exitIntentLastScrollDepth,
5873
+ cls_score: clsScore,
5874
+ long_tasks_count: longTasksCount,
5875
+ max_long_task_ms: maxLongTaskMs,
5876
+ inp_ms: inpMs,
5736
5877
  document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5737
5878
  document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5738
5879
  viewport_width: window.innerWidth,
package/lib/esm/index.js CHANGED
@@ -5517,104 +5517,157 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5517
5517
 
5518
5518
  /**
5519
5519
  * ElementVisibilityTracker - Emits element_view auto events when elements
5520
- * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5521
- * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5522
- * vs just didn't click them completely different problems, opposite fixes.
5520
+ * enter the viewport for ≥ 1 second, capturing the actual dwell duration.
5521
+ *
5522
+ * Covers interactive elements (buttons, links, inputs, headings, images) and
5523
+ * any element with an id or data-cq-track attribute. Each viewport entry is
5524
+ * tracked independently — if a user scrolls away and back, both dwells count.
5525
+ * This gives accurate attention data for the heatmap attention overlay.
5523
5526
  */
5524
5527
  startElementVisibilityTracking() {
5525
5528
  try {
5526
5529
  if (typeof IntersectionObserver === 'undefined') return;
5527
5530
 
5528
- // Only observe elements with an id or data-cq-track attribute
5531
+ // Observe interactive/structural elements in addition to id/data-cq-track
5532
+ const TRACKABLE_SELECTOR = [
5533
+ '[id]:not([id=""])', '[data-cq-track]',
5534
+ 'button', 'a[href]', 'input:not([type=hidden])', 'select', 'textarea',
5535
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img[alt]',
5536
+ '[role="button"]', '[role="tab"]', '[role="menuitem"]', '[role="link"]'
5537
+ ].join(', ');
5538
+
5529
5539
  const getTrackableElements = () =>
5530
- document.querySelectorAll('[id]:not([id=""]), [data-cq-track]');
5540
+ Array.from(document.querySelectorAll(TRACKABLE_SELECTOR)).filter(el => {
5541
+ try { return el.getBoundingClientRect().height >= 20; } catch (_) { return true; }
5542
+ });
5531
5543
 
5532
- const viewTimers = new Map(); // elementKey setTimeout handle
5533
- const reported = new Set(); // elementKey → already fired once per page load
5544
+ // enterTimes: key { enterTime, el } tracks elements currently in viewport
5545
+ const enterTimes = new Map();
5546
+ const observedEls = new WeakSet(); // prevents double-observing same DOM node
5547
+
5548
+ function elementKey(el) {
5549
+ return `${el.tagName}|${el.id || ''}|${el.getAttribute('data-cq-track') || ''}|${(el.textContent || '').trim().slice(0, 30)}`;
5550
+ }
5551
+
5552
+ function getElementData(el) {
5553
+ return {
5554
+ element_id: el.id || '',
5555
+ element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5556
+ element_tag_name: el.tagName || '',
5557
+ element_type: el.type || el.getAttribute('type') || '',
5558
+ element_class: el.className || '',
5559
+ element_text: (el.textContent || '').trim().slice(0, 100)
5560
+ };
5561
+ }
5534
5562
 
5535
5563
  const observer = new IntersectionObserver((entries) => {
5536
5564
  entries.forEach((entry) => {
5537
- const el = entry.target;
5538
- const key = el.id || el.getAttribute('data-cq-track') || el.className;
5539
- if (!key || reported.has(key)) return;
5565
+ const el = entry.target;
5566
+ const key = elementKey(el);
5540
5567
 
5541
5568
  if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5542
- // Element entered viewport — start 1s timer
5543
- if (!viewTimers.has(key)) {
5544
- const enterTime = Date.now();
5545
- const timer = setTimeout(() => {
5546
- if (reported.has(key)) return;
5547
- reported.add(key);
5548
- EventsManager.trackAutoEvent('element_view', {
5549
- time_in_viewport_ms: Date.now() - enterTime,
5550
- viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5551
- }, {
5552
- element_id: el.id || '',
5553
- element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5554
- element_tag_name: el.tagName || '',
5555
- element_type: el.type || el.getAttribute('type') || '',
5556
- element_class: el.className || '',
5557
- element_text: (el.textContent || '').trim().slice(0, 100)
5558
- });
5559
- viewTimers.delete(key);
5560
- }, 1000);
5561
- viewTimers.set(key, timer);
5569
+ // Element entered viewport — record entry time
5570
+ if (!enterTimes.has(key)) {
5571
+ enterTimes.set(key, { enterTime: Date.now(), el });
5562
5572
  }
5563
5573
  } else {
5564
- // Element left viewport before 1s cancel timer
5565
- if (viewTimers.has(key)) {
5566
- clearTimeout(viewTimers.get(key));
5567
- viewTimers.delete(key);
5574
+ // Element left viewport emit actual dwell if ≥ 1s
5575
+ const record = enterTimes.get(key);
5576
+ if (record) {
5577
+ const dwell = Date.now() - record.enterTime;
5578
+ enterTimes.delete(key);
5579
+ if (dwell >= 1000) {
5580
+ EventsManager.trackAutoEvent('element_view', {
5581
+ time_in_viewport_ms: dwell,
5582
+ viewport_percent_visible: Math.round(entry.intersectionRatio * 100),
5583
+ scroll_y_at_view: window.scrollY
5584
+ }, getElementData(el));
5585
+ }
5568
5586
  }
5569
5587
  }
5570
5588
  });
5571
- }, { threshold: 0.5 });
5589
+ }, { threshold: [0.5] });
5590
+
5591
+ const observe = (el) => {
5592
+ if (!observedEls.has(el)) { observedEls.add(el); observer.observe(el); }
5593
+ };
5572
5594
 
5573
- // Observe existing elements
5574
- getTrackableElements().forEach(el => observer.observe(el));
5595
+ getTrackableElements().forEach(observe);
5575
5596
 
5576
5597
  // Observe elements added to DOM later (SPAs)
5577
5598
  if (typeof MutationObserver !== 'undefined') {
5578
5599
  new MutationObserver(() => {
5579
- getTrackableElements().forEach(el => {
5580
- if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5581
- observer.observe(el);
5582
- }
5583
- });
5600
+ getTrackableElements().forEach(observe);
5584
5601
  }).observe(document.body, { childList: true, subtree: true });
5585
5602
  }
5586
- } catch (error) {
5587
- }
5603
+
5604
+ // On page unload, flush elements still in viewport
5605
+ window.addEventListener('pagehide', () => {
5606
+ enterTimes.forEach(({ enterTime, el }) => {
5607
+ const dwell = Date.now() - enterTime;
5608
+ if (dwell >= 1000) {
5609
+ EventsManager.trackAutoEvent('element_view', {
5610
+ time_in_viewport_ms: dwell,
5611
+ viewport_percent_visible: 100,
5612
+ scroll_y_at_view: window.scrollY
5613
+ }, getElementData(el));
5614
+ }
5615
+ });
5616
+ });
5617
+ } catch (error) {}
5588
5618
  },
5589
5619
 
5590
5620
  /**
5591
5621
  * PageSummaryTracker — collects high-frequency signals in-memory and flushes
5592
5622
  * them as a single 'page_summary' auto-event on page unload.
5593
5623
  *
5594
- * This keeps event counts low (1 per page visit) while capturing:
5595
- * - Mouse movement grid (move_grid)
5596
- * - Hover dwell grid (hover_grid)
5624
+ * High-volume/continuous data lives here (grids, counts) to keep event volume low.
5625
+ * Low-frequency, high-signal moments (exit_intent) are emitted as separate auto-events
5626
+ * but their counters are also included here for correlation.
5627
+ *
5628
+ * Captures:
5629
+ * - Mouse movement grid (move_grid) + hover dwell grid (hover_grid)
5630
+ * - Element-level hover dwells for named/interactive elements (element_dwells)
5597
5631
  * - Tab-switch count and hidden time
5598
- * - Scroll reversal count
5632
+ * - Scroll reversal count + depths at each reversal (scroll_reversal_depths)
5599
5633
  * - Per-field focus dwell times
5634
+ * - Exit intent count + scroll depth at last exit intent
5635
+ * - First interaction time (how long before user engaged)
5636
+ * - Performance: CLS score, long task count/max, INP
5600
5637
  */
5601
5638
  startPageSummaryTracking() {
5602
5639
  try {
5603
5640
  const GRID_SIZE = 40;
5604
- // In-memory accumulators — never sent to the server individually
5605
- const moveGrid = new Map(); // cellKey → move count
5606
- const hoverGrid = new Map(); // cellKey → total dwell_ms
5607
- const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5608
-
5609
- let tabSwitches = 0;
5610
- let totalTabHiddenMs = 0;
5611
- let tabHiddenTime = null;
5612
- let scrollReversals = 0;
5613
5641
 
5614
- // Track last significant scroll position for reversal detection
5642
+ // In-memory accumulators never sent to the server individually
5643
+ const moveGrid = new Map(); // cellKey → move count
5644
+ const hoverGrid = new Map(); // cellKey → total dwell_ms
5645
+ const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5646
+ const elementDwellsMap = new Map(); // elemKey → {tag, id, text, dwell_ms}
5647
+ const scrollReversalDepths = []; // scroll depth % at each reversal
5648
+
5649
+ let tabSwitches = 0;
5650
+ let totalTabHiddenMs = 0;
5651
+ let tabHiddenTime = null;
5652
+ let scrollReversals = 0;
5653
+ let exitIntentCount = 0;
5654
+ let exitIntentLastScrollDepth = 0;
5655
+ let firstInteractionMs = null;
5656
+ let clsScore = 0;
5657
+ let longTasksCount = 0;
5658
+ let maxLongTaskMs = 0;
5659
+ let inpMs = 0;
5660
+
5661
+ const pageLoadTime = Date.now();
5615
5662
  let lastSigScrollY = window.scrollY;
5616
5663
  let lastSigDir = 0; // 1=down, -1=up
5617
5664
 
5665
+ // ── Helpers
5666
+ function getScrollDepth() {
5667
+ const maxScroll = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight;
5668
+ return maxScroll <= 0 ? 100 : Math.round((window.scrollY / maxScroll) * 100);
5669
+ }
5670
+
5618
5671
  function getGridCell(pageX, pageY) {
5619
5672
  const docW = Math.max(1, document.documentElement.scrollWidth || document.body.scrollWidth || window.innerWidth);
5620
5673
  const docH = Math.max(1, document.documentElement.scrollHeight || document.body.scrollHeight || window.innerHeight);
@@ -5623,6 +5676,39 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5623
5676
  return col * GRID_SIZE + row;
5624
5677
  }
5625
5678
 
5679
+ // ── PerformanceObserver: CLS, Long Tasks, INP (best-effort, not all browsers)
5680
+ try {
5681
+ new PerformanceObserver(list => {
5682
+ for (const e of list.getEntries()) {
5683
+ if (!e.hadRecentInput) clsScore = Math.round((clsScore + e.value) * 1000) / 1000;
5684
+ }
5685
+ }).observe({ type: 'layout-shift', buffered: true });
5686
+ } catch (_) {}
5687
+ try {
5688
+ new PerformanceObserver(list => {
5689
+ for (const e of list.getEntries()) {
5690
+ longTasksCount++;
5691
+ if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
5692
+ }
5693
+ }).observe({ type: 'longtask', buffered: true });
5694
+ } catch (_) {}
5695
+ try {
5696
+ new PerformanceObserver(list => {
5697
+ for (const e of list.getEntries()) {
5698
+ if (e.duration > inpMs) inpMs = Math.round(e.duration);
5699
+ }
5700
+ }).observe({ type: 'event', durationThreshold: 40, buffered: true });
5701
+ } catch (_) {}
5702
+
5703
+ // ── First interaction: time from page load to first meaningful engagement
5704
+ const markFirstInteraction = () => {
5705
+ if (firstInteractionMs === null) firstInteractionMs = Date.now() - pageLoadTime;
5706
+ };
5707
+ document.addEventListener('click', markFirstInteraction, { once: true, passive: true });
5708
+ document.addEventListener('keydown', markFirstInteraction, { once: true, passive: true });
5709
+ document.addEventListener('touchstart', markFirstInteraction, { once: true, passive: true });
5710
+ window.addEventListener('scroll', markFirstInteraction, { once: true, passive: true });
5711
+
5626
5712
  // ── Mouse movement → move_grid (throttled to max 1 sample / 100 ms)
5627
5713
  let moveThrottle = false;
5628
5714
  document.addEventListener('mousemove', (e) => {
@@ -5633,7 +5719,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5633
5719
  moveGrid.set(key, (moveGrid.get(key) || 0) + 1);
5634
5720
  }, { passive: true });
5635
5721
 
5636
- // ── Hover dwell → hover_grid (track all elements, filter < 100 ms)
5722
+ // ── Hover dwell → hover_grid + element_dwells for named/interactive elements
5723
+ const ELEMENT_DWELL_TAGS = new Set(['BUTTON', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'INPUT', 'SELECT', 'TEXTAREA', 'IMG', 'LABEL']);
5637
5724
  const hoverStarts = new Map(); // element → enterTime
5638
5725
  document.addEventListener('mouseover', (e) => {
5639
5726
  if (e.target && e.target !== document.documentElement) {
@@ -5652,6 +5739,22 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5652
5739
  const cy = rect.top + rect.height / 2 + window.scrollY;
5653
5740
  const key = getGridCell(cx, cy);
5654
5741
  hoverGrid.set(key, (hoverGrid.get(key) || 0) + dwell);
5742
+
5743
+ // Element-level dwell: track named/interactive elements hovered ≥ 300ms
5744
+ if (dwell >= 300 && (target.id || ELEMENT_DWELL_TAGS.has(target.tagName))) {
5745
+ const elemKey = `${target.tagName}|${target.id || ''}|${(target.textContent || '').trim().slice(0, 30)}`;
5746
+ const existing = elementDwellsMap.get(elemKey);
5747
+ if (existing) {
5748
+ existing.dwell_ms += dwell;
5749
+ } else {
5750
+ elementDwellsMap.set(elemKey, {
5751
+ tag: target.tagName.toLowerCase(),
5752
+ id: target.id || '',
5753
+ text: (target.textContent || '').trim().slice(0, 50),
5754
+ dwell_ms: dwell
5755
+ });
5756
+ }
5757
+ }
5655
5758
  } catch (_) {}
5656
5759
  }, { passive: true });
5657
5760
 
@@ -5666,14 +5769,15 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5666
5769
  }
5667
5770
  });
5668
5771
 
5669
- // ── Scroll reversal detection (direction change > 200 px)
5772
+ // ── Scroll reversal detection (direction change 200 px) + depth recording
5670
5773
  window.addEventListener('scroll', () => {
5671
5774
  const curr = window.scrollY;
5672
5775
  const diff = curr - lastSigScrollY;
5673
- if (Math.abs(diff) < 50) return; // ignore tiny movements
5674
- const dir = diff > 0 ? 1 : -1;
5776
+ if (Math.abs(diff) < 50) return;
5777
+ const dir = diff > 0 ? 1 : -1;
5675
5778
  if (lastSigDir !== 0 && dir !== lastSigDir && Math.abs(curr - lastSigScrollY) >= 200) {
5676
5779
  scrollReversals++;
5780
+ scrollReversalDepths.push(getScrollDepth());
5677
5781
  }
5678
5782
  if (Math.abs(diff) >= 50) { lastSigDir = dir; lastSigScrollY = curr; }
5679
5783
  }, { passive: true });
@@ -5691,7 +5795,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5691
5795
  if (!t || !fieldFocusTimes.has(t)) return;
5692
5796
  const dwell = Date.now() - fieldFocusTimes.get(t);
5693
5797
  fieldFocusTimes.delete(t);
5694
- if (dwell < 100) return; // skip accidental focus
5798
+ if (dwell < 100) return;
5695
5799
  const label = t.id
5696
5800
  ? (document.querySelector(`label[for="${CSS.escape(t.id)}"]`)?.textContent?.trim() || t.placeholder || t.name || t.id)
5697
5801
  : (t.placeholder || t.name || t.type || '');
@@ -5704,6 +5808,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5704
5808
  });
5705
5809
  }, true);
5706
5810
 
5811
+ // ── Exit intent: mouse leaving viewport toward the top (user about to navigate away)
5812
+ // Also fires as a standalone auto-event for direct querying.
5813
+ let lastExitIntentTime = 0;
5814
+ document.addEventListener('mouseleave', (e) => {
5815
+ if (e.clientY > 0 || e.relatedTarget !== null) return; // only top-edge exits
5816
+ const now = Date.now();
5817
+ if (now - lastExitIntentTime < 2000) return; // debounce: max once per 2s
5818
+ lastExitIntentTime = now;
5819
+ exitIntentCount++;
5820
+ exitIntentLastScrollDepth = getScrollDepth();
5821
+ EventsManager.trackAutoEvent('exit_intent', {
5822
+ scroll_depth: exitIntentLastScrollDepth,
5823
+ time_on_page_ms: now - pageLoadTime,
5824
+ scroll_y: window.scrollY,
5825
+ document_height: Math.min(
5826
+ document.documentElement.scrollHeight || document.body.scrollHeight, 25000
5827
+ )
5828
+ }).catch(() => {});
5829
+ });
5830
+
5707
5831
  // ── Serialise grid Map to [[col, row, value], ...] sparse array
5708
5832
  function serializeGrid(grid) {
5709
5833
  const out = [];
@@ -5720,17 +5844,34 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5720
5844
  let summarySent = false;
5721
5845
  function firePageSummary() {
5722
5846
  if (summarySent) return; // only fire once per page lifecycle
5723
- if (moveGrid.size === 0 && hoverGrid.size === 0 && fieldDwells.length === 0
5724
- && tabSwitches === 0 && scrollReversals === 0) return;
5847
+ const hasData = moveGrid.size > 0 || hoverGrid.size > 0 || fieldDwells.length > 0
5848
+ || tabSwitches > 0 || scrollReversals > 0 || exitIntentCount > 0
5849
+ || firstInteractionMs !== null || clsScore > 0 || longTasksCount > 0;
5850
+ if (!hasData) return;
5725
5851
  summarySent = true;
5726
5852
  try {
5853
+ // Serialize element_dwells: filter ≥ 300ms, sort desc, cap at 20
5854
+ const elementDwells = Array.from(elementDwellsMap.values())
5855
+ .filter(e => e.dwell_ms >= 300)
5856
+ .sort((a, b) => b.dwell_ms - a.dwell_ms)
5857
+ .slice(0, 20);
5858
+
5727
5859
  EventsManager.trackAutoEvent('page_summary', {
5728
- scroll_reversals: scrollReversals,
5729
- tab_switches: tabSwitches,
5730
- total_tab_hidden_ms: totalTabHiddenMs,
5731
- move_grid: serializeGrid(moveGrid),
5732
- hover_grid: serializeGrid(hoverGrid),
5733
- field_dwells: fieldDwells,
5860
+ scroll_reversals: scrollReversals,
5861
+ scroll_reversal_depths: scrollReversalDepths,
5862
+ tab_switches: tabSwitches,
5863
+ total_tab_hidden_ms: totalTabHiddenMs,
5864
+ move_grid: serializeGrid(moveGrid),
5865
+ hover_grid: serializeGrid(hoverGrid),
5866
+ field_dwells: fieldDwells,
5867
+ element_dwells: elementDwells,
5868
+ first_interaction_ms: firstInteractionMs,
5869
+ exit_intent_count: exitIntentCount,
5870
+ exit_intent_last_scroll_depth: exitIntentLastScrollDepth,
5871
+ cls_score: clsScore,
5872
+ long_tasks_count: longTasksCount,
5873
+ max_long_task_ms: maxLongTaskMs,
5874
+ inp_ms: inpMs,
5734
5875
  document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5735
5876
  document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5736
5877
  viewport_width: window.innerWidth,
package/lib/umd/index.js CHANGED
@@ -5523,104 +5523,157 @@
5523
5523
 
5524
5524
  /**
5525
5525
  * ElementVisibilityTracker - Emits element_view auto events when elements
5526
- * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5527
- * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5528
- * vs just didn't click them completely different problems, opposite fixes.
5526
+ * enter the viewport for ≥ 1 second, capturing the actual dwell duration.
5527
+ *
5528
+ * Covers interactive elements (buttons, links, inputs, headings, images) and
5529
+ * any element with an id or data-cq-track attribute. Each viewport entry is
5530
+ * tracked independently — if a user scrolls away and back, both dwells count.
5531
+ * This gives accurate attention data for the heatmap attention overlay.
5529
5532
  */
5530
5533
  startElementVisibilityTracking() {
5531
5534
  try {
5532
5535
  if (typeof IntersectionObserver === 'undefined') return;
5533
5536
 
5534
- // Only observe elements with an id or data-cq-track attribute
5537
+ // Observe interactive/structural elements in addition to id/data-cq-track
5538
+ const TRACKABLE_SELECTOR = [
5539
+ '[id]:not([id=""])', '[data-cq-track]',
5540
+ 'button', 'a[href]', 'input:not([type=hidden])', 'select', 'textarea',
5541
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img[alt]',
5542
+ '[role="button"]', '[role="tab"]', '[role="menuitem"]', '[role="link"]'
5543
+ ].join(', ');
5544
+
5535
5545
  const getTrackableElements = () =>
5536
- document.querySelectorAll('[id]:not([id=""]), [data-cq-track]');
5546
+ Array.from(document.querySelectorAll(TRACKABLE_SELECTOR)).filter(el => {
5547
+ try { return el.getBoundingClientRect().height >= 20; } catch (_) { return true; }
5548
+ });
5537
5549
 
5538
- const viewTimers = new Map(); // elementKey setTimeout handle
5539
- const reported = new Set(); // elementKey → already fired once per page load
5550
+ // enterTimes: key { enterTime, el } tracks elements currently in viewport
5551
+ const enterTimes = new Map();
5552
+ const observedEls = new WeakSet(); // prevents double-observing same DOM node
5553
+
5554
+ function elementKey(el) {
5555
+ return `${el.tagName}|${el.id || ''}|${el.getAttribute('data-cq-track') || ''}|${(el.textContent || '').trim().slice(0, 30)}`;
5556
+ }
5557
+
5558
+ function getElementData(el) {
5559
+ return {
5560
+ element_id: el.id || '',
5561
+ element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5562
+ element_tag_name: el.tagName || '',
5563
+ element_type: el.type || el.getAttribute('type') || '',
5564
+ element_class: el.className || '',
5565
+ element_text: (el.textContent || '').trim().slice(0, 100)
5566
+ };
5567
+ }
5540
5568
 
5541
5569
  const observer = new IntersectionObserver((entries) => {
5542
5570
  entries.forEach((entry) => {
5543
- const el = entry.target;
5544
- const key = el.id || el.getAttribute('data-cq-track') || el.className;
5545
- if (!key || reported.has(key)) return;
5571
+ const el = entry.target;
5572
+ const key = elementKey(el);
5546
5573
 
5547
5574
  if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5548
- // Element entered viewport — start 1s timer
5549
- if (!viewTimers.has(key)) {
5550
- const enterTime = Date.now();
5551
- const timer = setTimeout(() => {
5552
- if (reported.has(key)) return;
5553
- reported.add(key);
5554
- EventsManager.trackAutoEvent('element_view', {
5555
- time_in_viewport_ms: Date.now() - enterTime,
5556
- viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5557
- }, {
5558
- element_id: el.id || '',
5559
- element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5560
- element_tag_name: el.tagName || '',
5561
- element_type: el.type || el.getAttribute('type') || '',
5562
- element_class: el.className || '',
5563
- element_text: (el.textContent || '').trim().slice(0, 100)
5564
- });
5565
- viewTimers.delete(key);
5566
- }, 1000);
5567
- viewTimers.set(key, timer);
5575
+ // Element entered viewport — record entry time
5576
+ if (!enterTimes.has(key)) {
5577
+ enterTimes.set(key, { enterTime: Date.now(), el });
5568
5578
  }
5569
5579
  } else {
5570
- // Element left viewport before 1s cancel timer
5571
- if (viewTimers.has(key)) {
5572
- clearTimeout(viewTimers.get(key));
5573
- viewTimers.delete(key);
5580
+ // Element left viewport emit actual dwell if ≥ 1s
5581
+ const record = enterTimes.get(key);
5582
+ if (record) {
5583
+ const dwell = Date.now() - record.enterTime;
5584
+ enterTimes.delete(key);
5585
+ if (dwell >= 1000) {
5586
+ EventsManager.trackAutoEvent('element_view', {
5587
+ time_in_viewport_ms: dwell,
5588
+ viewport_percent_visible: Math.round(entry.intersectionRatio * 100),
5589
+ scroll_y_at_view: window.scrollY
5590
+ }, getElementData(el));
5591
+ }
5574
5592
  }
5575
5593
  }
5576
5594
  });
5577
- }, { threshold: 0.5 });
5595
+ }, { threshold: [0.5] });
5596
+
5597
+ const observe = (el) => {
5598
+ if (!observedEls.has(el)) { observedEls.add(el); observer.observe(el); }
5599
+ };
5578
5600
 
5579
- // Observe existing elements
5580
- getTrackableElements().forEach(el => observer.observe(el));
5601
+ getTrackableElements().forEach(observe);
5581
5602
 
5582
5603
  // Observe elements added to DOM later (SPAs)
5583
5604
  if (typeof MutationObserver !== 'undefined') {
5584
5605
  new MutationObserver(() => {
5585
- getTrackableElements().forEach(el => {
5586
- if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5587
- observer.observe(el);
5588
- }
5589
- });
5606
+ getTrackableElements().forEach(observe);
5590
5607
  }).observe(document.body, { childList: true, subtree: true });
5591
5608
  }
5592
- } catch (error) {
5593
- }
5609
+
5610
+ // On page unload, flush elements still in viewport
5611
+ window.addEventListener('pagehide', () => {
5612
+ enterTimes.forEach(({ enterTime, el }) => {
5613
+ const dwell = Date.now() - enterTime;
5614
+ if (dwell >= 1000) {
5615
+ EventsManager.trackAutoEvent('element_view', {
5616
+ time_in_viewport_ms: dwell,
5617
+ viewport_percent_visible: 100,
5618
+ scroll_y_at_view: window.scrollY
5619
+ }, getElementData(el));
5620
+ }
5621
+ });
5622
+ });
5623
+ } catch (error) {}
5594
5624
  },
5595
5625
 
5596
5626
  /**
5597
5627
  * PageSummaryTracker — collects high-frequency signals in-memory and flushes
5598
5628
  * them as a single 'page_summary' auto-event on page unload.
5599
5629
  *
5600
- * This keeps event counts low (1 per page visit) while capturing:
5601
- * - Mouse movement grid (move_grid)
5602
- * - Hover dwell grid (hover_grid)
5630
+ * High-volume/continuous data lives here (grids, counts) to keep event volume low.
5631
+ * Low-frequency, high-signal moments (exit_intent) are emitted as separate auto-events
5632
+ * but their counters are also included here for correlation.
5633
+ *
5634
+ * Captures:
5635
+ * - Mouse movement grid (move_grid) + hover dwell grid (hover_grid)
5636
+ * - Element-level hover dwells for named/interactive elements (element_dwells)
5603
5637
  * - Tab-switch count and hidden time
5604
- * - Scroll reversal count
5638
+ * - Scroll reversal count + depths at each reversal (scroll_reversal_depths)
5605
5639
  * - Per-field focus dwell times
5640
+ * - Exit intent count + scroll depth at last exit intent
5641
+ * - First interaction time (how long before user engaged)
5642
+ * - Performance: CLS score, long task count/max, INP
5606
5643
  */
5607
5644
  startPageSummaryTracking() {
5608
5645
  try {
5609
5646
  const GRID_SIZE = 40;
5610
- // In-memory accumulators — never sent to the server individually
5611
- const moveGrid = new Map(); // cellKey → move count
5612
- const hoverGrid = new Map(); // cellKey → total dwell_ms
5613
- const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5614
-
5615
- let tabSwitches = 0;
5616
- let totalTabHiddenMs = 0;
5617
- let tabHiddenTime = null;
5618
- let scrollReversals = 0;
5619
5647
 
5620
- // Track last significant scroll position for reversal detection
5648
+ // In-memory accumulators never sent to the server individually
5649
+ const moveGrid = new Map(); // cellKey → move count
5650
+ const hoverGrid = new Map(); // cellKey → total dwell_ms
5651
+ const fieldDwells = []; // [{field_id, field_label, field_type, dwell_ms, was_filled}]
5652
+ const elementDwellsMap = new Map(); // elemKey → {tag, id, text, dwell_ms}
5653
+ const scrollReversalDepths = []; // scroll depth % at each reversal
5654
+
5655
+ let tabSwitches = 0;
5656
+ let totalTabHiddenMs = 0;
5657
+ let tabHiddenTime = null;
5658
+ let scrollReversals = 0;
5659
+ let exitIntentCount = 0;
5660
+ let exitIntentLastScrollDepth = 0;
5661
+ let firstInteractionMs = null;
5662
+ let clsScore = 0;
5663
+ let longTasksCount = 0;
5664
+ let maxLongTaskMs = 0;
5665
+ let inpMs = 0;
5666
+
5667
+ const pageLoadTime = Date.now();
5621
5668
  let lastSigScrollY = window.scrollY;
5622
5669
  let lastSigDir = 0; // 1=down, -1=up
5623
5670
 
5671
+ // ── Helpers
5672
+ function getScrollDepth() {
5673
+ const maxScroll = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight;
5674
+ return maxScroll <= 0 ? 100 : Math.round((window.scrollY / maxScroll) * 100);
5675
+ }
5676
+
5624
5677
  function getGridCell(pageX, pageY) {
5625
5678
  const docW = Math.max(1, document.documentElement.scrollWidth || document.body.scrollWidth || window.innerWidth);
5626
5679
  const docH = Math.max(1, document.documentElement.scrollHeight || document.body.scrollHeight || window.innerHeight);
@@ -5629,6 +5682,39 @@
5629
5682
  return col * GRID_SIZE + row;
5630
5683
  }
5631
5684
 
5685
+ // ── PerformanceObserver: CLS, Long Tasks, INP (best-effort, not all browsers)
5686
+ try {
5687
+ new PerformanceObserver(list => {
5688
+ for (const e of list.getEntries()) {
5689
+ if (!e.hadRecentInput) clsScore = Math.round((clsScore + e.value) * 1000) / 1000;
5690
+ }
5691
+ }).observe({ type: 'layout-shift', buffered: true });
5692
+ } catch (_) {}
5693
+ try {
5694
+ new PerformanceObserver(list => {
5695
+ for (const e of list.getEntries()) {
5696
+ longTasksCount++;
5697
+ if (e.duration > maxLongTaskMs) maxLongTaskMs = Math.round(e.duration);
5698
+ }
5699
+ }).observe({ type: 'longtask', buffered: true });
5700
+ } catch (_) {}
5701
+ try {
5702
+ new PerformanceObserver(list => {
5703
+ for (const e of list.getEntries()) {
5704
+ if (e.duration > inpMs) inpMs = Math.round(e.duration);
5705
+ }
5706
+ }).observe({ type: 'event', durationThreshold: 40, buffered: true });
5707
+ } catch (_) {}
5708
+
5709
+ // ── First interaction: time from page load to first meaningful engagement
5710
+ const markFirstInteraction = () => {
5711
+ if (firstInteractionMs === null) firstInteractionMs = Date.now() - pageLoadTime;
5712
+ };
5713
+ document.addEventListener('click', markFirstInteraction, { once: true, passive: true });
5714
+ document.addEventListener('keydown', markFirstInteraction, { once: true, passive: true });
5715
+ document.addEventListener('touchstart', markFirstInteraction, { once: true, passive: true });
5716
+ window.addEventListener('scroll', markFirstInteraction, { once: true, passive: true });
5717
+
5632
5718
  // ── Mouse movement → move_grid (throttled to max 1 sample / 100 ms)
5633
5719
  let moveThrottle = false;
5634
5720
  document.addEventListener('mousemove', (e) => {
@@ -5639,7 +5725,8 @@
5639
5725
  moveGrid.set(key, (moveGrid.get(key) || 0) + 1);
5640
5726
  }, { passive: true });
5641
5727
 
5642
- // ── Hover dwell → hover_grid (track all elements, filter < 100 ms)
5728
+ // ── Hover dwell → hover_grid + element_dwells for named/interactive elements
5729
+ const ELEMENT_DWELL_TAGS = new Set(['BUTTON', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'INPUT', 'SELECT', 'TEXTAREA', 'IMG', 'LABEL']);
5643
5730
  const hoverStarts = new Map(); // element → enterTime
5644
5731
  document.addEventListener('mouseover', (e) => {
5645
5732
  if (e.target && e.target !== document.documentElement) {
@@ -5658,6 +5745,22 @@
5658
5745
  const cy = rect.top + rect.height / 2 + window.scrollY;
5659
5746
  const key = getGridCell(cx, cy);
5660
5747
  hoverGrid.set(key, (hoverGrid.get(key) || 0) + dwell);
5748
+
5749
+ // Element-level dwell: track named/interactive elements hovered ≥ 300ms
5750
+ if (dwell >= 300 && (target.id || ELEMENT_DWELL_TAGS.has(target.tagName))) {
5751
+ const elemKey = `${target.tagName}|${target.id || ''}|${(target.textContent || '').trim().slice(0, 30)}`;
5752
+ const existing = elementDwellsMap.get(elemKey);
5753
+ if (existing) {
5754
+ existing.dwell_ms += dwell;
5755
+ } else {
5756
+ elementDwellsMap.set(elemKey, {
5757
+ tag: target.tagName.toLowerCase(),
5758
+ id: target.id || '',
5759
+ text: (target.textContent || '').trim().slice(0, 50),
5760
+ dwell_ms: dwell
5761
+ });
5762
+ }
5763
+ }
5661
5764
  } catch (_) {}
5662
5765
  }, { passive: true });
5663
5766
 
@@ -5672,14 +5775,15 @@
5672
5775
  }
5673
5776
  });
5674
5777
 
5675
- // ── Scroll reversal detection (direction change > 200 px)
5778
+ // ── Scroll reversal detection (direction change 200 px) + depth recording
5676
5779
  window.addEventListener('scroll', () => {
5677
5780
  const curr = window.scrollY;
5678
5781
  const diff = curr - lastSigScrollY;
5679
- if (Math.abs(diff) < 50) return; // ignore tiny movements
5680
- const dir = diff > 0 ? 1 : -1;
5782
+ if (Math.abs(diff) < 50) return;
5783
+ const dir = diff > 0 ? 1 : -1;
5681
5784
  if (lastSigDir !== 0 && dir !== lastSigDir && Math.abs(curr - lastSigScrollY) >= 200) {
5682
5785
  scrollReversals++;
5786
+ scrollReversalDepths.push(getScrollDepth());
5683
5787
  }
5684
5788
  if (Math.abs(diff) >= 50) { lastSigDir = dir; lastSigScrollY = curr; }
5685
5789
  }, { passive: true });
@@ -5697,7 +5801,7 @@
5697
5801
  if (!t || !fieldFocusTimes.has(t)) return;
5698
5802
  const dwell = Date.now() - fieldFocusTimes.get(t);
5699
5803
  fieldFocusTimes.delete(t);
5700
- if (dwell < 100) return; // skip accidental focus
5804
+ if (dwell < 100) return;
5701
5805
  const label = t.id
5702
5806
  ? (document.querySelector(`label[for="${CSS.escape(t.id)}"]`)?.textContent?.trim() || t.placeholder || t.name || t.id)
5703
5807
  : (t.placeholder || t.name || t.type || '');
@@ -5710,6 +5814,26 @@
5710
5814
  });
5711
5815
  }, true);
5712
5816
 
5817
+ // ── Exit intent: mouse leaving viewport toward the top (user about to navigate away)
5818
+ // Also fires as a standalone auto-event for direct querying.
5819
+ let lastExitIntentTime = 0;
5820
+ document.addEventListener('mouseleave', (e) => {
5821
+ if (e.clientY > 0 || e.relatedTarget !== null) return; // only top-edge exits
5822
+ const now = Date.now();
5823
+ if (now - lastExitIntentTime < 2000) return; // debounce: max once per 2s
5824
+ lastExitIntentTime = now;
5825
+ exitIntentCount++;
5826
+ exitIntentLastScrollDepth = getScrollDepth();
5827
+ EventsManager.trackAutoEvent('exit_intent', {
5828
+ scroll_depth: exitIntentLastScrollDepth,
5829
+ time_on_page_ms: now - pageLoadTime,
5830
+ scroll_y: window.scrollY,
5831
+ document_height: Math.min(
5832
+ document.documentElement.scrollHeight || document.body.scrollHeight, 25000
5833
+ )
5834
+ }).catch(() => {});
5835
+ });
5836
+
5713
5837
  // ── Serialise grid Map to [[col, row, value], ...] sparse array
5714
5838
  function serializeGrid(grid) {
5715
5839
  const out = [];
@@ -5726,17 +5850,34 @@
5726
5850
  let summarySent = false;
5727
5851
  function firePageSummary() {
5728
5852
  if (summarySent) return; // only fire once per page lifecycle
5729
- if (moveGrid.size === 0 && hoverGrid.size === 0 && fieldDwells.length === 0
5730
- && tabSwitches === 0 && scrollReversals === 0) return;
5853
+ const hasData = moveGrid.size > 0 || hoverGrid.size > 0 || fieldDwells.length > 0
5854
+ || tabSwitches > 0 || scrollReversals > 0 || exitIntentCount > 0
5855
+ || firstInteractionMs !== null || clsScore > 0 || longTasksCount > 0;
5856
+ if (!hasData) return;
5731
5857
  summarySent = true;
5732
5858
  try {
5859
+ // Serialize element_dwells: filter ≥ 300ms, sort desc, cap at 20
5860
+ const elementDwells = Array.from(elementDwellsMap.values())
5861
+ .filter(e => e.dwell_ms >= 300)
5862
+ .sort((a, b) => b.dwell_ms - a.dwell_ms)
5863
+ .slice(0, 20);
5864
+
5733
5865
  EventsManager.trackAutoEvent('page_summary', {
5734
- scroll_reversals: scrollReversals,
5735
- tab_switches: tabSwitches,
5736
- total_tab_hidden_ms: totalTabHiddenMs,
5737
- move_grid: serializeGrid(moveGrid),
5738
- hover_grid: serializeGrid(hoverGrid),
5739
- field_dwells: fieldDwells,
5866
+ scroll_reversals: scrollReversals,
5867
+ scroll_reversal_depths: scrollReversalDepths,
5868
+ tab_switches: tabSwitches,
5869
+ total_tab_hidden_ms: totalTabHiddenMs,
5870
+ move_grid: serializeGrid(moveGrid),
5871
+ hover_grid: serializeGrid(hoverGrid),
5872
+ field_dwells: fieldDwells,
5873
+ element_dwells: elementDwells,
5874
+ first_interaction_ms: firstInteractionMs,
5875
+ exit_intent_count: exitIntentCount,
5876
+ exit_intent_last_scroll_depth: exitIntentLastScrollDepth,
5877
+ cls_score: clsScore,
5878
+ long_tasks_count: longTasksCount,
5879
+ max_long_task_ms: maxLongTaskMs,
5880
+ inp_ms: inpMs,
5740
5881
  document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5741
5882
  document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5742
5883
  viewport_width: window.innerWidth,
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "cryptique-sdk",
3
- "version": "1.2.17",
3
+ "version": "1.2.18",
4
4
  "type": "module",
5
5
  "description": "Cryptique Analytics SDK - Comprehensive web analytics and user tracking for modern web applications",
6
6
  "main": "lib/cjs/index.js",
7
+
7
8
  "module": "lib/esm/index.js",
8
9
  "browser": "lib/umd/index.js",
9
10
  "types": "lib/types/index.d.ts",