@mooncompany/uplink-chat 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,543 @@
1
+ // ============================================
2
+ // ONBOARDING MODULE
3
+ // Setup flow, unlock screen
4
+ // ============================================
5
+
6
+ import { UplinkCore } from './core.js';
7
+
8
+ // ========== CONSTANTS ==========
9
+ // Init retry
10
+ const INIT_RETRY_DELAY_MS = 100;
11
+ const MAX_INIT_RETRIES = 50;
12
+
13
+ // URL validation debounce
14
+ const URL_VALIDATE_DEBOUNCE_MS = 1500; // Longer debounce to avoid rate limiting
15
+
16
+ // Unlock rate limiting
17
+ const MAX_UNLOCK_ATTEMPTS = 5;
18
+ const LOCKOUT_DURATION_MS = 60000;
19
+
20
+ // DOM elements
21
+ let onboardingScreen, unlockScreen;
22
+ let onboardUserName, onboardBotName, onboardGatewayUrl, onboardGatewayToken;
23
+ let urlStatus, urlError;
24
+ let onboardVoiceToggle, onboardEncryptToggle, onboardPasswordFields;
25
+ let onboardPassword, onboardPasswordConfirm, passwordError, onboardSubmit;
26
+ let unlockBotName, unlockPassword, unlockError, unlockSubmit;
27
+ let forgotLink, forgotConfirm, forgotCancel, forgotReset;
28
+
29
+ // Validation state
30
+ let urlValidateTimeout = null;
31
+ let urlValidState = null;
32
+
33
+ // Unlock rate limiting
34
+ let unlockAttempts = 0;
35
+ let unlockLockoutUntil = 0;
36
+
37
+ // Init retry tracking
38
+ let initRetryCount = 0;
39
+
40
+ function init() {
41
+ // Get DOM elements
42
+ onboardingScreen = document.getElementById('onboardingScreen');
43
+ unlockScreen = document.getElementById('unlockScreen');
44
+
45
+ if (!onboardingScreen || !unlockScreen) {
46
+ initRetryCount++;
47
+ if (initRetryCount >= MAX_INIT_RETRIES) {
48
+ console.error('Onboarding: Max retries reached, required DOM elements not found');
49
+ return;
50
+ }
51
+ console.warn(`Onboarding: Elements not found, retrying... (${initRetryCount}/${MAX_INIT_RETRIES})`);
52
+ setTimeout(init, INIT_RETRY_DELAY_MS);
53
+ return;
54
+ }
55
+
56
+ // Onboarding elements
57
+ onboardUserName = document.getElementById('onboardUserName');
58
+ onboardBotName = document.getElementById('onboardBotName');
59
+ onboardGatewayUrl = document.getElementById('onboardGatewayUrl');
60
+ onboardGatewayToken = document.getElementById('onboardGatewayToken');
61
+ urlStatus = document.getElementById('urlStatus');
62
+ urlError = document.getElementById('urlError');
63
+ onboardVoiceToggle = document.getElementById('onboardVoiceToggle');
64
+ onboardEncryptToggle = document.getElementById('onboardEncryptToggle');
65
+ onboardPasswordFields = document.getElementById('onboardPasswordFields');
66
+ onboardPassword = document.getElementById('onboardPassword');
67
+ onboardPasswordConfirm = document.getElementById('onboardPasswordConfirm');
68
+ passwordError = document.getElementById('passwordError');
69
+ onboardSubmit = document.getElementById('onboardSubmit');
70
+
71
+ // Unlock elements
72
+ unlockBotName = document.getElementById('unlockBotName');
73
+ unlockPassword = document.getElementById('unlockPassword');
74
+ unlockError = document.getElementById('unlockError');
75
+ unlockSubmit = document.getElementById('unlockSubmit');
76
+ forgotLink = document.getElementById('forgotLink');
77
+ forgotConfirm = document.getElementById('forgotConfirm');
78
+ forgotCancel = document.getElementById('forgotCancel');
79
+ forgotReset = document.getElementById('forgotReset');
80
+
81
+ // Set up event listeners
82
+ setupOnboardingEvents();
83
+ setupUnlockEvents();
84
+
85
+ // Check initial state (async)
86
+ checkState();
87
+
88
+ console.log('Onboarding: Initialized');
89
+ }
90
+
91
+ async function checkState() {
92
+ // First check server config
93
+ try {
94
+ const serverRes = await fetch('/api/config/needs-onboarding');
95
+ const serverData = await serverRes.json();
96
+
97
+ if (serverData.needsOnboarding) {
98
+ // Load any existing server config to prefill
99
+ const configRes = await fetch('/api/config');
100
+ const config = await configRes.json();
101
+
102
+ if (onboardBotName && config.assistantName) {
103
+ onboardBotName.value = config.assistantName;
104
+ }
105
+ if (onboardGatewayUrl && config.gatewayUrl) {
106
+ onboardGatewayUrl.value = config.gatewayUrl;
107
+ }
108
+ if (onboardUserName && config.userName) {
109
+ onboardUserName.value = config.userName;
110
+ }
111
+
112
+ // Show token status if already configured
113
+ if (config.hasGatewayToken && onboardGatewayToken) {
114
+ onboardGatewayToken.placeholder = 'Token already configured ✓ (leave blank to keep)';
115
+ }
116
+
117
+ showOnboarding();
118
+ return;
119
+ }
120
+ } catch (e) {
121
+ console.warn('Onboarding: Could not reach server');
122
+ const errorEl = document.querySelector('.onboarding-error') || document.createElement('div');
123
+ errorEl.className = 'onboarding-error';
124
+ errorEl.textContent = 'Cannot reach Uplink server. Is it still running?';
125
+ const screen = document.getElementById('onboardingScreen');
126
+ if (screen && !screen.querySelector('.onboarding-error')) {
127
+ screen.querySelector('.overlay-card')?.prepend(errorEl);
128
+ }
129
+ showOnboarding();
130
+ return;
131
+ }
132
+
133
+ // Fall back to local config
134
+ const core = window.UplinkCore;
135
+ const config = core?.loadConfig();
136
+
137
+ if (!config || !config.gatewayUrl) {
138
+ // First visit - show onboarding
139
+ showOnboarding();
140
+ return;
141
+ }
142
+
143
+ if (config.encryptionEnabled) {
144
+ // Need to unlock
145
+ if (unlockBotName) unlockBotName.textContent = config.agentName || 'Assistant';
146
+ showUnlock();
147
+ return;
148
+ }
149
+
150
+ // Ready to go
151
+ hideAll();
152
+ window.dispatchEvent(new CustomEvent('uplink:unlocked'));
153
+ }
154
+
155
+ function showOnboarding() {
156
+ if (onboardingScreen) onboardingScreen.classList.add('visible');
157
+ // Auto-detect OpenClaw gateway
158
+ autoDetectGateway();
159
+ }
160
+
161
+ /**
162
+ * Auto-detect OpenClaw gateway at common locations
163
+ * If found, auto-fill URL and focus token field
164
+ */
165
+ async function autoDetectGateway() {
166
+ // Skip if URL already filled
167
+ if (onboardGatewayUrl?.value.trim()) return;
168
+
169
+ const commonLocations = [
170
+ 'http://localhost:18789',
171
+ 'http://127.0.0.1:18789'
172
+ ];
173
+
174
+ if (urlStatus) urlStatus.className = 'url-status checking';
175
+
176
+ for (const url of commonLocations) {
177
+ try {
178
+ const res = await fetch('/api/gateway/validate', {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ url })
182
+ });
183
+ const data = await res.json();
184
+
185
+ if (data.valid) {
186
+ // Found OpenClaw!
187
+ if (onboardGatewayUrl) {
188
+ onboardGatewayUrl.value = url;
189
+ onboardGatewayUrl.classList.add('success');
190
+ onboardGatewayUrl.classList.remove('error');
191
+ }
192
+ if (urlStatus) urlStatus.className = 'url-status valid';
193
+ if (urlError) {
194
+ urlError.textContent = '✓ OpenClaw detected! Enter your token below.';
195
+ urlError.classList.add('visible');
196
+ urlError.style.color = 'var(--success-color, #4ade80)';
197
+ }
198
+ urlValidState = true;
199
+
200
+ // Focus token field
201
+ onboardGatewayToken?.focus();
202
+ console.log('Onboarding: Auto-detected OpenClaw at', url);
203
+ return;
204
+ }
205
+ } catch (e) {
206
+ // Continue to next location
207
+ }
208
+ }
209
+
210
+ // Not found - clear status and let user enter manually
211
+ if (urlStatus) urlStatus.className = 'url-status';
212
+ console.log('Onboarding: OpenClaw not detected, manual entry required');
213
+ }
214
+
215
+ function showUnlock() {
216
+ if (unlockScreen) {
217
+ unlockScreen.classList.add('visible');
218
+ unlockPassword?.focus();
219
+ }
220
+ }
221
+
222
+ function hideAll() {
223
+ onboardingScreen?.classList.remove('visible');
224
+ unlockScreen?.classList.remove('visible');
225
+ }
226
+
227
+ // ========== Onboarding Events ==========
228
+
229
+ function setupOnboardingEvents() {
230
+ // URL validation
231
+ onboardGatewayUrl?.addEventListener('input', () => {
232
+ clearTimeout(urlValidateTimeout);
233
+ urlValidateTimeout = setTimeout(() => {
234
+ validateGatewayUrl(onboardGatewayUrl.value.trim());
235
+ }, URL_VALIDATE_DEBOUNCE_MS);
236
+ });
237
+
238
+ // Voice toggle
239
+ onboardVoiceToggle?.addEventListener('click', () => {
240
+ onboardVoiceToggle.classList.toggle('on');
241
+ const enabled = onboardVoiceToggle.classList.contains('on');
242
+ onboardVoiceToggle.setAttribute('aria-checked', enabled ? 'true' : 'false');
243
+ });
244
+
245
+ // Voice toggle keyboard handler
246
+ onboardVoiceToggle?.addEventListener('keydown', (e) => {
247
+ if (e.key === 'Enter' || e.key === ' ') {
248
+ e.preventDefault();
249
+ onboardVoiceToggle.click();
250
+ }
251
+ });
252
+
253
+ // Encryption toggle
254
+ onboardEncryptToggle?.addEventListener('click', () => {
255
+ onboardEncryptToggle.classList.toggle('on');
256
+ const enabled = onboardEncryptToggle.classList.contains('on');
257
+ onboardEncryptToggle.setAttribute('aria-checked', enabled ? 'true' : 'false');
258
+ onboardPasswordFields?.classList.toggle('visible', enabled);
259
+ if (enabled) onboardPassword?.focus();
260
+ });
261
+
262
+ // Encryption toggle keyboard handler
263
+ onboardEncryptToggle?.addEventListener('keydown', (e) => {
264
+ if (e.key === 'Enter' || e.key === ' ') {
265
+ e.preventDefault();
266
+ onboardEncryptToggle.click();
267
+ }
268
+ });
269
+
270
+ // Submit
271
+ onboardSubmit?.addEventListener('click', submitOnboarding);
272
+ }
273
+
274
+ async function validateGatewayUrl(url) {
275
+ if (!url) {
276
+ if (urlStatus) urlStatus.className = 'url-status';
277
+ urlValidState = null;
278
+ return false;
279
+ }
280
+
281
+ // Mobile keyboard fixes: trim whitespace, fix common autocorrect issues
282
+ url = url.trim().toLowerCase();
283
+ url = url.replace(/\s+/g, ''); // Remove any spaces mobile keyboards might add
284
+ url = url.replace(/local\s*host/gi, 'localhost'); // Fix "local host" autocorrect
285
+
286
+ // Auto-convert ws:// to http://
287
+ url = url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
288
+
289
+ // Add http:// if no protocol specified
290
+ if (!/^https?:\/\//i.test(url)) {
291
+ url = 'http://' + url;
292
+ }
293
+
294
+ if (onboardGatewayUrl) onboardGatewayUrl.value = url;
295
+
296
+ if (urlStatus) urlStatus.className = 'url-status checking';
297
+
298
+ try {
299
+ const res = await fetch('/api/gateway/validate', {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ url })
303
+ });
304
+ const data = await res.json();
305
+
306
+ if (data.valid) {
307
+ if (urlStatus) urlStatus.className = 'url-status valid';
308
+ urlError?.classList.remove('visible');
309
+ onboardGatewayUrl?.classList.remove('error');
310
+ onboardGatewayUrl?.classList.add('success');
311
+ urlValidState = true;
312
+ return true;
313
+ } else {
314
+ if (urlStatus) urlStatus.className = 'url-status invalid';
315
+ if (urlError) {
316
+ urlError.textContent = data.error || 'Could not connect to gateway';
317
+ urlError.classList.add('visible');
318
+ urlError.style.color = ''; // Reset to default error color
319
+ }
320
+ onboardGatewayUrl?.classList.add('error');
321
+ onboardGatewayUrl?.classList.remove('success');
322
+ urlValidState = false;
323
+ return false;
324
+ }
325
+ } catch (e) {
326
+ if (urlStatus) urlStatus.className = 'url-status invalid';
327
+ if (urlError) {
328
+ urlError.textContent = 'Validation failed';
329
+ urlError.classList.add('visible');
330
+ urlError.style.color = ''; // Reset to default error color
331
+ }
332
+ urlValidState = false;
333
+ return false;
334
+ }
335
+ }
336
+
337
+ async function submitOnboarding() {
338
+ const userName = onboardUserName?.value.trim() || '';
339
+ const botName = onboardBotName?.value.trim() || 'Assistant';
340
+ const url = onboardGatewayUrl?.value.trim();
341
+ const token = onboardGatewayToken?.value.trim();
342
+ const voiceEnabled = onboardVoiceToggle?.classList.contains('on');
343
+ const encrypt = onboardEncryptToggle?.classList.contains('on');
344
+ const pass = onboardPassword?.value;
345
+ const passConfirm = onboardPasswordConfirm?.value;
346
+
347
+ // Validate URL
348
+ if (!url) {
349
+ if (urlError) {
350
+ urlError.textContent = 'Gateway URL is required';
351
+ urlError.classList.add('visible');
352
+ }
353
+ onboardGatewayUrl?.focus();
354
+ return;
355
+ }
356
+
357
+ if (urlValidState === null) {
358
+ const valid = await validateGatewayUrl(url);
359
+ if (!valid) return;
360
+ } else if (!urlValidState) {
361
+ return;
362
+ }
363
+
364
+ // Validate password if encryption enabled
365
+ if (encrypt) {
366
+ if (!pass) {
367
+ if (passwordError) {
368
+ passwordError.textContent = 'Password is required';
369
+ passwordError.classList.add('visible');
370
+ }
371
+ onboardPassword?.focus();
372
+ return;
373
+ }
374
+ if (pass !== passConfirm) {
375
+ if (passwordError) {
376
+ passwordError.textContent = 'Passwords do not match';
377
+ passwordError.classList.add('visible');
378
+ }
379
+ onboardPasswordConfirm?.focus();
380
+ return;
381
+ }
382
+ if (pass.length < 8) {
383
+ if (passwordError) {
384
+ passwordError.textContent = 'Password must be at least 8 characters';
385
+ passwordError.classList.add('visible');
386
+ }
387
+ onboardPassword?.focus();
388
+ return;
389
+ }
390
+ passwordError?.classList.remove('visible');
391
+ }
392
+
393
+ // Save to server config
394
+ try {
395
+ const serverConfig = {
396
+ userName,
397
+ assistantName: botName,
398
+ gatewayUrl: url,
399
+ encryptHistory: encrypt,
400
+ onboardingComplete: true,
401
+ };
402
+
403
+ // Only include token if provided (server may have it from .env)
404
+ if (token) {
405
+ serverConfig.gatewayToken = token;
406
+ }
407
+
408
+ const res = await fetch('/api/config', {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' },
411
+ body: JSON.stringify(serverConfig)
412
+ });
413
+
414
+ if (!res.ok) {
415
+ const data = await res.json();
416
+ if (urlError) {
417
+ urlError.textContent = data.error || 'Failed to save configuration';
418
+ urlError.classList.add('visible');
419
+ }
420
+ return;
421
+ }
422
+ } catch (e) {
423
+ console.warn('Onboarding: Could not save to server, continuing with local config');
424
+ }
425
+
426
+ // Save local config (for client-side features)
427
+ const core = window.UplinkCore;
428
+ if (core) {
429
+ core.state.agentName = botName;
430
+ core.state.gatewayUrl = url;
431
+ core.state.audioResponses = voiceEnabled;
432
+ core.state.encryptionEnabled = encrypt;
433
+ if (encrypt) core.state.currentPassword = pass;
434
+ core.saveConfig();
435
+ }
436
+
437
+ // Hide onboarding
438
+ hideAll();
439
+ window.dispatchEvent(new CustomEvent('uplink:unlocked'));
440
+ }
441
+
442
+ // ========== Unlock Events ==========
443
+
444
+ function setupUnlockEvents() {
445
+ unlockSubmit?.addEventListener('click', tryUnlock);
446
+ unlockPassword?.addEventListener('keydown', (e) => {
447
+ if (e.key === 'Enter') tryUnlock();
448
+ });
449
+
450
+ forgotLink?.addEventListener('click', () => {
451
+ forgotConfirm?.classList.add('visible');
452
+ });
453
+
454
+ forgotCancel?.addEventListener('click', () => {
455
+ forgotConfirm?.classList.remove('visible');
456
+ });
457
+
458
+ forgotReset?.addEventListener('click', resetEncryption);
459
+ }
460
+
461
+ async function tryUnlock() {
462
+ // Check lockout
463
+ if (Date.now() < unlockLockoutUntil) {
464
+ const remaining = Math.ceil((unlockLockoutUntil - Date.now()) / 1000);
465
+ if (unlockError) {
466
+ unlockError.textContent = `Too many attempts. Try again in ${remaining}s`;
467
+ unlockError.classList.add('visible');
468
+ }
469
+ return;
470
+ }
471
+
472
+ const pass = unlockPassword?.value;
473
+ if (!pass) {
474
+ if (unlockError) {
475
+ unlockError.textContent = 'Enter your password';
476
+ unlockError.classList.add('visible');
477
+ }
478
+ return;
479
+ }
480
+
481
+ // Try to verify password
482
+ const crypto = window.UplinkEncryption;
483
+ if (crypto) {
484
+ const valid = await crypto.verifyPassword(pass);
485
+ if (!valid) {
486
+ unlockAttempts++;
487
+ if (unlockAttempts >= MAX_UNLOCK_ATTEMPTS) {
488
+ unlockLockoutUntil = Date.now() + LOCKOUT_DURATION_MS;
489
+ if (unlockError) unlockError.textContent = `Too many attempts. Try again in ${LOCKOUT_DURATION_MS / 1000}s`;
490
+ } else {
491
+ if (unlockError) {
492
+ unlockError.textContent = `Incorrect password (${MAX_UNLOCK_ATTEMPTS - unlockAttempts} attempts remaining)`;
493
+ }
494
+ }
495
+ unlockError?.classList.add('visible');
496
+ unlockPassword?.select();
497
+ return;
498
+ }
499
+ }
500
+
501
+ // Success
502
+ unlockAttempts = 0;
503
+ const core = window.UplinkCore;
504
+ if (core) core.state.currentPassword = pass;
505
+
506
+ hideAll();
507
+ unlockError?.classList.remove('visible');
508
+ window.dispatchEvent(new CustomEvent('uplink:unlocked'));
509
+ }
510
+
511
+ function resetEncryption() {
512
+ // Clear encrypted data
513
+ localStorage.removeItem('uplink-history-encrypted');
514
+ localStorage.removeItem('uplink-history');
515
+
516
+ const crypto = window.UplinkEncryption;
517
+ if (crypto) crypto.clearEncryption();
518
+
519
+ // Disable encryption in config
520
+ const core = window.UplinkCore;
521
+ if (core) {
522
+ core.state.encryptionEnabled = false;
523
+ core.state.currentPassword = null;
524
+ core.saveConfig();
525
+ }
526
+
527
+ hideAll();
528
+ window.dispatchEvent(new CustomEvent('uplink:unlocked'));
529
+ }
530
+
531
+ // Export API
532
+ export const UplinkOnboarding = {
533
+ show: showOnboarding,
534
+ showUnlock,
535
+ hide: hideAll,
536
+ checkState
537
+ };
538
+
539
+ // Backward compat: assign to window
540
+ window.UplinkOnboarding = UplinkOnboarding;
541
+
542
+ // Register and init
543
+ UplinkCore.registerModule('onboarding', init);
@@ -0,0 +1,156 @@
1
+ // ============================================
2
+ // PANELS MODULE
3
+ // Central panel management with mutual exclusivity
4
+ // ============================================
5
+
6
+ // Registered panels: { name: { element, isOpen(), open(), close() } }
7
+ const panels = {};
8
+ let currentPanel = null;
9
+ let backdrop = null;
10
+
11
+ // Create backdrop element
12
+ function createBackdrop() {
13
+ if (backdrop) return;
14
+
15
+ backdrop = document.createElement('div');
16
+ backdrop.className = 'panel-backdrop';
17
+ backdrop.addEventListener('click', () => closeAll());
18
+ document.body.appendChild(backdrop);
19
+ }
20
+
21
+ // Show/hide backdrop
22
+ function showBackdrop() {
23
+ if (!backdrop) createBackdrop();
24
+ backdrop.classList.add('visible');
25
+ }
26
+
27
+ function hideBackdrop() {
28
+ if (backdrop) backdrop.classList.remove('visible');
29
+ }
30
+
31
+ /**
32
+ * Register a panel with the manager
33
+ * @param {string} name - Unique panel identifier
34
+ * @param {object} config - { element, isOpen, open, close }
35
+ */
36
+ export function register(name, config) {
37
+ panels[name] = {
38
+ element: config.element,
39
+ isOpen: config.isOpen || (() => config.element?.classList.contains('visible')),
40
+ open: config.open || (() => config.element?.classList.add('visible')),
41
+ close: config.close || (() => config.element?.classList.remove('visible'))
42
+ };
43
+ console.log(`Panels: Registered "${name}"`);
44
+ }
45
+
46
+ /**
47
+ * Open a panel (closes any currently open panel first)
48
+ * @param {string} name - Panel to open
49
+ * @returns {boolean} - Success
50
+ */
51
+ export function open(name) {
52
+ if (!panels[name]) {
53
+ console.warn(`Panels: Unknown panel "${name}"`);
54
+ return false;
55
+ }
56
+
57
+ // Close current panel if different
58
+ if (currentPanel && currentPanel !== name) {
59
+ close(currentPanel, true); // Skip hiding backdrop
60
+ }
61
+
62
+ // Open requested panel
63
+ panels[name].open();
64
+ currentPanel = name;
65
+
66
+ // Show backdrop
67
+ showBackdrop();
68
+
69
+ // Dispatch event for other components to react
70
+ window.dispatchEvent(new CustomEvent('panelOpened', { detail: { name } }));
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Close a specific panel
77
+ * @param {string} name - Panel to close
78
+ * @param {boolean} keepBackdrop - Don't hide backdrop (used when switching panels)
79
+ */
80
+ export function close(name, keepBackdrop = false) {
81
+ if (!panels[name]) return;
82
+
83
+ panels[name].close();
84
+ if (currentPanel === name) {
85
+ currentPanel = null;
86
+ }
87
+
88
+ // Hide backdrop unless we're switching panels
89
+ if (!keepBackdrop && !currentPanel) {
90
+ hideBackdrop();
91
+ }
92
+
93
+ window.dispatchEvent(new CustomEvent('panelClosed', { detail: { name } }));
94
+ }
95
+
96
+ /**
97
+ * Close all panels
98
+ */
99
+ export function closeAll() {
100
+ Object.keys(panels).forEach(name => close(name, true));
101
+ hideBackdrop();
102
+ currentPanel = null;
103
+ }
104
+
105
+ /**
106
+ * Toggle a panel (open if closed, close if open)
107
+ * @param {string} name - Panel to toggle
108
+ * @returns {boolean} - New state (true = open)
109
+ */
110
+ export function toggle(name) {
111
+ if (!panels[name]) {
112
+ console.warn(`Panels: Unknown panel "${name}"`);
113
+ return false;
114
+ }
115
+
116
+ if (panels[name].isOpen()) {
117
+ close(name);
118
+ return false;
119
+ } else {
120
+ open(name);
121
+ return true;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Check if a panel is currently open
127
+ * @param {string} name - Panel to check
128
+ * @returns {boolean}
129
+ */
130
+ export function isOpen(name) {
131
+ return panels[name]?.isOpen() || false;
132
+ }
133
+
134
+ /**
135
+ * Get currently open panel name
136
+ * @returns {string|null}
137
+ */
138
+ export function getCurrent() {
139
+ return currentPanel;
140
+ }
141
+
142
+ // Export API
143
+ export const UplinkPanels = {
144
+ register,
145
+ open,
146
+ close,
147
+ closeAll,
148
+ toggle,
149
+ isOpen,
150
+ getCurrent
151
+ };
152
+
153
+ // Backward compat: assign to window
154
+ window.UplinkPanels = UplinkPanels;
155
+
156
+ console.log('Panels: Module loaded');