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