@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
@@ -0,0 +1,1802 @@
1
+ import { cleanLatexText } from '../utils';
2
+
3
+ type OnComplete = (result: Record<string, any>) => void;
4
+ type OnCancel = (reason?: string) => void;
5
+
6
+ interface PresetContext {
7
+ toolCallId: string;
8
+ presetId: string;
9
+ clientFields: Record<string, any>;
10
+ args: Record<string, any>;
11
+ toolCall: any;
12
+ }
13
+
14
+ // โ”€โ”€โ”€ Format slot time helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
15
+ function formatSlotTime(item: any): string {
16
+ if (!item) return '';
17
+ if (typeof item === 'string') {
18
+ if (item.includes('T') || !isNaN(Date.parse(item))) {
19
+ try { return new Date(item).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } catch (_) { }
20
+ }
21
+ return item;
22
+ }
23
+ if (typeof item === 'object') {
24
+ const sv = item.start || item.startTime || item.time || item.slot;
25
+ const ev = item.end || item.endTime;
26
+ if (sv) {
27
+ let s = String(sv), e = ev ? String(ev) : '';
28
+ try {
29
+ if (s.includes('T') || !isNaN(Date.parse(s))) s = new Date(s).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
30
+ if (e && (e.includes('T') || !isNaN(Date.parse(e)))) e = new Date(e).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
31
+ } catch (_) { }
32
+ if (e) return `${s} - ${e}`;
33
+ return s;
34
+ }
35
+ }
36
+ return String(item);
37
+ }
38
+
39
+ function getDayBoundsInTimezone(y: number, month: number, day: number, timezone?: string) {
40
+ // Default UTC bounds
41
+ let startMs = Date.UTC(y, month, day, 0, 0, 0, 0);
42
+ let endMs = Date.UTC(y, month, day, 23, 59, 59, 999);
43
+
44
+ if (timezone) {
45
+ try {
46
+ const noonUtc = new Date(Date.UTC(y, month, day, 12, 0, 0));
47
+ const formatter = new Intl.DateTimeFormat('en-US', {
48
+ timeZone: timezone,
49
+ year: 'numeric', month: 'numeric', day: 'numeric',
50
+ hour: 'numeric', minute: 'numeric', second: 'numeric',
51
+ hour12: false
52
+ });
53
+ const parts = formatter.formatToParts(noonUtc);
54
+ const getPart = (type: string) => parts.find(p => p.type === type)?.value;
55
+
56
+ const yr = parseInt(getPart('year') || '0', 10);
57
+ const mo = parseInt(getPart('month') || '0', 10) - 1;
58
+ const dy = parseInt(getPart('day') || '0', 10);
59
+ const hr = parseInt(getPart('hour') || '0', 10);
60
+ const min = parseInt(getPart('minute') || '0', 10);
61
+ const sec = parseInt(getPart('second') || '0', 10);
62
+
63
+ // Handle hour 24 edge case in some environments
64
+ const hourNormalized = hr === 24 ? 0 : hr;
65
+
66
+ const targetUtc = Date.UTC(yr, mo, dy, hourNormalized, min, sec, 0);
67
+ const offsetMs = targetUtc - noonUtc.getTime();
68
+
69
+ startMs = Date.UTC(y, month, day, 0, 0, 0, 0) - offsetMs;
70
+ endMs = Date.UTC(y, month, day, 23, 59, 59, 999) - offsetMs;
71
+ } catch (e) {
72
+ console.warn(`[WidgetPresetRenderer] Failed to calculate day bounds for timezone ${timezone}:`, e);
73
+ }
74
+ }
75
+
76
+ return {
77
+ start: new Date(startMs),
78
+ end: new Date(endMs)
79
+ };
80
+ }
81
+
82
+ function toTimezoneISOString(date: Date, timeZone?: string): string {
83
+ if (!timeZone) {
84
+ const tzOffset = -date.getTimezoneOffset();
85
+ const diff = tzOffset >= 0 ? '+' : '-';
86
+ const pad = (num: number) => String(num).padStart(2, '0');
87
+ const pad3 = (num: number) => String(num).padStart(3, '0');
88
+ return date.getFullYear() +
89
+ '-' + pad(date.getMonth() + 1) +
90
+ '-' + pad(date.getDate()) +
91
+ 'T' + pad(date.getHours()) +
92
+ ':' + pad(date.getMinutes()) +
93
+ ':' + pad(date.getSeconds()) +
94
+ '.' + pad3(date.getMilliseconds()) +
95
+ diff + pad(Math.floor(Math.abs(tzOffset) / 60)) +
96
+ ':' + pad(Math.abs(tzOffset) % 60);
97
+ }
98
+
99
+ try {
100
+ const formatter = new Intl.DateTimeFormat('en-US', {
101
+ timeZone,
102
+ year: 'numeric', month: 'numeric', day: 'numeric',
103
+ hour: 'numeric', minute: 'numeric', second: 'numeric',
104
+ hour12: false
105
+ });
106
+ const parts = formatter.formatToParts(date);
107
+ const getPart = (type: string) => parts.find(p => p.type === type)?.value || '0';
108
+
109
+ const yr = getPart('year');
110
+ const mo = getPart('month').padStart(2, '0');
111
+ const dy = getPart('day').padStart(2, '0');
112
+ let hr = parseInt(getPart('hour'), 10);
113
+ if (hr === 24) hr = 0;
114
+ const hrStr = String(hr).padStart(2, '0');
115
+ const min = getPart('minute').padStart(2, '0');
116
+ const sec = getPart('second').padStart(2, '0');
117
+ const ms = String(date.getMilliseconds()).padStart(3, '0');
118
+
119
+ const yrInt = parseInt(yr, 10);
120
+ const moInt = parseInt(mo, 10) - 1;
121
+ const dyInt = parseInt(dy, 10);
122
+ const targetUtc = Date.UTC(yrInt, moInt, dyInt, hr, parseInt(min, 10), parseInt(sec, 10), date.getMilliseconds());
123
+ const offsetMs = targetUtc - date.getTime();
124
+
125
+ const offsetMin = Math.round(offsetMs / 60000);
126
+ const diff = offsetMin >= 0 ? '+' : '-';
127
+ const absOffsetMin = Math.abs(offsetMin);
128
+ const offsetHrStr = String(Math.floor(absOffsetMin / 60)).padStart(2, '0');
129
+ const offsetMinStr = String(absOffsetMin % 60).padStart(2, '0');
130
+
131
+ return `${yr}-${mo}-${dy}T${hrStr}:${min}:${sec}.${ms}${diff}${offsetHrStr}:${offsetMinStr}`;
132
+ } catch (e) {
133
+ console.warn(`[WidgetPresetRenderer] Failed to format to timezone ISO string for ${timeZone}:`, e);
134
+ return date.toISOString();
135
+ }
136
+ }
137
+
138
+ // โ”€โ”€โ”€ Shared overlay CSS (injected once) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
139
+ const OVERLAY_CSS = `
140
+ .vnr-overlay {
141
+ position: fixed; inset: 0; z-index: 2147483647;
142
+ background: rgba(0,0,0,0.45);
143
+ backdrop-filter: blur(4px);
144
+ display: flex; align-items: center; justify-content: center; padding: 16px;
145
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
146
+ box-sizing: border-box;
147
+ }
148
+ .vnr-modal {
149
+ background: #fff; border-radius: 16px;
150
+ box-shadow: 0 25px 60px rgba(0,0,0,0.25);
151
+ width: 100%; max-width: 400px; max-height: 88vh;
152
+ overflow-y: auto; box-sizing: border-box;
153
+ padding: 20px;
154
+ }
155
+ .vnr-close-btn {
156
+ position: absolute; top: 16px; right: 16px;
157
+ background: none; border: none; cursor: pointer;
158
+ color: #d1d5db; line-height: 1; padding: 4px;
159
+ }
160
+ .vnr-close-btn:hover { color: #6b7280; }
161
+ .vnr-title { margin: 0 0 8px; font-size: 17px; font-weight: 700; color: #111; letter-spacing: -0.02em; }
162
+ .vnr-reason {
163
+ display: flex; align-items: flex-start; gap: 8px;
164
+ padding: 10px 12px; background: #f9fafb; border: 1px solid #f3f4f6;
165
+ border-radius: 10px; font-size: 13px; color: #6b7280; line-height: 1.5; margin-bottom: 14px;
166
+ }
167
+ .vnr-divider { border-top: 1px solid #f3f4f6; margin: 12px 0; }
168
+ .vnr-section-label { font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
169
+ /* Month nav */
170
+ .vnr-month-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
171
+ .vnr-nav-btn {
172
+ width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
173
+ border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; cursor: pointer; color: #6b7280;
174
+ }
175
+ .vnr-nav-btn:hover:not(:disabled) { border-color: #9ca3af; background: #f9fafb; }
176
+ .vnr-nav-btn:disabled { opacity: 0.25; cursor: not-allowed; }
177
+ .vnr-month-label { font-size: 14px; font-weight: 600; color: #111; }
178
+ /* Calendar grid */
179
+ .vnr-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px 0; }
180
+ .vnr-wd { text-align: center; font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 6px; }
181
+ .vnr-day-btn {
182
+ display: block; margin: 0 auto; width: 34px; height: 34px; border-radius: 8px;
183
+ background: transparent; border: 1.5px solid transparent;
184
+ font-size: 13px; font-weight: 500; color: #111; cursor: pointer;
185
+ }
186
+ .vnr-day-btn:hover:not(:disabled):not(.vnr-day-sel) { background: #f3f4f6; }
187
+ .vnr-day-past { color: #d1d5db !important; cursor: not-allowed !important; }
188
+ .vnr-day-today { border-color: #111 !important; }
189
+ .vnr-day-sel { background: #111 !important; color: #fff !important; border-color: transparent !important; }
190
+ /* Slots */
191
+ .vnr-slots-grid { display: grid; gap: 6px; }
192
+ .vnr-slot-btn {
193
+ padding: 9px 6px; border-radius: 8px; border: 1px solid #e5e7eb;
194
+ background: #fff; color: #374151; font-size: 12px; font-weight: 500; cursor: pointer; text-align: center;
195
+ }
196
+ .vnr-slot-btn:hover:not(.vnr-slot-sel) { border-color: #9ca3af; }
197
+ .vnr-slot-sel { background: #111 !important; color: #fff !important; border-color: #111 !important; }
198
+ /* Actions */
199
+ .vnr-actions { display: flex; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; }
200
+ .vnr-cancel-btn {
201
+ flex: 1; padding: 10px; border-radius: 10px; background: #f3f4f6;
202
+ border: none; color: #6b7280; font-size: 13px; font-weight: 500; cursor: pointer;
203
+ }
204
+ .vnr-confirm-btn {
205
+ flex: 1; padding: 10px; border-radius: 10px; background: #111;
206
+ border: none; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
207
+ display: flex; align-items: center; justify-content: center; gap: 6px;
208
+ }
209
+ .vnr-confirm-btn:disabled { background: #e5e7eb !important; color: #9ca3af !important; cursor: not-allowed; }
210
+ /* Form */
211
+ .vnr-field-label { display: block; font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
212
+ .vnr-input {
213
+ width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px;
214
+ font-size: 13px; color: #111; background: #fff; box-sizing: border-box; outline: none;
215
+ font-family: inherit;
216
+ }
217
+ .vnr-input:focus { border-color: #111; }
218
+ .vnr-fields { display: flex; flex-direction: column; gap: 12px; margin-bottom: 4px; }
219
+ /* Spinner */
220
+ @keyframes vnr-spin { to { transform: rotate(360deg); } }
221
+ .vnr-spinner {
222
+ width: 16px; height: 16px; border-radius: 50%;
223
+ border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
224
+ animation: vnr-spin 0.7s linear infinite;
225
+ }
226
+ .vnr-center { text-align: center; padding: 28px 0; color: #9ca3af; font-size: 13px; }
227
+ /* Camera */
228
+ .vnr-video-wrap { width:100%; aspect-ratio:4/3; background:#0f172a; border-radius:12px; overflow:hidden; position:relative; }
229
+ .vnr-video-wrap video { width:100%; height:100%; object-fit:cover; display:block; }
230
+ .vnr-live-badge { position:absolute; top:10px; left:10px; display:flex; align-items:center; gap:6px; padding:3px 9px; border-radius:999px; background:rgba(15,23,42,0.72); border:1px solid rgba(255,255,255,0.1); font-size:11px; font-weight:600; color:#f8fafc; text-transform:uppercase; letter-spacing:.04em; }
231
+ .vnr-live-dot { width:7px; height:7px; border-radius:50%; background:#22c55e; flex-shrink:0; }
232
+ /* Upload dropzone */
233
+ .vnr-dropzone { border:2px dashed #cbd5e1; border-radius:14px; padding:32px 20px; text-align:center; cursor:pointer; background:#fafafa; transition:border-color 0.2s,background 0.2s; }
234
+ .vnr-dropzone:hover, .vnr-dropzone.drag { border-color:#374151; background:#f3f4f6; }
235
+ `;
236
+
237
+ // โ”€โ”€โ”€ WidgetPresetRenderer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
238
+
239
+ export class WidgetPresetRenderer {
240
+ private overlay: HTMLElement | null = null;
241
+ private styleInjected = false;
242
+ private chalkboardContent = ''; // Persisted session content for chalkboard fallback
243
+
244
+ // Calendar state
245
+ private calViewYear = 0;
246
+ private calViewMonth = 0;
247
+ private calSelectedTs: number | null = null;
248
+ private calSelectedTime: any = null;
249
+
250
+ // โ”€โ”€โ”€ Live Vision state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
251
+ private lvSessionId: string | null = null;
252
+ private lvStream: MediaStream | null = null;
253
+ private lvCaptureInterval: any = null;
254
+ private lvAutoStopTimeout: any = null;
255
+ private lvFrameSeq = 0;
256
+ private lvVideo: HTMLVideoElement | null = null;
257
+ private lvCanvas: HTMLCanvasElement | null = null;
258
+ private lvPip: HTMLElement | null = null; // floating PiP indicator
259
+
260
+ constructor(_root: ShadowRoot) {
261
+ // root is kept in signature for API compatibility but overlay renders in document.body
262
+ }
263
+
264
+ public resetChalkboard() {
265
+ this.chalkboardContent = '';
266
+ }
267
+
268
+ /** Returns true if this tool call contains a known preset_id and was handled */
269
+ handle(
270
+ toolCall: any,
271
+ onComplete: OnComplete,
272
+ onCancel: OnCancel,
273
+ client?: any
274
+ ): boolean {
275
+ const data = toolCall.data || toolCall;
276
+
277
+ // Parse arguments โ€” may be a JSON string or already an object
278
+ let args: Record<string, any> = {};
279
+ const rawArgs = data.arguments || data.args;
280
+ if (typeof rawArgs === 'string') {
281
+ try { args = JSON.parse(rawArgs); } catch (_) { args = {}; }
282
+ } else if (rawArgs && typeof rawArgs === 'object') {
283
+ args = rawArgs;
284
+ }
285
+
286
+ const clientFields = data.client_fields || {};
287
+
288
+ // preset_id could be in client_fields OR arguments โ€” check both
289
+ const presetId = clientFields?.preset_id || args?.preset_id;
290
+
291
+ console.log('[VaniraPreset] tool_call data:', {
292
+ name: data.name,
293
+ client_fields: clientFields,
294
+ args,
295
+ presetId,
296
+ });
297
+
298
+ if (!presetId) return false;
299
+
300
+ // merge so ctx has everything
301
+ const mergedClientFields = { ...args, ...clientFields };
302
+
303
+ const ctx: PresetContext = {
304
+ toolCallId: data.tool_call_id || data.call_id || '',
305
+ presetId,
306
+ clientFields: mergedClientFields,
307
+ args,
308
+ toolCall: data,
309
+ };
310
+
311
+ this.injectStyles();
312
+
313
+ const isCurrentWhiteboard = !!document.querySelector('.vnr-whiteboard-modal');
314
+ const isIncomingWhiteboard = presetId === 'vanira_type_text' || presetId === 'vanira_erase_text';
315
+
316
+ if (isCurrentWhiteboard && isIncomingWhiteboard) {
317
+ console.log('[VaniraPreset] Keeping existing whiteboard modal active');
318
+ } else {
319
+ this.dismiss(); // close any existing preset first
320
+ }
321
+
322
+ if (presetId === 'vanira_calendar') {
323
+ this.renderCalendar(ctx, onComplete, onCancel);
324
+ return true;
325
+ }
326
+ if (presetId === 'vanira_form') {
327
+ this.renderForm(ctx, onComplete, onCancel);
328
+ return true;
329
+ }
330
+ if (presetId === 'vanira_camera') {
331
+ this.renderCamera(ctx, onComplete, onCancel, client);
332
+ return true;
333
+ }
334
+ if (presetId === 'vanira_upload') {
335
+ this.renderUpload(ctx, onComplete, onCancel, client);
336
+ return true;
337
+ }
338
+ if (presetId === 'vanira_navigate') {
339
+ this.renderNavigate(ctx, onComplete, onCancel);
340
+ return true;
341
+ }
342
+ if (presetId === 'vanira_highlight_element') {
343
+ this.renderHighlight(ctx, onComplete, onCancel);
344
+ return true;
345
+ }
346
+ if (presetId === 'vanira_type_text') {
347
+ this.renderTypeText(ctx, onComplete, onCancel);
348
+ return true;
349
+ }
350
+ if (presetId === 'vanira_erase_text') {
351
+ this.renderEraseText(ctx, onComplete, onCancel);
352
+ return true;
353
+ }
354
+
355
+ if (presetId === 'vanira_live_vision') {
356
+ this.handleOpenLiveCamera(mergedClientFields, client);
357
+ return true;
358
+ }
359
+ if (presetId === 'vanira_close_live_camera') {
360
+ this.handleCloseLiveCamera(client);
361
+ return true;
362
+ }
363
+
364
+ return false; // unknown preset โ€” let host handle it
365
+ }
366
+
367
+ /** Force-dismiss the active preset (call on disconnect) */
368
+ dismiss() {
369
+ if (this.overlay) {
370
+ this.overlay.remove();
371
+ this.overlay = null;
372
+ }
373
+ // Stop any live vision session when the call ends
374
+ this.handleCloseLiveCamera(null);
375
+ }
376
+
377
+ // โ”€โ”€โ”€ Live Vision โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
378
+
379
+ private handleOpenLiveCamera(args: Record<string, any>, client: any) {
380
+ // Stop any existing session first (re-open = restart)
381
+ this.handleCloseLiveCamera(client);
382
+
383
+ const scope: string = args.scope ?? 'until_call_end';
384
+ const targetFps: number = Math.min(Number(args.target_fps ?? 1), Number(args.max_fps ?? 3));
385
+ const maxWidth: number = Number(args.max_width ?? 640);
386
+ const reason: string = args.reason ?? 'camera_capture';
387
+ const durationSec: number = Number(args.duration_sec ?? 0);
388
+ const facingMode: string = args.facing_mode ?? 'environment';
389
+ // show_preview: optional floating PiP camera badge (default false)
390
+ // show_preview defaults to TRUE โ€” only hide when explicitly set to false/'false'
391
+ const showPreview: boolean = args.show_preview !== false && args.show_preview !== 'false';
392
+
393
+ const sessionId = `lv_${crypto.randomUUID()}`;
394
+ this.lvSessionId = sessionId;
395
+ this.lvFrameSeq = 0;
396
+
397
+ // Offscreen video + canvas โ€” always hidden, used only for frame capture
398
+ const video = document.createElement('video');
399
+ video.autoplay = true;
400
+ video.playsInline = true;
401
+ video.muted = true;
402
+ video.style.display = 'none';
403
+ document.body.appendChild(video);
404
+ this.lvVideo = video;
405
+
406
+ const canvas = document.createElement('canvas');
407
+ canvas.style.display = 'none';
408
+ document.body.appendChild(canvas);
409
+ this.lvCanvas = canvas;
410
+
411
+ // Floating PiP badge is OPTIONAL โ€” only shown when show_preview is true
412
+ if (showPreview) {
413
+ this.lvPip = this.createLivePip(() => this.handleCloseLiveCamera(client));
414
+ }
415
+
416
+ navigator.mediaDevices.getUserMedia({ video: { facingMode, width: { ideal: maxWidth * 2 } } })
417
+ .then((stream) => {
418
+ if (!this.lvSessionId) { stream.getTracks().forEach(t => t.stop()); return; }
419
+ this.lvStream = stream;
420
+ video.srcObject = stream;
421
+
422
+ // Notify server: session started
423
+ if (client?.sendEvent) {
424
+ client.sendEvent('client_live_camera_started', {
425
+ data: {
426
+ session_id: sessionId,
427
+ scope,
428
+ ...(scope === 'timed' && durationSec > 0 ? { duration_sec: durationSec } : {}),
429
+ reason,
430
+ target_fps: targetFps,
431
+ }
432
+ });
433
+ }
434
+ console.log(`๐Ÿ“น [LiveVision] Session started: ${sessionId} (${scope}, ${targetFps} fps, preview=${showPreview})`);
435
+
436
+ // Begin capture loop
437
+ const intervalMs = Math.round(1000 / targetFps);
438
+ this.lvCaptureInterval = setInterval(() => {
439
+ this.captureAndSendFrame(client, sessionId, maxWidth, reason);
440
+ }, intervalMs);
441
+
442
+ // Auto-stop for timed scope
443
+ if (scope === 'timed' && durationSec > 0) {
444
+ this.lvAutoStopTimeout = setTimeout(() => {
445
+ console.log(`โฑ๏ธ [LiveVision] Timed session expired (${durationSec}s)`);
446
+ this.handleCloseLiveCamera(client);
447
+ }, durationSec * 1000);
448
+ }
449
+ })
450
+ .catch((err) => {
451
+ console.warn('[LiveVision] getUserMedia failed:', err);
452
+ this.lvSessionId = null;
453
+ this.lvPip?.remove();
454
+ this.lvPip = null;
455
+ video.remove();
456
+ canvas.remove();
457
+ });
458
+ }
459
+
460
+ private handleCloseLiveCamera(client: any | null) {
461
+ if (!this.lvSessionId) return;
462
+
463
+ const sessionId = this.lvSessionId;
464
+ this.lvSessionId = null;
465
+
466
+ // Stop capture loop + auto-stop timer
467
+ if (this.lvCaptureInterval) { clearInterval(this.lvCaptureInterval); this.lvCaptureInterval = null; }
468
+ if (this.lvAutoStopTimeout) { clearTimeout(this.lvAutoStopTimeout); this.lvAutoStopTimeout = null; }
469
+
470
+ // Release camera tracks
471
+ this.lvStream?.getTracks().forEach(t => t.stop());
472
+ this.lvStream = null;
473
+ this.lvVideo?.remove();
474
+ this.lvVideo = null;
475
+ this.lvCanvas?.remove();
476
+ this.lvCanvas = null;
477
+
478
+ // Remove PiP badge
479
+ this.lvPip?.remove();
480
+ this.lvPip = null;
481
+
482
+ // Notify server: session stopped
483
+ if (client?.sendEvent) {
484
+ client.sendEvent('client_live_camera_stopped', {
485
+ data: { session_id: sessionId }
486
+ });
487
+ }
488
+ console.log(`๐Ÿ“น [LiveVision] Session stopped: ${sessionId}`);
489
+ }
490
+
491
+ private async captureAndSendFrame(
492
+ client: any,
493
+ sessionId: string,
494
+ maxWidth: number,
495
+ reason: string
496
+ ) {
497
+ if (!client || !this.lvVideo || !this.lvCanvas || !this.lvSessionId) return;
498
+ const video = this.lvVideo;
499
+ if (video.readyState < 2) return; // not yet playing
500
+
501
+ const canvas = this.lvCanvas;
502
+ const vw = video.videoWidth || maxWidth;
503
+ const vh = video.videoHeight || maxWidth;
504
+ const scale = Math.min(1, maxWidth / vw);
505
+ canvas.width = Math.round(vw * scale);
506
+ canvas.height = Math.round(vh * scale);
507
+ const ctx2d = canvas.getContext('2d');
508
+ if (!ctx2d) return;
509
+ ctx2d.drawImage(video, 0, 0, canvas.width, canvas.height);
510
+
511
+ let blob: Blob;
512
+ try {
513
+ blob = await new Promise<Blob>((res, rej) =>
514
+ canvas.toBlob(b => b ? res(b) : rej(new Error('toBlob failed')), 'image/jpeg', 0.75)
515
+ );
516
+ } catch (e) {
517
+ console.warn('[LiveVision] Frame grab failed:', e);
518
+ return;
519
+ }
520
+
521
+ try {
522
+ // โ”€โ”€โ”€ RAW HTTP upload โ€” bypass client.uploadMedia() entirely โ”€โ”€โ”€โ”€โ”€โ”€โ”€
523
+ // client.uploadMedia() always fires client_media_update which triggers
524
+ // an LLM response and interrupts the call. For live vision frames we
525
+ // upload directly and ONLY send client_media_frame.
526
+ const serverUrl: string = client.serverUrl || '';
527
+ let uploadBase = '';
528
+ try { uploadBase = new URL(serverUrl).origin; } catch (_) {
529
+ uploadBase = serverUrl.replace(/\/webrtc.*$/, '').replace(/\/$/, '');
530
+ }
531
+ const callId: string = client.callId || '';
532
+ const uploadUrl = `${uploadBase}/media/upload`;
533
+
534
+ const form = new FormData();
535
+ form.append('file', blob, `frame_${sessionId}.jpg`);
536
+ form.append('call_id', callId);
537
+ form.append('reason', reason);
538
+
539
+ const res = await fetch(uploadUrl, { method: 'POST', body: form });
540
+ if (!res.ok) throw new Error(`Upload HTTP ${res.status}`);
541
+ const data = await res.json();
542
+ const media_id: string = data.media_id;
543
+ const url: string = data.url;
544
+ if (!media_id || !url) throw new Error('Upload response missing media_id/url');
545
+
546
+ // Guard: session may have closed while the upload was in flight
547
+ if (!this.lvSessionId || this.lvSessionId !== sessionId) return;
548
+
549
+ const seq = ++this.lvFrameSeq;
550
+ // Send ONLY client_media_frame โ€” no client_media_update, no LLM trigger
551
+ if (client.sendEvent) {
552
+ client.sendEvent('client_media_frame', {
553
+ data: {
554
+ session_id: sessionId,
555
+ frame_seq: seq,
556
+ media_id,
557
+ media_url: url,
558
+ content_type: 'image/jpeg',
559
+ reason,
560
+ }
561
+ });
562
+ }
563
+ console.log(`๐Ÿ“ธ [LiveVision] Frame #${seq} sent (media_id: ${media_id})`);
564
+ } catch (e) {
565
+ console.warn('[LiveVision] Frame upload failed:', e);
566
+ }
567
+ }
568
+
569
+ private createLivePip(onStop: () => void): HTMLElement {
570
+ const pip = document.createElement('div');
571
+ pip.id = 'vnr-live-pip';
572
+ pip.style.cssText = [
573
+ 'position:fixed', 'top:12px', 'right:12px', 'z-index:2147483647',
574
+ 'display:flex', 'align-items:center', 'gap:8px',
575
+ 'padding:6px 12px 6px 10px',
576
+ 'background:rgba(15,23,42,0.92)', 'border-radius:999px',
577
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
578
+ 'font-size:11px', 'font-weight:600', 'color:#fff',
579
+ 'box-shadow:0 4px 16px rgba(0,0,0,0.25)',
580
+ 'backdrop-filter:blur(8px)',
581
+ 'cursor:default', 'user-select:none',
582
+ ].join(';');
583
+ pip.innerHTML = `
584
+ <span style="width:8px;height:8px;border-radius:50%;background:#ef4444;flex-shrink:0;animation:vnr-pip-pulse 1.5s ease-in-out infinite;"></span>
585
+ <span>Camera Live</span>
586
+ <button id="vnr-pip-stop" style="margin-left:4px;padding:2px 8px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.2);border-radius:999px;color:#fff;font-size:10px;font-weight:600;cursor:pointer;line-height:1.4;">Stop</button>
587
+ `;
588
+ if (!document.getElementById('vnr-pip-style')) {
589
+ const s = document.createElement('style');
590
+ s.id = 'vnr-pip-style';
591
+ s.textContent = `@keyframes vnr-pip-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.5;transform:scale(1.2);}}`;
592
+ document.head.appendChild(s);
593
+ }
594
+ pip.querySelector('#vnr-pip-stop')!.addEventListener('click', () => onStop());
595
+ document.body.appendChild(pip);
596
+ return pip;
597
+ }
598
+
599
+ // โ”€โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
600
+
601
+ private injectStyles() {
602
+ if (this.styleInjected || document.getElementById('vnr-preset-styles')) return;
603
+ const s = document.createElement('style');
604
+ s.id = 'vnr-preset-styles';
605
+ s.textContent = OVERLAY_CSS;
606
+ document.head.appendChild(s);
607
+ this.styleInjected = true;
608
+ }
609
+
610
+ private createOverlay(): HTMLElement {
611
+ const el = document.createElement('div');
612
+ el.className = 'vnr-overlay';
613
+ // Append to document.body โ€” NOT shadow root โ€” so it sits above
614
+ // the widget's shadow stacking context at true document z-index
615
+ document.body.appendChild(el);
616
+ this.overlay = el;
617
+ return el;
618
+ }
619
+
620
+ private createModal(overlay: HTMLElement, title: string, reason: string): HTMLElement {
621
+ const modal = document.createElement('div');
622
+ modal.className = 'vnr-modal';
623
+ modal.style.position = 'relative';
624
+ modal.innerHTML = `
625
+ <button class="vnr-close-btn" aria-label="Close">
626
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
627
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
628
+ </svg>
629
+ </button>
630
+ <h2 class="vnr-title">${title}</h2>
631
+ <div class="vnr-reason">
632
+ <svg width="14" height="14" style="flex-shrink:0;margin-top:1px" fill="none" stroke="#9ca3af" stroke-width="2" viewBox="0 0 24 24">
633
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
634
+ </svg>
635
+ <span>${reason}</span>
636
+ </div>
637
+ `;
638
+ overlay.appendChild(modal);
639
+ return modal;
640
+ }
641
+
642
+ // โ”€โ”€โ”€ Calendar Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
643
+
644
+ private renderCalendar(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
645
+ const today = new Date(); today.setHours(0, 0, 0, 0);
646
+ this.calViewYear = today.getFullYear();
647
+ this.calViewMonth = today.getMonth();
648
+ this.calSelectedTs = null;
649
+ this.calSelectedTime = null;
650
+
651
+ const title = ctx.clientFields.title || 'Select Date & Time';
652
+ const reason = ctx.args.reason || 'Please select a preferred time for your appointment.';
653
+
654
+ const overlay = this.createOverlay();
655
+ const modal = this.createModal(overlay, title, reason);
656
+
657
+ // Close button
658
+ modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => {
659
+ this.dismiss(); onCancel('User closed the preset');
660
+ });
661
+
662
+ // Calendar container
663
+ const calContainer = document.createElement('div');
664
+ modal.appendChild(calContainer);
665
+
666
+ // Slots container (hidden until date picked)
667
+ const slotsSection = document.createElement('div');
668
+ slotsSection.style.display = 'none';
669
+ modal.appendChild(slotsSection);
670
+
671
+ // Actions
672
+ const actionsEl = document.createElement('div');
673
+ actionsEl.className = 'vnr-actions';
674
+ actionsEl.innerHTML = `
675
+ <button class="vnr-cancel-btn">Cancel</button>
676
+ <button class="vnr-confirm-btn" disabled>
677
+ <svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
678
+ Confirm
679
+ </button>
680
+ `;
681
+ modal.appendChild(actionsEl);
682
+
683
+ const confirmBtn = actionsEl.querySelector('.vnr-confirm-btn') as HTMLButtonElement;
684
+ actionsEl.querySelector('.vnr-cancel-btn')!.addEventListener('click', () => {
685
+ this.dismiss(); onCancel('User cancelled');
686
+ });
687
+ confirmBtn.addEventListener('click', () => {
688
+ if (!this.calSelectedTs || !this.calSelectedTime) return;
689
+ const dateObj = new Date(this.calSelectedTs);
690
+ const timeStr = formatSlotTime(this.calSelectedTime);
691
+ const y = dateObj.getFullYear();
692
+ const m = String(dateObj.getMonth() + 1).padStart(2, '0');
693
+ const d = String(dateObj.getDate()).padStart(2, '0');
694
+ const dateStr = `${y}-${m}-${d}`;
695
+
696
+ const tzStr = ctx.clientFields.timezone || '';
697
+ let slotData = typeof this.calSelectedTime === 'object' ? { ...this.calSelectedTime } : undefined;
698
+ if (slotData) {
699
+ for (const key of ['start', 'startTime', 'end', 'endTime']) {
700
+ if (slotData[key] && typeof slotData[key] === 'string' && (slotData[key].includes('T') || !isNaN(Date.parse(slotData[key])))) {
701
+ try {
702
+ slotData[key] = toTimezoneISOString(new Date(slotData[key]), tzStr);
703
+ } catch (_) { }
704
+ }
705
+ }
706
+ }
707
+
708
+ this.dismiss();
709
+ onComplete({
710
+ date: dateStr,
711
+ time: timeStr,
712
+ timestamp: this.calSelectedTs,
713
+ formatted: `${dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })} at ${timeStr}`,
714
+ slot_data: slotData,
715
+ });
716
+ });
717
+
718
+ const renderGrid = () => {
719
+ const today2 = new Date(); today2.setHours(0, 0, 0, 0);
720
+ const firstDay = new Date(this.calViewYear, this.calViewMonth, 1).getDay();
721
+ const daysInMonth = new Date(this.calViewYear, this.calViewMonth + 1, 0).getDate();
722
+ const monthLabel = new Date(this.calViewYear, this.calViewMonth, 1)
723
+ .toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
724
+ const isPrevDisabled = this.calViewYear === today2.getFullYear() && this.calViewMonth === today2.getMonth();
725
+
726
+ calContainer.innerHTML = `
727
+ <div class="vnr-month-nav">
728
+ <button class="vnr-nav-btn" id="vnr-prev" ${isPrevDisabled ? 'disabled' : ''}>
729
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
730
+ </button>
731
+ <span class="vnr-month-label">${monthLabel}</span>
732
+ <button class="vnr-nav-btn" id="vnr-next">
733
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
734
+ </button>
735
+ </div>
736
+ <div class="vnr-cal-grid" id="vnr-grid"></div>
737
+ `;
738
+
739
+ calContainer.querySelector('#vnr-prev')!.addEventListener('click', () => {
740
+ if (this.calViewMonth === 0) { this.calViewMonth = 11; this.calViewYear--; }
741
+ else this.calViewMonth--;
742
+ renderGrid();
743
+ });
744
+ calContainer.querySelector('#vnr-next')!.addEventListener('click', () => {
745
+ if (this.calViewMonth === 11) { this.calViewMonth = 0; this.calViewYear++; }
746
+ else this.calViewMonth++;
747
+ renderGrid();
748
+ });
749
+
750
+ const grid = calContainer.querySelector('#vnr-grid')!;
751
+ ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].forEach(wd => {
752
+ const d = document.createElement('div'); d.className = 'vnr-wd'; d.textContent = wd;
753
+ grid.appendChild(d);
754
+ });
755
+ for (let i = 0; i < firstDay; i++) {
756
+ const empty = document.createElement('div'); grid.appendChild(empty);
757
+ }
758
+ for (let day = 1; day <= daysInMonth; day++) {
759
+ const ts = new Date(this.calViewYear, this.calViewMonth, day).getTime();
760
+ const isPast = ts < today2.getTime();
761
+ const isToday = ts === today2.getTime();
762
+ const isSel = this.calSelectedTs === ts;
763
+ const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
764
+ const btn = document.createElement('button');
765
+ btn.className = 'vnr-day-btn';
766
+ if (isPast) btn.classList.add('vnr-day-past');
767
+ if (isToday && !isSel) btn.classList.add('vnr-day-today');
768
+ if (isSel) btn.classList.add('vnr-day-sel');
769
+ btn.disabled = isPast;
770
+ btn.textContent = String(day);
771
+ btn.addEventListener('click', () => {
772
+ this.calSelectedTs = ts;
773
+ this.calSelectedTime = null;
774
+ confirmBtn.disabled = true;
775
+ renderGrid();
776
+ this.loadSlots(ctx, slotsSection, confirmBtn);
777
+ });
778
+ wrap.appendChild(btn); grid.appendChild(wrap);
779
+ }
780
+ };
781
+
782
+ renderGrid();
783
+ }
784
+
785
+ private loadSlots(ctx: PresetContext, slotsSection: HTMLElement, confirmBtn: HTMLButtonElement) {
786
+ const slotsApiUrl = ctx.clientFields.slots_api_url;
787
+
788
+ if (!this.calSelectedTs || !slotsApiUrl) {
789
+ this.renderSlots(slotsSection, ['09:00 AM', '10:30 AM', '01:00 PM', '02:30 PM', '04:00 PM'], confirmBtn);
790
+ return;
791
+ }
792
+
793
+ const dateObj = new Date(this.calSelectedTs);
794
+ const y = dateObj.getFullYear();
795
+ const m = String(dateObj.getMonth() + 1).padStart(2, '0');
796
+ const d = String(dateObj.getDate()).padStart(2, '0');
797
+ const dateStr = `${y}-${m}-${d}`;
798
+ const tzStr = ctx.clientFields.timezone || '';
799
+ const bounds = getDayBoundsInTimezone(y, dateObj.getMonth(), dateObj.getDate(), tzStr);
800
+ const startOfDay = bounds.start;
801
+ const endOfDay = bounds.end;
802
+
803
+ const rp = (val: string) => {
804
+ if (typeof val !== 'string') return val;
805
+ let replaced = val
806
+ .replace(/\{\{date\}\}/g, dateStr)
807
+ .replace(/\{\{timezone\}\}/g, tzStr)
808
+ .replace(/\{\{start_of_day\}\}/g, startOfDay.toISOString())
809
+ .replace(/\{\{end_of_day\}\}/g, endOfDay.toISOString());
810
+
811
+ Object.entries(ctx.clientFields).forEach(([k, v]) => {
812
+ if (v !== undefined && v !== null) {
813
+ const escapedKey = k.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
814
+ const regex = new RegExp(`\\{\\{${escapedKey}\\}\\}`, 'g');
815
+ replaced = replaced.replace(regex, String(v));
816
+ }
817
+ });
818
+ return replaced;
819
+ };
820
+
821
+ const method = (ctx.clientFields.slots_api_method || 'GET').toUpperCase();
822
+ let headers: Record<string, string> = { 'Content-Type': 'application/json' };
823
+ try { Object.entries(JSON.parse(ctx.clientFields.slots_api_headers || '{}')).forEach(([k, v]) => { headers[k] = rp(String(v)); }); } catch (_) { }
824
+
825
+ let bodyObj: Record<string, any> = {};
826
+ try { Object.entries(JSON.parse(ctx.clientFields.slots_api_body || '{}')).forEach(([k, v]) => { bodyObj[k] = rp(String(v)); }); } catch (_) { }
827
+
828
+ let url = rp(slotsApiUrl);
829
+ const fetchOpts: RequestInit = { method, headers };
830
+ if (method === 'GET') {
831
+ const qs = new URLSearchParams(Object.entries(bodyObj).map(([k, v]) => [k, String(v)])).toString();
832
+ if (qs) url += (url.includes('?') ? '&' : '?') + qs;
833
+ } else {
834
+ fetchOpts.body = JSON.stringify(bodyObj);
835
+ }
836
+
837
+ slotsSection.style.display = 'block';
838
+ slotsSection.innerHTML = `<div class="vnr-divider"></div><div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Loading slotsโ€ฆ</div>`;
839
+
840
+ console.log("๐ŸŽฏ [SDK Preset Renderer] Calling slots API with URL:", url, "fetchOpts:", fetchOpts);
841
+ fetch(url, fetchOpts)
842
+ .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
843
+ .then(data => {
844
+ let arr: any[] = [];
845
+ if (Array.isArray(data)) arr = data;
846
+ else if (data && typeof data === 'object') {
847
+ for (const k of ['slots', 'free_slots', 'times', 'data', 'available_slots']) {
848
+ if (Array.isArray(data[k])) { arr = data[k]; break; }
849
+ }
850
+ }
851
+ this.renderSlots(slotsSection, arr, confirmBtn);
852
+ })
853
+ .catch(() => {
854
+ slotsSection.innerHTML = `<div class="vnr-divider"></div><div class="vnr-center" style="color:#ef4444">Failed to load slots. Please try again.</div>`;
855
+ });
856
+ }
857
+
858
+ private renderSlots(slotsSection: HTMLElement, times: any[], confirmBtn: HTMLButtonElement) {
859
+ const label = this.calSelectedTs
860
+ ? new Date(this.calSelectedTs).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
861
+ : 'Available Times';
862
+
863
+ const cols = times.some(t => formatSlotTime(t).length > 8) ? 2 : 3;
864
+ slotsSection.style.display = 'block';
865
+ slotsSection.innerHTML = `
866
+ <div class="vnr-divider"></div>
867
+ <div class="vnr-section-label">${label}</div>
868
+ ${times.length === 0
869
+ ? `<div class="vnr-center">No slots available for this date.</div>`
870
+ : `<div class="vnr-slots-grid" style="grid-template-columns:repeat(${cols},1fr)"></div>`
871
+ }
872
+ `;
873
+ if (times.length === 0) return;
874
+
875
+ const grid = slotsSection.querySelector('.vnr-slots-grid')!;
876
+ times.forEach((t) => {
877
+ const btn = document.createElement('button');
878
+ btn.className = 'vnr-slot-btn';
879
+ const isSel = this.calSelectedTime !== null && (
880
+ typeof t === 'object' && typeof this.calSelectedTime === 'object'
881
+ ? JSON.stringify(t) === JSON.stringify(this.calSelectedTime)
882
+ : t === this.calSelectedTime
883
+ );
884
+ if (isSel) btn.classList.add('vnr-slot-sel');
885
+ btn.textContent = formatSlotTime(t);
886
+ btn.addEventListener('click', () => {
887
+ this.calSelectedTime = t;
888
+ confirmBtn.disabled = false;
889
+ grid.querySelectorAll('.vnr-slot-btn').forEach(b => b.classList.remove('vnr-slot-sel'));
890
+ btn.classList.add('vnr-slot-sel');
891
+ });
892
+ grid.appendChild(btn);
893
+ });
894
+ }
895
+
896
+ // โ”€โ”€โ”€ Form Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
897
+
898
+ private renderForm(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
899
+ const title = ctx.clientFields.title || ctx.args.title || 'Please fill out this form';
900
+ const reason = ctx.args.reason || 'Please provide the following information.';
901
+ // fields may come as an array OR a comma-separated string (e.g. "Name, Phone, Email")
902
+ const rawFields = ctx.clientFields.fields || ctx.args.fields;
903
+ const fields: string[] = Array.isArray(rawFields)
904
+ ? rawFields
905
+ : typeof rawFields === 'string'
906
+ ? rawFields.split(',').map((f: string) => f.trim()).filter(Boolean)
907
+ : [];
908
+
909
+ const overlay = this.createOverlay();
910
+ const modal = this.createModal(overlay, title, reason);
911
+ modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => {
912
+ this.dismiss(); onCancel('User closed');
913
+ });
914
+
915
+ // Fields
916
+ const fieldsEl = document.createElement('div');
917
+ fieldsEl.className = 'vnr-fields';
918
+ fields.forEach((field: string) => {
919
+ const id = field.toLowerCase().replace(/[^a-z0-9]/g, '_');
920
+ const isEmail = id.includes('email');
921
+ const isPhone = id.includes('phone') || id.includes('number');
922
+ const type = isEmail ? 'email' : isPhone ? 'tel' : 'text';
923
+ const wrap = document.createElement('div');
924
+ wrap.innerHTML = `
925
+ <label class="vnr-field-label" for="vnr_${id}">${field} *</label>
926
+ <input class="vnr-input" type="${type}" id="vnr_${id}" name="${id}" placeholder="Enter your ${field.toLowerCase()}" required />
927
+ `;
928
+ fieldsEl.appendChild(wrap);
929
+ });
930
+ modal.appendChild(fieldsEl);
931
+
932
+ // Actions
933
+ const actionsEl = document.createElement('div');
934
+ actionsEl.className = 'vnr-actions';
935
+ actionsEl.style.marginTop = '16px';
936
+ actionsEl.innerHTML = `
937
+ <button class="vnr-cancel-btn">Cancel</button>
938
+ <button class="vnr-confirm-btn">
939
+ <svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
940
+ Submit
941
+ </button>
942
+ `;
943
+ modal.appendChild(actionsEl);
944
+
945
+ actionsEl.querySelector('.vnr-cancel-btn')!.addEventListener('click', () => {
946
+ this.dismiss(); onCancel('User cancelled');
947
+ });
948
+
949
+ const submitBtn = actionsEl.querySelector('.vnr-confirm-btn') as HTMLButtonElement;
950
+ submitBtn.addEventListener('click', () => {
951
+ const result: Record<string, string> = {};
952
+ let allFilled = true;
953
+ fields.forEach((field: string) => {
954
+ const id = field.toLowerCase().replace(/[^a-z0-9]/g, '_');
955
+ const input = modal.querySelector(`#vnr_${id}`) as HTMLInputElement;
956
+ if (!input?.value.trim()) { allFilled = false; input?.focus(); return; }
957
+ result[id] = input.value.trim();
958
+ });
959
+ if (!allFilled) return;
960
+ submitBtn.disabled = true;
961
+ submitBtn.innerHTML = `<div class="vnr-spinner"></div>`;
962
+ setTimeout(() => { this.dismiss(); onComplete(result); }, 300);
963
+ });
964
+ }
965
+
966
+ // โ”€โ”€โ”€ Camera Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
967
+
968
+ private renderCamera(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel, client?: any) {
969
+ const title = ctx.clientFields.title || 'Take a Photo';
970
+ const description = ctx.clientFields.description || 'Point your camera at the subject, then tap capture.';
971
+ const facingMode = ctx.clientFields.facing_mode || ctx.args.facing_mode || 'environment';
972
+ const reason = ctx.clientFields.reason || 'camera_capture';
973
+ const requireLivenessDefault = ctx.clientFields.liveness_check === true ||
974
+ ctx.clientFields.liveness_check === 'true' ||
975
+ ctx.args.liveness_check === true ||
976
+ ctx.args.liveness_check === 'true';
977
+
978
+ let stream: MediaStream | null = null;
979
+ let livenessInterval: any = null;
980
+
981
+ const overlay = this.createOverlay();
982
+
983
+ const modal = document.createElement('div');
984
+ modal.className = 'vnr-modal';
985
+ modal.style.cssText = 'position:relative;max-width:480px;width:100%';
986
+ modal.innerHTML = `
987
+ <button class="vnr-close-btn" aria-label="Close">
988
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
989
+ </button>
990
+ <h2 class="vnr-title" style="padding-right:32px">${title}</h2>
991
+ <p style="margin:0 0 14px;font-size:13px;color:#6b7280;line-height:1.5">${description}</p>
992
+
993
+ <div class="vnr-liveness-toggle-wrap" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:8px 12px">
994
+ <span style="font-size:12px;font-weight:600;color:#374151">Liveness Verification</span>
995
+ <label class="vnr-switch" style="position:relative;display:inline-block;width:34px;height:20px">
996
+ <input type="checkbox" id="vnr-cam-liveness-toggle" style="opacity:0;width:0;height:0">
997
+ <span class="vnr-slider" style="position:absolute;cursor:pointer;inset:0;background-color:#ccc;transition:.3s;border-radius:20px"></span>
998
+ </label>
999
+ </div>
1000
+
1001
+ <div class="vnr-video-wrap" id="vnr-cam-wrap" style="position:relative">
1002
+ <video id="vnr-cam-video" autoplay playsinline muted></video>
1003
+ <div id="vnr-cam-guide" style="display:none;position:absolute;inset:0;pointer-events:none;border:3px dashed rgba(255,255,255,0.45);border-radius:50%;margin:auto;width:180px;height:240px;box-shadow:0 0 0 9999px rgba(15,23,42,0.6);transition:border-color 0.3s ease, box-shadow 0.3s ease">
1004
+ <div id="vnr-cam-guide-text" style="position:absolute;bottom:-45px;left:-40px;right:-40px;text-align:center;color:#fff;font-size:11px;font-weight:600;background:rgba(15,23,42,0.85);padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.1)">
1005
+ Align your face
1006
+ </div>
1007
+ </div>
1008
+ <div id="vnr-cam-overlay" style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#0f172a">
1009
+ <div class="vnr-spinner" style="width:32px;height:32px;border-width:3px;border-color:rgba(255,255,255,.15);border-top-color:#fff"></div>
1010
+ <p style="margin:0;font-size:13px;color:#94a3b8">Opening cameraโ€ฆ</p>
1011
+ </div>
1012
+ </div>
1013
+
1014
+ <div id="vnr-liveness-progress-container" style="display:none;margin-top:12px">
1015
+ <div style="display:flex;justify-content:space-between;font-size:11px;font-weight:700;color:#6b7280;margin-bottom:6px;letter-spacing:0.04em">
1016
+ <span>LIVENESS ANALYSIS</span>
1017
+ <span id="vnr-liveness-pct" style="color:#6366f1">0%</span>
1018
+ </div>
1019
+ <div style="width:100%;height:6px;background:#f3f4f6;border-radius:3px;overflow:hidden">
1020
+ <div id="vnr-liveness-bar" style="width:0%;height:100%;background:#6366f1;transition:width 0.1s linear"></div>
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <canvas id="vnr-cam-canvas" style="display:none"></canvas>
1025
+ <div id="vnr-cam-status" style="display:none"></div>
1026
+ <div class="vnr-actions" style="margin-top:14px">
1027
+ <button class="vnr-cancel-btn" id="vnr-cam-cancel">Cancel</button>
1028
+ <button class="vnr-confirm-btn" id="vnr-cam-capture" disabled>
1029
+ <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
1030
+ Capture & Send
1031
+ </button>
1032
+ </div>
1033
+ `;
1034
+ overlay.appendChild(modal);
1035
+
1036
+ const video = modal.querySelector('#vnr-cam-video') as HTMLVideoElement;
1037
+ const canvas = modal.querySelector('#vnr-cam-canvas') as HTMLCanvasElement;
1038
+ const camOverlay = modal.querySelector('#vnr-cam-overlay') as HTMLElement;
1039
+ const statusEl = modal.querySelector('#vnr-cam-status') as HTMLElement;
1040
+ const captureBtn = modal.querySelector('#vnr-cam-capture') as HTMLButtonElement;
1041
+
1042
+ const livenessToggle = modal.querySelector('#vnr-cam-liveness-toggle') as HTMLInputElement;
1043
+ const livenessContainer = modal.querySelector('#vnr-liveness-progress-container') as HTMLElement;
1044
+ const livenessProgressBar = modal.querySelector('#vnr-liveness-bar') as HTMLElement;
1045
+ const livenessPctText = modal.querySelector('#vnr-liveness-pct') as HTMLElement;
1046
+ const guide = modal.querySelector('#vnr-cam-guide') as HTMLElement;
1047
+ const guideText = modal.querySelector('#vnr-cam-guide-text') as HTMLElement;
1048
+
1049
+ const stopStream = () => {
1050
+ if (livenessInterval) {
1051
+ clearInterval(livenessInterval);
1052
+ livenessInterval = null;
1053
+ }
1054
+ stream?.getTracks().forEach(t => t.stop());
1055
+ stream = null;
1056
+ video.srcObject = null;
1057
+ };
1058
+
1059
+ modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => { stopStream(); this.dismiss(); onCancel('User closed'); });
1060
+ modal.querySelector('#vnr-cam-cancel')!.addEventListener('click', () => { stopStream(); this.dismiss(); onCancel('User cancelled'); });
1061
+
1062
+ // Start camera
1063
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facingMode } }, audio: false })
1064
+ .then(s => {
1065
+ stream = s;
1066
+ video.srcObject = s;
1067
+ return video.play().catch(() => { });
1068
+ })
1069
+ .then(() => {
1070
+ camOverlay.style.display = 'none';
1071
+ // Add LIVE badge
1072
+ const badge = document.createElement('div');
1073
+ badge.className = 'vnr-live-badge';
1074
+ badge.innerHTML = '<span class="vnr-live-dot"></span>LIVE';
1075
+ modal.querySelector('#vnr-cam-wrap')!.appendChild(badge);
1076
+
1077
+ livenessToggle.checked = requireLivenessDefault;
1078
+ updateLivenessState();
1079
+ })
1080
+ .catch(() => {
1081
+ camOverlay.innerHTML = '<p style="color:#f87171;font-size:13px;text-align:center;padding:24px">Camera access denied.<br>Please allow camera permissions.</p>';
1082
+ });
1083
+
1084
+ function updateLivenessState() {
1085
+ if (livenessInterval) {
1086
+ clearInterval(livenessInterval);
1087
+ livenessInterval = null;
1088
+ }
1089
+
1090
+ if (livenessToggle.checked) {
1091
+ livenessContainer.style.display = 'block';
1092
+ guide.style.display = 'block';
1093
+ captureBtn.disabled = true;
1094
+ startLivenessCheck();
1095
+ } else {
1096
+ livenessContainer.style.display = 'none';
1097
+ guide.style.display = 'none';
1098
+ captureBtn.disabled = false;
1099
+ }
1100
+ }
1101
+
1102
+ livenessToggle.addEventListener('change', updateLivenessState);
1103
+
1104
+ function startLivenessCheck() {
1105
+ let prevPixels: Uint8ClampedArray | null = null;
1106
+ const baselineDiffs: number[] = [];
1107
+ let baseline = 1.0;
1108
+ let livenessProgress = 0;
1109
+ let livenessState = 0; // 0: calibrating, 1: action prompt, 2: verified
1110
+ let calibrationFrames = 0;
1111
+ let actionDetected = false;
1112
+
1113
+ const offscreenCanvas = document.createElement('canvas');
1114
+ const offscreenCtx = offscreenCanvas.getContext('2d')!;
1115
+ offscreenCanvas.width = 80;
1116
+ offscreenCanvas.height = 60;
1117
+
1118
+ livenessProgressBar.style.width = '0%';
1119
+ livenessProgressBar.style.backgroundColor = '#6366f1';
1120
+ livenessPctText.style.color = '#6366f1';
1121
+ livenessPctText.innerText = '0%';
1122
+ guide.style.borderColor = 'rgba(255,255,255,0.45)';
1123
+ guide.style.boxShadow = '0 0 0 9999px rgba(15,23,42,0.6)';
1124
+ guideText.innerText = "Align your face in center";
1125
+ guideText.style.color = "#fff";
1126
+
1127
+ livenessInterval = setInterval(() => {
1128
+ if (!stream || video.paused || video.ended) return;
1129
+
1130
+ offscreenCtx.drawImage(video, 0, 0, 80, 60);
1131
+ const imgData = offscreenCtx.getImageData(0, 0, 80, 60);
1132
+ const pixels = imgData.data;
1133
+
1134
+ if (prevPixels) {
1135
+ let totalDiff = 0;
1136
+ for (let i = 0; i < pixels.length; i += 4) {
1137
+ totalDiff += Math.abs(pixels[i] - prevPixels[i]);
1138
+ totalDiff += Math.abs(pixels[i + 1] - prevPixels[i + 1]);
1139
+ totalDiff += Math.abs(pixels[i + 2] - prevPixels[i + 2]);
1140
+ }
1141
+ const avgDiff = totalDiff / (pixels.length / 4 * 3);
1142
+
1143
+ if (livenessState === 0) {
1144
+ // Calibration
1145
+ calibrationFrames++;
1146
+ baselineDiffs.push(avgDiff);
1147
+
1148
+ const pct = Math.min(100, Math.round((calibrationFrames / 15) * 100));
1149
+ livenessProgressBar.style.width = `${pct * 0.3}%`;
1150
+ livenessPctText.innerText = `${Math.round(pct * 0.3)}%`;
1151
+ guideText.innerText = "Hold still to calibrateโ€ฆ";
1152
+ guideText.style.color = "#a5b4fc";
1153
+
1154
+ if (calibrationFrames >= 15) {
1155
+ const sum = baselineDiffs.reduce((a, b) => a + b, 0);
1156
+ baseline = sum / baselineDiffs.length;
1157
+ if (baseline < 0.2) baseline = 0.2;
1158
+ livenessState = 1;
1159
+ calibrationFrames = 0;
1160
+ livenessProgressBar.style.width = '30%';
1161
+ livenessPctText.innerText = '30%';
1162
+ }
1163
+ } else if (livenessState === 1) {
1164
+ // Action verification
1165
+ guideText.innerText = "Now blink or turn head slightly";
1166
+ guideText.style.color = "#ffd2d2";
1167
+
1168
+ const threshold = Math.max(3.0, baseline * 3.5);
1169
+
1170
+ if (avgDiff > threshold && avgDiff < 30.0) {
1171
+ livenessProgress += 15;
1172
+ if (livenessProgress > 70) {
1173
+ livenessProgress = 70;
1174
+ actionDetected = true;
1175
+ }
1176
+ const currentPct = 30 + livenessProgress;
1177
+ livenessProgressBar.style.width = `${currentPct}%`;
1178
+ livenessPctText.innerText = `${currentPct}%`;
1179
+ } else if (actionDetected && avgDiff <= threshold * 1.5) {
1180
+ livenessState = 2;
1181
+ livenessProgressBar.style.width = '100%';
1182
+ livenessProgressBar.style.backgroundColor = '#10b981';
1183
+ livenessPctText.innerText = '100%';
1184
+ livenessPctText.style.color = '#10b981';
1185
+ guideText.innerText = "โœ“ Liveness Verified! Click Capture & Send.";
1186
+ guideText.style.color = '#10b981';
1187
+ guide.style.borderColor = '#10b981';
1188
+ guide.style.boxShadow = '0 0 0 9999px rgba(16,185,129,0.2)';
1189
+ captureBtn.disabled = false;
1190
+
1191
+ if (livenessInterval) {
1192
+ clearInterval(livenessInterval);
1193
+ livenessInterval = null;
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ prevPixels = pixels;
1199
+ }, 100);
1200
+ }
1201
+
1202
+ captureBtn.addEventListener('click', async () => {
1203
+ if (!stream || video.videoWidth === 0) return;
1204
+ canvas.width = video.videoWidth;
1205
+ canvas.height = video.videoHeight;
1206
+ const ctx2d = canvas.getContext('2d')!;
1207
+ ctx2d.drawImage(video, 0, 0);
1208
+ stopStream();
1209
+
1210
+ modal.querySelector('#vnr-cam-wrap')!.setAttribute('style', 'display:none');
1211
+ if (modal.querySelector('.vnr-liveness-toggle-wrap')) {
1212
+ (modal.querySelector('.vnr-liveness-toggle-wrap') as HTMLElement).style.display = 'none';
1213
+ }
1214
+ livenessContainer.style.display = 'none';
1215
+ captureBtn.disabled = true;
1216
+ statusEl.style.display = 'block';
1217
+ statusEl.innerHTML = `<div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Sending photoโ€ฆ</div>`;
1218
+
1219
+ try {
1220
+ const blob = await new Promise<Blob | null>(res => canvas.toBlob(b => res(b), 'image/jpeg', 0.85));
1221
+ if (!blob) throw new Error('Capture failed');
1222
+ const file = new File([blob], `capture-${Date.now()}.jpg`, { type: 'image/jpeg' });
1223
+
1224
+ if (client && typeof client.uploadMedia === 'function') {
1225
+ const { media_id, url } = await client.uploadMedia(file, reason, 'User captured a photo via camera');
1226
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โœ“ Photo sent!</div>`;
1227
+ setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, media_id, url, message: 'User captured and sent a photo.' }); }, 400);
1228
+ } else {
1229
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
1230
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โœ“ Photo ready!</div>`;
1231
+ setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, data_url: dataUrl, message: 'User captured a photo.' }); }, 400);
1232
+ }
1233
+ } catch (err: any) {
1234
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#ef4444">${err?.message || 'Capture failed'}</div>`;
1235
+ }
1236
+ });
1237
+ }
1238
+
1239
+ // โ”€โ”€โ”€ Upload Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1240
+
1241
+ private renderUpload(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel, client?: any) {
1242
+ const title = ctx.clientFields.title || 'Upload a File';
1243
+ const description = ctx.clientFields.description || 'Drop or select a file to send to your agent.';
1244
+ const reason = ctx.clientFields.reason || 'upload';
1245
+ const acceptTypes = ctx.clientFields.accept || 'image/*,application/pdf,text/plain,text/csv';
1246
+
1247
+ let selectedFile: File | null = null;
1248
+
1249
+ const overlay = this.createOverlay();
1250
+
1251
+ const modal = document.createElement('div');
1252
+ modal.className = 'vnr-modal';
1253
+ modal.style.cssText = 'position:relative;max-width:480px;width:100%';
1254
+ modal.innerHTML = `
1255
+ <button class="vnr-close-btn" aria-label="Close">
1256
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1257
+ </button>
1258
+ <h2 class="vnr-title" style="padding-right:32px">${title}</h2>
1259
+ <p style="margin:0 0 14px;font-size:13px;color:#6b7280;line-height:1.5">${description}</p>
1260
+ <div class="vnr-dropzone" id="vnr-drop-zone">
1261
+ <div style="font-size:32px;margin-bottom:8px">๐Ÿ“</div>
1262
+ <p style="margin:0 0 4px;font-weight:600;color:#374151;font-size:14px">Drag & drop here</p>
1263
+ <p style="margin:0 0 14px;color:#9ca3af;font-size:12px">or click to browse</p>
1264
+ <span style="display:inline-block;padding:7px 16px;background:#111;color:#fff;border-radius:9px;font-size:13px;font-weight:600">Choose File</span>
1265
+ <p style="margin:12px 0 0;font-size:11px;color:#9ca3af">Images, PDF, CSV, Docs ยท Max 10 MB</p>
1266
+ </div>
1267
+ <input id="vnr-file-input" type="file" accept="${acceptTypes}" style="display:none">
1268
+ <div id="vnr-file-preview" style="display:none;border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:#f9fafb"></div>
1269
+ <div id="vnr-upload-status" style="display:none"></div>
1270
+ <div class="vnr-actions" style="margin-top:14px">
1271
+ <button class="vnr-cancel-btn" id="vnr-up-cancel">Cancel</button>
1272
+ <button class="vnr-confirm-btn" id="vnr-up-submit" disabled>
1273
+ <svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
1274
+ Upload & Send
1275
+ </button>
1276
+ </div>
1277
+ `;
1278
+ overlay.appendChild(modal);
1279
+
1280
+ const dropZone = modal.querySelector('#vnr-drop-zone') as HTMLElement;
1281
+ const fileInput = modal.querySelector('#vnr-file-input') as HTMLInputElement;
1282
+ const previewEl = modal.querySelector('#vnr-file-preview') as HTMLElement;
1283
+ const statusEl = modal.querySelector('#vnr-upload-status') as HTMLElement;
1284
+ const submitBtn = modal.querySelector('#vnr-up-submit') as HTMLButtonElement;
1285
+
1286
+ modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => { this.dismiss(); onCancel('User closed'); });
1287
+ modal.querySelector('#vnr-up-cancel')!.addEventListener('click', () => { this.dismiss(); onCancel('User cancelled'); });
1288
+
1289
+ const processFile = (f: File) => {
1290
+ selectedFile = f;
1291
+ dropZone.style.display = 'none';
1292
+ const icon = f.type.startsWith('image/') ? '๐Ÿ–ผ๏ธ' : f.type.includes('pdf') ? '๐Ÿ“„' : '๐Ÿ“';
1293
+ const kb = (f.size / 1024).toFixed(1);
1294
+ previewEl.style.display = 'block';
1295
+ previewEl.innerHTML = `
1296
+ <div style="display:flex;align-items:center;gap:10px">
1297
+ <span style="font-size:28px">${icon}</span>
1298
+ <div style="flex:1;min-width:0">
1299
+ <p style="margin:0;font-weight:600;font-size:13px;color:#111;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${f.name}</p>
1300
+ <p style="margin:3px 0 0;font-size:11px;color:#6b7280">${f.type || 'unknown'} ยท ${kb} KB</p>
1301
+ </div>
1302
+ <button id="vnr-remove-file" style="width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:#fff;border:1px solid #e5e7eb;border-radius:8px;cursor:pointer;color:#6b7280;flex-shrink:0">
1303
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1304
+ </button>
1305
+ </div>
1306
+ `;
1307
+ previewEl.querySelector('#vnr-remove-file')!.addEventListener('click', () => {
1308
+ selectedFile = null;
1309
+ previewEl.style.display = 'none';
1310
+ dropZone.style.display = 'block';
1311
+ submitBtn.disabled = true;
1312
+ });
1313
+ submitBtn.disabled = false;
1314
+ };
1315
+
1316
+ dropZone.addEventListener('click', () => fileInput.click());
1317
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
1318
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
1319
+ dropZone.addEventListener('drop', e => {
1320
+ e.preventDefault(); dropZone.classList.remove('drag');
1321
+ const f = (e as DragEvent).dataTransfer?.files[0];
1322
+ if (f) processFile(f);
1323
+ });
1324
+ fileInput.addEventListener('change', () => {
1325
+ const f = fileInput.files?.[0];
1326
+ if (f) processFile(f);
1327
+ });
1328
+
1329
+ submitBtn.addEventListener('click', async () => {
1330
+ if (!selectedFile) return;
1331
+ const file = selectedFile;
1332
+ dropZone.style.display = 'none';
1333
+ previewEl.style.display = 'none';
1334
+ statusEl.style.display = 'block';
1335
+ statusEl.innerHTML = `<div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Uploadingโ€ฆ</div>`;
1336
+ submitBtn.disabled = true;
1337
+
1338
+ try {
1339
+ if (client && typeof client.uploadMedia === 'function') {
1340
+ const { media_id, url } = await client.uploadMedia(file, reason, `User uploaded: ${file.name}`);
1341
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โœ“ File sent!</div>`;
1342
+ setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, media_id, url, message: `User uploaded: ${file.name}` }); }, 400);
1343
+ } else {
1344
+ const reader = new FileReader();
1345
+ reader.onload = e => {
1346
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โœ“ File ready!</div>`;
1347
+ setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, data_url: (e.target as FileReader).result, message: `User uploaded: ${file.name}` }); }, 400);
1348
+ };
1349
+ reader.readAsDataURL(file);
1350
+ }
1351
+ } catch (err: any) {
1352
+ statusEl.innerHTML = `<div class="vnr-center" style="color:#ef4444">${err?.message || 'Upload failed. Please try again.'}</div>`;
1353
+ submitBtn.disabled = false;
1354
+ }
1355
+ });
1356
+ }
1357
+
1358
+ // โ”€โ”€โ”€ Navigate Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1359
+
1360
+ private renderNavigate(ctx: PresetContext, onComplete: OnComplete, _onCancel: OnCancel) {
1361
+ const targetUrl = String(
1362
+ ctx.clientFields?.target_url ||
1363
+ ctx.args?.target_url ||
1364
+ '/'
1365
+ );
1366
+
1367
+ console.log(`๐Ÿš€ [WidgetPresetRenderer] Navigating to: ${targetUrl}`);
1368
+
1369
+ const isExternal = targetUrl.startsWith('http://') || targetUrl.startsWith('https://');
1370
+
1371
+ if (isExternal) {
1372
+ window.open(targetUrl, '_blank', 'noopener,noreferrer');
1373
+ } else {
1374
+ const oldUrl = window.location.href;
1375
+ window.dispatchEvent(new CustomEvent('vanira:navigate', { detail: { url: targetUrl } }));
1376
+
1377
+ // Check if page/URL changed after a short delay. If not, fallback to direct location change
1378
+ setTimeout(() => {
1379
+ if (window.location.href === oldUrl) {
1380
+ window.location.href = targetUrl;
1381
+ }
1382
+ }, 150);
1383
+ }
1384
+
1385
+ onComplete({ navigated_to: targetUrl, status: 'success' });
1386
+ }
1387
+
1388
+ // โ”€โ”€โ”€ Highlight Element Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1389
+
1390
+ private renderHighlight(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
1391
+ const toolArgs = ctx.args || {};
1392
+ const clientFields = ctx.clientFields || {};
1393
+
1394
+ const selector = String(toolArgs.css_selector || clientFields.css_selector || toolArgs.selector || clientFields.selector || '');
1395
+ const color = String(toolArgs.highlight_color || clientFields.highlight_color || '#a855f7');
1396
+ const durationMs = Number(toolArgs.duration_ms || clientFields.duration_ms || 3000);
1397
+
1398
+ console.log(`๐Ÿ”ฆ [WidgetPresetRenderer] renderHighlight triggered`);
1399
+ console.log(` โ†’ selector: "${selector}"`);
1400
+ console.log(` โ†’ color: "${color}"`);
1401
+ console.log(` โ†’ duration: ${durationMs}ms`);
1402
+
1403
+ if (!selector) {
1404
+ console.warn(`โš ๏ธ [WidgetPresetRenderer] No css_selector provided for highlight`);
1405
+ onCancel('No css_selector provided for highlight');
1406
+ return;
1407
+ }
1408
+
1409
+ const el = document.querySelector(selector) as HTMLElement | null;
1410
+ console.log(`๐Ÿ”ฆ [WidgetPresetRenderer] querySelector("${selector}") โ†’`, el);
1411
+
1412
+ if (!el) {
1413
+ console.warn(`โš ๏ธ [WidgetPresetRenderer] Element not found for selector: "${selector}"`);
1414
+ onCancel(`Element not found for selector: "${selector}"`);
1415
+ return;
1416
+ }
1417
+
1418
+ // Save original styles
1419
+ const prevOutline = el.style.outline;
1420
+ const prevOutlineOffset = el.style.outlineOffset;
1421
+ const prevBoxShadow = el.style.boxShadow;
1422
+ const prevTransition = el.style.transition;
1423
+ const prevScrollMargin = el.style.scrollMarginTop;
1424
+
1425
+ // Apply glowing highlight
1426
+ el.style.transition = 'outline 0.2s ease, box-shadow 0.2s ease';
1427
+ el.style.outline = `3px solid ${color}`;
1428
+ el.style.outlineOffset = '6px';
1429
+ el.style.boxShadow = `0 0 0 8px ${color}33, 0 0 32px ${color}88`;
1430
+ el.style.scrollMarginTop = '100px';
1431
+
1432
+ console.log(`โœ… [WidgetPresetRenderer] Highlight applied to:`, el);
1433
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
1434
+
1435
+ // Remove after duration
1436
+ setTimeout(() => {
1437
+ el.style.outline = prevOutline;
1438
+ el.style.outlineOffset = prevOutlineOffset;
1439
+ el.style.boxShadow = prevBoxShadow;
1440
+ el.style.transition = prevTransition;
1441
+ el.style.scrollMarginTop = prevScrollMargin;
1442
+ console.log(`๐Ÿ”ฆ [WidgetPresetRenderer] Highlight removed from: "${selector}"`);
1443
+ }, durationMs);
1444
+
1445
+ onComplete({ status: 'success', message: `Element highlighted: ${selector}` });
1446
+ }
1447
+
1448
+ // โ”€โ”€โ”€ Type Text Preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1449
+
1450
+ private renderTypeText(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
1451
+ const toolArgs = ctx.args || {};
1452
+ const clientFields = ctx.clientFields || {};
1453
+
1454
+ const idOrSelector = String(
1455
+ toolArgs.element_id || clientFields.element_id ||
1456
+ toolArgs.css_selector || clientFields.css_selector ||
1457
+ toolArgs.selector || clientFields.selector || ''
1458
+ ).trim();
1459
+ let text = String(
1460
+ toolArgs.text_to_type || clientFields.text_to_type ||
1461
+ toolArgs.text || clientFields.text ||
1462
+ toolArgs.value || clientFields.value ||
1463
+ toolArgs.search_query || clientFields.search_query ||
1464
+ toolArgs.query || clientFields.query || ''
1465
+ );
1466
+
1467
+ // Clean up LaTeX formulas for better plain text readability
1468
+ text = cleanLatexText(text);
1469
+ const delayMs = Number(
1470
+ toolArgs.delay_ms !== undefined ? toolArgs.delay_ms :
1471
+ clientFields.delay_ms !== undefined ? clientFields.delay_ms :
1472
+ 50
1473
+ );
1474
+
1475
+ console.log(`โœ๏ธ [WidgetPresetRenderer] renderTypeText triggered`);
1476
+ console.log(` โ†’ idOrSelector: "${idOrSelector}"`);
1477
+ console.log(` โ†’ text_to_type: "${text}"`);
1478
+ console.log(` โ†’ delayMs: ${delayMs}ms`);
1479
+
1480
+ // Find element by selector or ID
1481
+ let el: HTMLInputElement | HTMLTextAreaElement | HTMLElement | null = null;
1482
+ if (idOrSelector) {
1483
+ try {
1484
+ el = document.querySelector(idOrSelector) as HTMLElement | null;
1485
+ } catch (_) { }
1486
+ if (!el) {
1487
+ el = document.getElementById(idOrSelector);
1488
+ }
1489
+ }
1490
+
1491
+ const showFallbackPopup = () => {
1492
+ console.log(`โ„น๏ธ [WidgetPresetRenderer] Target element not found or selector not provided. Displaying fallback popup.`);
1493
+
1494
+ // Check if there is already an existing whiteboard modal in the document
1495
+ let overlay = document.querySelector('.vnr-overlay') as HTMLElement | null;
1496
+ let modal = document.querySelector('.vnr-whiteboard-modal') as HTMLElement | null;
1497
+ let textarea = document.querySelector('.vnr-whiteboard-textarea') as HTMLTextAreaElement | null;
1498
+
1499
+ if (!overlay || !modal || !textarea) {
1500
+ // Load chalk fonts
1501
+ if (!document.getElementById('vnr-chalk-font')) {
1502
+ const link = document.createElement('link');
1503
+ link.id = 'vnr-chalk-font';
1504
+ link.rel = 'stylesheet';
1505
+ link.href = 'https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Caveat:wght@400;700&display=swap';
1506
+ document.head.appendChild(link);
1507
+ }
1508
+
1509
+ // If it doesn't exist, create it
1510
+ overlay = this.createOverlay();
1511
+ modal = document.createElement('div');
1512
+ modal.className = 'vnr-modal vnr-whiteboard-modal';
1513
+
1514
+ // Style modal as a large chalkboard
1515
+ modal.style.position = 'relative';
1516
+ modal.style.width = '100%';
1517
+ modal.style.maxWidth = '750px';
1518
+ modal.style.height = '600px';
1519
+ modal.style.maxHeight = '85vh';
1520
+ modal.style.display = 'flex';
1521
+ modal.style.flexDirection = 'column';
1522
+ modal.style.background = '#0d0e12'; // Slate black
1523
+ modal.style.border = '12px solid #5c4033'; // Thick wooden frame
1524
+ modal.style.borderRadius = '16px';
1525
+ modal.style.boxShadow = '0 25px 60px rgba(0,0,0,0.65), inset 0 0 30px rgba(0,0,0,0.95)';
1526
+ modal.style.padding = '0'; // Clean slate without padding, textarea spans the whole inner area
1527
+ modal.style.overflow = 'hidden';
1528
+
1529
+ // Setup close button on the chalkboard frame/top-right
1530
+ const closeBtn = document.createElement('button');
1531
+ closeBtn.className = 'vnr-close-btn';
1532
+ closeBtn.ariaLabel = 'Close';
1533
+ closeBtn.style.cssText = 'position:absolute;top:16px;right:16px;z-index:10;background:rgba(0,0,0,0.4);border:none;cursor:pointer;color:#9ca3af;line-height:1;padding:6px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background 0.2s, color 0.2s;';
1534
+ closeBtn.innerHTML = `
1535
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
1536
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
1537
+ </svg>
1538
+ `;
1539
+ closeBtn.onmouseenter = () => {
1540
+ closeBtn.style.background = 'rgba(0,0,0,0.6)';
1541
+ closeBtn.style.color = '#ffffff';
1542
+ };
1543
+ closeBtn.onmouseleave = () => {
1544
+ closeBtn.style.background = 'rgba(0,0,0,0.4)';
1545
+ closeBtn.style.color = '#9ca3af';
1546
+ };
1547
+ closeBtn.onclick = () => {
1548
+ this.dismiss();
1549
+ onCancel('User dismissed typing popup');
1550
+ };
1551
+ modal.appendChild(closeBtn);
1552
+
1553
+ const container = document.createElement('div');
1554
+ container.style.display = 'flex';
1555
+ container.style.flexDirection = 'column';
1556
+ container.style.flex = '1';
1557
+ container.style.height = '100%';
1558
+
1559
+ textarea = document.createElement('textarea');
1560
+ textarea.className = 'vnr-whiteboard-textarea';
1561
+ textarea.style.width = '100%';
1562
+ textarea.style.height = '100%';
1563
+ textarea.style.flex = '1';
1564
+ textarea.style.background = '#0a0a0c'; // Blackboard black
1565
+ textarea.style.border = 'none';
1566
+ textarea.style.padding = '32px 48px 32px 32px'; // Right padding extra for close button clearance
1567
+ textarea.style.fontSize = '24px'; // Slightly larger font size for premium chalk look
1568
+ textarea.style.lineHeight = '1.6';
1569
+ textarea.style.fontFamily = "'Architects Daughter', 'Caveat', 'Chalkboard SE', 'Chalkboard', cursive, sans-serif";
1570
+ textarea.style.color = '#ffffff';
1571
+ textarea.style.textShadow = '0 0 3px rgba(255, 255, 255, 0.45), 0 0 8px rgba(255, 255, 255, 0.15)'; // realistic soft chalk glow
1572
+ textarea.style.outline = 'none';
1573
+ textarea.style.resize = 'none';
1574
+ textarea.style.boxSizing = 'border-box';
1575
+ textarea.placeholder = 'Chalkboard active...';
1576
+
1577
+ // Initialize value from persisted chalkboardContent
1578
+ textarea.value = this.chalkboardContent;
1579
+
1580
+ // Keep manual user edits synchronized with this.chalkboardContent
1581
+ textarea.addEventListener('input', () => {
1582
+ this.chalkboardContent = textarea!.value;
1583
+ });
1584
+
1585
+ container.appendChild(textarea);
1586
+ modal.appendChild(container);
1587
+ overlay.appendChild(modal);
1588
+ }
1589
+
1590
+ textarea.focus();
1591
+
1592
+ const initialText = this.chalkboardContent || '';
1593
+ const textToAppend = initialText ? '\n' + text : text;
1594
+
1595
+ if (delayMs <= 0) {
1596
+ this.chalkboardContent = initialText + textToAppend;
1597
+ textarea.value = this.chalkboardContent;
1598
+ textarea.scrollTop = textarea.scrollHeight;
1599
+ onComplete({ status: 'success' });
1600
+ } else {
1601
+ let currentText = initialText;
1602
+ let index = 0;
1603
+
1604
+ const typeNextChar = () => {
1605
+ if (!this.overlay) return; // dismissed
1606
+ if (index < textToAppend.length) {
1607
+ currentText += textToAppend[index];
1608
+ this.chalkboardContent = currentText;
1609
+ textarea!.value = currentText;
1610
+ textarea!.scrollTop = textarea!.scrollHeight;
1611
+ index++;
1612
+ setTimeout(typeNextChar, delayMs);
1613
+ } else {
1614
+ onComplete({ status: 'success' });
1615
+ }
1616
+ };
1617
+ typeNextChar();
1618
+ }
1619
+ };
1620
+
1621
+ if (!el) {
1622
+ showFallbackPopup();
1623
+ return;
1624
+ }
1625
+
1626
+ // Focus the input
1627
+ el.focus();
1628
+
1629
+ const isInput = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || 'value' in el;
1630
+ if (!isInput) {
1631
+ console.log(`โ„น๏ธ [WidgetPresetRenderer] Target is not a form input. Setting textContent directly.`);
1632
+ el.textContent = text;
1633
+ onComplete({ status: 'success' });
1634
+ return;
1635
+ }
1636
+
1637
+ const inputEl = el as HTMLInputElement | HTMLTextAreaElement;
1638
+
1639
+ const initialVal = inputEl.value || '';
1640
+
1641
+ if (delayMs <= 0) {
1642
+ inputEl.value = initialVal + text;
1643
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1644
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
1645
+ onComplete({ status: 'success' });
1646
+ return;
1647
+ }
1648
+
1649
+ // Simulate character-by-character typing
1650
+ let currentText = initialVal;
1651
+ let index = 0;
1652
+
1653
+ const typeNextChar = () => {
1654
+ if (index < text.length) {
1655
+ currentText += text[index];
1656
+ inputEl.value = currentText;
1657
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1658
+ index++;
1659
+ setTimeout(typeNextChar, delayMs);
1660
+ } else {
1661
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
1662
+ onComplete({ status: 'success' });
1663
+ }
1664
+ };
1665
+
1666
+ typeNextChar();
1667
+ }
1668
+
1669
+ private renderEraseText(ctx: PresetContext, onComplete: OnComplete, _onCancel: OnCancel) {
1670
+ const toolArgs = ctx.args || {};
1671
+ const clientFields = ctx.clientFields || {};
1672
+
1673
+ const idOrSelector = String(
1674
+ toolArgs.element_id || clientFields.element_id ||
1675
+ toolArgs.css_selector || clientFields.css_selector ||
1676
+ toolArgs.selector || clientFields.selector || ''
1677
+ ).trim();
1678
+
1679
+ // Options: 'all' (default) | 'words'
1680
+ const mode = String(
1681
+ toolArgs.mode || clientFields.mode || 'all'
1682
+ ).trim().toLowerCase();
1683
+
1684
+ // Options: num_words / words_count / count (default: 1)
1685
+ const numWords = Number(
1686
+ toolArgs.num_words !== undefined ? toolArgs.num_words :
1687
+ toolArgs.words_count !== undefined ? toolArgs.words_count :
1688
+ toolArgs.count !== undefined ? toolArgs.count : 1
1689
+ );
1690
+
1691
+ const delayMs = Number(
1692
+ toolArgs.delay_ms !== undefined ? toolArgs.delay_ms :
1693
+ clientFields.delay_ms !== undefined ? clientFields.delay_ms :
1694
+ 50
1695
+ );
1696
+
1697
+ console.log(`๐Ÿงน [WidgetPresetRenderer] renderEraseText triggered`);
1698
+ console.log(` โ†’ idOrSelector: "${idOrSelector}"`);
1699
+ console.log(` โ†’ mode: "${mode}"`);
1700
+ console.log(` โ†’ numWords: ${numWords}`);
1701
+ console.log(` โ†’ delayMs: ${delayMs}ms`);
1702
+
1703
+ // Find element by selector or ID, fallback to whiteboard
1704
+ let el: HTMLInputElement | HTMLTextAreaElement | HTMLElement | null = null;
1705
+ if (idOrSelector) {
1706
+ try {
1707
+ el = document.querySelector(idOrSelector) as HTMLElement | null;
1708
+ } catch (_) { }
1709
+ if (!el) {
1710
+ el = document.getElementById(idOrSelector);
1711
+ }
1712
+ }
1713
+
1714
+ // Fallback to whiteboard if no element found
1715
+ if (!el) {
1716
+ el = document.querySelector('.vnr-whiteboard-textarea') as HTMLTextAreaElement | null;
1717
+ }
1718
+
1719
+ if (!el) {
1720
+ console.warn(`[WidgetPresetRenderer] Target element for erase not found.`);
1721
+ onComplete({ status: 'error', message: 'Target element not found' });
1722
+ return;
1723
+ }
1724
+
1725
+ // If it's a generic div or span, just clear textContent
1726
+ if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
1727
+ if (mode === 'words') {
1728
+ const text = el.textContent || '';
1729
+ el.textContent = this.eraseLastWords(text, numWords);
1730
+ } else {
1731
+ el.textContent = '';
1732
+ }
1733
+ onComplete({ status: 'success' });
1734
+ return;
1735
+ }
1736
+
1737
+ const inputEl = el as HTMLInputElement | HTMLTextAreaElement;
1738
+ const currentVal = inputEl.value || '';
1739
+ let targetVal = '';
1740
+
1741
+ if (mode === 'words') {
1742
+ targetVal = this.eraseLastWords(currentVal, numWords);
1743
+ } else {
1744
+ targetVal = '';
1745
+ }
1746
+
1747
+ if (delayMs <= 0) {
1748
+ inputEl.value = targetVal;
1749
+ if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
1750
+ this.chalkboardContent = targetVal;
1751
+ }
1752
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1753
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
1754
+ onComplete({ status: 'success' });
1755
+ return;
1756
+ }
1757
+
1758
+ // Animate deletion character-by-character (simulating backspaces)
1759
+ const deleteNextChar = () => {
1760
+ if (inputEl.value.length > targetVal.length) {
1761
+ inputEl.value = inputEl.value.slice(0, -1);
1762
+ if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
1763
+ this.chalkboardContent = inputEl.value;
1764
+ }
1765
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
1766
+
1767
+ // Keep scroll at bottom if it's the whiteboard
1768
+ if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
1769
+ inputEl.scrollTop = inputEl.scrollHeight;
1770
+ }
1771
+
1772
+ // Delete slightly faster than standard typing delay
1773
+ setTimeout(deleteNextChar, Math.max(10, delayMs / 2));
1774
+ } else {
1775
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
1776
+ onComplete({ status: 'success' });
1777
+ }
1778
+ };
1779
+
1780
+ deleteNextChar();
1781
+ }
1782
+
1783
+ private eraseLastWords(text: string, count: number): string {
1784
+ if (!text) return '';
1785
+ const trimmed = text.trimEnd();
1786
+ if (!trimmed) return '';
1787
+
1788
+ let current = trimmed;
1789
+ for (let i = 0; i < count; i++) {
1790
+ const lastSpace = current.lastIndexOf(' ');
1791
+ const lastNewline = current.lastIndexOf('\n');
1792
+ const cutIndex = Math.max(lastSpace, lastNewline);
1793
+ if (cutIndex === -1) {
1794
+ current = '';
1795
+ break;
1796
+ } else {
1797
+ current = current.slice(0, cutIndex).trimEnd();
1798
+ }
1799
+ }
1800
+ return current;
1801
+ }
1802
+ }