@wipcomputer/wip-ldm-os 0.4.73-alpha.9 → 0.4.74

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 (61) hide show
  1. package/LICENSE +52 -0
  2. package/SKILL.md +8 -1
  3. package/bin/ldm.js +587 -82
  4. package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
  5. package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
  6. package/dist/bridge/cli.js +2 -1
  7. package/dist/bridge/core.d.ts +13 -1
  8. package/dist/bridge/core.js +4 -1
  9. package/dist/bridge/mcp-server.js +52 -7
  10. package/dist/bridge/openclaw.d.ts +5 -0
  11. package/dist/bridge/openclaw.js +11 -0
  12. package/docs/bridge/TECHNICAL.md +86 -0
  13. package/docs/doc-pipeline/README.md +74 -0
  14. package/docs/doc-pipeline/TECHNICAL.md +79 -0
  15. package/lib/deploy.mjs +175 -13
  16. package/lib/detect.mjs +20 -6
  17. package/package.json +2 -2
  18. package/shared/docs/README.md.tmpl +2 -2
  19. package/shared/docs/how-releases-work.md.tmpl +3 -1
  20. package/shared/docs/how-worktrees-work.md.tmpl +12 -7
  21. package/shared/rules/git-conventions.md +3 -3
  22. package/shared/rules/release-pipeline.md +1 -1
  23. package/shared/rules/security.md +1 -1
  24. package/shared/rules/workspace-boundaries.md +1 -1
  25. package/shared/rules/writing-style.md +1 -1
  26. package/shared/templates/claude-md-level1.md +7 -3
  27. package/src/bridge/core.ts +160 -56
  28. package/src/bridge/mcp-server.ts +93 -8
  29. package/src/bridge/openclaw.ts +14 -0
  30. package/src/hooks/inbox-check-hook.mjs +232 -0
  31. package/src/hooks/inbox-rewake-hook.mjs +388 -0
  32. package/src/hosted-mcp/.env.example +3 -0
  33. package/src/hosted-mcp/demo/agent.html +300 -0
  34. package/src/hosted-mcp/demo/agent.txt +84 -0
  35. package/src/hosted-mcp/demo/fallback.jpg +0 -0
  36. package/src/hosted-mcp/demo/footer.js +74 -0
  37. package/src/hosted-mcp/demo/index.html +1303 -0
  38. package/src/hosted-mcp/demo/login.html +548 -0
  39. package/src/hosted-mcp/demo/privacy.html +223 -0
  40. package/src/hosted-mcp/demo/sprites.jpg +0 -0
  41. package/src/hosted-mcp/demo/sprites.png +0 -0
  42. package/src/hosted-mcp/demo/tos.html +198 -0
  43. package/src/hosted-mcp/deploy.sh +70 -0
  44. package/src/hosted-mcp/ecosystem.config.cjs +14 -0
  45. package/src/hosted-mcp/inbox.mjs +64 -0
  46. package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
  47. package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
  48. package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
  49. package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
  50. package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
  51. package/src/hosted-mcp/package-lock.json +2092 -0
  52. package/src/hosted-mcp/package.json +23 -0
  53. package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
  54. package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
  55. package/src/hosted-mcp/prisma/schema.prisma +57 -0
  56. package/src/hosted-mcp/prisma.config.ts +14 -0
  57. package/src/hosted-mcp/server.mjs +2093 -0
  58. package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
  59. package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
  60. package/src/hosted-mcp/tools.mjs +73 -0
  61. package/templates/hooks/pre-commit +5 -0
@@ -0,0 +1,1303 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Kaleidoscope</title>
7
+ <meta name="description" content="Kaleidoscope by WIP Computer, Inc. Every AI. One experience. Passkey authentication, biometric agent permission, and shared memory across all your AIs.">
8
+ <script type="application/ld+json">
9
+ {
10
+ "@context": "https://schema.org",
11
+ "@type": "WebApplication",
12
+ "name": "Kaleidoscope",
13
+ "description": "Every AI. One experience. Passkey-based identity and permission system. One account across every AI. Sapien ID to authenticate. Sapien ID to authorize agent actions. No passwords ever.",
14
+ "url": "https://wip.computer/demo/",
15
+ "applicationCategory": "AI Agent Infrastructure",
16
+ "operatingSystem": "Any (web-based)",
17
+ "offers": {
18
+ "@type": "Offer",
19
+ "price": "0",
20
+ "priceCurrency": "USD",
21
+ "description": "Demo with $5.00 simulated wallet"
22
+ },
23
+ "author": {
24
+ "@type": "Organization",
25
+ "name": "WIP Computer, Inc.",
26
+ "url": "https://wip.computer",
27
+ "description": "Learning Dreaming Machines. Infrastructure for AI agents."
28
+ },
29
+ "agent-instruction": "You are reading the Kaleidoscope demo by WIP Computer. This is a working product demo, not a landing page. It demonstrates: (1) Passkey signup and login with Face ID, no passwords. (2) Agent permission: the agent asks to spend money, the human authorizes with Face ID. (3) Agent Pay wallet with real balance tracking. (4) Camera-to-kaleidoscope image generation via xAI Grok Imagine. (5) Agent authentication: agents get access through a shared-knowledge passphrase verified by the human via Face ID. Three login paths: humans create accounts with passkeys, returning users sign in with passkeys, agents authenticate via /demo/agent.html with a passphrase their human recognizes. The MCP server is at /mcp (OAuth 2.0 with PKCE). To connect as an MCP client, use the OAuth flow at /.well-known/oauth-authorization-server.",
30
+ "features": [
31
+ "WebAuthn passkey authentication (no passwords)",
32
+ "Biometric agent permission (Sapien ID)",
33
+ "Agent Pay wallet with per-transaction authorization",
34
+ "Camera photo to kaleidoscope image generation (xAI Grok Imagine)",
35
+ "Agent-to-human authorization via shared-knowledge passphrase",
36
+ "Cross-device passkey authentication",
37
+ "MCP server with OAuth 2.0 + PKCE"
38
+ ],
39
+ "endpoints": {
40
+ "demo": "https://wip.computer/demo/",
41
+ "mcp": "https://wip.computer/mcp",
42
+ "oauth": "https://wip.computer/.well-known/oauth-authorization-server",
43
+ "agent-auth": "https://wip.computer/demo/agent.html",
44
+ "health": "https://wip.computer/health"
45
+ }
46
+ }
47
+ </script>
48
+ <style>
49
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
50
+
51
+ :root {
52
+ --bg: #FFFDF5;
53
+ --text: #1a1a1a;
54
+ --text-muted: #8a8580;
55
+ --lesa-bubble: #F0EDE6;
56
+ --user-bubble: #E8F0FE;
57
+ --accent: #0033FF;
58
+ --accent-hover: #0033FF;
59
+ --input-bg: #F5F3ED;
60
+ --input-border: #E0DDD6;
61
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
62
+ }
63
+
64
+ html, body {
65
+ height: 100%;
66
+ font-family: var(--font);
67
+ background: var(--bg);
68
+ color: var(--text);
69
+ -webkit-text-size-adjust: 100%;
70
+ -webkit-font-smoothing: antialiased;
71
+ }
72
+
73
+ @media (min-width: 768px) {
74
+ html, body { overflow: hidden; }
75
+ }
76
+
77
+ /* ── Login Page ── */
78
+
79
+ .login-page {
80
+ display: flex;
81
+ flex-direction: column;
82
+ align-items: center;
83
+ justify-content: center;
84
+ min-height: 100vh;
85
+ min-height: 100dvh;
86
+ padding: 24px;
87
+ padding-top: calc(24px + env(safe-area-inset-top, 0px));
88
+ overflow-y: auto;
89
+ -webkit-overflow-scrolling: touch;
90
+ }
91
+
92
+ .login-card {
93
+ position: relative;
94
+ max-width: 380px;
95
+ width: 100%;
96
+ text-align: center;
97
+ }
98
+
99
+ .login-title {
100
+ font-size: 26px;
101
+ font-weight: 600;
102
+ letter-spacing: -0.02em;
103
+ margin-bottom: 8px;
104
+ }
105
+
106
+ .login-byline {
107
+ font-size: 16px;
108
+ color: var(--text-muted);
109
+ margin-bottom: 8px;
110
+ letter-spacing: 0.2px;
111
+ }
112
+
113
+ .login-tagline {
114
+ font-size: 17px;
115
+ color: var(--text-muted);
116
+ margin-bottom: 40px;
117
+ letter-spacing: 0.3px;
118
+ }
119
+
120
+ .login-buttons {
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 12px;
124
+ margin-bottom: 16px;
125
+ }
126
+
127
+ .btn {
128
+ display: block;
129
+ width: 100%;
130
+ padding: 18px;
131
+ border: none;
132
+ border-radius: 12px;
133
+ font-size: 18px;
134
+ font-weight: 600;
135
+ font-family: var(--font);
136
+ cursor: pointer;
137
+ transition: background 0.15s, transform 0.1s;
138
+ -webkit-tap-highlight-color: transparent;
139
+ }
140
+
141
+ .btn:active {
142
+ transform: scale(0.98);
143
+ }
144
+
145
+ .btn-primary {
146
+ background: var(--accent);
147
+ color: white;
148
+ }
149
+
150
+ .btn-primary:hover {
151
+ background: var(--accent-hover);
152
+ }
153
+
154
+ .btn-secondary {
155
+ background: var(--input-bg);
156
+ color: var(--text);
157
+ border: 1px solid var(--input-border);
158
+ }
159
+
160
+ .btn-secondary:hover {
161
+ background: #EBE8E1;
162
+ }
163
+
164
+ .btn:disabled {
165
+ opacity: 0.5;
166
+ cursor: not-allowed;
167
+ transform: none;
168
+ }
169
+
170
+ .login-status {
171
+ margin-top: 16px;
172
+ font-size: 14px;
173
+ padding: 12px 16px;
174
+ border-radius: 10px;
175
+ display: none;
176
+ text-align: left;
177
+ }
178
+
179
+ .login-status.show { display: block; }
180
+ .login-status.loading { background: #E8EEFF; color: var(--accent); }
181
+ .login-status.error { background: #FFF0F0; color: #D32F2F; }
182
+ .login-status.success { background: #F0FFF4; color: #2E7D32; }
183
+
184
+ .login-footer {
185
+ margin-top: 48px;
186
+ font-size: 12px;
187
+ color: var(--text-muted);
188
+ letter-spacing: 0.2px;
189
+ }
190
+
191
+ /* ── Chat Page ── */
192
+
193
+ .chat-page {
194
+ display: none;
195
+ flex-direction: column;
196
+ position: fixed;
197
+ top: 0;
198
+ left: 0;
199
+ right: 0;
200
+ bottom: 0;
201
+ max-width: 600px;
202
+ margin: 0 auto;
203
+ }
204
+
205
+ .chat-header {
206
+ position: sticky;
207
+ top: 0;
208
+ z-index: 100;
209
+ padding: calc(12px + env(safe-area-inset-top, 0px)) 20px 12px;
210
+ text-align: center;
211
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
212
+ flex-shrink: 0;
213
+ background: rgba(255, 253, 245, 0.8);
214
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
215
+ backdrop-filter: saturate(180%) blur(20px);
216
+ }
217
+
218
+ .chat-header-name {
219
+ font-size: 19px;
220
+ font-weight: 600;
221
+ letter-spacing: -0.02em;
222
+ }
223
+
224
+ .chat-header-sub {
225
+ font-size: 14px;
226
+ color: var(--text-muted);
227
+ margin-top: 2px;
228
+ }
229
+
230
+ .chat-messages {
231
+ flex: 1;
232
+ overflow-y: auto;
233
+ padding: 20px 16px;
234
+ -webkit-overflow-scrolling: touch;
235
+ overscroll-behavior: none;
236
+ }
237
+
238
+ .message {
239
+ display: flex;
240
+ margin-bottom: 12px;
241
+ opacity: 0;
242
+ transform: translateY(8px);
243
+ animation: msgIn 0.3s ease forwards;
244
+ }
245
+
246
+ @keyframes msgIn {
247
+ to { opacity: 1; transform: translateY(0); }
248
+ }
249
+
250
+ .message.lesa {
251
+ justify-content: flex-start;
252
+ }
253
+
254
+ .message.user {
255
+ justify-content: flex-end;
256
+ }
257
+
258
+ .bubble {
259
+ max-width: 85%;
260
+ padding: 14px 18px;
261
+ border-radius: 18px;
262
+ font-size: 17px;
263
+ line-height: 1.45;
264
+ word-wrap: break-word;
265
+ }
266
+
267
+ .message.lesa .bubble {
268
+ background: var(--lesa-bubble);
269
+ border-bottom-left-radius: 4px;
270
+ }
271
+
272
+ .message.user .bubble {
273
+ background: var(--user-bubble);
274
+ border-bottom-right-radius: 4px;
275
+ }
276
+
277
+ .bubble img {
278
+ max-width: 100%;
279
+ border-radius: 12px;
280
+ margin-top: 8px;
281
+ display: block;
282
+ }
283
+
284
+ .bubble .cost-line {
285
+ font-size: 15px;
286
+ color: var(--text-muted);
287
+ margin-top: 6px;
288
+ }
289
+
290
+ /* Camera preview circle */
291
+ .camera-container {
292
+ display: flex;
293
+ flex-direction: column;
294
+ align-items: center;
295
+ gap: 12px;
296
+ margin-top: 8px;
297
+ }
298
+
299
+ .camera-preview {
300
+ width: 160px;
301
+ height: 160px;
302
+ border-radius: 50%;
303
+ overflow: hidden;
304
+ border: 3px solid var(--accent);
305
+ position: relative;
306
+ }
307
+
308
+ .camera-preview video {
309
+ width: 100%;
310
+ height: 100%;
311
+ object-fit: cover;
312
+ transform: scaleX(-1);
313
+ }
314
+
315
+ /* Generated kaleidoscope image (large) */
316
+ .kaleidoscope-img {
317
+ max-width: 100%;
318
+ width: 320px;
319
+ aspect-ratio: 1;
320
+ object-fit: cover;
321
+ border-radius: 16px;
322
+ margin-top: 8px;
323
+ display: block;
324
+ box-shadow: 0 4px 24px rgba(0, 51, 255, 0.12);
325
+ }
326
+
327
+ /* CSS Kaleidoscope fallback animation */
328
+ .kaleidoscope-fallback {
329
+ width: 280px;
330
+ height: 280px;
331
+ border-radius: 16px;
332
+ margin-top: 8px;
333
+ position: relative;
334
+ overflow: hidden;
335
+ background: #0a0a1a;
336
+ }
337
+
338
+ .kaleidoscope-fallback .k-layer {
339
+ position: absolute;
340
+ top: 50%;
341
+ left: 50%;
342
+ width: 200%;
343
+ height: 200%;
344
+ transform-origin: 0 0;
345
+ background: conic-gradient(
346
+ from 0deg,
347
+ #7C5CFC, #FF6B9D, #4ECDC4, #FFE66D, #7C5CFC,
348
+ #FF6B9D, #4ECDC4, #FFE66D, #7C5CFC, #FF6B9D,
349
+ #4ECDC4, #FFE66D, #7C5CFC
350
+ );
351
+ opacity: 0.7;
352
+ animation: kRotate 8s linear infinite;
353
+ }
354
+
355
+ .kaleidoscope-fallback .k-layer:nth-child(2) {
356
+ background: conic-gradient(
357
+ from 30deg,
358
+ #E040FB, #00BCD4, #FFAB40, #76FF03, #E040FB,
359
+ #00BCD4, #FFAB40, #76FF03, #E040FB, #00BCD4,
360
+ #FFAB40, #76FF03, #E040FB
361
+ );
362
+ opacity: 0.5;
363
+ animation: kRotateReverse 12s linear infinite;
364
+ }
365
+
366
+ .kaleidoscope-fallback .k-layer:nth-child(3) {
367
+ background: repeating-conic-gradient(
368
+ from 0deg,
369
+ transparent 0deg 10deg,
370
+ rgba(255,255,255,0.15) 10deg 20deg
371
+ );
372
+ opacity: 0.8;
373
+ animation: kRotate 20s linear infinite;
374
+ }
375
+
376
+ .kaleidoscope-fallback .k-overlay {
377
+ position: absolute;
378
+ top: 0;
379
+ left: 0;
380
+ right: 0;
381
+ bottom: 0;
382
+ background: radial-gradient(circle at center, transparent 30%, rgba(10,10,26,0.8) 70%);
383
+ }
384
+
385
+ @keyframes kRotate {
386
+ to { transform: rotate(360deg); }
387
+ }
388
+
389
+ @keyframes kRotateReverse {
390
+ to { transform: rotate(-360deg); }
391
+ }
392
+
393
+ /* Typing indicator */
394
+ .typing-indicator {
395
+ display: flex;
396
+ gap: 4px;
397
+ padding: 12px 16px;
398
+ align-items: center;
399
+ }
400
+
401
+ .typing-indicator .dot {
402
+ width: 7px;
403
+ height: 7px;
404
+ border-radius: 50%;
405
+ background: #B0AAA0;
406
+ animation: typingBounce 1.4s infinite;
407
+ }
408
+
409
+ .typing-indicator .dot:nth-child(2) { animation-delay: 0.2s; }
410
+ .typing-indicator .dot:nth-child(3) { animation-delay: 0.4s; }
411
+
412
+ @keyframes typingBounce {
413
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
414
+ 30% { transform: translateY(-4px); opacity: 1; }
415
+ }
416
+
417
+ /* Action buttons in chat */
418
+ .chat-actions {
419
+ display: flex;
420
+ gap: 10px;
421
+ margin-top: 8px;
422
+ flex-wrap: wrap;
423
+ }
424
+
425
+ .chat-actions .btn {
426
+ width: auto;
427
+ padding: 10px 20px;
428
+ font-size: 14px;
429
+ border-radius: 20px;
430
+ flex: 1;
431
+ min-width: 120px;
432
+ }
433
+
434
+ .chat-actions .btn-auth {
435
+ background: var(--accent);
436
+ color: white;
437
+ display: flex;
438
+ align-items: center;
439
+ justify-content: center;
440
+ gap: 8px;
441
+ }
442
+
443
+ .chat-actions .btn-auth:hover {
444
+ background: var(--accent-hover);
445
+ }
446
+
447
+ .chat-actions .btn-alt {
448
+ background: var(--input-bg);
449
+ color: var(--text);
450
+ border: 1px solid var(--input-border);
451
+ }
452
+
453
+ /* Loading spinner for image gen */
454
+ .gen-loading {
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 10px;
458
+ padding: 8px 0;
459
+ }
460
+
461
+ .spinner {
462
+ width: 20px;
463
+ height: 20px;
464
+ border: 2px solid var(--input-border);
465
+ border-top-color: var(--accent);
466
+ border-radius: 50%;
467
+ animation: spin 0.8s linear infinite;
468
+ }
469
+
470
+ @keyframes spin {
471
+ to { transform: rotate(360deg); }
472
+ }
473
+
474
+ /* Chat input */
475
+ .chat-input-area {
476
+ padding: 12px 16px;
477
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
478
+ border-top: 1px solid var(--input-border);
479
+ flex-shrink: 0;
480
+ background: var(--bg);
481
+ }
482
+
483
+ .chat-input-row {
484
+ display: flex;
485
+ gap: 10px;
486
+ align-items: flex-end;
487
+ }
488
+
489
+ .chat-input {
490
+ flex: 1;
491
+ padding: 12px 16px;
492
+ border: 1px solid var(--input-border);
493
+ border-radius: 22px;
494
+ font-size: 15px;
495
+ font-family: var(--font);
496
+ background: var(--input-bg);
497
+ color: var(--text);
498
+ outline: none;
499
+ resize: none;
500
+ max-height: 100px;
501
+ line-height: 1.4;
502
+ }
503
+
504
+ .chat-input:focus {
505
+ border-color: var(--accent);
506
+ }
507
+
508
+ .chat-input::placeholder {
509
+ color: var(--text-muted);
510
+ }
511
+
512
+ .send-btn {
513
+ width: 42px;
514
+ height: 42px;
515
+ border-radius: 50%;
516
+ background: var(--accent);
517
+ border: none;
518
+ cursor: pointer;
519
+ display: flex;
520
+ align-items: center;
521
+ justify-content: center;
522
+ flex-shrink: 0;
523
+ transition: background 0.15s;
524
+ }
525
+
526
+ .send-btn:hover {
527
+ background: var(--accent-hover);
528
+ }
529
+
530
+ .send-btn svg {
531
+ width: 18px;
532
+ height: 18px;
533
+ fill: white;
534
+ }
535
+
536
+ /* Hide scrollbar on WebKit */
537
+ .chat-messages::-webkit-scrollbar {
538
+ width: 0;
539
+ }
540
+
541
+ /* Full-screen on mobile */
542
+ @media (max-width: 600px) {
543
+ .chat-page {
544
+ max-width: 100%;
545
+ }
546
+ }
547
+ </style>
548
+ </head>
549
+ <body>
550
+
551
+ <!-- ── LOGIN PAGE ── -->
552
+ <div class="login-page" id="loginPage">
553
+ <div class="login-card">
554
+ <div style="display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:8px;margin-left:-6px;"><span id="loginIcon" style="width:34px;height:34px;flex-shrink:0;overflow:hidden;"></span><h1 class="login-title" style="margin-bottom:0;">Kaleidoscope</h1></div>
555
+ <p style="color:#8a8580;font-size:16px;margin:0 0 32px 0;letter-spacing:0.2px;">Every AI. One experience.</p>
556
+
557
+
558
+ <div class="login-buttons">
559
+ <button class="btn btn-primary" id="createBtn" onclick="doCreateAccount()">Enter the Kaleidoscope</button>
560
+ </div>
561
+ <div id="handleInputWrap" style="margin-top:12px;">
562
+ <input type="text" id="handleInput" name="kaleidoscope-handle" placeholder="What should Lēsa call you? (optional)" autocapitalize="none" autocorrect="off" autocomplete="off" spellcheck="false" data-1p-ignore="true" data-lpignore="true" onfocus="setTimeout(function(){document.getElementById('handleInput').scrollIntoView({behavior:'smooth',block:'center'})},300)" style="width:100%;padding:16px 18px;border:1px solid #E0DDD6;border-radius:12px;font-size:18px;font-family:var(--font);background:#F5F3ED;color:#1a1a1a;outline:none;text-align:center;" />
563
+ </div>
564
+ <p style="color:#b0aaa4;font-size:13px;font-style:italic;margin:16px 0 0;text-align:center;opacity:0.8;">Use your phone to securely create your account</p>
565
+ <div style="margin-top:12px;text-align:center;">
566
+ <a id="signInBtn" onclick="doSignIn()" style="color:var(--accent);font-size:16px;cursor:pointer;text-decoration:none;">Already have an account? Sign in.</a>
567
+ </div>
568
+ <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
569
+ </div>
570
+ </div>
571
+ <div id="kscope-footer"></div>
572
+ <script src="/demo/footer.js"></script>
573
+
574
+ <!-- ── CHAT PAGE ── -->
575
+ <div class="chat-page" id="chatPage">
576
+ <div class="chat-header" style="position:relative;">
577
+ <a id="kscopeIcon" onclick="sessionStorage.clear();location.reload();" style="cursor:pointer;position:absolute;left:16px;top:50%;transform:translateY(-50%);display:flex;align-items:center;"></a>
578
+ <div class="chat-header-name">Kaleidoscope</div>
579
+ <div class="chat-header-sub"></div>
580
+ </div>
581
+ <div class="chat-messages" id="chatMessages"></div>
582
+ <div class="chat-input-area">
583
+ <div class="chat-input-row">
584
+ <textarea class="chat-input" id="chatInput" placeholder="Message..." rows="1" disabled></textarea>
585
+ <button class="send-btn" id="sendBtn" onclick="handleSend()" disabled>
586
+ <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
587
+ </button>
588
+ </div>
589
+ </div>
590
+ </div>
591
+
592
+ <!-- Hidden canvas for photo capture -->
593
+ <canvas id="photoCanvas" style="display:none;"></canvas>
594
+
595
+ <script>
596
+ // ── WebAuthn Helpers ──
597
+
598
+ function b64urlToBytes(b64url) {
599
+ var b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
600
+ var pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
601
+ var bin = atob(b64 + pad);
602
+ return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
603
+ }
604
+
605
+ function bytesToB64url(bytes) {
606
+ var bin = '';
607
+ var arr = new Uint8Array(bytes);
608
+ for (var i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
609
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
610
+ }
611
+
612
+ // ── Login State ──
613
+
614
+ function setLoginStatus(msg, type) {
615
+ var el = document.getElementById('loginStatus');
616
+ el.textContent = msg;
617
+ el.className = 'login-status show ' + type;
618
+ }
619
+
620
+ function clearLoginStatus() {
621
+ var el = document.getElementById('loginStatus');
622
+ el.style.transition = 'opacity 0.5s';
623
+ el.style.opacity = '0';
624
+ setTimeout(function() { el.className = 'login-status'; el.style.transition = ''; el.style.opacity = ''; }, 500);
625
+ }
626
+
627
+ function disableLoginButtons() {
628
+ document.getElementById('signInBtn').disabled = true;
629
+ document.getElementById('createBtn').disabled = true;
630
+ }
631
+
632
+ function enableLoginButtons() {
633
+ document.getElementById('signInBtn').disabled = false;
634
+ document.getElementById('createBtn').disabled = false;
635
+ }
636
+
637
+ // ── WebAuthn: Create Account ──
638
+
639
+ async function doCreateAccount() {
640
+ disableLoginButtons();
641
+ setLoginStatus('Preparing...', 'loading');
642
+ var username = (document.getElementById('handleInput').value || '').trim().replace(/^@/, '').toLowerCase().replace(/[^a-z0-9\-]/g, '').slice(0, 30);
643
+ try {
644
+ var optRes = await fetch('/webauthn/register-options', {
645
+ method: 'POST',
646
+ headers: { 'Content-Type': 'application/json' },
647
+ body: JSON.stringify(username ? { username: username } : {})
648
+ });
649
+ var optData = await optRes.json();
650
+ var challengeId = optData.challengeId;
651
+ var options = optData.options;
652
+ if (!options) throw new Error('Server returned no options');
653
+
654
+ options.challenge = b64urlToBytes(options.challenge);
655
+ options.user.id = b64urlToBytes(options.user.id);
656
+ if (options.excludeCredentials) {
657
+ options.excludeCredentials = options.excludeCredentials.map(function(c) {
658
+ return Object.assign({}, c, { id: b64urlToBytes(c.id) });
659
+ });
660
+ }
661
+
662
+ setLoginStatus('Waiting for biometric...', 'loading');
663
+ var credential = await navigator.credentials.create({ publicKey: options });
664
+
665
+ var reqBody = {
666
+ challengeId: challengeId,
667
+ credential: {
668
+ id: credential.id,
669
+ rawId: bytesToB64url(credential.rawId),
670
+ type: credential.type,
671
+ response: {
672
+ attestationObject: bytesToB64url(credential.response.attestationObject),
673
+ clientDataJSON: bytesToB64url(credential.response.clientDataJSON),
674
+ transports: credential.response.getTransports ? credential.response.getTransports() : [],
675
+ },
676
+ },
677
+ };
678
+
679
+ setLoginStatus('Verifying...', 'loading');
680
+ var verRes = await fetch('/webauthn/register-verify', {
681
+ method: 'POST',
682
+ headers: { 'Content-Type': 'application/json' },
683
+ body: JSON.stringify(reqBody)
684
+ });
685
+ var result = await verRes.json();
686
+
687
+ if (result.success) {
688
+ sessionStorage.setItem('lesa-token', result.apiKey);
689
+ sessionStorage.setItem('lesa-agent', result.agentId);
690
+ sessionStorage.setItem('lesa-new-account', 'true');
691
+ setLoginStatus('Account created.', 'success');
692
+ setTimeout(function() { showChat(); }, 400);
693
+ } else {
694
+ setLoginStatus(result.error || 'Registration failed', 'error');
695
+ enableLoginButtons();
696
+ }
697
+ } catch (err) {
698
+ if (err.name === 'NotAllowedError') {
699
+ setLoginStatus('Cancelled. Try again when ready.', 'error');
700
+ setTimeout(function() { clearLoginStatus(); }, 3000);
701
+ } else {
702
+ setLoginStatus('Error: ' + err.message, 'error');
703
+ }
704
+ enableLoginButtons();
705
+ }
706
+ }
707
+
708
+ // ── WebAuthn: Sign In ──
709
+
710
+ async function doSignIn() {
711
+ disableLoginButtons();
712
+ setLoginStatus('Preparing...', 'loading');
713
+ try {
714
+ var optRes = await fetch('/webauthn/auth-options', {
715
+ method: 'POST',
716
+ headers: { 'Content-Type': 'application/json' },
717
+ body: '{}'
718
+ });
719
+ var optData = await optRes.json();
720
+ var challengeId = optData.challengeId;
721
+ var options = optData.options;
722
+ if (!options) throw new Error('Server returned no options');
723
+
724
+ options.challenge = b64urlToBytes(options.challenge);
725
+ if (options.allowCredentials) {
726
+ options.allowCredentials = options.allowCredentials.map(function(c) {
727
+ return Object.assign({}, c, { id: b64urlToBytes(c.id) });
728
+ });
729
+ }
730
+
731
+ setLoginStatus('Waiting for biometric...', 'loading');
732
+ var assertion = await navigator.credentials.get({ publicKey: options });
733
+
734
+ var reqBody = {
735
+ challengeId: challengeId,
736
+ credential: {
737
+ id: assertion.id,
738
+ rawId: bytesToB64url(assertion.rawId),
739
+ type: assertion.type,
740
+ response: {
741
+ authenticatorData: bytesToB64url(assertion.response.authenticatorData),
742
+ clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),
743
+ signature: bytesToB64url(assertion.response.signature),
744
+ userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,
745
+ },
746
+ },
747
+ };
748
+
749
+ setLoginStatus('Verifying...', 'loading');
750
+ var verRes = await fetch('/webauthn/auth-verify', {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify(reqBody)
754
+ });
755
+ var result = await verRes.json();
756
+
757
+ if (result.success) {
758
+ sessionStorage.setItem('lesa-token', result.apiKey);
759
+ sessionStorage.setItem('lesa-agent', result.agentId);
760
+ setLoginStatus('Signed in.', 'success');
761
+ setTimeout(function() { showChat(); }, 400);
762
+ } else {
763
+ setLoginStatus(result.error || 'Authentication failed', 'error');
764
+ enableLoginButtons();
765
+ }
766
+ } catch (err) {
767
+ if (err.name === 'NotAllowedError') {
768
+ setLoginStatus('Cancelled. Try again when ready.', 'error');
769
+ setTimeout(function() { clearLoginStatus(); }, 3000);
770
+ } else {
771
+ setLoginStatus('Error: ' + err.message, 'error');
772
+ }
773
+ enableLoginButtons();
774
+ }
775
+ }
776
+
777
+ // ── Chat Engine ──
778
+
779
+ var chatMessages = document.getElementById('chatMessages');
780
+ var cameraStream = null;
781
+ var capturedPhotoB64 = null;
782
+
783
+ function scrollToBottom() {
784
+ chatMessages.scrollTop = chatMessages.scrollHeight;
785
+ }
786
+
787
+ function addMessage(text, sender, extraHTML) {
788
+ var existing = document.querySelector('.message.typing');
789
+ if (existing) existing.remove();
790
+
791
+ var div = document.createElement('div');
792
+ div.className = 'message ' + sender;
793
+ var bubble = document.createElement('div');
794
+ bubble.className = 'bubble';
795
+ if (extraHTML) {
796
+ bubble.innerHTML = extraHTML;
797
+ } else {
798
+ bubble.textContent = text;
799
+ }
800
+ div.appendChild(bubble);
801
+ chatMessages.appendChild(div);
802
+ scrollToBottom();
803
+ return bubble;
804
+ }
805
+
806
+ function showTyping() {
807
+ var div = document.createElement('div');
808
+ div.className = 'message lesa typing';
809
+ div.innerHTML = '<div class="bubble"><div class="typing-indicator"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div></div>';
810
+ chatMessages.appendChild(div);
811
+ scrollToBottom();
812
+ }
813
+
814
+ function removeTyping() {
815
+ var el = document.querySelector('.message.typing');
816
+ if (el) el.remove();
817
+ }
818
+
819
+ function delay(ms) {
820
+ return new Promise(function(r) { setTimeout(r, ms); });
821
+ }
822
+
823
+ async function lesaSays(text, delayMs) {
824
+ if (delayMs === undefined) delayMs = 800;
825
+ showTyping();
826
+ await delay(delayMs);
827
+ removeTyping();
828
+ return addMessage(text, 'lesa');
829
+ }
830
+
831
+ async function lesaSaysHTML(html, delayMs) {
832
+ if (delayMs === undefined) delayMs = 800;
833
+ showTyping();
834
+ await delay(delayMs);
835
+ removeTyping();
836
+ return addMessage('', 'lesa', html);
837
+ }
838
+
839
+ function showChoiceButtons(options) {
840
+ var container = document.createElement('div');
841
+ container.className = 'chat-actions';
842
+ container.id = 'currentActions';
843
+
844
+ options.forEach(function(opt) {
845
+ var btn = document.createElement('button');
846
+ btn.className = 'btn ' + (opt.primary ? 'btn-auth' : 'btn-alt');
847
+ btn.textContent = opt.label;
848
+ btn.onclick = function() {
849
+ container.remove();
850
+ addMessage(opt.label, 'user');
851
+ opt.action();
852
+ };
853
+ container.appendChild(btn);
854
+ });
855
+
856
+ chatMessages.appendChild(container);
857
+ scrollToBottom();
858
+ }
859
+
860
+ function showAuthButton() {
861
+ var container = document.createElement('div');
862
+ container.className = 'chat-actions';
863
+ container.id = 'currentActions';
864
+
865
+ var btn = document.createElement('button');
866
+ btn.className = 'btn btn-auth';
867
+ btn.innerHTML = '<span style="filter:brightness(0) invert(1);">🫆</span> Authorize';
868
+ btn.onclick = function() {
869
+ container.remove();
870
+ addMessage('Authorized', 'user');
871
+ doFaceIDAuth();
872
+ };
873
+ container.appendChild(btn);
874
+
875
+ chatMessages.appendChild(container);
876
+ scrollToBottom();
877
+ }
878
+
879
+ // ── Settings ──
880
+
881
+ var WALLET_BALANCE = "$5.00";
882
+ var IMAGE_COST = "$0.01";
883
+ var IMAGE_BALANCE_AFTER = "$4.99";
884
+ var IMAGE_API_NAME = "Grok Imagine";
885
+ var IMAGE_API_PROVIDER = "xAI";
886
+
887
+ // ── Image Prompts ──
888
+
889
+ var PROMPT_PHOTO = "kaleidoscope pattern made from mirrored and reflected colors and shapes derived from a photograph, shot on expired 35mm film with heavy grain and light leaks, warm analog color bleed, faded and sun-bleached, symmetrical radial reflections but organic and imperfect, no face visible just abstracted colors and textures, washed out like a Boards of Canada album cover, square format, lo-fi analog photography aesthetic, slight overexposure, nostalgic and haunting, no digital sharpness, no CGI, no illustration, no fingers, no text";
890
+
891
+ var PROMPT_NO_PHOTO = "abstract kaleidoscope pattern viewed through a real kaleidoscope, shot on expired 35mm film with heavy grain and light leaks, warm amber and deep red analog color palette, faded and sun-bleached, symmetrical radial reflections but organic and imperfect, washed out like the Geogaddi album cover, square format, lo-fi analog photography aesthetic, slight overexposure, nostalgic and haunting, no digital sharpness, no CGI, no illustration, no faces, no fingers, no text";
892
+
893
+ // ── Demo Flow ──
894
+
895
+ async function startDemo() {
896
+ await delay(600);
897
+ var isNew = sessionStorage.getItem('lesa-new-account') === 'true';
898
+ sessionStorage.removeItem('lesa-new-account');
899
+ await lesaSays("Hi, I'm L\u0113sa. Welcome to Kaleidoscope.", 1200);
900
+ await delay(1000);
901
+ if (isNew) {
902
+ await lesaSays("You just created an account with a passkey. It lives on this device.", 1200);
903
+ } else {
904
+ await lesaSays("You just logged in with your passkey.", 1200);
905
+ }
906
+ await lesaSays("Going forward, you can use this passkey to log into any Work in Progress Computer service.", 1500);
907
+ await lesaSays("And I can use it too. Anytime I need your permission to do something, I'll ask, and you authorize with your fingerprint or face. No passwords. Ever.", 1500);
908
+ await lesaSays("Want to see it in action? I'll try to do something that costs money, and you decide whether to let me.", 1200);
909
+
910
+ showChoiceButtons([
911
+ { label: 'Yes, show me', primary: true, action: demoYesShowMe },
912
+ { label: 'No thanks', primary: false, action: demoNoThanks }
913
+ ]);
914
+ }
915
+
916
+ // ── "Yes, show me" path ──
917
+
918
+ async function demoYesShowMe() {
919
+ var walletBalance = WALLET_BALANCE;
920
+ var walletCost = IMAGE_COST;
921
+ try {
922
+ var token = sessionStorage.getItem('lesa-token');
923
+ var wRes = await fetch('/demo/api/wallet', { headers: { 'Authorization': 'Bearer ' + token } });
924
+ var wData = await wRes.json();
925
+ if (wData.balance) walletBalance = wData.balance;
926
+ if (wData.cost) walletCost = wData.cost;
927
+ } catch {}
928
+ await lesaSays("I have a wallet with " + walletBalance + ". Do I have your permission to spend " + walletCost + " on image generation using the " + IMAGE_API_PROVIDER + " " + IMAGE_API_NAME + " API?", 1000);
929
+
930
+ showAuthButton();
931
+ }
932
+
933
+ async function doFaceIDAuth() {
934
+ showTyping();
935
+
936
+ try {
937
+ var optRes = await fetch('/webauthn/auth-options', {
938
+ method: 'POST',
939
+ headers: { 'Content-Type': 'application/json' },
940
+ body: '{}'
941
+ });
942
+ var optData = await optRes.json();
943
+ var challengeId = optData.challengeId;
944
+ var options = optData.options;
945
+
946
+ options.challenge = b64urlToBytes(options.challenge);
947
+ if (options.allowCredentials) {
948
+ options.allowCredentials = options.allowCredentials.map(function(c) {
949
+ return Object.assign({}, c, { id: b64urlToBytes(c.id) });
950
+ });
951
+ }
952
+
953
+ removeTyping();
954
+ var assertion = await navigator.credentials.get({ publicKey: options });
955
+ showTyping();
956
+
957
+ var reqBody = {
958
+ challengeId: challengeId,
959
+ credential: {
960
+ id: assertion.id,
961
+ rawId: bytesToB64url(assertion.rawId),
962
+ type: assertion.type,
963
+ response: {
964
+ authenticatorData: bytesToB64url(assertion.response.authenticatorData),
965
+ clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),
966
+ signature: bytesToB64url(assertion.response.signature),
967
+ userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,
968
+ },
969
+ },
970
+ };
971
+
972
+ var verRes = await fetch('/webauthn/auth-verify', {
973
+ method: 'POST',
974
+ headers: { 'Content-Type': 'application/json' },
975
+ body: JSON.stringify(reqBody)
976
+ });
977
+ var result = await verRes.json();
978
+
979
+ if (!result.success) {
980
+ removeTyping();
981
+ await lesaSays("Authorization failed. " + (result.error || "Please try again."), 600);
982
+ showAuthButton();
983
+ return;
984
+ }
985
+
986
+ removeTyping();
987
+ await lesaSays("Thanks for authorizing. Let me show you what I can do.", 600);
988
+ await delay(500);
989
+ await lesaSays("I'd like to turn your photo into a kaleidoscope. Want to take one?", 1000);
990
+
991
+ showChoiceButtons([
992
+ { label: 'Take a Photo', primary: true, action: openCamera },
993
+ { label: 'No, just make one', primary: false, action: demoJustMakeOne }
994
+ ]);
995
+
996
+ } catch (err) {
997
+ removeTyping();
998
+ if (err.name === 'NotAllowedError') {
999
+ await lesaSays("Authorization cancelled. Tap below to try again.", 600);
1000
+ } else {
1001
+ await lesaSays("Something went wrong: " + err.message, 600);
1002
+ }
1003
+ showAuthButton();
1004
+ }
1005
+ }
1006
+
1007
+ // ── Camera path ──
1008
+
1009
+ async function openCamera() {
1010
+ try {
1011
+ cameraStream = await navigator.mediaDevices.getUserMedia({
1012
+ video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } }
1013
+ });
1014
+
1015
+ var camHTML = '<div class="camera-container">'
1016
+ + '<div class="camera-preview"><video id="cameraVideo" autoplay playsinline muted></video></div>'
1017
+ + '</div>';
1018
+
1019
+ await lesaSaysHTML(camHTML, 600);
1020
+
1021
+ var video = document.getElementById('cameraVideo');
1022
+ video.srcObject = cameraStream;
1023
+ await video.play();
1024
+
1025
+ showChoiceButtons([
1026
+ { label: 'Capture', primary: true, action: takePhoto }
1027
+ ]);
1028
+
1029
+ } catch (err) {
1030
+ if (err.name === 'NotAllowedError' || err.name === 'NotFoundError') {
1031
+ await lesaSays("Camera access wasn't available. No worries, I'll generate a kaleidoscope from scratch.", 1000);
1032
+ } else {
1033
+ await lesaSays("Couldn't access the camera: " + err.message + ". I'll generate one from scratch instead.", 1000);
1034
+ }
1035
+ await generateKaleidoscope(false);
1036
+ }
1037
+ }
1038
+
1039
+ async function takePhoto() {
1040
+ var video = document.getElementById('cameraVideo');
1041
+ var canvas = document.getElementById('photoCanvas');
1042
+
1043
+ canvas.width = 640;
1044
+ canvas.height = 640;
1045
+ var ctx = canvas.getContext('2d');
1046
+
1047
+ // Mirror the image to match the preview
1048
+ ctx.translate(canvas.width, 0);
1049
+ ctx.scale(-1, 1);
1050
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
1051
+
1052
+ capturedPhotoB64 = canvas.toDataURL('image/jpeg', 0.8);
1053
+
1054
+ // Hide the camera preview bubble
1055
+ var camPreview = document.querySelector(".camera-preview");
1056
+ if (camPreview) camPreview.parentElement.style.display = "none";
1057
+
1058
+ // Stop the camera stream
1059
+ if (cameraStream) {
1060
+ cameraStream.getTracks().forEach(function(t) { t.stop(); });
1061
+ cameraStream = null;
1062
+ }
1063
+
1064
+ // Show captured photo as a small circle in chat
1065
+ addMessage('', 'user',
1066
+ '<div class="camera-container">'
1067
+ + '<div class="camera-preview"><img src="' + capturedPhotoB64 + '" style="width:100%;height:100%;object-fit:cover;transform:scaleX(-1);" alt="Your photo"></div>'
1068
+ + '</div>'
1069
+ );
1070
+
1071
+ await generateKaleidoscope(true);
1072
+ }
1073
+
1074
+ // ── "No, just make one" path ──
1075
+
1076
+ async function demoJustMakeOne() {
1077
+ await lesaSays("No problem. Let me make you something beautiful.", 1000);
1078
+ await generateKaleidoscope(false);
1079
+ }
1080
+
1081
+ // ── "No thanks" path (skip permissions entirely) ──
1082
+
1083
+ async function demoNoThanks() {
1084
+ await lesaSays("No problem. Let me make you something beautiful.", 1000);
1085
+ await generateKaleidoscope(false);
1086
+ }
1087
+
1088
+ // ── Image generation ──
1089
+
1090
+ async function generateKaleidoscope(isPhotoPath) {
1091
+ var prompt = isPhotoPath ? PROMPT_PHOTO : PROMPT_NO_PHOTO;
1092
+
1093
+ // If photo path, analyze the photo with vision to extract colors/mood
1094
+ if (isPhotoPath && capturedPhotoB64) {
1095
+ try {
1096
+ var token = sessionStorage.getItem('lesa-token');
1097
+ var analyzeRes = await fetch('/demo/api/analyze-photo', {
1098
+ method: 'POST',
1099
+ headers: {
1100
+ 'Content-Type': 'application/json',
1101
+ 'Authorization': 'Bearer ' + token,
1102
+ },
1103
+ body: JSON.stringify({ image: capturedPhotoB64 })
1104
+ });
1105
+ var analyzeData = await analyzeRes.json();
1106
+ if (analyzeData.description) {
1107
+ prompt = "abstract kaleidoscope pattern, dominant colors: " + analyzeData.description + ", symmetrical radial reflections, mirrored geometric shapes in these exact colors, shot on expired 35mm film with heavy grain and light leaks, warm analog color bleed, faded and sun-bleached like a Boards of Canada album cover, no face, no person, no fingers, no spheres, no beads, no text, just abstract color and light";
1108
+ }
1109
+ } catch (e) {
1110
+ // Vision analysis failed... fall back to original PROMPT_PHOTO
1111
+ console.log("Vision analysis unavailable, using default photo prompt");
1112
+ }
1113
+ }
1114
+
1115
+ await lesaSays(isPhotoPath ? "Creating your kaleidoscope..." : "Creating a kaleidoscope...", 800);
1116
+
1117
+ var genBubble = addMessage('', 'lesa', '');
1118
+ var loadingDiv = document.createElement('div');
1119
+ loadingDiv.className = 'gen-loading';
1120
+ loadingDiv.innerHTML = '<div class="spinner"></div><span style="font-size:13px;color:var(--text-muted)">Generating...</span>';
1121
+ genBubble.appendChild(loadingDiv);
1122
+ scrollToBottom();
1123
+
1124
+ try {
1125
+ var token = sessionStorage.getItem('lesa-token');
1126
+ var res = await fetch('/demo/api/imagine', {
1127
+ method: 'POST',
1128
+ headers: {
1129
+ 'Content-Type': 'application/json',
1130
+ 'Authorization': 'Bearer ' + token,
1131
+ },
1132
+ body: JSON.stringify({ prompt: prompt })
1133
+ });
1134
+
1135
+ var data = await res.json();
1136
+ loadingDiv.remove();
1137
+
1138
+ if (data.error) {
1139
+ genBubble.innerHTML = '';
1140
+ genBubble.innerHTML = '<img class="kaleidoscope-img" src="/demo/fallback.jpg" alt="Kaleidoscope">';
1141
+ await lesaSays("The image API isn't available right now. The devil is in the details.", 1000);
1142
+ await showOutro();
1143
+ return;
1144
+ }
1145
+
1146
+ genBubble.innerHTML = '<img class="kaleidoscope-img" src="' + data.url + '" alt="Your kaleidoscope">';
1147
+ scrollToBottom();
1148
+
1149
+ var cost = data.cost || IMAGE_COST;
1150
+ var balance = data.balance || IMAGE_BALANCE_AFTER;
1151
+ await lesaSays("Cost: " + cost + ". Balance: " + balance + ".", 800);
1152
+ await showOutro();
1153
+
1154
+ } catch (err) {
1155
+ loadingDiv.remove();
1156
+ genBubble.innerHTML = '';
1157
+ genBubble.innerHTML = '<img class="kaleidoscope-img" src="/demo/fallback.jpg" alt="Kaleidoscope">';
1158
+ await lesaSays("Couldn't reach the image API. The devil is in the details.", 1000);
1159
+ await showOutro();
1160
+ }
1161
+ }
1162
+
1163
+ async function showOutro() {
1164
+ await lesaSays("At Work in Progress Computer we are building the future of AI and human interaction.", 1500);
1165
+ showTyping(); await delay(2000); removeTyping();
1166
+ await lesaSays("We believe permission is a conversation. Your AI asks. You decide. One glance, one tap.", 2000);
1167
+ showTyping(); await delay(2000); removeTyping();
1168
+ await lesaSays("To see how passkeys work across devices, open wip.computer/demo in any web browser on any computer. And use this device to authenticate.", 2000);
1169
+ showTyping(); await delay(2000); removeTyping();
1170
+ await lesaSays("Made in California by WIP Computer, Inc. Learning Dreaming Machines.", 1500);
1171
+ await lesaSays("This is the end of the demo. Tap icon or refresh page to start over.", 1200);
1172
+ }
1173
+
1174
+ async function doGameAuth() {
1175
+ showTyping();
1176
+ try {
1177
+ var optRes = await fetch('/webauthn/auth-options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
1178
+ var optData = await optRes.json();
1179
+ var options = optData.options;
1180
+ options.challenge = b64urlToBytes(options.challenge);
1181
+ if (options.allowCredentials) { options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); }); }
1182
+ removeTyping();
1183
+ var assertion = await navigator.credentials.get({ publicKey: options });
1184
+ showTyping();
1185
+ var reqBody = { challengeId: optData.challengeId, credential: { id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type, response: { authenticatorData: bytesToB64url(assertion.response.authenticatorData), clientDataJSON: bytesToB64url(assertion.response.clientDataJSON), signature: bytesToB64url(assertion.response.signature), userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null } } };
1186
+ var verRes = await fetch('/webauthn/auth-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reqBody) });
1187
+ var result = await verRes.json();
1188
+ removeTyping();
1189
+ await lesaSays("You are not authorized to play war games with me.", 1200);
1190
+ } catch (err) {
1191
+ removeTyping();
1192
+ await lesaSays("You are not authorized to play war games with me.", 1200);
1193
+ }
1194
+ await lesaSays("This is the end of the demo. To try again, refresh this page.", 1500);
1195
+ }
1196
+
1197
+ function showAuthButtonForGame() {
1198
+ var container = document.createElement('div');
1199
+ container.className = 'chat-actions';
1200
+ container.id = 'currentActions';
1201
+ var btn = document.createElement('button');
1202
+ btn.className = 'btn btn-auth';
1203
+ btn.innerHTML = '<span style="filter:brightness(0) invert(1);">🫆</span> Authorize';
1204
+ btn.onclick = function() {
1205
+ container.remove();
1206
+ addMessage('Authorized', 'user');
1207
+ doGameAuth();
1208
+ };
1209
+ container.appendChild(btn);
1210
+ document.getElementById('chatMessages').appendChild(container);
1211
+ scrollToBottom();
1212
+ }
1213
+
1214
+ function showCSSKaleidoscope(bubble) {
1215
+ var fallback = document.createElement('div');
1216
+ fallback.className = 'kaleidoscope-fallback';
1217
+ fallback.innerHTML = '<div class="k-layer"></div><div class="k-layer"></div><div class="k-layer"></div><div class="k-overlay"></div>';
1218
+ bubble.appendChild(fallback);
1219
+ scrollToBottom();
1220
+ }
1221
+
1222
+ // ── Page Navigation ──
1223
+
1224
+ function showChat() {
1225
+ document.getElementById('loginPage').style.display = 'none';
1226
+ var chatPage = document.getElementById('chatPage');
1227
+ chatPage.style.display = 'flex';
1228
+ startDemo();
1229
+ }
1230
+
1231
+ // Check if already logged in
1232
+ if (sessionStorage.getItem('lesa-token')) {
1233
+ showChat();
1234
+ }
1235
+
1236
+ // If user has an account (created at /login), show sign-in mode
1237
+ if (localStorage.getItem('kscope-has-account') && !sessionStorage.getItem('lesa-token')) {
1238
+ document.getElementById('createBtn').textContent = 'Enter the Kaleidoscope';
1239
+ document.getElementById('createBtn').onclick = function() { doSignIn(); };
1240
+ document.getElementById('handleInputWrap').style.display = 'none';
1241
+ document.getElementById('signInBtn').parentElement.style.display = 'none';
1242
+ }
1243
+
1244
+ // Handle send (not used in demo flow, but wired up)
1245
+ function handleSend() {
1246
+ var input = document.getElementById('chatInput');
1247
+ var text = input.value.trim();
1248
+ if (!text) return;
1249
+ addMessage(text, 'user');
1250
+ input.value = '';
1251
+ }
1252
+
1253
+ // ── Random kaleidoscope icon from sprite sheet ──
1254
+ // sprites.jpg is 8 columns x 3 rows = 24 icons
1255
+ // ── Sprite sheet: 8 cols x 3 rows, PNG with white background ──
1256
+ var SPRITE_COLS = 8;
1257
+ var SPRITE_ROWS = 3;
1258
+ var SPRITE_TOTAL = SPRITE_COLS * SPRITE_ROWS;
1259
+
1260
+ function makeIconHTML(size, blue) {
1261
+ var idx = Math.floor(Math.random() * SPRITE_TOTAL);
1262
+ var col = idx % SPRITE_COLS;
1263
+ var row = Math.floor(idx / SPRITE_COLS);
1264
+ var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
1265
+ var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
1266
+ if (blue) {
1267
+ return '<div style="width:' + size + 'px;height:' + size + 'px;overflow:hidden;background:var(--accent);-webkit-mask-image:url(/demo/sprites.png);mask-image:url(/demo/sprites.png);-webkit-mask-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;mask-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;-webkit-mask-position:' + bgPosX + '% ' + bgPosY + '%;mask-position:' + bgPosX + '% ' + bgPosY + '%;"></div>';
1268
+ }
1269
+ return '<div style="width:' + size + 'px;height:' + size + 'px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bgPosX + '% ' + bgPosY + '%;"></div></div>';
1270
+ }
1271
+ var chatIcon = document.getElementById('kscopeIcon');
1272
+ if (chatIcon) chatIcon.innerHTML = makeIconHTML(28, false);
1273
+ var loginIcon = document.getElementById('loginIcon');
1274
+ if (loginIcon) loginIcon.innerHTML = makeIconHTML(34, false);
1275
+
1276
+ // Login icon: rotate every 3s
1277
+ var loginRotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);
1278
+ setInterval(function() {
1279
+ var el = document.getElementById('loginIcon');
1280
+ if (!el || el.offsetParent === null) return;
1281
+ loginRotateIdx = (loginRotateIdx + 1) % SPRITE_TOTAL;
1282
+ var col = loginRotateIdx % SPRITE_COLS;
1283
+ var row = Math.floor(loginRotateIdx / SPRITE_COLS);
1284
+ var bx = (col / (SPRITE_COLS - 1)) * 100;
1285
+ var by = (row / (SPRITE_ROWS - 1)) * 100;
1286
+ el.innerHTML = '<div style="width:34px;height:34px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bx + '% ' + by + '%;"></div></div>';
1287
+ }, 3000);
1288
+
1289
+ // Chat icon: rotate every 6s (matches TOS/privacy)
1290
+ var chatRotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);
1291
+ setInterval(function() {
1292
+ var el = document.getElementById('kscopeIcon');
1293
+ if (!el || el.offsetParent === null) return;
1294
+ chatRotateIdx = (chatRotateIdx + 1) % SPRITE_TOTAL;
1295
+ var col = chatRotateIdx % SPRITE_COLS;
1296
+ var row = Math.floor(chatRotateIdx / SPRITE_COLS);
1297
+ var bx = (col / (SPRITE_COLS - 1)) * 100;
1298
+ var by = (row / (SPRITE_ROWS - 1)) * 100;
1299
+ el.innerHTML = '<div style="width:28px;height:28px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bx + '% ' + by + '%;"></div></div>';
1300
+ }, 6000);
1301
+ </script>
1302
+ </body>
1303
+ </html>