cryptique-sdk 1.2.16 → 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/esm/index.js CHANGED
@@ -90,8 +90,8 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
90
90
 
91
91
  // Geolocation API
92
92
  GEOLOCATION: {
93
- PRIMARY_URL: "https://ipinfo.io/json?token=73937f74acc045",
94
- BACKUP_URL: "https://ipinfo.io/json?token=73937f74acc045&http=1.1",
93
+ PRIMARY_URL: "https://ipinfo.io/json?token=8fc6409059aa39",
94
+ BACKUP_URL: "https://ipinfo.io/json?token=8fc6409059aa39&http=1.1",
95
95
  TIMEOUT_MS: 5000 // 5 second timeout
96
96
  },
97
97
 
@@ -4997,19 +4997,26 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
4997
4997
  startCopyPasteTracking() {
4998
4998
  try {
4999
4999
  document.addEventListener('copy', (event) => {
5000
+ const sel = window.getSelection();
5000
5001
  const copyData = {
5001
5002
  type: 'copy_action',
5002
- selectedText: window.getSelection().toString().substring(0, 100),
5003
+ selectedText: sel ? sel.toString().substring(0, 100) : '',
5003
5004
  target: {
5004
5005
  tagName: event.target?.tagName || '',
5005
5006
  id: event.target?.id || ''
5006
5007
  },
5007
5008
  path: window.location.pathname
5008
5009
  };
5009
-
5010
5010
  InteractionManager.add('copyPasteEvents', copyData);
5011
+ // Fire structured auto-event (low volume — only when user explicitly copies)
5012
+ EventsManager.trackAutoEvent('copy_action', {
5013
+ text_length: sel ? sel.toString().length : 0,
5014
+ element_id: event.target?.id || '',
5015
+ element_tag: event.target?.tagName?.toLowerCase() || '',
5016
+ element_class: (event.target?.className || '').split(' ').filter(Boolean).slice(0, 3).join(' '),
5017
+ });
5011
5018
  });
5012
-
5019
+
5013
5020
  document.addEventListener('paste', (event) => {
5014
5021
  const pasteData = {
5015
5022
  type: 'paste_action',
@@ -5019,7 +5026,6 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5019
5026
  },
5020
5027
  path: window.location.pathname
5021
5028
  };
5022
-
5023
5029
  InteractionManager.add('copyPasteEvents', pasteData);
5024
5030
  });
5025
5031
  } catch (error) {
@@ -5045,8 +5051,15 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5045
5051
  },
5046
5052
  path: window.location.pathname
5047
5053
  };
5048
-
5049
5054
  InteractionManager.add('contextMenuEvents', contextData);
5055
+ // Fire structured auto-event (low volume — only when user right-clicks)
5056
+ EventsManager.trackAutoEvent('context_menu', {
5057
+ element_tag: event.target?.tagName?.toLowerCase() || '',
5058
+ element_id: event.target?.id || '',
5059
+ element_class: (event.target?.className || '').split(' ').filter(Boolean).slice(0, 3).join(' '),
5060
+ page_x: event.pageX,
5061
+ page_y: event.pageY,
5062
+ });
5050
5063
  });
5051
5064
  } catch (error) {
5052
5065
  }
@@ -5504,74 +5517,376 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5504
5517
 
5505
5518
  /**
5506
5519
  * ElementVisibilityTracker - Emits element_view auto events when elements
5507
- * with IDs or data-track attributes enter the viewport for ≥ 1 second.
5508
- * Tells PMs whether users actually SAW key elements (CTAs, pricing, etc.)
5509
- * 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.
5510
5526
  */
5511
5527
  startElementVisibilityTracking() {
5512
5528
  try {
5513
5529
  if (typeof IntersectionObserver === 'undefined') return;
5514
5530
 
5515
- // 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
+
5516
5539
  const getTrackableElements = () =>
5517
- 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
+ });
5518
5543
 
5519
- const viewTimers = new Map(); // elementKey setTimeout handle
5520
- 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
+ }
5521
5562
 
5522
5563
  const observer = new IntersectionObserver((entries) => {
5523
5564
  entries.forEach((entry) => {
5524
- const el = entry.target;
5525
- const key = el.id || el.getAttribute('data-cq-track') || el.className;
5526
- if (!key || reported.has(key)) return;
5565
+ const el = entry.target;
5566
+ const key = elementKey(el);
5527
5567
 
5528
5568
  if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
5529
- // Element entered viewport — start 1s timer
5530
- if (!viewTimers.has(key)) {
5531
- const enterTime = Date.now();
5532
- const timer = setTimeout(() => {
5533
- if (reported.has(key)) return;
5534
- reported.add(key);
5535
- EventsManager.trackAutoEvent('element_view', {
5536
- time_in_viewport_ms: Date.now() - enterTime,
5537
- viewport_percent_visible: Math.round(entry.intersectionRatio * 100)
5538
- }, {
5539
- element_id: el.id || '',
5540
- element_name: el.getAttribute('name') || el.getAttribute('data-cq-track') || '',
5541
- element_tag_name: el.tagName || '',
5542
- element_type: el.type || el.getAttribute('type') || '',
5543
- element_class: el.className || '',
5544
- element_text: (el.textContent || '').trim().slice(0, 100)
5545
- });
5546
- viewTimers.delete(key);
5547
- }, 1000);
5548
- viewTimers.set(key, timer);
5569
+ // Element entered viewport — record entry time
5570
+ if (!enterTimes.has(key)) {
5571
+ enterTimes.set(key, { enterTime: Date.now(), el });
5549
5572
  }
5550
5573
  } else {
5551
- // Element left viewport before 1s cancel timer
5552
- if (viewTimers.has(key)) {
5553
- clearTimeout(viewTimers.get(key));
5554
- 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
+ }
5555
5586
  }
5556
5587
  }
5557
5588
  });
5558
- }, { threshold: 0.5 });
5589
+ }, { threshold: [0.5] });
5590
+
5591
+ const observe = (el) => {
5592
+ if (!observedEls.has(el)) { observedEls.add(el); observer.observe(el); }
5593
+ };
5559
5594
 
5560
- // Observe existing elements
5561
- getTrackableElements().forEach(el => observer.observe(el));
5595
+ getTrackableElements().forEach(observe);
5562
5596
 
5563
5597
  // Observe elements added to DOM later (SPAs)
5564
5598
  if (typeof MutationObserver !== 'undefined') {
5565
5599
  new MutationObserver(() => {
5566
- getTrackableElements().forEach(el => {
5567
- if (!viewTimers.has(el.id || el.getAttribute('data-cq-track') || el.className)) {
5568
- observer.observe(el);
5569
- }
5570
- });
5600
+ getTrackableElements().forEach(observe);
5571
5601
  }).observe(document.body, { childList: true, subtree: true });
5572
5602
  }
5573
- } catch (error) {
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) {}
5618
+ },
5619
+
5620
+ /**
5621
+ * PageSummaryTracker — collects high-frequency signals in-memory and flushes
5622
+ * them as a single 'page_summary' auto-event on page unload.
5623
+ *
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)
5631
+ * - Tab-switch count and hidden time
5632
+ * - Scroll reversal count + depths at each reversal (scroll_reversal_depths)
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
5637
+ */
5638
+ startPageSummaryTracking() {
5639
+ try {
5640
+ const GRID_SIZE = 40;
5641
+
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();
5662
+ let lastSigScrollY = window.scrollY;
5663
+ let lastSigDir = 0; // 1=down, -1=up
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
+
5671
+ function getGridCell(pageX, pageY) {
5672
+ const docW = Math.max(1, document.documentElement.scrollWidth || document.body.scrollWidth || window.innerWidth);
5673
+ const docH = Math.max(1, document.documentElement.scrollHeight || document.body.scrollHeight || window.innerHeight);
5674
+ const col = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor((pageX / docW) * GRID_SIZE)));
5675
+ const row = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor((pageY / docH) * GRID_SIZE)));
5676
+ return col * GRID_SIZE + row;
5677
+ }
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
+
5712
+ // ── Mouse movement → move_grid (throttled to max 1 sample / 100 ms)
5713
+ let moveThrottle = false;
5714
+ document.addEventListener('mousemove', (e) => {
5715
+ if (moveThrottle) return;
5716
+ moveThrottle = true;
5717
+ setTimeout(() => { moveThrottle = false; }, 100);
5718
+ const key = getGridCell(e.pageX, e.pageY);
5719
+ moveGrid.set(key, (moveGrid.get(key) || 0) + 1);
5720
+ }, { passive: true });
5721
+
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']);
5724
+ const hoverStarts = new Map(); // element → enterTime
5725
+ document.addEventListener('mouseover', (e) => {
5726
+ if (e.target && e.target !== document.documentElement) {
5727
+ hoverStarts.set(e.target, Date.now());
5728
+ }
5729
+ }, { passive: true });
5730
+ document.addEventListener('mouseout', (e) => {
5731
+ const target = e.target;
5732
+ if (!target || !hoverStarts.has(target)) return;
5733
+ const dwell = Date.now() - hoverStarts.get(target);
5734
+ hoverStarts.delete(target);
5735
+ if (dwell < 100) return; // skip accidental hovers
5736
+ try {
5737
+ const rect = target.getBoundingClientRect();
5738
+ const cx = rect.left + rect.width / 2 + window.scrollX;
5739
+ const cy = rect.top + rect.height / 2 + window.scrollY;
5740
+ const key = getGridCell(cx, cy);
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
+ }
5758
+ } catch (_) {}
5759
+ }, { passive: true });
5760
+
5761
+ // ── Tab visibility → tab_switches + total_tab_hidden_ms
5762
+ document.addEventListener('visibilitychange', () => {
5763
+ if (document.hidden) {
5764
+ tabHiddenTime = Date.now();
5765
+ tabSwitches++;
5766
+ } else if (tabHiddenTime !== null) {
5767
+ totalTabHiddenMs += Date.now() - tabHiddenTime;
5768
+ tabHiddenTime = null;
5769
+ }
5770
+ });
5771
+
5772
+ // ── Scroll reversal detection (direction change ≥ 200 px) + depth recording
5773
+ window.addEventListener('scroll', () => {
5774
+ const curr = window.scrollY;
5775
+ const diff = curr - lastSigScrollY;
5776
+ if (Math.abs(diff) < 50) return;
5777
+ const dir = diff > 0 ? 1 : -1;
5778
+ if (lastSigDir !== 0 && dir !== lastSigDir && Math.abs(curr - lastSigScrollY) >= 200) {
5779
+ scrollReversals++;
5780
+ scrollReversalDepths.push(getScrollDepth());
5781
+ }
5782
+ if (Math.abs(diff) >= 50) { lastSigDir = dir; lastSigScrollY = curr; }
5783
+ }, { passive: true });
5784
+
5785
+ // ── Field dwell → per-field focus duration (no content captured)
5786
+ const fieldFocusTimes = new Map();
5787
+ document.addEventListener('focus', (e) => {
5788
+ const t = e.target;
5789
+ if (!t || !['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName)) return;
5790
+ if (t.type === 'hidden') return;
5791
+ fieldFocusTimes.set(t, Date.now());
5792
+ }, true);
5793
+ document.addEventListener('blur', (e) => {
5794
+ const t = e.target;
5795
+ if (!t || !fieldFocusTimes.has(t)) return;
5796
+ const dwell = Date.now() - fieldFocusTimes.get(t);
5797
+ fieldFocusTimes.delete(t);
5798
+ if (dwell < 100) return;
5799
+ const label = t.id
5800
+ ? (document.querySelector(`label[for="${CSS.escape(t.id)}"]`)?.textContent?.trim() || t.placeholder || t.name || t.id)
5801
+ : (t.placeholder || t.name || t.type || '');
5802
+ fieldDwells.push({
5803
+ field_id: (t.id || t.name || t.type || '').substring(0, 100),
5804
+ field_label: (label || '').substring(0, 100),
5805
+ field_type: t.type || t.tagName.toLowerCase(),
5806
+ dwell_ms: dwell,
5807
+ was_filled: !!(t.value && t.value.length > 0),
5808
+ });
5809
+ }, true);
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
+
5831
+ // ── Serialise grid Map to [[col, row, value], ...] sparse array
5832
+ function serializeGrid(grid) {
5833
+ const out = [];
5834
+ grid.forEach((val, key) => {
5835
+ if (val > 0) {
5836
+ const col = Math.floor(key / GRID_SIZE);
5837
+ const row = key % GRID_SIZE;
5838
+ out.push([col, row, Math.round(val)]);
5839
+ }
5840
+ });
5841
+ return out;
5842
+ }
5843
+
5844
+ let summarySent = false;
5845
+ function firePageSummary() {
5846
+ if (summarySent) return; // only fire once per page lifecycle
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;
5851
+ summarySent = true;
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
+
5859
+ EventsManager.trackAutoEvent('page_summary', {
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,
5875
+ document_height: document.documentElement.scrollHeight || document.body.scrollHeight || 0,
5876
+ document_width: document.documentElement.scrollWidth || document.body.scrollWidth || 0,
5877
+ viewport_width: window.innerWidth,
5878
+ viewport_height: window.innerHeight,
5879
+ });
5880
+ } catch (_) {}
5574
5881
  }
5882
+
5883
+ // Fire on hard navigation / tab close (pagehide is more reliable than beforeunload)
5884
+ window.addEventListener('pagehide', firePageSummary);
5885
+ // Also fire when tab becomes hidden (covers SPA navigation that doesn't fire pagehide)
5886
+ document.addEventListener('visibilitychange', () => {
5887
+ if (document.hidden) firePageSummary();
5888
+ });
5889
+ } catch (error) {}
5575
5890
  },
5576
5891
 
5577
5892
  /**
@@ -5597,6 +5912,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
5597
5912
  this.startAdvancedFormTracking();
5598
5913
  this.startFormAbandonmentTracking();
5599
5914
  this.startElementVisibilityTracking();
5915
+ this.startPageSummaryTracking();
5600
5916
  }
5601
5917
  };
5602
5918
 
@@ -6425,7 +6741,12 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
6425
6741
  // Performance
6426
6742
  'page_performance': 'performance',
6427
6743
  // Visibility
6428
- 'element_view': 'visibility'
6744
+ 'element_view': 'visibility',
6745
+ // Page summary (aggregate)
6746
+ 'page_summary': 'interaction',
6747
+ // Clipboard & context
6748
+ 'copy_action': 'interaction',
6749
+ 'context_menu': 'interaction',
6429
6750
  };
6430
6751
 
6431
6752
  return categoryMap[eventName] || 'other';
@@ -8222,6 +8543,7 @@ if (window.Cryptique && window.Cryptique.initialized) ; else {
8222
8543
  * The script.js file is inlined during the build process by Rollup.
8223
8544
  */
8224
8545
 
8546
+
8225
8547
  // Create a wrapper that provides programmatic initialization
8226
8548
  const CryptiqueSDK = {
8227
8549
  /**