@cognior/iap-sdk 0.1.0

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.

Potentially problematic release.


This version of @cognior/iap-sdk might be problematic. Click here for more details.

Files changed (60) hide show
  1. package/.github/copilot-instructions.md +95 -0
  2. package/README.md +79 -0
  3. package/TRACKING.md +105 -0
  4. package/USER_CONTEXT_README.md +284 -0
  5. package/package.json +154 -0
  6. package/src/config.ts +25 -0
  7. package/src/core/flowEngine.ts +1833 -0
  8. package/src/core/triggerManager.ts +1011 -0
  9. package/src/experiences/banner.ts +366 -0
  10. package/src/experiences/beacon.ts +668 -0
  11. package/src/experiences/hotspotTour.ts +654 -0
  12. package/src/experiences/hotspots.ts +566 -0
  13. package/src/experiences/modal.ts +1337 -0
  14. package/src/experiences/modalSequence.ts +1247 -0
  15. package/src/experiences/popover.ts +652 -0
  16. package/src/experiences/registry.ts +21 -0
  17. package/src/experiences/survey.ts +1639 -0
  18. package/src/experiences/taskList.ts +625 -0
  19. package/src/experiences/tooltip.ts +740 -0
  20. package/src/experiences/types.ts +395 -0
  21. package/src/experiences/walkthrough.ts +670 -0
  22. package/src/flow-sequence.ts +177 -0
  23. package/src/flows.ts +512 -0
  24. package/src/http.ts +61 -0
  25. package/src/index.ts +355 -0
  26. package/src/services/flowManager.ts +905 -0
  27. package/src/services/flowNormalizer.ts +74 -0
  28. package/src/services/locationContextService.ts +189 -0
  29. package/src/services/pageContextService.ts +221 -0
  30. package/src/services/userContextService.ts +286 -0
  31. package/src/state/appState.ts +0 -0
  32. package/src/state/hooks.ts +0 -0
  33. package/src/state/index.ts +0 -0
  34. package/src/state/migration.ts +0 -0
  35. package/src/state/store.ts +0 -0
  36. package/src/styles/banner.css.ts +0 -0
  37. package/src/styles/hotspot.css.ts +0 -0
  38. package/src/styles/hotspotTour.css.ts +0 -0
  39. package/src/styles/modal.css.ts +564 -0
  40. package/src/styles/survey.css.ts +1013 -0
  41. package/src/styles/taskList.css.ts +0 -0
  42. package/src/styles/tooltip.css.ts +149 -0
  43. package/src/styles/walkthrough.css.ts +0 -0
  44. package/src/tourUtils.ts +0 -0
  45. package/src/tracking.ts +223 -0
  46. package/src/utils/debounce.ts +66 -0
  47. package/src/utils/eventSequenceValidator.ts +124 -0
  48. package/src/utils/flowTrackingSystem.ts +524 -0
  49. package/src/utils/idGenerator.ts +155 -0
  50. package/src/utils/immediateValidationPrevention.ts +184 -0
  51. package/src/utils/normalize.ts +50 -0
  52. package/src/utils/privacyManager.ts +166 -0
  53. package/src/utils/ruleEvaluator.ts +199 -0
  54. package/src/utils/sanitize.ts +79 -0
  55. package/src/utils/selectors.ts +107 -0
  56. package/src/utils/stepExecutor.ts +345 -0
  57. package/src/utils/triggerNormalizer.ts +149 -0
  58. package/src/utils/validationInterceptor.ts +650 -0
  59. package/tsconfig.json +13 -0
  60. package/tsup.config.ts +13 -0
@@ -0,0 +1,668 @@
1
+ // src/experiences/beacon.ts
2
+ // Beacon experience renderer - creates pulsing alert notifications
3
+
4
+ import { sanitizeHtml } from "../utils/sanitize";
5
+ import { register } from "./registry";
6
+ import type { BeaconPayload } from "./types";
7
+ import { resolveSelector } from "../utils/selectors";
8
+
9
+ type BeaconFlow = { id: string; type: "beacon"; payload: BeaconPayload };
10
+
11
+ interface BeaconState {
12
+ id: string;
13
+ element: HTMLElement;
14
+ targetElement?: HTMLElement;
15
+ cleanup: (() => void)[];
16
+ isActive: boolean;
17
+ }
18
+
19
+ const activeBeacons = new Map<string, BeaconState>();
20
+
21
+ export function registerBeacon() {
22
+ register("beacon", renderBeacon);
23
+ }
24
+
25
+ export async function renderBeacon(flow: BeaconFlow): Promise<void> {
26
+ const { payload, id } = flow;
27
+
28
+ console.debug("[DAP] Beacon initialized", { id, payload });
29
+
30
+ // Validate required data
31
+ if (!payload.title && !payload.body) {
32
+ console.error("[DAP] Beacon missing required content (title or body)");
33
+ payload._completionTracker?.onComplete?.();
34
+ return;
35
+ }
36
+
37
+ // Clean up any existing beacon with same ID
38
+ if (activeBeacons.has(id)) {
39
+ cleanupBeacon(id);
40
+ }
41
+
42
+ // Find target element if selector provided
43
+ let targetElement: HTMLElement | undefined;
44
+ if (payload.targetSelector) {
45
+ const element = resolveSelector(payload.targetSelector);
46
+ if (element instanceof HTMLElement) {
47
+ targetElement = element;
48
+ } else {
49
+ console.warn(`[DAP] Beacon: Target element not found for selector: ${payload.targetSelector}`);
50
+ }
51
+ }
52
+
53
+ // Create beacon element
54
+ const beaconElement = createBeaconElement(payload, id);
55
+
56
+ const beaconState: BeaconState = {
57
+ id,
58
+ element: beaconElement,
59
+ targetElement,
60
+ cleanup: [],
61
+ isActive: false
62
+ };
63
+
64
+ activeBeacons.set(id, beaconState);
65
+
66
+ // Show beacon
67
+ showBeacon(beaconState, payload);
68
+
69
+ console.debug("[DAP] Beacon setup complete", { id });
70
+ }
71
+
72
+ function createBeaconElement(payload: BeaconPayload, id: string): HTMLElement {
73
+ const beacon = document.createElement('div');
74
+ beacon.className = 'dap-beacon';
75
+ beacon.id = `dap-beacon-${id}`;
76
+ beacon.setAttribute('role', 'alert');
77
+ beacon.setAttribute('aria-live', 'assertive');
78
+
79
+ // Position and base styling
80
+ const position = payload.position || 'top-right';
81
+ const positionStyles = getPositionStyles(position);
82
+
83
+ Object.assign(beacon.style, {
84
+ position: 'fixed',
85
+ zIndex: '10000',
86
+ padding: '12px 16px',
87
+ borderRadius: '16px',
88
+ background: 'rgba(255, 255, 255, 0.95)',
89
+ border: '2px solid #3b82f6',
90
+ boxShadow: '0 8px 32px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08)',
91
+ backdropFilter: 'blur(12px)',
92
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
93
+ fontSize: '13px',
94
+ lineHeight: '1.4',
95
+ color: '#1e40af',
96
+ maxWidth: '280px',
97
+ minWidth: '200px',
98
+ opacity: '0',
99
+ transform: 'translateY(-10px) scale(0.95)',
100
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
101
+ cursor: 'pointer',
102
+ pointerEvents: 'auto',
103
+ userSelect: 'none',
104
+ ...positionStyles
105
+ });
106
+
107
+ // Add subtle pulsing animation
108
+ beacon.style.animation = 'dap-beacon-pulse 2s ease-in-out infinite';
109
+
110
+ // Add beacon pulse animation styles if not already present
111
+ if (!document.querySelector('#dap-beacon-pulse-styles')) {
112
+ const pulseStyles = document.createElement('style');
113
+ pulseStyles.id = 'dap-beacon-pulse-styles';
114
+ pulseStyles.textContent = `
115
+ @keyframes dap-beacon-pulse {
116
+ 0%, 100% {
117
+ box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 0 rgba(59, 130, 246, 0.4);
118
+ }
119
+ 50% {
120
+ box-shadow: 0 8px 32px rgba(59, 130, 246, 0.25), 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 8px rgba(59, 130, 246, 0.1);
121
+ }
122
+ }
123
+
124
+ .dap-beacon:hover {
125
+ transform: translateY(-2px) scale(1.02) !important;
126
+ box-shadow: 0 12px 40px rgba(59, 130, 246, 0.25), 0 6px 20px rgba(0, 0, 0, 0.12) !important;
127
+ animation: none !important;
128
+ }
129
+ `;
130
+ document.head.appendChild(pulseStyles);
131
+ }
132
+
133
+ // Add icon if specified
134
+ if (payload.icon) {
135
+ const icon = document.createElement('span');
136
+ icon.style.cssText = `
137
+ display: inline-block;
138
+ margin-right: 8px;
139
+ font-size: 18px;
140
+ vertical-align: middle;
141
+ `;
142
+ icon.textContent = payload.icon;
143
+ beacon.appendChild(icon);
144
+ }
145
+
146
+ // Add title if provided
147
+ if (payload.title) {
148
+ const title = document.createElement('div');
149
+ title.style.cssText = `
150
+ font-weight: 600;
151
+ font-size: 15px;
152
+ color: #78350f;
153
+ margin-bottom: ${payload.body ? '6px' : '0'};
154
+ line-height: 1.3;
155
+ `;
156
+ title.textContent = payload.title;
157
+ beacon.appendChild(title);
158
+ }
159
+
160
+ // Add body content if provided
161
+ if (payload.body) {
162
+ const body = document.createElement('div');
163
+ body.style.cssText = `
164
+ color: #a16207;
165
+ line-height: 1.4;
166
+ font-size: 13px;
167
+ `;
168
+ body.innerHTML = sanitizeHtml(payload.body);
169
+ beacon.appendChild(body);
170
+ }
171
+
172
+ // Add close button
173
+ const closeButton = document.createElement('button');
174
+ closeButton.style.cssText = `
175
+ position: absolute;
176
+ top: 6px;
177
+ right: 6px;
178
+ background: rgba(59, 130, 246, 0.1);
179
+ border: none;
180
+ font-size: 14px;
181
+ color: #3b82f6;
182
+ cursor: pointer;
183
+ padding: 4px 6px;
184
+ border-radius: 8px;
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ transition: all 0.15s ease;
189
+ font-weight: 500;
190
+ width: 24px;
191
+ height: 24px;
192
+ `;
193
+ closeButton.innerHTML = '×';
194
+ closeButton.title = 'Close beacon';
195
+ closeButton.setAttribute('aria-label', 'Close beacon');
196
+
197
+ closeButton.addEventListener('mouseenter', () => {
198
+ closeButton.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
199
+ closeButton.style.transform = 'scale(1.1)';
200
+ });
201
+
202
+ closeButton.addEventListener('mouseleave', () => {
203
+ closeButton.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
204
+ closeButton.style.transform = 'scale(1)';
205
+ });
206
+
207
+ closeButton.addEventListener('click', (e) => {
208
+ e.stopPropagation();
209
+ dismissBeacon(id);
210
+ });
211
+
212
+ beacon.appendChild(closeButton);
213
+
214
+ // Store payload for later access
215
+ (beacon as any).__beaconPayload = payload;
216
+
217
+ return beacon;
218
+ }
219
+
220
+ function getPositionStyles(position: string) {
221
+ const margin = '20px';
222
+
223
+ switch (position) {
224
+ case 'top-left':
225
+ return { top: margin, left: margin };
226
+ case 'top-center':
227
+ return { top: margin, left: '50%', transform: 'translateX(-50%) translateY(-20px) scale(0.9)' };
228
+ case 'top-right':
229
+ default:
230
+ return { top: margin, right: margin };
231
+ case 'bottom-left':
232
+ return { bottom: margin, left: margin };
233
+ case 'bottom-center':
234
+ return { bottom: margin, left: '50%', transform: 'translateX(-50%) translateY(20px) scale(0.9)' };
235
+ case 'bottom-right':
236
+ return { bottom: margin, right: margin };
237
+ case 'center':
238
+ return {
239
+ top: '50%',
240
+ left: '50%',
241
+ transform: 'translate(-50%, -50%) translateY(-20px) scale(0.9)'
242
+ };
243
+ }
244
+ }
245
+
246
+ function showBeacon(state: BeaconState, payload: BeaconPayload): void {
247
+ if (state.isActive) return;
248
+
249
+ state.isActive = true;
250
+
251
+ console.debug("[DAP] Beacon shown", { id: state.id, hasTarget: !!state.targetElement });
252
+
253
+ // Add to DOM first
254
+ document.body.appendChild(state.element);
255
+
256
+ // Wait for DOM to settle, then position
257
+ requestAnimationFrame(() => {
258
+ // Always position beacon relative to target element if available
259
+ if (state.targetElement) {
260
+ // Use default position if not specified
261
+ const position = payload.position ? parsePosition(payload.position) : { x: 'right', y: 'center' };
262
+ console.debug("[DAP] Positioning beacon with position:", position);
263
+ if (position) {
264
+ positionBeaconRelativeToElement(state.element, state.targetElement, position);
265
+ }
266
+ } else {
267
+ // Fallback positioning without target element
268
+ console.debug("[DAP] No target element, using fallback positioning");
269
+ const positionStyles = getPositionStyles(payload.position || 'top-right');
270
+ Object.assign(state.element.style, positionStyles);
271
+ }
272
+
273
+ // Apply beacon pulsing animation
274
+ applyBeaconAnimation(state.element, payload.beaconStyles);
275
+
276
+ // Show with smooth entrance animation after positioning
277
+ setTimeout(() => {
278
+ state.element.style.opacity = '1';
279
+ state.element.style.transform = 'scale(1)';
280
+ console.debug("[DAP] Beacon animation complete");
281
+ }, 50);
282
+ });
283
+
284
+ // Setup click handler for main content (not close button)
285
+ const clickHandler = (e: Event) => {
286
+ const target = e.target as HTMLElement;
287
+ if (!target.closest('button')) {
288
+ console.debug("[DAP] Beacon clicked", { id: state.id });
289
+
290
+ // Execute custom action if defined
291
+ if (payload.action) {
292
+ console.debug("[DAP] Executing beacon action", { action: payload.action });
293
+ // Custom actions can be handled by completion tracker
294
+ }
295
+
296
+ dismissBeacon(state.id);
297
+ }
298
+ };
299
+
300
+ state.element.addEventListener('click', clickHandler);
301
+ state.cleanup.push(() => state.element.removeEventListener('click', clickHandler));
302
+
303
+ // Setup global event handlers
304
+ setupGlobalEventHandlers(state, payload);
305
+
306
+ // Setup position observer for target element
307
+ if (state.targetElement) {
308
+ setupPositionObserver(state, payload);
309
+ }
310
+
311
+ // Auto-dismiss if specified
312
+ if (payload.autoDismiss && payload.autoDismiss > 0) {
313
+ setTimeout(() => {
314
+ dismissBeacon(state.id);
315
+ }, payload.autoDismiss * 1000);
316
+ }
317
+ }
318
+
319
+ function applyBeaconAnimation(element: HTMLElement, beaconStyles?: any): void {
320
+ const styles = {
321
+ enabled: true,
322
+ color1: '#f59e0b',
323
+ color2: '#eab308',
324
+ duration: '2s',
325
+ padding: '8px',
326
+ borderWidth: '3px',
327
+ borderRadius: '16px',
328
+ shadowSize: '20px',
329
+ ...beaconStyles
330
+ };
331
+
332
+ if (!styles.enabled) return;
333
+
334
+ const animationId = `beacon-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
335
+
336
+ const style = document.createElement('style');
337
+ style.dataset.beaconAnimation = animationId;
338
+ style.textContent = `
339
+ .dap-beacon[data-beacon-id="${animationId}"]::before {
340
+ content: '';
341
+ position: absolute;
342
+ top: -${styles.padding};
343
+ left: -${styles.padding};
344
+ right: -${styles.padding};
345
+ bottom: -${styles.padding};
346
+ border: ${styles.borderWidth} solid ${styles.color1};
347
+ border-radius: ${styles.borderRadius};
348
+ animation: beaconPulse-${animationId} ${styles.duration} ease-in-out infinite;
349
+ pointer-events: none;
350
+ z-index: -1;
351
+ }
352
+
353
+ @keyframes beaconPulse-${animationId} {
354
+ 0% {
355
+ border-color: ${styles.color1};
356
+ box-shadow: 0 0 0 0 ${styles.color1}40, 0 0 ${styles.shadowSize} ${styles.color1}30;
357
+ transform: scale(1);
358
+ }
359
+ 50% {
360
+ border-color: ${styles.color2};
361
+ box-shadow: 0 0 0 10px ${styles.color2}20, 0 0 ${styles.shadowSize} ${styles.color2}40;
362
+ transform: scale(1.05);
363
+ }
364
+ 100% {
365
+ border-color: ${styles.color1};
366
+ box-shadow: 0 0 0 0 ${styles.color1}40, 0 0 ${styles.shadowSize} ${styles.color1}30;
367
+ transform: scale(1);
368
+ }
369
+ }
370
+ `;
371
+
372
+ document.head.appendChild(style);
373
+ element.setAttribute('data-beacon-id', animationId);
374
+
375
+ // Cleanup function
376
+ const cleanup = () => {
377
+ if (style.parentNode) {
378
+ style.parentNode.removeChild(style);
379
+ }
380
+ };
381
+
382
+ // Store cleanup in beacon state if available
383
+ const beaconId = element.id.replace('dap-beacon-', '');
384
+ const state = activeBeacons.get(beaconId);
385
+ if (state) {
386
+ state.cleanup.push(cleanup);
387
+ }
388
+ }
389
+
390
+ function setupGlobalEventHandlers(state: BeaconState, payload: BeaconPayload): void {
391
+ // ESC key handler
392
+ const keyHandler = (e: KeyboardEvent) => {
393
+ if (e.key === 'Escape') {
394
+ e.preventDefault();
395
+ dismissBeacon(state.id);
396
+ }
397
+ };
398
+
399
+ // Navigation handler
400
+ const navigationHandler = () => {
401
+ dismissBeacon(state.id);
402
+ };
403
+
404
+ document.addEventListener('keydown', keyHandler);
405
+ window.addEventListener('beforeunload', navigationHandler);
406
+ window.addEventListener('popstate', navigationHandler);
407
+
408
+ state.cleanup.push(() => {
409
+ document.removeEventListener('keydown', keyHandler);
410
+ window.removeEventListener('beforeunload', navigationHandler);
411
+ window.removeEventListener('popstate', navigationHandler);
412
+ });
413
+ }
414
+
415
+ function dismissBeacon(id: string): void {
416
+ const state = activeBeacons.get(id);
417
+ if (!state || !state.isActive) return;
418
+
419
+ state.isActive = false;
420
+
421
+ console.debug("[DAP] Beacon dismissed", { id });
422
+
423
+ // Animate out
424
+ state.element.style.opacity = '0';
425
+ state.element.style.transform = state.element.style.transform.replace(/translateY\([^)]+\)/, 'translateY(-20px)').replace(/scale\([^)]+\)/, 'scale(0.9)');
426
+
427
+ // Complete the step
428
+ const beaconElement = state.element;
429
+ const payloadData = (beaconElement as any).__beaconPayload;
430
+ if (payloadData?._completionTracker?.onComplete) {
431
+ payloadData._completionTracker.onComplete();
432
+ }
433
+
434
+ // Remove from DOM after animation
435
+ setTimeout(() => {
436
+ if (state.element.parentNode) {
437
+ state.element.parentNode.removeChild(state.element);
438
+ }
439
+ cleanupBeacon(id);
440
+ }, 300);
441
+ }
442
+
443
+ function cleanupBeacon(id: string): void {
444
+ console.debug("[DAP] Beacon destroyed", { id });
445
+
446
+ const state = activeBeacons.get(id);
447
+ if (!state) return;
448
+
449
+ // Run all cleanup functions
450
+ state.cleanup.forEach(cleanup => {
451
+ try {
452
+ cleanup();
453
+ } catch (error) {
454
+ console.warn("[DAP] Error during beacon cleanup:", error);
455
+ }
456
+ });
457
+
458
+ // Remove from DOM if still attached
459
+ if (state.element && state.element.parentNode) {
460
+ state.element.parentNode.removeChild(state.element);
461
+ }
462
+
463
+ // Signal completion
464
+ // Note: We don't call completion tracker here since dismiss already handles it
465
+
466
+ // Remove from active beacons
467
+ activeBeacons.delete(id);
468
+ }
469
+
470
+ function parsePosition(position: string | { x: string; y: string }): { x: string; y: string } | null {
471
+ // If already an object, return it
472
+ if (typeof position === 'object' && position.x && position.y) {
473
+ return { x: position.x, y: position.y };
474
+ }
475
+
476
+ // Parse string position into x,y coordinates
477
+ if (typeof position === 'string') {
478
+ switch (position) {
479
+ case 'top-left':
480
+ return { x: 'left', y: 'top' };
481
+ case 'top-center':
482
+ return { x: 'center', y: 'top' };
483
+ case 'top-right':
484
+ return { x: 'right', y: 'top' };
485
+ case 'bottom-left':
486
+ return { x: 'left', y: 'bottom' };
487
+ case 'bottom-center':
488
+ return { x: 'center', y: 'bottom' };
489
+ case 'bottom-right':
490
+ return { x: 'right', y: 'bottom' };
491
+ case 'center':
492
+ return { x: 'center', y: 'center' };
493
+ default:
494
+ return { x: 'center', y: 'center' };
495
+ }
496
+ }
497
+
498
+ return null;
499
+ }
500
+
501
+ function positionBeaconRelativeToElement(
502
+ beaconElement: HTMLElement,
503
+ targetElement: HTMLElement,
504
+ position: { x: string; y: string }
505
+ ): void {
506
+ console.debug("[DAP] Starting beacon positioning", {
507
+ targetElement: targetElement.tagName,
508
+ targetSelector: targetElement.id || targetElement.className,
509
+ position
510
+ });
511
+
512
+ const targetRect = targetElement.getBoundingClientRect();
513
+ console.debug("[DAP] Target element bounds:", targetRect);
514
+
515
+ // Ensure beacon is properly styled and in DOM for measurements
516
+ beaconElement.style.position = 'fixed';
517
+ beaconElement.style.display = 'block';
518
+ beaconElement.style.visibility = 'visible';
519
+ beaconElement.style.opacity = '0'; // Hidden but measurable
520
+
521
+ if (!beaconElement.parentNode) {
522
+ document.body.appendChild(beaconElement);
523
+ }
524
+
525
+ // Force a layout to get accurate measurements
526
+ beaconElement.offsetHeight; // Trigger layout
527
+ const beaconRect = beaconElement.getBoundingClientRect();
528
+ console.debug("[DAP] Beacon element bounds:", beaconRect);
529
+
530
+ const spacing = 30; // Even more generous spacing
531
+ const viewportWidth = window.innerWidth;
532
+ const viewportHeight = window.innerHeight;
533
+
534
+ let left = 0;
535
+ let top = 0;
536
+ let placement = 'right'; // Default
537
+
538
+ // Try positioning to the right first (best for form elements)
539
+ left = targetRect.right + spacing;
540
+ top = targetRect.top + (targetRect.height - beaconRect.height) / 2;
541
+
542
+ // Check if right position fits
543
+ if (left + beaconRect.width > viewportWidth - 10) {
544
+ // Try left position
545
+ left = targetRect.left - beaconRect.width - spacing;
546
+ placement = 'left';
547
+
548
+ // If left doesn't fit either, try bottom
549
+ if (left < 10) {
550
+ left = targetRect.left + (targetRect.width - beaconRect.width) / 2;
551
+ top = targetRect.bottom + spacing;
552
+ placement = 'bottom';
553
+
554
+ // If bottom doesn't fit, try top
555
+ if (top + beaconRect.height > viewportHeight - 10) {
556
+ top = targetRect.top - beaconRect.height - spacing;
557
+ placement = 'top';
558
+
559
+ // If top doesn't fit, force right with viewport constraints
560
+ if (top < 10) {
561
+ left = Math.min(targetRect.right + spacing, viewportWidth - beaconRect.width - 10);
562
+ top = Math.max(10, Math.min(targetRect.top, viewportHeight - beaconRect.height - 10));
563
+ placement = 'right-constrained';
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ // Apply final constraints
570
+ left = Math.max(10, Math.min(left, viewportWidth - beaconRect.width - 10));
571
+ top = Math.max(10, Math.min(top, viewportHeight - beaconRect.height - 10));
572
+
573
+ console.debug("[DAP] Final beacon position:", {
574
+ left,
575
+ top,
576
+ placement,
577
+ beaconWidth: beaconRect.width,
578
+ beaconHeight: beaconRect.height,
579
+ viewportWidth,
580
+ viewportHeight
581
+ });
582
+
583
+ // Apply the position
584
+ beaconElement.style.left = `${Math.round(left)}px`;
585
+ beaconElement.style.top = `${Math.round(top)}px`;
586
+ beaconElement.style.transform = 'none';
587
+ beaconElement.style.zIndex = '10000';
588
+
589
+ // Store placement info
590
+ beaconElement.setAttribute('data-placement', placement);
591
+
592
+ console.debug("[DAP] Beacon positioned successfully");
593
+ }
594
+
595
+ function setupPositionObserver(state: BeaconState, payload: BeaconPayload): void {
596
+ if (!state.targetElement) return;
597
+
598
+ console.debug("[DAP] Setting up position observer for beacon", { id: state.id });
599
+
600
+ // More frequent updates for better responsiveness
601
+ let updateTimeout: number | null = null;
602
+
603
+ const updatePosition = () => {
604
+ if (updateTimeout) clearTimeout(updateTimeout);
605
+ updateTimeout = setTimeout(() => {
606
+ if (state.targetElement && state.isActive) {
607
+ const position = payload.position ? parsePosition(payload.position) : { x: 'right', y: 'center' };
608
+ console.debug("[DAP] Updating beacon position on scroll");
609
+ if (position) {
610
+ positionBeaconRelativeToElement(state.element, state.targetElement, position);
611
+ }
612
+ }
613
+ }, 8) as any; // More frequent updates for smoother scrolling
614
+ };
615
+
616
+ // Setup intersection observer to detect if target becomes visible/hidden
617
+ const intersectionObserver = new IntersectionObserver((entries) => {
618
+ entries.forEach(entry => {
619
+ if (entry.isIntersecting) {
620
+ state.element.style.display = 'block';
621
+ updatePosition();
622
+ } else {
623
+ state.element.style.display = 'none';
624
+ }
625
+ });
626
+ }, { threshold: 0.1 });
627
+
628
+ intersectionObserver.observe(state.targetElement);
629
+
630
+ // Add multiple scroll listeners for better coverage
631
+ const handleScroll = () => updatePosition();
632
+ const handleResize = () => updatePosition();
633
+
634
+ // Listen to different scroll contexts
635
+ window.addEventListener('scroll', handleScroll, { passive: true });
636
+ window.addEventListener('resize', handleResize, { passive: true });
637
+ document.addEventListener('scroll', handleScroll, { passive: true, capture: true });
638
+
639
+ // Also listen for any scroll on scrollable containers
640
+ let scrollableParent = state.targetElement.parentElement;
641
+ const scrollListeners: Array<{ element: Element; listener: () => void }> = [];
642
+
643
+ while (scrollableParent) {
644
+ const style = window.getComputedStyle(scrollableParent);
645
+ if (style.overflow === 'auto' || style.overflow === 'scroll' ||
646
+ style.overflowY === 'auto' || style.overflowY === 'scroll') {
647
+ scrollableParent.addEventListener('scroll', handleScroll, { passive: true });
648
+ scrollListeners.push({ element: scrollableParent, listener: handleScroll });
649
+ }
650
+ scrollableParent = scrollableParent.parentElement;
651
+ }
652
+
653
+ state.cleanup.push(() => {
654
+ intersectionObserver.disconnect();
655
+ window.removeEventListener('scroll', handleScroll);
656
+ window.removeEventListener('resize', handleResize);
657
+ document.removeEventListener('scroll', handleScroll, true);
658
+
659
+ // Clean up scrollable parent listeners
660
+ scrollListeners.forEach(({ element, listener }) => {
661
+ element.removeEventListener('scroll', listener);
662
+ });
663
+
664
+ if (updateTimeout) clearTimeout(updateTimeout);
665
+ });
666
+
667
+ console.debug("[DAP] Position observer setup complete");
668
+ }