handhold-sdk 1.0.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.
Files changed (50) hide show
  1. package/README.md +427 -0
  2. package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
  3. package/dist/fonts/Phosphor.ttf +0 -0
  4. package/dist/fonts/Phosphor.woff +0 -0
  5. package/dist/fonts/Phosphor.woff2 +0 -0
  6. package/dist/handhold.min.js +1 -0
  7. package/package.json +114 -0
  8. package/src/HandHold.js +1599 -0
  9. package/src/agent/VENDOR.md +203 -0
  10. package/src/agent/agent/AgentLoop.js +366 -0
  11. package/src/agent/agent/prompts.js +163 -0
  12. package/src/agent/agent/tools.js +148 -0
  13. package/src/agent/controller.js +235 -0
  14. package/src/agent/dom/actions.js +456 -0
  15. package/src/agent/dom/domTree.js +1761 -0
  16. package/src/agent/dom/redact.js +98 -0
  17. package/src/agent/dom/serializer.js +332 -0
  18. package/src/agent/dom/utils.js +99 -0
  19. package/src/agent/index.js +169 -0
  20. package/src/agent/llm/connector.js +143 -0
  21. package/src/agent/package.json +3 -0
  22. package/src/agent/policy/PolicyGuard.js +172 -0
  23. package/src/agent/site/manifest.js +145 -0
  24. package/src/agent/ui/ChatWidget.js +219 -0
  25. package/src/agent-mode/AgentMode.js +429 -0
  26. package/src/api/APIClient.js +258 -0
  27. package/src/core/Config.js +207 -0
  28. package/src/core/UiState.js +83 -0
  29. package/src/core/defaults.js +20 -0
  30. package/src/events.js +59 -0
  31. package/src/index.js +77 -0
  32. package/src/intent/ActionVerbMap.js +324 -0
  33. package/src/intent/IntentExtractor.js +317 -0
  34. package/src/observers/ElementScanner.js +284 -0
  35. package/src/observers/NavigationObserver.js +182 -0
  36. package/src/observers/PageObserver.js +293 -0
  37. package/src/session/SessionManager.js +402 -0
  38. package/src/session/SessionStorage.js +148 -0
  39. package/src/ui/HelpWidget.js +1189 -0
  40. package/src/ui/UICoach.js +1725 -0
  41. package/src/ui/components/Button.css +137 -0
  42. package/src/ui/components/Button.js +102 -0
  43. package/src/ui/widget.css +599 -0
  44. package/src/utils/Logger.js +132 -0
  45. package/src/utils/MarkdownRenderer.js +39 -0
  46. package/src/utils/domUtils.js +350 -0
  47. package/src/utils/eventBus.js +102 -0
  48. package/src/utils/index.js +8 -0
  49. package/src/utils/stringUtils.js +202 -0
  50. package/types/index.d.ts +538 -0
@@ -0,0 +1,1725 @@
1
+ /**
2
+ * Hand Hold SDK - UI Coach
3
+ * Handles visual UI guidance with element highlighting, overlays, arrows, and dimming
4
+ */
5
+
6
+ import eventBus from '../utils/eventBus.js';
7
+ import { MarkdownRenderer } from '../utils/MarkdownRenderer.js';
8
+ import '@phosphor-icons/web/regular';
9
+ import { Button, ButtonVariant, ButtonSize } from './components/Button.js';
10
+
11
+ class UICoach {
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.overlay = null;
15
+ this.spotlight = null;
16
+ this.highlightBox = null;
17
+ this.arrow = null;
18
+ this.tooltip = null;
19
+ this.currentElement = null;
20
+ this.resizeObserver = null;
21
+ this.isHighlighting = false;
22
+ this.selectionButton = null;
23
+ this.selectionEnabled = false;
24
+ this.selectionText = '';
25
+ this._selectionHandler = null;
26
+ this._selectionHideHandler = null;
27
+
28
+ // Inject styles
29
+ this._injectStyles();
30
+
31
+ // Listen for scan requests
32
+ eventBus.on('ui:scan_request', (data) => this._onScanRequest(data));
33
+ }
34
+
35
+ /**
36
+ * Handle scan request animation
37
+ * @param {Object} data - { selector }
38
+ */
39
+ _onScanRequest(data) {
40
+ const selector = data.selector;
41
+ if (!selector) return;
42
+
43
+ const element = document.querySelector(selector);
44
+ if (!element) return;
45
+
46
+ // Scroll into view first
47
+ try {
48
+ element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
49
+ } catch (e) {
50
+ element.scrollIntoView(true);
51
+ }
52
+
53
+ // Create scanner if needed
54
+ if (!this.scanner) {
55
+ this._createScanner();
56
+ }
57
+
58
+ // Get element position
59
+ const rect = element.getBoundingClientRect();
60
+
61
+ // Position scanner initially at center of screen
62
+ const startX = window.innerWidth / 2 - 30;
63
+ const startY = window.innerHeight / 2 - 30;
64
+
65
+ this.scanner.style.left = `${startX}px`;
66
+ this.scanner.style.top = `${startY}px`;
67
+ this.scanner.classList.add('active');
68
+
69
+ // Animate to target after a tiny delay to ensure CSS transition works
70
+ requestAnimationFrame(() => {
71
+ setTimeout(() => {
72
+ // Recalculate rect in case scroll happened
73
+ const updatedRect = element.getBoundingClientRect();
74
+ const targetX = updatedRect.left + (updatedRect.width / 2) - 30;
75
+ const targetY = updatedRect.top + (updatedRect.height / 2) - 30;
76
+
77
+ this.scanner.style.left = `${targetX}px`;
78
+ this.scanner.style.top = `${targetY}px`;
79
+ }, 50);
80
+ });
81
+
82
+ // Hide after animation
83
+ setTimeout(() => {
84
+ this.scanner.classList.remove('active');
85
+ }, 1500);
86
+ }
87
+
88
+ /**
89
+ * Create scanner element (magnifying glass)
90
+ */
91
+ _createScanner() {
92
+ this.scanner = document.createElement('div');
93
+ this.scanner.className = 'handhold-scanner';
94
+ // Use a Phosphor-style magnifying glass icon for better visibility
95
+ this.scanner.innerHTML = `
96
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
97
+ <circle cx="11" cy="11" r="7"></circle>
98
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
99
+ </svg>
100
+ `;
101
+ document.body.appendChild(this.scanner);
102
+ }
103
+
104
+ /**
105
+ * Inject CSS styles for highlighting
106
+ */
107
+ _injectStyles() {
108
+ const existingStyles = document.getElementById('handhold-uicoach-styles');
109
+ if (existingStyles) {
110
+ existingStyles.remove();
111
+ }
112
+
113
+ const primaryColor = this.config.primaryColor || '#004AF5';
114
+ const primaryRgb = this._hexToRgb(primaryColor);
115
+ const zIndex = this.config.zIndex || 999990;
116
+ const isDark = this.config.theme === 'dark';
117
+
118
+ const styles = document.createElement('style');
119
+ styles.id = 'handhold-uicoach-styles';
120
+ styles.textContent = `
121
+ :root {
122
+ --hh-coach-bg: ${isDark ? 'rgba(30, 41, 59, 0.75)' : 'rgba(255, 255, 255, 0.9)'};
123
+ --hh-coach-backdrop: blur(24px);
124
+ --hh-coach-fg: ${isDark ? '#f8fafc' : '#334155'};
125
+ --hh-coach-border: ${isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.6)'};
126
+ --hh-coach-primary: ${primaryColor};
127
+ --hh-coach-primary-rgb: ${primaryRgb};
128
+ --hh-coach-primary-fg: #ffffff;
129
+ --hh-coach-primary-dark: ${this._adjustColor(primaryColor, -20)};
130
+ --hh-coach-secondary: ${isDark ? 'rgba(51, 65, 85, 0.6)' : '#f1f5f9'};
131
+ --hh-coach-secondary-fg: ${isDark ? '#f8fafc' : '#475569'};
132
+ --hh-coach-muted: ${isDark ? '#94a3b8' : '#64748b'};
133
+ --hh-coach-shadow: ${isDark ? '0 20px 40px -5px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05)' : '0 24px 48px -12px rgba(50, 50, 93, 0.15), 0 12px 24px -8px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.5)'};
134
+ --hh-coach-radius: 24px;
135
+ }
136
+
137
+ /* Overlay background */
138
+ .handhold-overlay {
139
+ position: fixed;
140
+ top: 0;
141
+ left: 0;
142
+ width: 100vw;
143
+ height: 100vh;
144
+ background: transparent;
145
+ z-index: ${zIndex + 1};
146
+ pointer-events: none;
147
+ opacity: 0;
148
+ transition: opacity 0.4s ease;
149
+ }
150
+
151
+ .handhold-overlay.active {
152
+ opacity: 1;
153
+ }
154
+
155
+ /* Highlight box around element */
156
+ .handhold-highlight {
157
+ position: fixed;
158
+ border: 2px solid var(--hh-coach-primary);
159
+ border-radius: 12px;
160
+ z-index: ${zIndex + 3};
161
+ pointer-events: none;
162
+ box-shadow: 0 0 0 4px rgba(var(--hh-coach-primary-rgb), 0.2);
163
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
164
+ animation: handhold-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
165
+ }
166
+
167
+ @keyframes handhold-pulse {
168
+ 0%, 100% { opacity: 1; }
169
+ 50% { opacity: .7; }
170
+ }
171
+
172
+ /* Scanning Animation (Magnifying Glass) */
173
+ .handhold-scanner {
174
+ position: fixed;
175
+ width: 60px;
176
+ height: 60px;
177
+ pointer-events: none;
178
+ z-index: ${zIndex + 10};
179
+ transition: top 0.4s ease-in-out, left 0.4s ease-in-out;
180
+ opacity: 0;
181
+ }
182
+
183
+ .handhold-scanner.active {
184
+ opacity: 1;
185
+ animation: handhold-scan-float 3s infinite ease-in-out;
186
+ }
187
+
188
+ .handhold-scanner svg {
189
+ width: 100%;
190
+ height: 100%;
191
+ filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
192
+ color: var(--hh-coach-primary);
193
+ }
194
+
195
+ @keyframes handhold-scan-float {
196
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
197
+ 25% { transform: translateY(-10px) rotate(-5deg); }
198
+ 75% { transform: translateY(5px) rotate(5deg); }
199
+ }
200
+
201
+ /* Arrow pointing to element */
202
+ .handhold-arrow {
203
+ position: fixed;
204
+ z-index: ${zIndex + 4};
205
+ pointer-events: none;
206
+ animation: handhold-bounce 1s ease-in-out infinite;
207
+ filter: drop-shadow(0 4px 8px rgba(0,0,0,0.15));
208
+ }
209
+
210
+ .handhold-arrow svg {
211
+ width: 36px;
212
+ height: 36px;
213
+ }
214
+
215
+ .handhold-arrow svg path {
216
+ fill: var(--hh-coach-primary);
217
+ }
218
+
219
+ @keyframes handhold-bounce {
220
+ 0%, 100% { transform: translateY(0); }
221
+ 50% { transform: translateY(-8px); }
222
+ }
223
+
224
+ /* Direction-specific arrow positions */
225
+ .handhold-arrow.top { transform-origin: center bottom; }
226
+ .handhold-arrow.bottom { transform-origin: center top; }
227
+ .handhold-arrow.left { transform-origin: right center; }
228
+ .handhold-arrow.right { transform-origin: left center; }
229
+
230
+ .handhold-arrow.bottom svg { transform: rotate(180deg); }
231
+ .handhold-arrow.left svg { transform: rotate(-90deg); }
232
+ .handhold-arrow.right svg { transform: rotate(90deg); }
233
+
234
+ /* Step tooltip */
235
+ .handhold-step-tooltip {
236
+ position: fixed;
237
+ background: var(--hh-coach-bg);
238
+ backdrop-filter: var(--hh-coach-backdrop);
239
+ -webkit-backdrop-filter: var(--hh-coach-backdrop);
240
+ border: 1px solid var(--hh-coach-border);
241
+ border-radius: 16px;
242
+ box-shadow: var(--hh-coach-shadow);
243
+ padding: 16px 20px;
244
+ max-width: 300px;
245
+ z-index: ${zIndex + 5};
246
+ pointer-events: auto;
247
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
248
+ font-size: 14px;
249
+ line-height: 1.5;
250
+ color: var(--hh-coach-fg);
251
+ animation: handhold-fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
252
+ }
253
+
254
+ @keyframes handhold-fadeIn {
255
+ from { opacity: 0; transform: translateY(12px) scale(0.96); }
256
+ to { opacity: 1; transform: translateY(0) scale(1); }
257
+ }
258
+
259
+ .handhold-step-tooltip.dark {
260
+ /* handled by variables now */
261
+ }
262
+
263
+ .handhold-step-tooltip-text {
264
+ margin: 0 0 16px 0;
265
+ font-weight: 500;
266
+ color: var(--hh-coach-fg);
267
+ }
268
+
269
+ .handhold-step-tooltip-actions {
270
+ display: flex;
271
+ justify-content: flex-end;
272
+ gap: 8px;
273
+ }
274
+
275
+ .handhold-step-tooltip-btn {
276
+ padding: 8px 16px;
277
+ border-radius: 10px;
278
+ border: 1px solid transparent;
279
+ cursor: pointer;
280
+ font-size: 13px;
281
+ font-weight: 600;
282
+ transition: all 0.2s;
283
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
284
+ }
285
+
286
+ .handhold-step-tooltip-btn.primary {
287
+ background: var(--hh-coach-primary);
288
+ color: var(--hh-coach-primary-fg);
289
+ box-shadow: 0 8px 20px -4px rgba(0, 0, 0, 0.2);
290
+ }
291
+
292
+ .handhold-step-tooltip-btn.primary:hover {
293
+ transform: none;
294
+ background: var(--hh-coach-primary-dark);
295
+ box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.25);
296
+ }
297
+
298
+ .handhold-step-tooltip-btn.secondary {
299
+ background: var(--hh-coach-secondary);
300
+ color: var(--hh-coach-secondary-fg);
301
+ }
302
+
303
+ .handhold-step-tooltip-btn.secondary:hover {
304
+ background: ${isDark ? 'rgba(51, 65, 85, 0.8)' : '#e2e8f0'};
305
+ }
306
+
307
+ /* Explanation Modal */
308
+ .handhold-explain-modal-overlay {
309
+ position: fixed;
310
+ top: 0;
311
+ left: 0;
312
+ width: 100vw;
313
+ height: 100vh;
314
+ background: rgba(0, 0, 0, 0.2);
315
+ backdrop-filter: blur(2px);
316
+ z-index: ${zIndex + 10};
317
+ opacity: 0;
318
+ transition: opacity 0.2s ease;
319
+ pointer-events: none;
320
+ }
321
+
322
+ .handhold-explain-modal-overlay.active {
323
+ opacity: 1;
324
+ pointer-events: auto;
325
+ }
326
+
327
+ .handhold-explain-modal {
328
+ position: fixed;
329
+ top: 20px;
330
+ right: 20px;
331
+ bottom: 20px;
332
+ height: auto;
333
+ background: #FFFFFF;
334
+ border: 1px solid #E2E8F0;
335
+ border-radius: 6px;
336
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
337
+ width: 380px;
338
+ max-width: calc(100vw - 40px);
339
+ z-index: ${zIndex + 11};
340
+ font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
341
+ font-size: 14px;
342
+ line-height: 1.5;
343
+ color: #0F172A;
344
+ transform: translateX(calc(100% + 20px));
345
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
346
+ display: flex;
347
+ flex-direction: column;
348
+ gap: 0;
349
+ }
350
+
351
+ .handhold-explain-modal.active {
352
+ transform: translateX(0);
353
+ }
354
+
355
+ .handhold-explain-header {
356
+ display: flex;
357
+ justify-content: space-between;
358
+ align-items: center;
359
+ padding: 12px 16px;
360
+ border-bottom: 2px solid #E2E8F0;
361
+ flex-shrink: 0;
362
+ }
363
+
364
+ .handhold-explain-title {
365
+ font-weight: 700;
366
+ font-size: 16px;
367
+ margin: 0;
368
+ color: #0F172A; /* slate-900 */
369
+ }
370
+
371
+ .handhold-explain-close-icon {
372
+ background: none;
373
+ border: none;
374
+ cursor: pointer;
375
+ padding: 0;
376
+ width: 56px;
377
+ height: 56px;
378
+ color: #64748B; /* slate-500 */
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ border-radius: 4px;
383
+ transition: all 0.2s;
384
+ margin-right: -4px;
385
+ font-size: 24px; /* For Phosphor icon */
386
+ }
387
+
388
+ .handhold-explain-close-icon:hover {
389
+ background: #F1F5F9; /* slate-100 */
390
+ color: #0F172A;
391
+ }
392
+
393
+ .handhold-explain-content {
394
+ padding: 16px;
395
+ overflow-y: auto;
396
+ flex: 1;
397
+ display: flex;
398
+ flex-direction: column;
399
+ gap: 12px; /* Gap between group and close button */
400
+ }
401
+
402
+ .handhold-explain-body-group {
403
+ display: flex;
404
+ flex-direction: column;
405
+ gap: 8px; /* Closer gap between term and text */
406
+ }
407
+
408
+ .handhold-explain-term {
409
+ font-weight: 700;
410
+ font-size: 18px;
411
+ color: #0F172A; /* slate-900 */
412
+ line-height: 1.3;
413
+ }
414
+
415
+ .handhold-explain-text {
416
+ font-size: 14px;
417
+ line-height: 1.6;
418
+ color: #64748B; /* slate-500 */
419
+ }
420
+
421
+ /* Markdown Styles */
422
+ .handhold-explain-text p {
423
+ margin-bottom: 0px;
424
+ }
425
+
426
+ .handhold-explain-text strong {
427
+ font-weight: 700;
428
+ color: #334155; /* slate-700 */
429
+ }
430
+
431
+ .handhold-explain-text em {
432
+ font-style: italic;
433
+ }
434
+
435
+ .handhold-explain-text ul, .handhold-explain-text ol {
436
+ margin-left: 20px;
437
+ margin-bottom: 12px;
438
+ list-style-type: disc;
439
+ }
440
+
441
+ .handhold-explain-text ol {
442
+ list-style-type: decimal;
443
+ }
444
+
445
+ .handhold-explain-text li {
446
+ margin-bottom: 4px;
447
+ }
448
+
449
+ .handhold-explain-text code {
450
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
451
+ font-size: 0.9em;
452
+ background-color: #F1F5F9; /* slate-100 */
453
+ padding: 2px 4px;
454
+ border-radius: 4px;
455
+ color: #0F172A; /* slate-900 */
456
+ }
457
+
458
+ .handhold-explain-text pre {
459
+ background-color: #F8FAFC; /* slate-50 */
460
+ border: 1px solid #E2E8F0; /* slate-200 */
461
+ border-radius: 6px;
462
+ padding: 12px;
463
+ overflow-x: auto;
464
+ margin-bottom: 12px;
465
+ }
466
+
467
+ .handhold-explain-text pre code {
468
+ background-color: transparent;
469
+ padding: 0;
470
+ color: inherit;
471
+ font-size: 0.85em;
472
+ }
473
+
474
+ .handhold-explain-text blockquote {
475
+ border-left: 4px solid #E2E8F0; /* slate-200 */
476
+ padding-left: 12px;
477
+ color: #64748B; /* slate-500 */
478
+ font-style: italic;
479
+ margin-bottom: 12px;
480
+ }
481
+
482
+ .handhold-explain-text h1, .handhold-explain-text h2, .handhold-explain-text h3, .handhold-explain-text h4 {
483
+ font-weight: 600;
484
+ color: #1E293B; /* slate-800 */
485
+ margin-top: 16px;
486
+ margin-bottom: 8px;
487
+ line-height: 1.4;
488
+ }
489
+
490
+ .handhold-explain-text h1 { font-size: 1.5em; }
491
+ .handhold-explain-text h2 { font-size: 1.3em; }
492
+ .handhold-explain-text h3 { font-size: 1.1em; }
493
+
494
+ .handhold-explain-text a {
495
+ color: var(--hh-coach-primary);
496
+ text-decoration: none;
497
+ }
498
+
499
+ .handhold-explain-text a:hover {
500
+ text-decoration: underline;
501
+ }
502
+
503
+ .handhold-explain-text hr {
504
+ border: 0;
505
+ border-top: 1px solid #E2E8F0; /* slate-200 */
506
+ margin: 24px 0;
507
+ }
508
+
509
+ .handhold-explain-text img {
510
+ max-width: 100%;
511
+ height: auto;
512
+ border-radius: 6px;
513
+ margin: 16px 0;
514
+ border: 1px solid #E2E8F0;
515
+ }
516
+
517
+ .handhold-explain-text table {
518
+ width: 100%;
519
+ border-collapse: collapse;
520
+ margin-bottom: 16px;
521
+ font-size: 0.9em;
522
+ }
523
+
524
+ .handhold-explain-text th, .handhold-explain-text td {
525
+ border: 1px solid #E2E8F0;
526
+ padding: 8px 12px;
527
+ text-align: left;
528
+ }
529
+
530
+ .handhold-explain-text th {
531
+ background-color: #F8FAFC; /* slate-50 */
532
+ font-weight: 600;
533
+ color: #1E293B; /* slate-800 */
534
+ }
535
+
536
+ .handhold-explain-text tr:nth-child(even) {
537
+ background-color: #F8FAFC; /* slate-50 */
538
+ }
539
+
540
+ .handhold-explain-footer-close {
541
+ width: 100%;
542
+ padding: 8px 16px;
543
+ background: #FFFFFF;
544
+ border: 2px solid #E2E8F0;
545
+ border-radius: 6px;
546
+ color: #0F172A;
547
+ font-weight: 500;
548
+ font-size: 14px;
549
+ cursor: pointer;
550
+ transition: all 0.2s;
551
+ display: flex;
552
+ align-items: center;
553
+ justify-content: center;
554
+ margin-top: auto;
555
+ }
556
+
557
+ .handhold-explain-footer-close:hover {
558
+ background: #F8FAFC; /* slate-50 */
559
+ border-color: #CBD5E1; /* slate-300 */
560
+ }
561
+
562
+ .handhold-explain-loading {
563
+ display: flex;
564
+ flex-direction: column;
565
+ align-items: center;
566
+ justify-content: center;
567
+ padding: 40px;
568
+ color: #64748B;
569
+ }
570
+
571
+ .handhold-explain-spinner {
572
+ width: 32px;
573
+ height: 32px;
574
+ border: 3px solid #E2E8F0;
575
+ border-top-color: #0F172A;
576
+ border-radius: 50%;
577
+ animation: handhold-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
578
+ margin-bottom: 16px;
579
+ }
580
+
581
+ @keyframes handhold-spin {
582
+ from { transform: rotate(0deg); }
583
+ to { transform: rotate(360deg); }
584
+ }
585
+
586
+ /* Selection Explain Button */
587
+ .handhold-selection-explain {
588
+ position: fixed;
589
+ z-index: ${zIndex + 6};
590
+ display: inline-flex;
591
+ height: 40px;
592
+ padding: 0 16px;
593
+ flex-direction: row;
594
+ justify-content: center;
595
+ align-items: center;
596
+ gap: 8px;
597
+
598
+ border-radius: 9999px;
599
+ border: 1px solid #004AF5;
600
+ background: #004AF5;
601
+
602
+ /* shadow/xs */
603
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
604
+
605
+ color: #FFFFFF;
606
+ font-size: 14px;
607
+ font-weight: 600;
608
+
609
+ cursor: pointer;
610
+ transform: translate(-50%, -100%) scale(0.9);
611
+ opacity: 0;
612
+ pointer-events: none;
613
+ transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
614
+ font-family: 'Manrope', Circular Std, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
615
+ }
616
+
617
+ .handhold-selection-explain i, .handhold-selection-explain svg {
618
+ font-size: 20px;
619
+ display: flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ }
623
+
624
+ .handhold-selection-explain:hover {
625
+ transform: translate(-50%, -100%) scale(1);
626
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
627
+ background: #003BC4; /* paidhr-blue-600 */
628
+ border-color: #003BC4;
629
+ }
630
+
631
+ .handhold-selection-explain.visible {
632
+ opacity: 1;
633
+ pointer-events: auto;
634
+ transform: translate(-50%, -120%) scale(1);
635
+ }
636
+
637
+ /* Error State */
638
+ .handhold-explain-error {
639
+ display: flex;
640
+ flex-direction: column;
641
+ align-items: center;
642
+ justify-content: center;
643
+ padding: 40px 20px;
644
+ text-align: center;
645
+ height: 100%;
646
+ color: #64748B;
647
+ }
648
+
649
+ .handhold-explain-error-icon {
650
+ width: 48px;
651
+ height: 48px;
652
+ background: #FEF2F2; /* red-50 */
653
+ color: #EF4444; /* red-500 */
654
+ border-radius: 50%;
655
+ display: flex;
656
+ align-items: center;
657
+ justify-content: center;
658
+ margin-bottom: 16px;
659
+ font-size: 24px;
660
+ font-weight: 700;
661
+ font-family: ui-sans-serif, system-ui, sans-serif;
662
+ }
663
+
664
+ .handhold-explain-error-title {
665
+ font-weight: 700;
666
+ font-size: 16px;
667
+ color: #1E293B;
668
+ margin-bottom: 8px;
669
+ }
670
+
671
+ .handhold-explain-error-text {
672
+ font-size: 14px;
673
+ line-height: 1.5;
674
+ margin-bottom: 24px;
675
+ }
676
+
677
+ .handhold-explain-retry-btn {
678
+ background: #FFFFFF;
679
+ border: 1px solid #E2E8F0;
680
+ border-radius: 6px;
681
+ padding: 8px 16px;
682
+ font-size: 14px;
683
+ font-weight: 600;
684
+ color: #0F172A;
685
+ cursor: pointer;
686
+ transition: all 0.2s;
687
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
688
+ }
689
+
690
+ .handhold-explain-retry-btn:hover {
691
+ background: #F8FAFC;
692
+ border-color: #CBD5E1;
693
+ }
694
+
695
+ /* Skeleton Loader */
696
+ .handhold-skeleton {
697
+ animation: handhold-pulse-bg 1.5s infinite ease-in-out;
698
+ background-color: ${isDark ? '#334155' : '#E2E8F0'};
699
+ border-radius: 4px;
700
+ }
701
+
702
+ @keyframes handhold-pulse-bg {
703
+ 0% { opacity: 0.6; }
704
+ 50% { opacity: 1; }
705
+ 100% { opacity: 0.6; }
706
+ }
707
+
708
+ .handhold-explain-skeleton-container {
709
+ padding: 16px;
710
+ display: flex;
711
+ flex-direction: column;
712
+ gap: 24px;
713
+ }
714
+
715
+ .handhold-skeleton-group {
716
+ display: flex;
717
+ flex-direction: column;
718
+ gap: 8px;
719
+ }
720
+
721
+ .handhold-skeleton-text {
722
+ height: 14px;
723
+ width: 100%;
724
+ }
725
+
726
+ .handhold-skeleton-text.short {
727
+ width: 60%;
728
+ }
729
+
730
+ .handhold-skeleton-text.medium {
731
+ width: 80%;
732
+ }
733
+
734
+ .handhold-skeleton-term {
735
+ height: 20px;
736
+ width: 40%;
737
+ margin-bottom: 4px;
738
+ }
739
+ `;
740
+
741
+ document.head.appendChild(styles);
742
+ }
743
+
744
+ /**
745
+ * Adjust color brightness
746
+ */
747
+ _adjustColor(color, amount) {
748
+ const hex = color.replace('#', '');
749
+ const num = parseInt(hex, 16);
750
+ let r = (num >> 16) + amount;
751
+ if (r > 255) r = 255; else if (r < 0) r = 0;
752
+ let b = ((num >> 8) & 0x00FF) + amount;
753
+ if (b > 255) b = 255; else if (b < 0) b = 0;
754
+ let g = (num & 0x0000FF) + amount;
755
+ if (g > 255) g = 255; else if (g < 0) g = 0;
756
+ return `#${(g | (b << 8) | (r << 16)).toString(16).padStart(6, '0')}`;
757
+ }
758
+
759
+ /**
760
+ * Convert hex to rgb
761
+ */
762
+ _hexToRgb(hex) {
763
+ const num = parseInt(hex.replace('#', ''), 16);
764
+ const r = (num >> 16) & 255;
765
+ const g = (num >> 8) & 255;
766
+ const b = num & 255;
767
+ return `${r}, ${g}, ${b}`;
768
+ }
769
+
770
+ /**
771
+ * Darken a hex color
772
+ */
773
+ _darkenColor(hex, percent) {
774
+ const num = parseInt(hex.replace('#', ''), 16);
775
+ const amt = Math.round(2.55 * percent);
776
+ const R = Math.max(0, (num >> 16) - amt);
777
+ const G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
778
+ const B = Math.max(0, (num & 0x0000FF) - amt);
779
+ return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1)}`;
780
+ }
781
+
782
+ /**
783
+ * Highlight a UI element
784
+ * @param {string} selector - CSS selector for element
785
+ * @param {Object} options - { message, showArrow, showTooltip, arrowPosition }
786
+ * @returns {boolean} - Success status
787
+ */
788
+ highlightElement(selector, options = {}) {
789
+ const element = document.querySelector(selector);
790
+
791
+ if (!element) {
792
+ console.warn(`[HandHold UICoach] Element not found: ${selector}`);
793
+ eventBus.emit('highlight:not_found', { selector });
794
+ return Promise.resolve(false);
795
+ }
796
+
797
+ // Scroll element into view first to ensure visibility (guarded: scrollIntoView
798
+ // is absent in some environments, and the old catch re-threw by calling it again)
799
+ if (typeof element.scrollIntoView === 'function') {
800
+ try {
801
+ element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
802
+ } catch (e) {
803
+ try { element.scrollIntoView(true); } catch (_) { /* not supported */ }
804
+ }
805
+ }
806
+
807
+ // Phase 5.3: resolve with the ACTUAL outcome after the highlight is built,
808
+ // so a caller can fall back when the element detached (SPA re-render) between
809
+ // the query and the delayed build. Previously this returned true synchronously
810
+ // before the setTimeout ran, so the fallback could never trigger on that race.
811
+ return new Promise((resolve) => {
812
+ // Wait for scroll to potentially finish or at least start
813
+ setTimeout(() => {
814
+ // The element may have been removed while we waited — bail so the caller
815
+ // can re-match instead of silently doing nothing.
816
+ if (!element.isConnected) {
817
+ eventBus.emit('highlight:not_found', { selector });
818
+ resolve(false);
819
+ return;
820
+ }
821
+ // Clear previous highlight
822
+ this.clearHighlight();
823
+
824
+ this.currentElement = element;
825
+ this.isHighlighting = true;
826
+
827
+ // Add click listener to the element itself to auto-advance
828
+ if (options.onClick) {
829
+ this._elementClickListener = options.onClick;
830
+ element.addEventListener('click', this._elementClickListener, { once: true });
831
+
832
+ // Also listen for input/focus events if it's an input element
833
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable) {
834
+ this._elementInputListener = () => {
835
+ // Remove listeners immediately to prevent double firing
836
+ element.removeEventListener('input', this._elementInputListener);
837
+ if (this._elementClickListener) {
838
+ element.removeEventListener('click', this._elementClickListener);
839
+ }
840
+ options.onClick();
841
+ };
842
+ element.addEventListener('input', this._elementInputListener, { once: true });
843
+
844
+ // Also treat focus as interaction if requested?
845
+ // User said "click into... and start typing", so 'input' is safest.
846
+ // But for checkboxes/radios 'change' is better.
847
+ if (element.type === 'checkbox' || element.type === 'radio') {
848
+ element.addEventListener('change', this._elementInputListener, { once: true });
849
+ }
850
+ }
851
+ }
852
+
853
+ // Create overlay (for dimming)
854
+ this._createOverlay();
855
+
856
+ // Create highlight box
857
+ this._createHighlightBox(element);
858
+
859
+ // Create arrow
860
+ if (options.showArrow !== false) {
861
+ this._createArrow(element, options.arrowPosition);
862
+ }
863
+
864
+ // Create tooltip if message provided
865
+ if (options.message && options.showTooltip !== false) {
866
+ // Pass arrow position preference to tooltip for smarter placement relative to arrow
867
+ const tooltipOptions = { ...options, preferredPosition: options.arrowPosition };
868
+ this._createTooltip(element, options.message, tooltipOptions);
869
+ }
870
+
871
+ // Watch for element position changes
872
+ this._watchElementPosition(element);
873
+
874
+ eventBus.emit('highlight:shown', { selector, element });
875
+ resolve(true);
876
+ }, 100); // Short delay to allow layout to settle
877
+ });
878
+ }
879
+
880
+ /**
881
+ * Create overlay background
882
+ */
883
+ _createOverlay() {
884
+ this.overlay = document.createElement('div');
885
+ this.overlay.className = 'handhold-overlay';
886
+ this.overlay.id = 'handhold-overlay';
887
+ document.body.appendChild(this.overlay);
888
+
889
+ // Trigger animation
890
+ requestAnimationFrame(() => {
891
+ if (this.overlay) {
892
+ this.overlay.classList.add('active');
893
+ }
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Create highlight box around element
899
+ */
900
+ _createHighlightBox(element) {
901
+ const rect = element.getBoundingClientRect();
902
+ const padding = 4;
903
+
904
+ this.highlightBox = document.createElement('div');
905
+ this.highlightBox.className = 'handhold-highlight';
906
+ this.highlightBox.id = 'handhold-highlight';
907
+ this.highlightBox.style.cssText = `
908
+ top: ${rect.top - padding}px;
909
+ left: ${rect.left - padding}px;
910
+ width: ${rect.width + padding * 2}px;
911
+ height: ${rect.height + padding * 2}px;
912
+ `;
913
+
914
+ document.body.appendChild(this.highlightBox);
915
+ }
916
+
917
+ /**
918
+ * Create arrow pointing to element
919
+ */
920
+ _createArrow(element, preferredPosition) {
921
+ const rect = element.getBoundingClientRect();
922
+ const arrowSize = 40;
923
+ const offset = 10;
924
+
925
+ // Determine best arrow position
926
+ const position = preferredPosition || this._calculateArrowPosition(rect);
927
+
928
+ this.arrow = document.createElement('div');
929
+ this.arrow.className = `handhold-arrow ${position}`;
930
+ this.arrow.id = 'handhold-arrow';
931
+
932
+ // Arrow SVG
933
+ this.arrow.innerHTML = `
934
+ <svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
935
+ <path d="M20 5 L35 35 L20 28 L5 35 Z" />
936
+ </svg>
937
+ `;
938
+
939
+ // Position arrow based on direction
940
+ let top, left;
941
+ switch (position) {
942
+ case 'top':
943
+ top = rect.top - arrowSize - offset;
944
+ left = rect.left + (rect.width / 2) - (arrowSize / 2);
945
+ break;
946
+ case 'bottom':
947
+ top = rect.bottom + offset;
948
+ left = rect.left + (rect.width / 2) - (arrowSize / 2);
949
+ break;
950
+ case 'left':
951
+ top = rect.top + (rect.height / 2) - (arrowSize / 2);
952
+ left = rect.left - arrowSize - offset;
953
+ break;
954
+ case 'right':
955
+ top = rect.top + (rect.height / 2) - (arrowSize / 2);
956
+ left = rect.right + offset;
957
+ break;
958
+ default:
959
+ top = rect.top - arrowSize - offset;
960
+ left = rect.left + (rect.width / 2) - (arrowSize / 2);
961
+ }
962
+
963
+ this.arrow.style.cssText = `top: ${top}px; left: ${left}px;`;
964
+ document.body.appendChild(this.arrow);
965
+ }
966
+
967
+ /**
968
+ * Calculate best arrow position based on element position
969
+ */
970
+ _calculateArrowPosition(rect) {
971
+ const viewport = {
972
+ width: window.innerWidth,
973
+ height: window.innerHeight,
974
+ };
975
+
976
+ // Check available space in each direction
977
+ const spaceAbove = rect.top;
978
+ const spaceBelow = viewport.height - rect.bottom;
979
+ const spaceLeft = rect.left;
980
+ const spaceRight = viewport.width - rect.right;
981
+
982
+ const minSpace = 60;
983
+
984
+ // Prefer top, then bottom, then sides
985
+ if (spaceAbove >= minSpace) return 'top';
986
+ if (spaceBelow >= minSpace) return 'bottom';
987
+ if (spaceLeft >= minSpace) return 'left';
988
+ if (spaceRight >= minSpace) return 'right';
989
+
990
+ return 'top';
991
+ }
992
+
993
+ /**
994
+ * Create tooltip near element
995
+ */
996
+ _createTooltip(element, message, options = {}) {
997
+ const rect = element.getBoundingClientRect();
998
+
999
+ this.tooltip = document.createElement('div');
1000
+ this.tooltip.className = `handhold-step-tooltip ${this.config.theme === 'dark' ? 'dark' : ''}`;
1001
+ this.tooltip.id = 'handhold-step-tooltip';
1002
+
1003
+ const showDoneButton = options.showDoneButton !== false;
1004
+ const showSkipButton = options.showSkipButton === true;
1005
+
1006
+ this.tooltip.innerHTML = `
1007
+ <p class="handhold-step-tooltip-text">${message}</p>
1008
+ <div class="handhold-step-tooltip-actions">
1009
+ ${showSkipButton ? new Button({
1010
+ text: 'Skip',
1011
+ variant: ButtonVariant.SECONDARY,
1012
+ size: ButtonSize.SMALL,
1013
+ className: 'handhold-step-tooltip-btn',
1014
+ attributes: { 'data-action': 'skip' }
1015
+ }).render() : ''}
1016
+ ${showDoneButton ? new Button({
1017
+ text: 'Got it',
1018
+ variant: ButtonVariant.PRIMARY,
1019
+ size: ButtonSize.SMALL,
1020
+ className: 'handhold-step-tooltip-btn',
1021
+ attributes: { 'data-action': 'done' }
1022
+ }).render() : ''}
1023
+ </div>
1024
+ `;
1025
+
1026
+ // Position tooltip logic
1027
+ const tooltipWidth = 280;
1028
+ // We can't know height yet as it's not in DOM, assume roughly 150px
1029
+ const estimatedHeight = 150;
1030
+ const margin = 12;
1031
+
1032
+ const viewport = {
1033
+ width: window.innerWidth,
1034
+ height: window.innerHeight
1035
+ };
1036
+
1037
+ // Default: try placing it on the side with most space
1038
+ // Similar to arrow logic but with collision detection against the element itself
1039
+
1040
+ // Check available space
1041
+ const spaceTop = rect.top;
1042
+ const spaceBottom = viewport.height - rect.bottom;
1043
+ const spaceLeft = rect.left;
1044
+ const spaceRight = viewport.width - rect.right;
1045
+
1046
+ let top, left;
1047
+
1048
+ // Prefer bottom if space allows
1049
+ if (spaceBottom >= estimatedHeight + margin) {
1050
+ top = rect.bottom + margin + 10; // +10 for arrow offset
1051
+ left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
1052
+ }
1053
+ // Else try top
1054
+ else if (spaceTop >= estimatedHeight + margin) {
1055
+ top = rect.top - estimatedHeight - margin;
1056
+ left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
1057
+ }
1058
+ // Else try right
1059
+ else if (spaceRight >= tooltipWidth + margin) {
1060
+ top = rect.top;
1061
+ left = rect.right + margin;
1062
+ }
1063
+ // Else try left
1064
+ else if (spaceLeft >= tooltipWidth + margin) {
1065
+ top = rect.top;
1066
+ left = rect.left - tooltipWidth - margin;
1067
+ }
1068
+ // Fallback: Stick to bottom but force it on screen (might overlap if screen really small)
1069
+ else {
1070
+ top = rect.bottom + margin;
1071
+ left = rect.left;
1072
+ }
1073
+
1074
+ // Horizontal boundary check (keep within viewport)
1075
+ left = Math.max(10, Math.min(left, viewport.width - tooltipWidth - 10));
1076
+
1077
+ // Vertical boundary check
1078
+ if (top + estimatedHeight > viewport.height) {
1079
+ top = viewport.height - estimatedHeight - 10;
1080
+ }
1081
+ if (top < 10) {
1082
+ top = 10;
1083
+ }
1084
+
1085
+ // Final Collision Check: Ensure we don't overlap the target element
1086
+ // If tooltip rect overlaps element rect, move it
1087
+ const tooltipRect = {
1088
+ top: top,
1089
+ bottom: top + estimatedHeight,
1090
+ left: left,
1091
+ right: left + tooltipWidth
1092
+ };
1093
+
1094
+ const elementRect = {
1095
+ top: rect.top,
1096
+ bottom: rect.bottom,
1097
+ left: rect.left,
1098
+ right: rect.right
1099
+ };
1100
+
1101
+ const overlap = !(tooltipRect.right < elementRect.left ||
1102
+ tooltipRect.left > elementRect.right ||
1103
+ tooltipRect.bottom < elementRect.top ||
1104
+ tooltipRect.top > elementRect.bottom);
1105
+
1106
+ if (overlap) {
1107
+ // If overlapping, try to move it away aggressively
1108
+ // If mostly overlapping vertically, move sideways
1109
+ if (spaceRight > spaceLeft) {
1110
+ left = rect.right + margin;
1111
+ } else {
1112
+ left = rect.left - tooltipWidth - margin;
1113
+ }
1114
+
1115
+ // Clamp again
1116
+ left = Math.max(10, Math.min(left, viewport.width - tooltipWidth - 10));
1117
+ }
1118
+
1119
+ this.tooltip.style.cssText = `top: ${top}px; left: ${left}px;`;
1120
+
1121
+ // Add event listeners
1122
+ this.tooltip.addEventListener('click', (e) => {
1123
+ const target = e.target;
1124
+ const action = target.dataset.action;
1125
+
1126
+ if (action) {
1127
+ // Disable button to prevent double-clicks
1128
+ if (target.tagName === 'BUTTON') {
1129
+ target.disabled = true;
1130
+ // target.textContent = '...'; // Removed to prevent conflicts with text-analysis extensions
1131
+ target.style.opacity = '0.7';
1132
+ target.style.cursor = 'wait';
1133
+ }
1134
+
1135
+ try {
1136
+ if (action === 'done') {
1137
+ eventBus.emit('step:done');
1138
+ } else if (action === 'skip') {
1139
+ eventBus.emit('step:skip');
1140
+ }
1141
+ } catch (error) {
1142
+ console.error('[HandHold] Error handling click action:', error);
1143
+ }
1144
+ }
1145
+ });
1146
+
1147
+ document.body.appendChild(this.tooltip);
1148
+ }
1149
+
1150
+ /**
1151
+ * Watch for element position changes
1152
+ */
1153
+ _watchElementPosition(element) {
1154
+ // Use ResizeObserver to detect size changes
1155
+ if (window.ResizeObserver) {
1156
+ this.resizeObserver = new ResizeObserver(() => {
1157
+ this._updateHighlightPosition();
1158
+ });
1159
+ this.resizeObserver.observe(element);
1160
+ }
1161
+
1162
+ // Also update on scroll and resize
1163
+ this._boundUpdatePosition = () => this._updateHighlightPosition();
1164
+ window.addEventListener('scroll', this._boundUpdatePosition, { passive: true });
1165
+ window.addEventListener('resize', this._boundUpdatePosition, { passive: true });
1166
+ }
1167
+
1168
+ /**
1169
+ * Update highlight position (for scroll/resize)
1170
+ */
1171
+ _updateHighlightPosition() {
1172
+ if (!this.currentElement || !this.isHighlighting) return;
1173
+
1174
+ const rect = this.currentElement.getBoundingClientRect();
1175
+ const padding = 4;
1176
+
1177
+ if (this.highlightBox) {
1178
+ this.highlightBox.style.top = `${rect.top - padding}px`;
1179
+ this.highlightBox.style.left = `${rect.left - padding}px`;
1180
+ this.highlightBox.style.width = `${rect.width + padding * 2}px`;
1181
+ this.highlightBox.style.height = `${rect.height + padding * 2}px`;
1182
+ }
1183
+
1184
+ // Update arrow position too
1185
+ if (this.arrow) {
1186
+ const position = this.arrow.className.split(' ').find(c => ['top', 'bottom', 'left', 'right'].includes(c));
1187
+ const arrowSize = 40;
1188
+ const offset = 10;
1189
+ let top, left;
1190
+
1191
+ switch (position) {
1192
+ case 'top':
1193
+ top = rect.top - arrowSize - offset;
1194
+ left = rect.left + (rect.width / 2) - (arrowSize / 2);
1195
+ break;
1196
+ case 'bottom':
1197
+ top = rect.bottom + offset;
1198
+ left = rect.left + (rect.width / 2) - (arrowSize / 2);
1199
+ break;
1200
+ case 'left':
1201
+ top = rect.top + (rect.height / 2) - (arrowSize / 2);
1202
+ left = rect.left - arrowSize - offset;
1203
+ break;
1204
+ case 'right':
1205
+ top = rect.top + (rect.height / 2) - (arrowSize / 2);
1206
+ left = rect.right + offset;
1207
+ break;
1208
+ }
1209
+
1210
+ this.arrow.style.top = `${top}px`;
1211
+ this.arrow.style.left = `${left}px`;
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Clear all highlights
1217
+ */
1218
+ clearHighlight() {
1219
+ // Remove click listener if exists
1220
+ if (this.currentElement && this._elementClickListener) {
1221
+ this.currentElement.removeEventListener('click', this._elementClickListener);
1222
+ this._elementClickListener = null;
1223
+ }
1224
+
1225
+ this.isHighlighting = false;
1226
+ this.currentElement = null;
1227
+
1228
+ // Remove elements
1229
+ if (this.overlay) {
1230
+ this.overlay.remove();
1231
+ this.overlay = null;
1232
+ }
1233
+ if (this.spotlight) {
1234
+ this.spotlight.remove();
1235
+ this.spotlight = null;
1236
+ }
1237
+ if (this.highlightBox) {
1238
+ this.highlightBox.remove();
1239
+ this.highlightBox = null;
1240
+ }
1241
+ if (this.arrow) {
1242
+ this.arrow.remove();
1243
+ this.arrow = null;
1244
+ }
1245
+ if (this.tooltip) {
1246
+ this.tooltip.remove();
1247
+ this.tooltip = null;
1248
+ }
1249
+
1250
+ // Clean up observers and listeners
1251
+ if (this.resizeObserver) {
1252
+ this.resizeObserver.disconnect();
1253
+ this.resizeObserver = null;
1254
+ }
1255
+ if (this._boundUpdatePosition) {
1256
+ window.removeEventListener('scroll', this._boundUpdatePosition);
1257
+ window.removeEventListener('resize', this._boundUpdatePosition);
1258
+ this._boundUpdatePosition = null;
1259
+ }
1260
+
1261
+ eventBus.emit('highlight:cleared');
1262
+ }
1263
+
1264
+ /**
1265
+ * Update tooltip message
1266
+ */
1267
+ updateTooltipMessage(message) {
1268
+ if (this.tooltip) {
1269
+ const textEl = this.tooltip.querySelector('.handhold-step-tooltip-text');
1270
+ if (textEl) {
1271
+ textEl.textContent = message;
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ enableSelectionExplain() {
1277
+ if (this.selectionEnabled) return;
1278
+ this.selectionEnabled = true;
1279
+ this._createSelectionButton();
1280
+ this._selectionHandler = () => this._handleSelection();
1281
+ this._selectionHideHandler = (e) => {
1282
+ // Check if event target is valid Node before checking contains
1283
+ if (this.selectionButton && e.target instanceof Node && this.selectionButton.contains(e.target)) return;
1284
+ this._hideSelectionButton();
1285
+ };
1286
+ document.addEventListener('mouseup', this._selectionHandler);
1287
+ document.addEventListener('keyup', this._selectionHandler);
1288
+ document.addEventListener('scroll', this._selectionHideHandler, true);
1289
+ document.addEventListener('mousedown', this._selectionHideHandler);
1290
+ // Hide on window blur (tab switch) or visibility change
1291
+ window.addEventListener('blur', this._selectionHideHandler);
1292
+ document.addEventListener('visibilitychange', this._selectionHideHandler);
1293
+ }
1294
+
1295
+ disableSelectionExplain() {
1296
+ if (!this.selectionEnabled) return;
1297
+ this.selectionEnabled = false;
1298
+
1299
+ // Clear debounce timer
1300
+ if (this._selectionDebounce) {
1301
+ clearTimeout(this._selectionDebounce);
1302
+ this._selectionDebounce = null;
1303
+ }
1304
+
1305
+ if (this._selectionHandler) {
1306
+ document.removeEventListener('mouseup', this._selectionHandler);
1307
+ document.removeEventListener('keyup', this._selectionHandler);
1308
+ this._selectionHandler = null;
1309
+ }
1310
+ if (this._selectionHideHandler) {
1311
+ document.removeEventListener('scroll', this._selectionHideHandler, true);
1312
+ document.removeEventListener('mousedown', this._selectionHideHandler);
1313
+ window.removeEventListener('blur', this._selectionHideHandler);
1314
+ document.removeEventListener('visibilitychange', this._selectionHideHandler);
1315
+ this._selectionHideHandler = null;
1316
+ }
1317
+ this._hideSelectionButton();
1318
+ }
1319
+
1320
+ _createSelectionButton() {
1321
+ if (this.selectionButton) return;
1322
+
1323
+ this.selectionButton = new Button({
1324
+ text: 'Explain this',
1325
+ icon: '<i class="ph ph-question"></i>',
1326
+ variant: ButtonVariant.PRIMARY,
1327
+ className: 'handhold-selection-explain',
1328
+ attributes: { type: 'button' },
1329
+ onClick: (e) => {
1330
+ e.preventDefault();
1331
+ e.stopPropagation();
1332
+ const text = this.selectionText;
1333
+ this._hideSelectionButton();
1334
+ if (text) {
1335
+ // Capture page context (truncated to ~15k chars)
1336
+ let context = document.body.innerText || '';
1337
+ if (context.length > 15000) {
1338
+ context = context.substring(0, 15000) + '...[truncated]';
1339
+ }
1340
+
1341
+ eventBus.emit('selection:explain', { text, context });
1342
+ }
1343
+ }
1344
+ }).create();
1345
+
1346
+ // Use mousedown to prevent focus loss issues
1347
+ this.selectionButton.addEventListener('mousedown', (e) => e.stopPropagation());
1348
+
1349
+ document.body.appendChild(this.selectionButton);
1350
+ }
1351
+
1352
+ /**
1353
+ * Expand selection to nearest meaningful container
1354
+ * @param {Selection} selection
1355
+ * @returns {Range|null}
1356
+ */
1357
+ _getExpansionRange(selection) {
1358
+ if (!selection.rangeCount) return null;
1359
+
1360
+ const range = selection.getRangeAt(0);
1361
+ const container = range.commonAncestorContainer;
1362
+
1363
+ // Ensure we have an element, not a text node
1364
+ let element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
1365
+
1366
+ // Define "meaningful" containers
1367
+ const meaningfulTags = ['P', 'DIV', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TD', 'TH', 'BUTTON', 'A', 'LABEL', 'SECTION', 'ARTICLE', 'ASIDE'];
1368
+
1369
+ // Scenario B: Multi-Element Selection
1370
+ // If common ancestor contains both start and end nodes but they are different
1371
+ if (range.startContainer !== range.endContainer) {
1372
+ // Safety check for very large containers
1373
+ const isTooLarge = ['BODY', 'MAIN'].includes(element.tagName) || (element.textContent && element.textContent.length > 2000);
1374
+
1375
+ if (isTooLarge) {
1376
+ // Fallback: return original range to avoid selecting entire page
1377
+ return range;
1378
+ }
1379
+
1380
+ // Expand to full content of the common ancestor
1381
+ const newRange = document.createRange();
1382
+ newRange.selectNodeContents(element);
1383
+ return newRange;
1384
+ }
1385
+
1386
+ // Scenario A/C: Single Element/Partial Selection
1387
+ // Walk up to find best container
1388
+ let targetElement = element;
1389
+ let attempts = 0;
1390
+ while (targetElement && targetElement.parentElement && attempts < 5) {
1391
+ // Stop if we hit a meaningful tag
1392
+ if (meaningfulTags.includes(targetElement.tagName)) {
1393
+ break;
1394
+ }
1395
+ // Stop if growing too large
1396
+ if (targetElement.textContent && targetElement.textContent.length > 500) {
1397
+ break;
1398
+ }
1399
+ // Stop if we hit root-ish things
1400
+ if (['BODY', 'HTML', 'MAIN'].includes(targetElement.tagName)) {
1401
+ break;
1402
+ }
1403
+
1404
+ targetElement = targetElement.parentElement;
1405
+ attempts++;
1406
+ }
1407
+
1408
+ if (targetElement) {
1409
+ const newRange = document.createRange();
1410
+ newRange.selectNodeContents(targetElement);
1411
+ return newRange;
1412
+ }
1413
+
1414
+ return range;
1415
+ }
1416
+
1417
+ _handleSelection() {
1418
+ // Clear existing debounce
1419
+ if (this._selectionDebounce) {
1420
+ clearTimeout(this._selectionDebounce);
1421
+ }
1422
+
1423
+ // Debounce to prevent flickering on double clicks/drags
1424
+ this._selectionDebounce = setTimeout(() => {
1425
+ const selection = window.getSelection();
1426
+
1427
+ // Basic validation
1428
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
1429
+ this._hideSelectionButton();
1430
+ return;
1431
+ }
1432
+
1433
+ // Initial check on original selection to avoid expanding in ignored areas
1434
+ let range = selection.getRangeAt(0);
1435
+ let container = range.commonAncestorContainer;
1436
+ let element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement;
1437
+
1438
+ if (!element) {
1439
+ this._hideSelectionButton();
1440
+ return;
1441
+ }
1442
+
1443
+ // Ignore if inside our own UI
1444
+ if (element.closest('#handhold-container') ||
1445
+ element.closest('.handhold-selection-explain') ||
1446
+ element.closest('.handhold-explain-modal')) {
1447
+ this._hideSelectionButton();
1448
+ return;
1449
+ }
1450
+
1451
+ // Ignore input fields
1452
+ if (element.closest('input, textarea, [contenteditable="true"]')) {
1453
+ this._hideSelectionButton();
1454
+ return;
1455
+ }
1456
+
1457
+ // --- EXPANSION LOGIC ---
1458
+ const expandedRange = this._getExpansionRange(selection);
1459
+ if (expandedRange) {
1460
+ // Apply expansion
1461
+ selection.removeAllRanges();
1462
+ selection.addRange(expandedRange);
1463
+
1464
+ // Update range reference
1465
+ range = expandedRange;
1466
+ }
1467
+ // -----------------------
1468
+
1469
+ const text = selection.toString().trim();
1470
+ // Length check - ensure we have meaningful content
1471
+ if (!text || text.length < 2) {
1472
+ this._hideSelectionButton();
1473
+ return;
1474
+ }
1475
+
1476
+ const rect = range.getBoundingClientRect();
1477
+
1478
+ // Ignore zero-width rects (hidden elements or empty selections)
1479
+ if (!rect || rect.width === 0 || rect.height === 0) {
1480
+ this._hideSelectionButton();
1481
+ return;
1482
+ }
1483
+
1484
+ this.selectionText = text;
1485
+ this._showSelectionButton(rect);
1486
+ }, 200); // 200ms delay
1487
+ }
1488
+
1489
+ _showSelectionButton(rect) {
1490
+ if (!this.selectionButton) return;
1491
+ const top = Math.max(8, rect.top - 8);
1492
+ const left = rect.left + rect.width / 2;
1493
+ this.selectionButton.style.top = `${top}px`;
1494
+ this.selectionButton.style.left = `${left}px`;
1495
+ this.selectionButton.classList.add('visible');
1496
+ }
1497
+
1498
+ _hideSelectionButton() {
1499
+ if (!this.selectionButton) return;
1500
+ this.selectionText = '';
1501
+ this.selectionButton.classList.remove('visible');
1502
+ }
1503
+
1504
+ /**
1505
+ * Check if currently highlighting
1506
+ */
1507
+ isActive() {
1508
+ return this.isHighlighting;
1509
+ }
1510
+
1511
+ /**
1512
+ * Get current highlighted element
1513
+ */
1514
+ getCurrentElement() {
1515
+ return this.currentElement;
1516
+ }
1517
+
1518
+ /**
1519
+ * Destroy the UI Coach (cleanup)
1520
+ */
1521
+ destroy() {
1522
+ this.clearHighlight();
1523
+ this.disableSelectionExplain();
1524
+ if (this.selectionButton) {
1525
+ this.selectionButton.remove();
1526
+ this.selectionButton = null;
1527
+ }
1528
+ this._removeExplainModal();
1529
+
1530
+ // Remove injected styles
1531
+ const styles = document.getElementById('handhold-uicoach-styles');
1532
+ if (styles) {
1533
+ styles.remove();
1534
+ }
1535
+ }
1536
+
1537
+ /**
1538
+ * Show explanation modal for a term
1539
+ * @param {Object} data - { terms: [{term, definition}], summary }
1540
+ */
1541
+ showExplainModal(data) {
1542
+ this._createExplainModal();
1543
+
1544
+ // Build content HTML for multiple terms
1545
+ const termsHtml = (data.terms || []).map(item => {
1546
+ // If term is just the full selection (likely error fallback), skip it
1547
+ // But if it's a real term, show it.
1548
+ // We can use a heuristic: if term length > 100, it's probably the selection text, not a "term"
1549
+ const isLongTerm = item.term && item.term.length > 100;
1550
+
1551
+ const definitionHtml = MarkdownRenderer.parse(item.definition);
1552
+ return `
1553
+ <div class="handhold-explain-body-group">
1554
+ ${!isLongTerm ? `<div class="handhold-explain-term">${this._escapeHtml(item.term)}</div>` : ''}
1555
+ <div class="handhold-explain-text">${definitionHtml}</div>
1556
+ </div>
1557
+ `;
1558
+ }).join('');
1559
+
1560
+ const body = this.explainModal.querySelector('.handhold-explain-content');
1561
+ body.innerHTML = `
1562
+ ${termsHtml}
1563
+ ${new Button({
1564
+ text: 'Close',
1565
+ variant: ButtonVariant.OUTLINE,
1566
+ className: 'handhold-explain-footer-close'
1567
+ }).render()}
1568
+ `;
1569
+
1570
+ // Add listeners
1571
+ const closeBtn = body.querySelector('.handhold-explain-footer-close');
1572
+ if (closeBtn) {
1573
+ closeBtn.addEventListener('click', () => this._hideModal());
1574
+ }
1575
+
1576
+ // Show
1577
+ this._showModal();
1578
+ }
1579
+
1580
+ /**
1581
+ * Show loading state in explain modal
1582
+ * @param {string} term
1583
+ */
1584
+ showExplainLoading(term) {
1585
+ this._createExplainModal();
1586
+
1587
+ const body = this.explainModal.querySelector('.handhold-explain-content');
1588
+ body.innerHTML = `
1589
+ <div class="handhold-explain-skeleton-container">
1590
+ <div class="handhold-skeleton-group">
1591
+ <div class="handhold-skeleton handhold-skeleton-term"></div>
1592
+ <div class="handhold-skeleton handhold-skeleton-text"></div>
1593
+ <div class="handhold-skeleton handhold-skeleton-text medium"></div>
1594
+ </div>
1595
+ <div class="handhold-skeleton-group">
1596
+ <div class="handhold-skeleton handhold-skeleton-term"></div>
1597
+ <div class="handhold-skeleton handhold-skeleton-text"></div>
1598
+ <div class="handhold-skeleton handhold-skeleton-text"></div>
1599
+ <div class="handhold-skeleton handhold-skeleton-text short"></div>
1600
+ </div>
1601
+ <div class="handhold-explain-loading-message" style="display: flex; align-items: center; gap: 10px; margin-top: 8px; color: #64748B; font-size: 13px; font-weight: 500;">
1602
+ <div style="width: 16px; height: 16px; border: 2px solid #CBD5E1; border-top-color: #334155; border-radius: 50%; animation: handhold-spin 1s linear infinite;"></div>
1603
+ <span>Consulting knowledgebase...</span>
1604
+ </div>
1605
+ </div>
1606
+ `;
1607
+
1608
+ this._showModal();
1609
+ }
1610
+
1611
+ /**
1612
+ * Show error state in explain modal
1613
+ * @param {string} term
1614
+ * @param {Function} onRetry
1615
+ */
1616
+ showExplainError(term, onRetry) {
1617
+ this._createExplainModal();
1618
+
1619
+ const body = this.explainModal.querySelector('.handhold-explain-content');
1620
+ body.innerHTML = `
1621
+ <div class="handhold-explain-error" style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; height: 100%;">
1622
+ <div style="width: 112px; height: 112px; margin-bottom: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;">
1623
+ <img src="/illustration-error.svg" alt="Error" style="width: 100%; height: 100%; object-fit: contain;" />
1624
+ </div>
1625
+ <div class="handhold-explain-error-title" style="font-size: 18px; font-weight: 700; color: #0F172A; margin-bottom: 4px; line-height: 1.5;">We're sorry, something went wrong</div>
1626
+ <div class="handhold-explain-error-text" style="font-size: 14px; color: #64748B; margin-bottom: 16px; line-height: 1.5; padding: 0 8px;">We encountered an issue while trying to pull the definitions of the selected terms please try again.</div>
1627
+ ${new Button({
1628
+ text: 'Try again',
1629
+ variant: ButtonVariant.PRIMARY,
1630
+ className: 'handhold-explain-retry-btn',
1631
+ attributes: { 'style': 'width: 100%; justify-content: center;' }
1632
+ }).render()}
1633
+ </div>
1634
+ `;
1635
+
1636
+ // Add listeners
1637
+ const retryBtn = body.querySelector('.handhold-explain-retry-btn');
1638
+ if (retryBtn && onRetry) {
1639
+ retryBtn.addEventListener('click', () => {
1640
+ onRetry();
1641
+ });
1642
+ }
1643
+
1644
+ const closeBtn = body.querySelector('.handhold-explain-footer-close');
1645
+ if (closeBtn) {
1646
+ closeBtn.addEventListener('click', () => this._hideModal());
1647
+ }
1648
+
1649
+ this._showModal();
1650
+ }
1651
+
1652
+ _createExplainModal() {
1653
+ if (this.explainModal) return;
1654
+
1655
+ // Create overlay
1656
+ this.explainOverlay = document.createElement('div');
1657
+ this.explainOverlay.className = 'handhold-explain-modal-overlay';
1658
+ document.body.appendChild(this.explainOverlay);
1659
+
1660
+ // Create modal
1661
+ this.explainModal = document.createElement('div');
1662
+ this.explainModal.className = `handhold-explain-modal ${this.config.theme === 'dark' ? 'dark' : ''}`;
1663
+ this.explainModal.innerHTML = `
1664
+ <div class="handhold-explain-header">
1665
+ <h3 class="handhold-explain-title">Glossary / FAQs</h3>
1666
+ ${new Button({
1667
+ icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
1668
+ variant: ButtonVariant.GHOST,
1669
+ size: ButtonSize.ICON_LG,
1670
+ className: 'handhold-explain-close-icon',
1671
+ attributes: { 'aria-label': 'Close' }
1672
+ }).render()}
1673
+ </div>
1674
+ <div class="handhold-explain-content"></div>
1675
+ `;
1676
+ document.body.appendChild(this.explainModal);
1677
+
1678
+ // Events
1679
+ this.explainOverlay.addEventListener('click', () => this._hideModal());
1680
+ this.explainModal.querySelector('.handhold-explain-close-icon').addEventListener('click', () => this._hideModal());
1681
+ }
1682
+
1683
+ _showModal() {
1684
+ if (!this.explainModal) return;
1685
+
1686
+ // Side panel animation
1687
+ requestAnimationFrame(() => {
1688
+ this.explainOverlay.classList.add('active');
1689
+ this.explainModal.classList.add('active');
1690
+ });
1691
+ }
1692
+
1693
+ _hideModal() {
1694
+ if (!this.explainModal) return;
1695
+ this.explainOverlay.classList.remove('active');
1696
+ this.explainModal.classList.remove('active');
1697
+
1698
+ // Remove from DOM after transition to prevent overlay blocking
1699
+ setTimeout(() => {
1700
+ this._removeExplainModal();
1701
+ }, 250);
1702
+ }
1703
+
1704
+ _removeExplainModal() {
1705
+ if (this.explainModal) {
1706
+ this.explainModal.remove();
1707
+ this.explainModal = null;
1708
+ }
1709
+ if (this.explainOverlay) {
1710
+ this.explainOverlay.remove();
1711
+ this.explainOverlay = null;
1712
+ }
1713
+ }
1714
+
1715
+ _escapeHtml(value) {
1716
+ return String(value)
1717
+ .replace(/&/g, '&amp;')
1718
+ .replace(/</g, '&lt;')
1719
+ .replace(/>/g, '&gt;')
1720
+ .replace(/"/g, '&quot;')
1721
+ .replace(/'/g, '&#39;');
1722
+ }
1723
+ }
1724
+
1725
+ export default UICoach;