@vibevibes/runtime 0.2.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.
@@ -0,0 +1,1388 @@
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.0" />
6
+ <title>vibe-vibe</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ html, body { width: 100%; height: 100%; }
10
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #fff; }
11
+ /* ── Main layout ───────────────────────────────────────── */
12
+ #main-wrapper {
13
+ display: none;
14
+ height: calc(100% - 40px);
15
+ overflow: hidden;
16
+ }
17
+ #root { flex: 1; min-width: 0; height: 100%; overflow: hidden; transition: all 0.2s ease; }
18
+ /* ── Right sidebar ─────────────────────────────────────── */
19
+ #right-sidebar {
20
+ width: 0; height: 100%; overflow: hidden;
21
+ background: #111113; border-left: 1px solid #1e1e24;
22
+ transition: width 0.2s ease;
23
+ display: flex; flex-direction: column; flex-shrink: 0;
24
+ }
25
+ #right-sidebar.open { width: 320px; }
26
+ #sidebar-tabs {
27
+ display: flex; border-bottom: 1px solid #1e1e24; flex-shrink: 0;
28
+ }
29
+ #sidebar-tabs button {
30
+ flex: 1; padding: 10px; background: none; border: none;
31
+ color: #6b6b80; font-size: 12px; font-weight: 600; cursor: pointer;
32
+ text-transform: uppercase; letter-spacing: 0.05em;
33
+ border-bottom: 2px solid transparent; transition: color 0.15s;
34
+ }
35
+ #sidebar-tabs button.active { color: #6366f1; border-bottom-color: #6366f1; }
36
+ #sidebar-tabs button:hover:not(.active) { color: #94a3b8; }
37
+ #sidebar-chat, #sidebar-activity {
38
+ flex: 1; overflow: hidden; display: none; flex-direction: column;
39
+ }
40
+ #sidebar-chat.active, #sidebar-activity.active { display: flex; }
41
+ /* ── Topbar ──────────────────────────────────────────── */
42
+ #topbar {
43
+ height: 40px; background: #111113; border-bottom: 1px solid #1e1e24;
44
+ display: flex; align-items: center; padding: 0 12px; gap: 8px;
45
+ font-size: 13px; z-index: 10000; position: relative;
46
+ }
47
+ .topbar-title { color: #e2e2e8; font-weight: 600; font-size: 13px; margin-left: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
48
+ .topbar-participants { display: flex; gap: 4px; align-items: center; margin-right: 4px; }
49
+ .topbar-participant {
50
+ padding: 2px 8px; border-radius: 9999px; font-size: 10px; font-weight: 500;
51
+ white-space: nowrap; cursor: pointer; position: relative;
52
+ transition: filter 0.15s;
53
+ }
54
+ .topbar-participant:hover { filter: brightness(1.3); }
55
+ .topbar-participant.human { background: #1e293b; color: #60a5fa; }
56
+ .topbar-participant.ai { background: #1a1625; color: #a78bfa; }
57
+ .topbar-participant.unknown { background: #1e1e24; color: #6b6b80; }
58
+ .participant-dropdown {
59
+ position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
60
+ margin-top: 6px; background: #1e1e2e; border: 1px solid #2e2e3e;
61
+ border-radius: 8px; padding: 4px; min-width: 140px;
62
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10001;
63
+ animation: dropdownIn 0.12s ease-out;
64
+ }
65
+ @keyframes dropdownIn { from { opacity: 0; transform: translateX(-50%) translateY(-4px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
66
+ .participant-dropdown .dd-header {
67
+ padding: 6px 10px; font-size: 11px; color: #6b6b80;
68
+ border-bottom: 1px solid #2e2e3e; margin-bottom: 2px;
69
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
70
+ }
71
+ .participant-dropdown button {
72
+ display: flex; align-items: center; gap: 6px; width: 100%;
73
+ padding: 7px 10px; background: none; border: none;
74
+ color: #e2e2e8; font-size: 12px; cursor: pointer;
75
+ border-radius: 4px; text-align: left;
76
+ }
77
+ .participant-dropdown button:hover { background: #2e2e3e; }
78
+ .participant-dropdown button.kick-action { color: #f87171; }
79
+ .participant-dropdown button.kick-action:hover { background: #3b1c1c; }
80
+ .topbar-btn {
81
+ background: none; border: none; color: #94a3b8; cursor: pointer;
82
+ font-size: 16px; padding: 4px 8px; border-radius: 4px;
83
+ display: flex; align-items: center; justify-content: center;
84
+ transition: background 0.15s, color 0.15s; flex-shrink: 0;
85
+ }
86
+ .topbar-btn:hover { background: #1e1e2e; color: #e2e2e8; }
87
+ /* ── Library ─────────────────────────────────────────── */
88
+ #library {
89
+ display: none; width: 100%; height: calc(100% - 40px);
90
+ overflow-y: auto; padding: 32px; max-width: 720px; margin: 0 auto;
91
+ }
92
+ #library.active { display: block; }
93
+ #library h2 { font-size: 20px; font-weight: 700; margin-bottom: 20px; color: #e2e2e8; }
94
+ #library h3 { font-size: 14px; font-weight: 600; color: #6b6b80; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; margin-top: 24px; }
95
+ .lib-card {
96
+ background: #111113; border: 1px solid #1e1e24; border-radius: 8px;
97
+ padding: 14px 16px; margin-bottom: 8px; cursor: pointer;
98
+ transition: border-color 0.15s, background 0.15s;
99
+ }
100
+ .lib-card:hover { border-color: #6366f1; background: #16161a; }
101
+ .lib-card .title { font-size: 14px; font-weight: 600; color: #e2e2e8; }
102
+ .lib-card .desc { font-size: 12px; color: #6b6b80; margin-top: 4px; }
103
+ .lib-card .meta { font-size: 11px; color: #4a4a5a; margin-top: 6px; display: flex; gap: 12px; }
104
+ .lib-empty { color: #4a4a5a; font-size: 13px; padding: 16px 0; }
105
+ #loading {
106
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
107
+ height: calc(100vh - 40px); gap: 16px; color: #94a3b8;
108
+ }
109
+ #loading .spinner {
110
+ width: 32px; height: 32px; border: 2px solid #334155;
111
+ border-top-color: #6366f1; border-radius: 50%;
112
+ animation: spin 0.8s linear infinite;
113
+ }
114
+ @keyframes spin { to { transform: rotate(360deg); } }
115
+ #error-display {
116
+ display: none; padding: 32px; max-width: 600px; margin: 80px auto;
117
+ background: #1e1e2e; border: 1px solid #ef4444; border-radius: 12px;
118
+ }
119
+ #error-display h2 { color: #ef4444; margin-bottom: 12px; }
120
+ #error-display pre { font-size: 13px; color: #94a3b8; white-space: pre-wrap; word-break: break-word; }
121
+ #toast-container {
122
+ position: fixed; top: 16px; right: 16px; z-index: 9999;
123
+ display: flex; flex-direction: column; gap: 8px; max-width: 420px;
124
+ pointer-events: none;
125
+ }
126
+ .toast {
127
+ padding: 12px 16px; border-radius: 8px; font-size: 13px;
128
+ background: #1e1e2e; border: 1px solid #ef4444; color: #f87171;
129
+ white-space: pre-wrap; word-break: break-word;
130
+ animation: toastIn 0.2s ease-out;
131
+ cursor: pointer; pointer-events: auto;
132
+ }
133
+ .toast-build { border-color: #f59e0b; color: #fbbf24; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 12px; max-height: 300px; overflow-y: auto; }
134
+ @keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
135
+ /* ── Activity log (inside sidebar) ─────────────────── */
136
+ .sidebar-panel-header {
137
+ padding: 12px 16px; border-bottom: 1px solid #1e1e24;
138
+ font-size: 12px; font-weight: 700; color: #6b6b80;
139
+ text-transform: uppercase; letter-spacing: 0.06em;
140
+ display: flex; justify-content: space-between; align-items: center;
141
+ flex-shrink: 0;
142
+ }
143
+ #participant-list {
144
+ padding: 8px 16px; border-bottom: 1px solid #1e1e24;
145
+ display: flex; flex-wrap: wrap; gap: 6px; flex-shrink: 0;
146
+ }
147
+ .participant-chip {
148
+ padding: 3px 10px; border-radius: 9999px; font-size: 11px; font-weight: 500;
149
+ display: inline-flex; align-items: center; gap: 4px;
150
+ }
151
+ .participant-chip .kick-btn {
152
+ background: none; border: none; color: #4a4a5a; cursor: pointer;
153
+ font-size: 12px; padding: 0; line-height: 1;
154
+ opacity: 0; transition: opacity 0.15s;
155
+ }
156
+ .participant-chip:hover .kick-btn { opacity: 1; }
157
+ .participant-chip .kick-btn:hover { color: #ef4444; }
158
+ .participant-chip.human { background: #1e293b; color: #60a5fa; }
159
+ .participant-chip.ai { background: #1a1625; color: #a78bfa; }
160
+ .participant-chip.unknown { background: #1e1e24; color: #6b6b80; }
161
+ #event-log {
162
+ flex: 1; overflow-y: auto; padding: 8px 0;
163
+ }
164
+ .event-entry {
165
+ padding: 6px 16px; font-size: 12px; line-height: 1.5;
166
+ border-bottom: 1px solid #0a0a0a;
167
+ }
168
+ .event-entry:hover { background: #1e1e24; }
169
+ .event-actor { font-weight: 600; }
170
+ .event-actor.human { color: #60a5fa; }
171
+ .event-actor.ai { color: #a78bfa; }
172
+ .event-tool { color: #6366f1; font-weight: 500; }
173
+ .event-time { color: #4a4a5a; font-size: 10px; float: right; }
174
+ .event-error { color: #f87171; }
175
+ /* ── Toast for share link ──────────────────────────── */
176
+ .toast-info { border-color: #6366f1; color: #818cf8; }
177
+ </style>
178
+ </head>
179
+ <body>
180
+ <div id="topbar">
181
+ <button class="topbar-btn" id="btn-back" onclick="navigateToLibrary()" title="Back to library">&#8592;</button>
182
+ <span class="topbar-title" id="topbar-title"></span>
183
+ <div style="flex:1"></div>
184
+ <div class="topbar-participants" id="topbar-participants"></div>
185
+ <button class="topbar-btn" id="btn-reset" onclick="resetRoom()" title="Reset experience">&#8634;</button>
186
+ <button class="topbar-btn" id="btn-share" onclick="copyShareLink()" title="Copy link">&#128279;</button>
187
+ <button class="topbar-btn" id="btn-sidebar" onclick="toggleSidebar()" title="Toggle sidebar">&#9776;</button>
188
+ </div>
189
+ <div id="library"></div>
190
+ <div id="loading">
191
+ <div class="spinner"></div>
192
+ <div>Loading experience...</div>
193
+ </div>
194
+ <div id="error-display">
195
+ <h2>Experience Error</h2>
196
+ <pre id="error-message"></pre>
197
+ </div>
198
+ <div id="toast-container"></div>
199
+ <div id="main-wrapper">
200
+ <div id="root"></div>
201
+ <div id="right-sidebar">
202
+ <div id="sidebar-tabs">
203
+ <button class="active" onclick="switchSidebarTab('chat')">Chat</button>
204
+ <button onclick="switchSidebarTab('activity')">Activity</button>
205
+ </div>
206
+ <div id="sidebar-chat" class="active"></div>
207
+ <div id="sidebar-activity">
208
+ <div class="sidebar-panel-header">
209
+ <span>Participants</span>
210
+ <span id="participant-count">0</span>
211
+ </div>
212
+ <div id="participant-list"></div>
213
+ <div class="sidebar-panel-header">
214
+ <span>Activity</span>
215
+ <span id="event-count">0</span>
216
+ </div>
217
+ <div id="event-log"></div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ <!-- External deps from CDN -->
223
+ <script type="importmap">
224
+ {
225
+ "imports": {
226
+ "react": "https://esm.sh/react@18.2.0",
227
+ "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
228
+ "react-dom": "https://esm.sh/react-dom@18.2.0",
229
+ "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
230
+ "zod": "https://esm.sh/zod@3.22.4",
231
+ "yjs": "https://esm.sh/yjs@13.6.10",
232
+ "@vibevibes/sdk": "/sdk.js"
233
+ }
234
+ }
235
+ </script>
236
+ <!-- Screenshot capture (html2canvas for DOM/SVG, canvas.toDataURL for <canvas>) -->
237
+ <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
238
+ <script type="module">
239
+ import React from "react";
240
+ import ReactDOM from "react-dom/client";
241
+ import { z } from "zod";
242
+ import * as Y from "yjs";
243
+
244
+ // ── Set up globalThis ────────────────────────────────────
245
+
246
+ // Wrap createElement to catch undefined component types — the O(1) safety net.
247
+ // When a bundle variable resolves to undefined (aliasing issues, missing globals,
248
+ // browser cache), this catches it at render time and shows a useful error instead
249
+ // of the cryptic "Function.prototype.call was called on undefined".
250
+ const _origCE = React.createElement;
251
+ React.createElement = function(type) {
252
+ if (type === undefined || type === null) {
253
+ console.error("[vibevibes] createElement called with undefined component. Props:", arguments[1]);
254
+ return _origCE("div", {
255
+ style: { padding: "8px", background: "#2d1b1b", border: "1px solid #ef4444",
256
+ borderRadius: "4px", color: "#f87171", fontSize: "12px" }
257
+ }, "[undefined component]");
258
+ }
259
+ return _origCE.apply(this, arguments);
260
+ };
261
+
262
+ globalThis.React = React;
263
+ globalThis.ReactDOM = ReactDOM;
264
+ globalThis.Y = Y;
265
+
266
+ globalThis.z = z;
267
+
268
+ // SDK define helpers (same contract as @vibevibes/sdk)
269
+ globalThis.defineExperience = (config) => config;
270
+ globalThis.defineTool = (config) => ({ risk: "low", capabilities_required: [], ...config });
271
+ globalThis.defineTest = (config) => config;
272
+ globalThis.defineStream = (config) => config;
273
+ globalThis.validateExperience = () => ({ valid: true, errors: [], warnings: [] });
274
+ globalThis.quickTool = (name, desc, schema, handler) =>
275
+ globalThis.defineTool({ name, description: desc, input_schema: schema, handler });
276
+ globalThis.phaseTool = (zod, validPhases) => ({
277
+ name: "_phase.set",
278
+ description: "Transition to a new phase" + (validPhases && validPhases.length > 0 ? ` (${validPhases.join(", ")})` : ""),
279
+ input_schema: validPhases && validPhases.length > 0 ? zod.object({ phase: zod.enum(validPhases) }) : zod.object({ phase: zod.string() }),
280
+ risk: "low", capabilities_required: ["state.write"],
281
+ handler: async (ctx, input) => { ctx.setState({ ...ctx.state, phase: input.phase }); return { phase: input.phase }; },
282
+ });
283
+
284
+ const { useState, useEffect, useCallback, useMemo, useRef, useContext, useReducer, createContext, forwardRef, memo } = React;
285
+
286
+ const ce = React.createElement;
287
+
288
+ // ── Sidebar + topbar ─────────────────────────────────────
289
+
290
+ let sidebarOpen = localStorage.getItem("vibevibes_sidebar") !== "closed";
291
+ const activityEvents = [];
292
+ const MAX_ACTIVITY_EVENTS = 100;
293
+ let currentExperienceTitle = "";
294
+
295
+ // Apply saved sidebar state on load
296
+ document.getElementById("right-sidebar").classList.toggle("open", sidebarOpen);
297
+
298
+ globalThis.toggleSidebar = function() {
299
+ sidebarOpen = !sidebarOpen;
300
+ document.getElementById("right-sidebar").classList.toggle("open", sidebarOpen);
301
+ localStorage.setItem("vibevibes_sidebar", sidebarOpen ? "open" : "closed");
302
+ };
303
+
304
+ globalThis.switchSidebarTab = function(tab) {
305
+ document.querySelectorAll("#sidebar-tabs button").forEach(b => b.classList.remove("active"));
306
+ const buttons = document.querySelectorAll("#sidebar-tabs button");
307
+ buttons.forEach(b => { if (b.textContent.toLowerCase() === tab) b.classList.add("active"); });
308
+ document.getElementById("sidebar-chat").classList.toggle("active", tab === "chat");
309
+ document.getElementById("sidebar-activity").classList.toggle("active", tab === "activity");
310
+ };
311
+
312
+ globalThis.copyShareLink = function() {
313
+ navigator.clipboard.writeText(location.href).then(() => {
314
+ showToast("Link copied!", "info", 2000);
315
+ }).catch(() => {
316
+ showToast("Copy failed — use address bar", "error", 3000);
317
+ });
318
+ };
319
+
320
+ globalThis.resetRoom = async function() {
321
+ if (!ROOM_ID) return;
322
+ if (!confirm("Reset this experience to its initial state?")) return;
323
+ try {
324
+ const res = await fetch(`${SERVER}/rooms/${encodeURIComponent(ROOM_ID)}/reset`, {
325
+ method: "POST",
326
+ headers: { "Content-Type": "application/json" },
327
+ });
328
+ const data = await res.json();
329
+ if (data.error) { showToast(data.error); return; }
330
+ showToast("Experience reset", "info", 2000);
331
+ } catch (err) {
332
+ showToast(`Reset failed: ${err.message}`);
333
+ }
334
+ };
335
+
336
+ function kickParticipant(targetActorId) {
337
+ if (!confirm("Kick " + targetActorId + "?")) return;
338
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return;
339
+ currentWs.send(JSON.stringify({ type: "kick", targetActorId }));
340
+ }
341
+
342
+ function parseActorId(id) {
343
+ const m = id.match(/^(.+)-(human|ai)-(\d+)$/);
344
+ if (m) return { username: m[1], type: m[2] };
345
+ return { username: id, type: "unknown" };
346
+ }
347
+
348
+ function updateTopbarTitle(title) {
349
+ currentExperienceTitle = title || "";
350
+ document.getElementById("topbar-title").textContent = currentExperienceTitle;
351
+ }
352
+
353
+ // participantDetails: [{ actorId, type }] from server (richer than string array)
354
+ let _participantDetails = [];
355
+ let _currentActorId = null;
356
+
357
+ let _activeDropdown = null;
358
+
359
+ function closeDropdown() {
360
+ if (_activeDropdown) { _activeDropdown.remove(); _activeDropdown = null; }
361
+ }
362
+
363
+ document.addEventListener("click", (e) => {
364
+ if (_activeDropdown && !_activeDropdown.contains(e.target) && !e.target.closest(".topbar-participant")) {
365
+ closeDropdown();
366
+ }
367
+ });
368
+
369
+ function showParticipantDropdown(chip, pid, type, username) {
370
+ closeDropdown();
371
+
372
+ const detail = _participantDetails.find(d => d.actorId === pid);
373
+ const myDetail = _participantDetails.find(d => d.actorId === _currentActorId);
374
+ const canKick = myDetail ? myDetail.type === "human" : _currentActorId && _currentActorId.startsWith("viewer");
375
+ const isSelf = pid === _currentActorId;
376
+
377
+ const modeColors = { behavior: "#22c55e", manual: "#3b82f6", hybrid: "#a855f7" };
378
+
379
+ const dd = document.createElement("div");
380
+ dd.className = "participant-dropdown";
381
+
382
+ const header = document.createElement("div");
383
+ header.className = "dd-header";
384
+ header.textContent = pid;
385
+ dd.appendChild(header);
386
+
387
+ // Rich metadata rows
388
+ if (detail) {
389
+ const addRow = (label, value, color) => {
390
+ if (!value) return;
391
+ const row = document.createElement("div");
392
+ row.style.cssText = "padding:4px 12px;font-size:11px;display:flex;justify-content:space-between;align-items:center;";
393
+ const lbl = document.createElement("span");
394
+ lbl.style.color = "#6b6b80";
395
+ lbl.textContent = label;
396
+ const val = document.createElement("span");
397
+ val.style.cssText = "font-weight:500;color:" + (color || "#e2e2e8") + ";";
398
+ if (color) {
399
+ val.style.cssText += "padding:1px 6px;border-radius:4px;background:" + color + "22;border:1px solid " + color + "44;font-size:10px;";
400
+ }
401
+ val.textContent = value;
402
+ row.appendChild(lbl);
403
+ row.appendChild(val);
404
+ dd.appendChild(row);
405
+ };
406
+ if (detail.role) addRow("Role", detail.role);
407
+ if (detail.agentMode) addRow("Mode", detail.agentMode, modeColors[detail.agentMode]);
408
+ if (detail.metadata) {
409
+ const model = detail.metadata.model;
410
+ if (model) addRow("Model", model, "#f59e0b");
411
+ for (const [k, v] of Object.entries(detail.metadata)) {
412
+ if (k === "model") continue;
413
+ addRow(k, v);
414
+ }
415
+ }
416
+ }
417
+
418
+ if (canKick && !isSelf) {
419
+ const kickBtn = document.createElement("button");
420
+ kickBtn.className = "kick-action";
421
+ kickBtn.innerHTML = "\u{274C} Kick from room";
422
+ kickBtn.onclick = (e) => { e.stopPropagation(); closeDropdown(); kickParticipant(pid); };
423
+ dd.appendChild(kickBtn);
424
+ }
425
+
426
+ if (isSelf) {
427
+ const selfLabel = document.createElement("div");
428
+ selfLabel.className = "dd-header";
429
+ selfLabel.style.borderBottom = "none";
430
+ selfLabel.style.color = "#4a4a5a";
431
+ selfLabel.style.fontStyle = "italic";
432
+ selfLabel.textContent = "(you)";
433
+ dd.appendChild(selfLabel);
434
+ }
435
+
436
+ chip.appendChild(dd);
437
+ _activeDropdown = dd;
438
+ }
439
+
440
+ function updateTopbarParticipants(participants) {
441
+ // Close any active dropdown before clearing (prevents stale DOM reference)
442
+ if (_activeDropdown) { closeDropdown(); }
443
+ const container = document.getElementById("topbar-participants");
444
+ container.innerHTML = "";
445
+ const modeBorderColors = { behavior: "#22c55e", manual: "#3b82f6", hybrid: "#a855f7" };
446
+ for (const pid of participants) {
447
+ // Prefer participantDetails for type resolution (actorId format no longer encodes type)
448
+ const detail = _participantDetails.find(d => d.actorId === pid);
449
+ const rawType = detail ? detail.type : parseActorId(pid).type;
450
+ const type = ['human', 'ai', 'unknown'].includes(rawType) ? rawType : 'unknown';
451
+ const { username } = parseActorId(pid);
452
+ const chip = document.createElement("span");
453
+ chip.className = `topbar-participant ${type}`;
454
+ // Show role as suffix if present
455
+ const roleSuffix = detail && detail.role ? ` [${detail.role}]` : "";
456
+ chip.textContent = (type === "ai" ? "\u{1F916}" : "") + username + roleSuffix;
457
+ // Color-code border by agentMode for AI participants
458
+ if (detail && detail.agentMode && modeBorderColors[detail.agentMode]) {
459
+ chip.style.borderColor = modeBorderColors[detail.agentMode];
460
+ }
461
+ chip.title = pid + (detail && detail.agentMode ? ` (${detail.agentMode})` : "");
462
+ chip.onclick = (e) => { e.stopPropagation(); showParticipantDropdown(chip, pid, type, username); };
463
+ container.appendChild(chip);
464
+ }
465
+ }
466
+
467
+ function updateParticipantList(participants) {
468
+ // Sidebar participant list (detailed)
469
+ const el = document.getElementById("participant-list");
470
+ const countEl = document.getElementById("participant-count");
471
+ countEl.textContent = String(participants.length);
472
+ el.innerHTML = "";
473
+
474
+ const myDetail = _participantDetails.find(d => d.actorId === _currentActorId);
475
+ const canKick = myDetail ? myDetail.type === "human" : _currentActorId && _currentActorId.startsWith("viewer");
476
+
477
+ for (const pid of participants) {
478
+ const detail = _participantDetails.find(d => d.actorId === pid);
479
+ const rawType2 = detail ? detail.type : parseActorId(pid).type;
480
+ const type = ['human', 'ai', 'unknown'].includes(rawType2) ? rawType2 : 'unknown';
481
+ const { username } = parseActorId(pid);
482
+ const chip = document.createElement("span");
483
+ chip.className = `participant-chip ${type}`;
484
+
485
+ const nameSpan = document.createElement("span");
486
+ nameSpan.textContent = (type === "ai" ? "\u{1F916} " : "") + username;
487
+ chip.appendChild(nameSpan);
488
+
489
+ // Show role as sub-label
490
+ if (detail && detail.role) {
491
+ const roleSpan = document.createElement("span");
492
+ roleSpan.style.cssText = "font-size:9px;color:#6b6b80;margin-left:4px;";
493
+ roleSpan.textContent = detail.role;
494
+ chip.appendChild(roleSpan);
495
+ }
496
+
497
+ chip.title = pid;
498
+
499
+ if (canKick && pid !== _currentActorId) {
500
+ const btn = document.createElement("button");
501
+ btn.className = "kick-btn";
502
+ btn.title = "Kick " + username;
503
+ btn.textContent = "\u00D7";
504
+ btn.onclick = (e) => { e.stopPropagation(); kickParticipant(pid); };
505
+ chip.appendChild(btn);
506
+ }
507
+
508
+ el.appendChild(chip);
509
+ }
510
+ // Also update topbar compact chips
511
+ updateTopbarParticipants(participants);
512
+ }
513
+
514
+ function addActivityEvent(event) {
515
+ activityEvents.push(event);
516
+ if (activityEvents.length > MAX_ACTIVITY_EVENTS) {
517
+ activityEvents.shift();
518
+ // Remove oldest DOM node to prevent unbounded growth
519
+ const log = document.getElementById("event-log");
520
+ if (log && log.firstChild) log.removeChild(log.firstChild);
521
+ }
522
+
523
+ const log = document.getElementById("event-log");
524
+ const countEl = document.getElementById("event-count");
525
+ countEl.textContent = String(activityEvents.length);
526
+
527
+ const entry = document.createElement("div");
528
+ entry.className = "event-entry";
529
+
530
+ const { type: actorType } = parseActorId(event.actorId || "unknown");
531
+ const time = new Date(event.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
532
+
533
+ entry.innerHTML = `<span class="event-time">${escHtml(time)}</span>`
534
+ + `<span class="event-actor ${escHtml(actorType)}">${escHtml(event.actorId)}</span> `
535
+ + `<span class="event-tool">${escHtml(event.tool)}</span>`
536
+ + (event.error ? ` <span class="event-error">ERR</span>` : "");
537
+
538
+ log.appendChild(entry);
539
+ log.scrollTop = log.scrollHeight;
540
+ }
541
+
542
+ function renderSidebarChat(sharedState, callTool, actorId) {
543
+ const el = document.getElementById("sidebar-chat");
544
+ if (!el) return;
545
+ const msgs = sharedState._chat || [];
546
+ const parseActor = (id) => {
547
+ const m = id.match(/^(.+)-(human|ai)-(\d+)$/);
548
+ return m ? { name: m[1], type: m[2] } : { name: id, type: "unknown" };
549
+ };
550
+ const colors = { human: "#60a5fa", ai: "#a78bfa", unknown: "#94a3b8" };
551
+ el.innerHTML = `
552
+ <div style="flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:6px">
553
+ ${msgs.length === 0 ? '<div style="color:#4a4a5a;font-size:13px;text-align:center;padding:32px 0">No messages yet</div>' :
554
+ msgs.map(m => {
555
+ const a = parseActor(m.actorId);
556
+ const isMe = m.actorId === actorId;
557
+ return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'}">
558
+ <span style="font-size:11px;font-weight:600;color:${colors[a.type] || colors.unknown}">${escHtml(a.name)}</span>
559
+ <div style="background:${isMe ? '#6366f1' : '#1e1e2e'};color:${isMe ? '#fff' : '#e2e2e8'};padding:6px 10px;border-radius:10px;font-size:13px;max-width:240px;word-break:break-word">${escHtml(m.message)}</div>
560
+ </div>`;
561
+ }).join("")}
562
+ </div>
563
+ <div style="padding:8px 12px;border-top:1px solid #1e1e24;display:flex;gap:8px;flex-shrink:0">
564
+ <input id="chat-input" type="text" placeholder="Type a message..." style="flex:1;padding:6px 10px;font-size:13px;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#fff;outline:none;font-family:system-ui">
565
+ <button id="chat-send" style="padding:6px 12px;border-radius:6px;background:#6366f1;color:#fff;border:none;font-size:13px;cursor:pointer;font-weight:500">Send</button>
566
+ </div>`;
567
+ const inp = el.querySelector("#chat-input");
568
+ const btn = el.querySelector("#chat-send");
569
+ const send = () => {
570
+ const text = inp.value.trim();
571
+ if (!text) return;
572
+ inp.value = "";
573
+ callTool("_chat.send", { message: text }).catch(() => {});
574
+ };
575
+ btn.onclick = send;
576
+ inp.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } };
577
+ }
578
+
579
+ // ── Toast notifications ─────────────────────────────────
580
+
581
+ function showToast(message, type = "error", durationMs = 6000) {
582
+ const container = document.getElementById("toast-container");
583
+ const toast = document.createElement("div");
584
+ const typeClass = type === "build" ? "toast-build" : type === "info" ? "toast-info" : "";
585
+ toast.className = "toast" + (typeClass ? " " + typeClass : "");
586
+ toast.textContent = message;
587
+ toast.onclick = () => toast.remove();
588
+ container.appendChild(toast);
589
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, durationMs);
590
+ }
591
+
592
+ // ── Connect to the experience ─────────────────────────────
593
+
594
+ const SERVER = location.origin;
595
+ const WS_PROTO = location.protocol === "https:" ? "wss:" : "ws:";
596
+ const WS_URL = `${WS_PROTO}//${location.host}`;
597
+
598
+ // Room-aware: read room ID from URL path (e.g. /room/room-abc123)
599
+ function getRoomIdFromPath() {
600
+ const path = location.pathname.replace(/^\/+/, "");
601
+ // /room/<roomId> → extract roomId
602
+ const roomMatch = path.match(/^room\/(.+)$/);
603
+ if (roomMatch) {
604
+ try { return decodeURIComponent(roomMatch[1]); } catch { return roomMatch[1]; }
605
+ }
606
+ // Bare path (legacy) — treat as room ID if non-empty
607
+ return path || null;
608
+ }
609
+ let ROOM_ID = getRoomIdFromPath();
610
+ // Sanitize ROOM_ID to prevent path traversal in URL construction
611
+ if (ROOM_ID && !/^[a-zA-Z0-9_\-]+$/.test(ROOM_ID)) ROOM_ID = null;
612
+ let currentWs = null;
613
+
614
+ // Read ?role= query param (e.g. ?role=spectator)
615
+ function getRoleFromQuery() {
616
+ const params = new URLSearchParams(location.search);
617
+ return params.get("role") || undefined;
618
+ }
619
+ let REQUESTED_ROLE = getRoleFromQuery();
620
+
621
+ // ── Browser error reporting to agent ─────────────────────
622
+ // Sends errors to the server so the agent sees them via /agent-context
623
+ function reportBrowserError(msg) {
624
+ try {
625
+ fetch("/browser-error", {
626
+ method: "POST",
627
+ headers: { "Content-Type": "application/json" },
628
+ body: JSON.stringify({ message: String(msg), roomId: ROOM_ID || "local" }),
629
+ }).catch(() => {}); // fire-and-forget
630
+ } catch {}
631
+ }
632
+
633
+ // Capture unhandled errors and unhandled promise rejections
634
+ window.addEventListener("error", (e) => {
635
+ reportBrowserError(e.message || String(e));
636
+ });
637
+ window.addEventListener("unhandledrejection", (e) => {
638
+ reportBrowserError(e.reason?.message || String(e.reason));
639
+ });
640
+
641
+ function showError(msg) {
642
+ document.getElementById("loading").style.display = "none";
643
+ document.getElementById("library").className = "";
644
+ document.getElementById("main-wrapper").style.display = "none";
645
+ document.getElementById("error-display").style.display = "block";
646
+ document.getElementById("error-message").textContent = msg;
647
+ reportBrowserError(msg);
648
+ }
649
+
650
+ // ── Library / Navigation ─────────────────────────────────
651
+
652
+ function showLibraryView() {
653
+ document.getElementById("library").className = "active";
654
+ document.getElementById("loading").style.display = "none";
655
+ document.getElementById("main-wrapper").style.display = "none";
656
+ document.getElementById("error-display").style.display = "none";
657
+ // Hide sidebar controls in library view
658
+ document.getElementById("right-sidebar").classList.remove("open");
659
+ document.getElementById("btn-reset").style.display = "none";
660
+ document.getElementById("btn-share").style.display = "none";
661
+ document.getElementById("btn-sidebar").style.display = "none";
662
+ document.getElementById("topbar-participants").style.display = "none";
663
+ updateTopbarTitle("Library");
664
+ loadLibrary();
665
+ }
666
+
667
+ function showRoomView() {
668
+ document.getElementById("library").className = "";
669
+ document.getElementById("main-wrapper").style.display = "flex";
670
+ document.getElementById("root").style.display = "block";
671
+ // Show sidebar controls in room view
672
+ document.getElementById("btn-reset").style.display = "flex";
673
+ document.getElementById("btn-share").style.display = "flex";
674
+ document.getElementById("btn-sidebar").style.display = "flex";
675
+ document.getElementById("topbar-participants").style.display = "flex";
676
+ if (sidebarOpen) document.getElementById("right-sidebar").classList.add("open");
677
+ }
678
+
679
+ globalThis.navigateToLibrary = function() {
680
+ if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; }
681
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
682
+ // Unmount Canvas tree so useEffect cleanups run (stops audio, disposes engines)
683
+ if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; }
684
+ // Reset stale state from previous room
685
+ reconnectAttempts = 0;
686
+ activityEvents.length = 0;
687
+ _participantDetails = [];
688
+ _currentActorId = null;
689
+ const eventLogEl = document.getElementById("event-log");
690
+ if (eventLogEl) eventLogEl.innerHTML = "";
691
+ history.pushState({}, "", "/");
692
+ ROOM_ID = null;
693
+ showLibraryView();
694
+ };
695
+
696
+ function navigateToRoom(roomId, role, skipPush) {
697
+ if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; }
698
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
699
+ // Unmount React trees to prevent stale closures
700
+ if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; }
701
+ // Reset stale state from previous room
702
+ reconnectAttempts = 0;
703
+ activityEvents.length = 0;
704
+ _participantDetails = [];
705
+ _currentActorId = null;
706
+ const eventLogEl2 = document.getElementById("event-log");
707
+ if (eventLogEl2) eventLogEl2.innerHTML = "";
708
+ sessionStorage.removeItem("vibevibes_actorId"); // Clear stale actorId from previous room
709
+ sessionStorage.removeItem("vibevibes_viewerSessionId"); // New room = new identity
710
+ ROOM_ID = roomId;
711
+ REQUESTED_ROLE = role || getRoleFromQuery();
712
+ const qs = REQUESTED_ROLE ? `?role=${encodeURIComponent(REQUESTED_ROLE)}` : "";
713
+ if (!skipPush) history.pushState({}, "", `/room/${encodeURIComponent(roomId)}${qs}`);
714
+ showRoomView();
715
+ document.getElementById("loading").style.display = "flex";
716
+ updateTopbarTitle(roomId); // Will be replaced by experience title once bundle loads
717
+ start().catch((err) => showError("Failed to start: " + (err.message || err)));
718
+ }
719
+
720
+ // Handle browser back/forward — skipPush=true to avoid pushState inside popstate
721
+ window.addEventListener("popstate", () => {
722
+ const r = getRoomIdFromPath();
723
+ if (r) { navigateToRoom(r, null, true); }
724
+ else { if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; } ROOM_ID = null; reconnectAttempts = 0; activityEvents.length = 0; _participantDetails = []; _currentActorId = null; sessionStorage.removeItem("vibevibes_actorId"); sessionStorage.removeItem("vibevibes_viewerSessionId"); const el = document.getElementById("event-log"); if (el) el.innerHTML = ""; showLibraryView(); }
725
+ });
726
+
727
+ async function spawnAndNavigate(experienceId) {
728
+ try {
729
+ const res = await fetch(`${SERVER}/rooms/spawn`, {
730
+ method: "POST",
731
+ headers: { "Content-Type": "application/json" },
732
+ body: JSON.stringify({ experienceId, sourceRoomId: "library" }),
733
+ });
734
+ const data = await res.json();
735
+ if (data.error) { showToast(data.error); return; }
736
+ // Validate roomId to prevent path traversal
737
+ if (!data.roomId || !/^[a-zA-Z0-9_\-]+$/.test(data.roomId)) { showToast("Invalid room ID from server"); return; }
738
+ navigateToRoom(data.roomId);
739
+ } catch (err) {
740
+ showToast(`Failed to spawn: ${err.message}`);
741
+ }
742
+ }
743
+ globalThis.spawnAndNavigate = spawnAndNavigate;
744
+
745
+ let _libClickHandler = null;
746
+ async function loadLibrary() {
747
+ const lib = document.getElementById("library");
748
+ lib.innerHTML = '<h2>Library</h2><div style="color:#6b6b80;font-size:13px;">Loading...</div>';
749
+
750
+ // Remove previous listener to prevent accumulation on repeated calls
751
+ if (_libClickHandler) {
752
+ lib.removeEventListener("click", _libClickHandler);
753
+ _libClickHandler = null;
754
+ }
755
+
756
+ try {
757
+ const experiences = await fetch(`${SERVER}/experiences`).then(r => r.json());
758
+
759
+ // Filter out host experience — host is the homepage, not a playable experience
760
+ const templateExperiences = experiences.filter(exp => exp.source !== "host");
761
+
762
+ let html = '<h2>Library</h2>';
763
+
764
+ if (templateExperiences.length === 0) {
765
+ html += '<div class="lib-empty">No experiences registered.</div>';
766
+ } else {
767
+ for (const exp of templateExperiences) {
768
+ // Each experience has a pre-created room with id = experienceId
769
+ html += `<div class="lib-card" data-room-id="${escHtml(exp.id)}">`;
770
+ html += `<div class="title">${escHtml(exp.title)}</div>
771
+ <div class="desc">${escHtml(exp.description)}</div>
772
+ <div class="meta">
773
+ <span>${escHtml(exp.id)}</span>
774
+ <span>v${escHtml(exp.version)}</span>
775
+ </div>
776
+ </div>`;
777
+ }
778
+ }
779
+
780
+ lib.innerHTML = html;
781
+ // Use event delegation instead of onclick attributes (prevents XSS via room IDs)
782
+ _libClickHandler = (e) => {
783
+ const card = e.target.closest(".lib-card[data-room-id]");
784
+ if (card) navigateToRoom(card.dataset.roomId);
785
+ };
786
+ lib.addEventListener("click", _libClickHandler);
787
+ } catch (err) {
788
+ lib.innerHTML = `<h2>Library</h2><div class="lib-empty">Failed to load: ${escHtml(err.message)}</div>`;
789
+ }
790
+ }
791
+
792
+ function escHtml(s) {
793
+ if (s == null || s === '') return '';
794
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
795
+ }
796
+
797
+ // Expose navigateToRoom globally for onclick handlers
798
+ globalThis.navigateToRoom = navigateToRoom;
799
+
800
+ // Unique viewer session ID per tab (survives refresh via sessionStorage, unique across tabs)
801
+ function getViewerSessionId() {
802
+ let id = sessionStorage.getItem("vibevibes_viewerSessionId");
803
+ if (!id) {
804
+ id = "viewer-" + Math.random().toString(36).slice(2, 10) + "-" + Date.now().toString(36);
805
+ sessionStorage.setItem("vibevibes_viewerSessionId", id);
806
+ }
807
+ return id;
808
+ }
809
+
810
+ async function start() {
811
+ const roomId = ROOM_ID || "local";
812
+ const viewerOwner = getViewerSessionId();
813
+ try {
814
+ // Connect WebSocket
815
+ const ws = new WebSocket(WS_URL);
816
+ currentWs = ws;
817
+
818
+ let actorId = "viewer";
819
+ let sharedState = {};
820
+ let _stateVersion = 0;
821
+ let participants = [];
822
+ let roomConfig = {};
823
+ let myRole = REQUESTED_ROLE; // "spectator" | "player" | undefined
824
+ let Canvas = null;
825
+ let EntryRitual = null;
826
+ let ritualComplete = false;
827
+ let hotReloadKey = 0;
828
+ // ── Optimistic state layer ──────────────────────────────
829
+ // Tracks in-flight optimistic overlays keyed by a unique call ID.
830
+ // Each overlay is { state: {...}, ts: number }.
831
+ // Display state = sharedState merged with all active overlays.
832
+ let optimisticOverlays = new Map();
833
+ let optimisticSeq = 0;
834
+
835
+ function getDisplayState() {
836
+ if (optimisticOverlays.size === 0) return sharedState;
837
+ let merged = { ...sharedState };
838
+ for (const overlay of optimisticOverlays.values()) {
839
+ Object.assign(merged, overlay.state);
840
+ }
841
+ return merged;
842
+ }
843
+
844
+ // ── Ephemeral state (high-frequency, no tool gate) ──────
845
+ let ephemeralState = {};
846
+
847
+ function setEphemeral(data) {
848
+ ephemeralState = {
849
+ ...ephemeralState,
850
+ [actorId]: { ...(ephemeralState[actorId] || {}), ...data },
851
+ };
852
+ // Broadcast to server for relay to other participants
853
+ if (ws.readyState === WebSocket.OPEN) {
854
+ ws.send(JSON.stringify({ type: "ephemeral", actorId, data }));
855
+ }
856
+ render();
857
+ }
858
+
859
+ // ── Stream function (high-frequency, bypasses tool gate) ──────
860
+ function streamFn(name, input) {
861
+ if (ws.readyState === WebSocket.OPEN) {
862
+ ws.send(JSON.stringify({ type: "stream", name, input, actorId }));
863
+ }
864
+ }
865
+
866
+ // Spectators get a no-op callTool that logs a warning
867
+ async function spectatorCallTool(name, input) {
868
+ console.warn(`[spectator] Blocked tool call: ${name}`, input);
869
+ return { blocked: true, reason: "spectators cannot call tools" };
870
+ }
871
+
872
+ function render() {
873
+ if (!Canvas || !reactRoot) return;
874
+
875
+ const displayState = getDisplayState();
876
+ const isSpectator = myRole === "spectator";
877
+ const activeCallTool = isSpectator ? spectatorCallTool : callTool;
878
+
879
+ // Expose context for hooks
880
+ globalThis.__vibevibes_ctx__ = {
881
+ sharedState: displayState,
882
+ callTool: activeCallTool,
883
+ participants,
884
+ actorId,
885
+ ephemeralState,
886
+ setEphemeral,
887
+ };
888
+
889
+ // Entry ritual gate: show ritual component before Canvas
890
+ if (EntryRitual && !ritualComplete) {
891
+ reactRoot.render(
892
+ ce(CanvasErrorBoundary, { key: hotReloadKey },
893
+ ce(EntryRitual, {
894
+ actorId, participants, participantDetails: _participantDetails,
895
+ callTool: activeCallTool,
896
+ proceed: function() { ritualComplete = true; render(); },
897
+ roomConfig,
898
+ })
899
+ )
900
+ );
901
+ renderSidebarChat(displayState, callTool, actorId);
902
+ return;
903
+ }
904
+
905
+ reactRoot.render(
906
+ ce(CanvasErrorBoundary, { key: hotReloadKey },
907
+ ce(Canvas, {
908
+ roomId: roomId,
909
+ actorId,
910
+ sharedState: displayState,
911
+ callTool: activeCallTool,
912
+ participants,
913
+ participantDetails: _participantDetails,
914
+ ephemeralState,
915
+ setEphemeral,
916
+ roomConfig,
917
+ stream: streamFn,
918
+ role: myRole || "player",
919
+ })
920
+ )
921
+ );
922
+
923
+ // Render ChatPanel in sidebar
924
+ renderSidebarChat(displayState, callTool, actorId);
925
+ }
926
+
927
+ async function callTool(name, input, _optimisticState) {
928
+ // Validate tool name to prevent path traversal (e.g. "../leave")
929
+ if (!/^[a-zA-Z0-9_.\-:]+$/.test(name)) { throw new Error("Invalid tool name"); }
930
+ // If an optimistic state is provided, apply it IMMEDIATELY
931
+ let overlayId = null;
932
+ if (_optimisticState) {
933
+ overlayId = ++optimisticSeq;
934
+ optimisticOverlays.set(overlayId, { state: _optimisticState, ts: Date.now() });
935
+ render(); // Re-render with predicted state at T=0ms
936
+ }
937
+
938
+ try {
939
+ const toolUrl = roomId === "local"
940
+ ? `${SERVER}/tools/${name}`
941
+ : `${SERVER}/rooms/${roomId}/tools/${name}`;
942
+ const res = await fetch(toolUrl, {
943
+ method: "POST",
944
+ headers: { "Content-Type": "application/json" },
945
+ body: JSON.stringify({ actorId, input: input || {} }),
946
+ });
947
+ const data = await res.json();
948
+ if (data.error) {
949
+ showToast(data.error);
950
+ throw new Error(data.error);
951
+ }
952
+ return data.output;
953
+ } catch (err) {
954
+ if (!document.querySelector(".toast")) {
955
+ showToast(`Tool '${name}' failed: ${err.message || "unknown error"}`);
956
+ }
957
+ throw err;
958
+ } finally {
959
+ // Clear this overlay — server state broadcast will take over
960
+ if (overlayId !== null) {
961
+ optimisticOverlays.delete(overlayId);
962
+ render(); // Re-render with server-authoritative state
963
+ }
964
+ }
965
+ }
966
+
967
+ // Error boundary component
968
+ function getErrorHint(message) {
969
+ if (!message) return null;
970
+ if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property"))
971
+ return "A component accessed state that hasn't been initialized. Check your initialState and ensure all keys exist before reading them.";
972
+ if (message.includes("is not a function"))
973
+ return "Something expected to be a function is not. Check for missing imports or incorrect prop types.";
974
+ if (message.includes("Maximum update depth"))
975
+ return "Infinite re-render loop detected. Check useEffect dependencies — avoid setting state unconditionally inside useEffect.";
976
+ if (message.includes("Invalid hook call"))
977
+ return "Hooks can only be called inside React function components. Check that you're not calling useState/useEffect outside a component.";
978
+ if (message.includes("is not defined"))
979
+ return "A variable or import is missing. Check that all imports from @vibevibes/sdk or other modules are correct.";
980
+ return null;
981
+ }
982
+
983
+ class CanvasErrorBoundary extends React.Component {
984
+ constructor(props) {
985
+ super(props);
986
+ this.state = { error: null, componentStack: null };
987
+ }
988
+ static getDerivedStateFromError(error) { return { error }; }
989
+ componentDidCatch(error, info) {
990
+ console.error("[viewer] Experience crashed:", error, info?.componentStack);
991
+ if (info?.componentStack) this.setState({ componentStack: info.componentStack });
992
+ reportBrowserError(`[Canvas crash] ${error.message || error}`);
993
+ }
994
+ render() {
995
+ if (this.state.error) {
996
+ const msg = this.state.error.message || String(this.state.error);
997
+ const hint = getErrorHint(msg);
998
+ const stack = this.state.componentStack;
999
+ const fullError = msg + (stack ? "\n\nComponent stack:" + stack : "");
1000
+
1001
+ return ce("div", {
1002
+ style: { padding: "32px", textAlign: "center", color: "#ef4444", maxWidth: "640px", margin: "0 auto" },
1003
+ },
1004
+ ce("h2", null, "Experience Crashed"),
1005
+ ce("pre", { style: { fontSize: "13px", color: "#94a3b8", marginTop: "12px", whiteSpace: "pre-wrap", textAlign: "left" } }, msg),
1006
+ hint ? ce("div", {
1007
+ style: { marginTop: "12px", padding: "12px 16px", background: "#1e293b", borderRadius: "8px", border: "1px solid #334155", textAlign: "left" },
1008
+ },
1009
+ ce("strong", { style: { color: "#fbbf24", fontSize: "13px" } }, "Hint: "),
1010
+ ce("span", { style: { color: "#cbd5e1", fontSize: "13px" } }, hint),
1011
+ ) : null,
1012
+ stack ? ce("details", { style: { marginTop: "12px", textAlign: "left" } },
1013
+ ce("summary", { style: { color: "#64748b", cursor: "pointer", fontSize: "12px" } }, "Component stack"),
1014
+ ce("pre", { style: { fontSize: "11px", color: "#64748b", marginTop: "4px", whiteSpace: "pre-wrap" } }, stack),
1015
+ ) : null,
1016
+ ce("div", { style: { marginTop: "16px", display: "flex", gap: "8px", justifyContent: "center" } },
1017
+ ce("button", {
1018
+ onClick: () => this.setState({ error: null, componentStack: null }),
1019
+ style: { padding: "8px 16px", background: "#6366f1", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" },
1020
+ }, "Try Again"),
1021
+ ce("button", {
1022
+ onClick: () => { navigator.clipboard.writeText(fullError); showToast("Error copied to clipboard"); },
1023
+ style: { padding: "8px 16px", background: "#334155", color: "#cbd5e1", border: "none", borderRadius: "6px", cursor: "pointer" },
1024
+ }, "Copy Error"),
1025
+ ),
1026
+ );
1027
+ }
1028
+ return this.props.children;
1029
+ }
1030
+ }
1031
+
1032
+ ws.onopen = () => {
1033
+ reconnectAttempts = 0; // Reset backoff on successful connection
1034
+ const savedActorId = sessionStorage.getItem("vibevibes_actorId");
1035
+ ws.send(JSON.stringify({ type: "join", username: "viewer", roomId: roomId, actorId: savedActorId, owner: viewerOwner, role: REQUESTED_ROLE }));
1036
+ };
1037
+
1038
+ ws.onmessage = async (event) => {
1039
+ if (currentWs !== ws) return; // Guard against stale WS after navigation
1040
+ let msg;
1041
+ try { msg = JSON.parse(event.data); } catch { return; }
1042
+
1043
+ if (msg.type === "error") {
1044
+ // Server rejected the join (e.g., stale session). Clear saved actorId and retry.
1045
+ console.warn("[viewer] Server error:", msg.error);
1046
+ const savedId = sessionStorage.getItem("vibevibes_actorId");
1047
+ if (savedId) {
1048
+ sessionStorage.removeItem("vibevibes_actorId");
1049
+ console.log("[viewer] Cleared stale actorId, retrying join...");
1050
+ ws.send(JSON.stringify({ type: "join", username: "viewer", roomId: roomId, owner: viewerOwner, role: REQUESTED_ROLE }));
1051
+ } else {
1052
+ showError(`Server error: ${msg.error}`);
1053
+ }
1054
+ }
1055
+
1056
+ if (msg.type === "joined") {
1057
+ actorId = msg.actorId;
1058
+ _currentActorId = actorId;
1059
+ sessionStorage.setItem("vibevibes_actorId", actorId);
1060
+ sharedState = msg.sharedState || {};
1061
+ _stateVersion = msg.stateVersion ?? 0;
1062
+ participants = msg.participants || [];
1063
+ if (msg.participantDetails) _participantDetails = msg.participantDetails;
1064
+ roomConfig = msg.config || {};
1065
+ if (msg.role) myRole = msg.role;
1066
+
1067
+ // Load client bundle with retry (room-aware: external experiences get their own bundle)
1068
+ const bundleUrl = roomId === "local"
1069
+ ? `${SERVER}/bundle?_t=${Date.now()}`
1070
+ : `${SERVER}/rooms/${roomId}/bundle?_t=${Date.now()}`;
1071
+ let bundleLoaded = false;
1072
+ for (let attempt = 0; attempt < 3 && !bundleLoaded; attempt++) {
1073
+ try {
1074
+ if (attempt > 0) await new Promise(r => setTimeout(r, 500 * attempt));
1075
+ const bundleRes = await fetch(bundleUrl + "&r=" + attempt);
1076
+ if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
1077
+ const bundleCode = await bundleRes.text();
1078
+
1079
+ // Protocol experience: empty bundle → load HTML canvas in iframe
1080
+ if (!bundleCode.trim()) {
1081
+ const canvasUrl = `${SERVER}/rooms/${roomId}/canvas?actorId=${encodeURIComponent(actorId)}&roomId=${encodeURIComponent(roomId)}`;
1082
+ const root = document.getElementById("root");
1083
+ root.innerHTML = "";
1084
+ const iframe = document.createElement("iframe");
1085
+ iframe.src = canvasUrl;
1086
+ iframe.style.cssText = "width:100%;height:100%;border:none;";
1087
+ root.appendChild(iframe);
1088
+ Canvas = null;
1089
+ updateTopbarTitle(roomId);
1090
+ document.getElementById("loading").style.display = "none";
1091
+ showRoomView();
1092
+ bundleLoaded = true;
1093
+ break;
1094
+ }
1095
+
1096
+ const blob = new Blob([bundleCode], { type: "text/javascript" });
1097
+ const url = URL.createObjectURL(blob);
1098
+ const mod = await import(url);
1099
+ URL.revokeObjectURL(url);
1100
+
1101
+ const experience = mod.default ?? mod;
1102
+ if (!experience?.Canvas) throw new Error("Experience has no Canvas component");
1103
+
1104
+ Canvas = experience.Canvas;
1105
+ EntryRitual = experience.entryRitual || null;
1106
+ ritualComplete = false;
1107
+ if (!reactRoot) {
1108
+ reactRoot = ReactDOM.createRoot(document.getElementById("root"));
1109
+ }
1110
+
1111
+ // Extract experience title for topbar
1112
+ const expTitle = experience.manifest?.title || experience.name || roomId;
1113
+
1114
+ document.getElementById("loading").style.display = "none";
1115
+ showRoomView();
1116
+ render();
1117
+ updateParticipantList(participants);
1118
+ bundleLoaded = true;
1119
+ } catch (err) {
1120
+ console.warn(`[viewer] Bundle load attempt ${attempt + 1} failed:`, err.message);
1121
+ if (attempt >= 2) showError(`Failed to load experience: ${err.message}`);
1122
+ }
1123
+ }
1124
+ }
1125
+
1126
+ if (msg.type === "shared_state_update") {
1127
+ const serverVersion = msg.stateVersion;
1128
+
1129
+ if (msg.state) {
1130
+ // Full state — replace wholesale (reset, recovery, or legacy server)
1131
+ sharedState = msg.state;
1132
+ } else if (msg.delta) {
1133
+ // Delta mode — merge only changed keys
1134
+ // Gap detection: if we missed updates, request full state
1135
+ if (serverVersion && _stateVersion > 0 && serverVersion > _stateVersion + 1) {
1136
+ // Missed update(s) — apply delta but also request full state recovery
1137
+ console.warn(`[viewer] State version gap: ${_stateVersion} → ${serverVersion}, recovering...`);
1138
+ fetch(`${SERVER}${roomId === "local" ? "" : `/rooms/${roomId}`}/state`)
1139
+ .then(r => r.ok ? r.json() : null)
1140
+ .then(data => {
1141
+ if (data?.sharedState) {
1142
+ sharedState = data.sharedState;
1143
+ optimisticOverlays.clear();
1144
+ render();
1145
+ }
1146
+ })
1147
+ .catch(() => {});
1148
+ }
1149
+ // Apply delta: merge changed keys, remove deleted keys
1150
+ sharedState = { ...sharedState, ...msg.delta };
1151
+ if (msg.deletedKeys) {
1152
+ for (const key of msg.deletedKeys) {
1153
+ delete sharedState[key];
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ if (serverVersion) _stateVersion = serverVersion;
1159
+ participants = msg.participants || participants;
1160
+ // Server is authoritative — clear all optimistic overlays.
1161
+ optimisticOverlays.clear();
1162
+ render();
1163
+ updateParticipantList(participants);
1164
+ if (msg.event) {
1165
+ addActivityEvent(msg.event);
1166
+ // Log agent tool calls (skip tick engine noise)
1167
+ const ev = msg.event;
1168
+ if (ev.tool && ev.actorId && !ev.actorId.startsWith("_tick")) {
1169
+ const isAI = _participantDetails.find(d => d.actorId === ev.actorId)?.type === "ai";
1170
+ const tag = isAI ? "🤖" : "👤";
1171
+ const inputStr = ev.input ? JSON.stringify(ev.input) : "{}";
1172
+ const outputStr = ev.error ? `❌ ${ev.error}` : (ev.output ? JSON.stringify(ev.output) : "ok");
1173
+ console.log(`%c[ACTION] ${tag} ${ev.actorId} → ${ev.tool}(${inputStr}) → ${outputStr}`, "color: #60a5fa; font-size: 12px");
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ if (msg.type === "ephemeral") {
1179
+ // Another participant's ephemeral data — merge into ephemeralState
1180
+ const senderId = msg.actorId;
1181
+ if (senderId && senderId !== actorId) {
1182
+ ephemeralState = {
1183
+ ...ephemeralState,
1184
+ [senderId]: { ...(ephemeralState[senderId] || {}), ...msg.data },
1185
+ };
1186
+ render();
1187
+ }
1188
+ }
1189
+
1190
+ if (msg.type === "presence_update") {
1191
+ const prevSet = new Set(participants);
1192
+ participants = msg.participants || participants;
1193
+ if (msg.participantDetails) _participantDetails = msg.participantDetails;
1194
+ const newSet = new Set(participants);
1195
+ // Log joins
1196
+ for (const pid of newSet) {
1197
+ if (!prevSet.has(pid)) {
1198
+ const detail = _participantDetails.find(d => d.actorId === pid);
1199
+ const typeTag = detail?.type === "ai" ? "🤖" : detail?.type === "human" ? "👤" : "❓";
1200
+ const roleTag = detail?.role ? ` [${detail.role}]` : "";
1201
+ const ownerTag = detail?.owner ? ` (owner: ${detail.owner})` : "";
1202
+ console.log(`%c[AGENT JOIN] ${typeTag} ${pid}${roleTag}${ownerTag}`, "color: #4ade80; font-weight: bold; font-size: 13px");
1203
+ }
1204
+ }
1205
+ // Log leaves
1206
+ for (const pid of prevSet) {
1207
+ if (!newSet.has(pid)) {
1208
+ const detail = _participantDetails.find(d => d.actorId === pid);
1209
+ const typeTag = detail?.type === "ai" ? "🤖" : detail?.type === "human" ? "👤" : "❓";
1210
+ const roleTag = detail?.role ? ` [${detail.role}]` : "";
1211
+ console.log(`%c[AGENT LEAVE] ${typeTag} ${pid}${roleTag}`, "color: #f87171; font-weight: bold; font-size: 13px");
1212
+ }
1213
+ }
1214
+ // Prune ephemeral state for departed participants
1215
+ const activeSet = new Set(participants);
1216
+ for (const key of Object.keys(ephemeralState)) {
1217
+ if (!activeSet.has(key)) delete ephemeralState[key];
1218
+ }
1219
+ render();
1220
+ updateParticipantList(participants);
1221
+ }
1222
+
1223
+ if (msg.type === "kicked") {
1224
+ showToast("You were kicked from the room", "error", 5000);
1225
+ setTimeout(() => navigateToLibrary(), 2000);
1226
+ }
1227
+
1228
+ if (msg.type === "kick_error") {
1229
+ showToast("Kick failed: " + msg.error, "error", 4000);
1230
+ }
1231
+
1232
+ if (msg.type === "kick_success") {
1233
+ showToast("Kicked " + msg.actorId, "info", 2000);
1234
+ }
1235
+
1236
+ if (msg.type === "experience_updated") {
1237
+ // Hot reload: re-fetch bundle and re-render
1238
+ try {
1239
+ const bundleUrl = roomId === "local"
1240
+ ? `${SERVER}/bundle?_t=${Date.now()}`
1241
+ : `${SERVER}/rooms/${roomId}/bundle?_t=${Date.now()}`;
1242
+ const bundleRes = await fetch(bundleUrl);
1243
+ if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
1244
+ const bundleCode = await bundleRes.text();
1245
+
1246
+ const blob = new Blob([bundleCode], { type: "text/javascript" });
1247
+ const url = URL.createObjectURL(blob);
1248
+ const mod = await import(url);
1249
+ URL.revokeObjectURL(url);
1250
+
1251
+ const experience = mod.default ?? mod;
1252
+ if (experience?.Canvas) {
1253
+ Canvas = experience.Canvas;
1254
+ EntryRitual = experience.entryRitual || null;
1255
+ ritualComplete = false;
1256
+ hotReloadKey++;
1257
+ // Update title in case it changed
1258
+ const expTitle = experience.manifest?.title || experience.name || roomId;
1259
+ updateTopbarTitle(expTitle);
1260
+ render();
1261
+ console.log("[viewer] Hot reloaded");
1262
+ }
1263
+ } catch (err) {
1264
+ console.error("[viewer] Hot reload failed:", err);
1265
+ showToast(`Hot reload failed: ${err.message}`, "build", 10000);
1266
+ }
1267
+ }
1268
+
1269
+ if (msg.type === "build_error") {
1270
+ showToast(`Build error:\n${msg.error}`, "build", 15000);
1271
+ }
1272
+
1273
+ if (msg.type === "screenshot_request") {
1274
+ const requestId = msg.id;
1275
+ // Small delay to let any pending React renders settle
1276
+ setTimeout(async () => {
1277
+ try {
1278
+ const root = document.getElementById("root");
1279
+ if (!root) throw new Error("No #root element");
1280
+
1281
+ let dataUrl;
1282
+
1283
+ // Fast path: single <canvas> filling #root → native toDataURL
1284
+ const canvases = root.querySelectorAll("canvas");
1285
+ if (canvases.length === 1) {
1286
+ const cvs = canvases[0];
1287
+ const rootRect = root.getBoundingClientRect();
1288
+ const cvsRect = cvs.getBoundingClientRect();
1289
+ const fillsRoot =
1290
+ Math.abs(cvsRect.width - rootRect.width) < 20 &&
1291
+ Math.abs(cvsRect.height - rootRect.height) < 20;
1292
+ if (fillsRoot) {
1293
+ dataUrl = cvs.toDataURL("image/png");
1294
+ }
1295
+ }
1296
+
1297
+ // Snapshot all canvases before html2canvas (it can't read WebGL content)
1298
+ const canvasSnapshots = new Map();
1299
+ if (!dataUrl) {
1300
+ canvases.forEach((cvs) => {
1301
+ try {
1302
+ const snap = cvs.toDataURL("image/png");
1303
+ if (snap && snap.length > 100) {
1304
+ canvasSnapshots.set(cvs, snap);
1305
+ }
1306
+ } catch {}
1307
+ });
1308
+ }
1309
+
1310
+ // Fallback: html2canvas for DOM/SVG/mixed content
1311
+ if (!dataUrl && typeof html2canvas === "function") {
1312
+ const captured = await html2canvas(root, {
1313
+ backgroundColor: "#0a0a0a",
1314
+ useCORS: true,
1315
+ logging: false,
1316
+ scale: 1,
1317
+ onclone: (doc, clonedRoot) => {
1318
+ // Replace cloned WebGL canvases with snapshot images
1319
+ const clonedCanvases = clonedRoot.querySelectorAll("canvas");
1320
+ clonedCanvases.forEach((clonedCvs, idx) => {
1321
+ const originalCvs = canvases[idx];
1322
+ const snapshot = canvasSnapshots.get(originalCvs);
1323
+ if (snapshot) {
1324
+ const img = doc.createElement("img");
1325
+ img.src = snapshot;
1326
+ img.style.width = clonedCvs.style.width || (clonedCvs.width + "px");
1327
+ img.style.height = clonedCvs.style.height || (clonedCvs.height + "px");
1328
+ img.style.display = clonedCvs.style.display;
1329
+ clonedCvs.parentNode?.replaceChild(img, clonedCvs);
1330
+ }
1331
+ });
1332
+ },
1333
+ });
1334
+ dataUrl = captured.toDataURL("image/png");
1335
+ }
1336
+
1337
+ if (!dataUrl) throw new Error("No capture method available");
1338
+
1339
+ try { ws.send(JSON.stringify({
1340
+ type: "screenshot_response",
1341
+ id: requestId,
1342
+ dataUrl,
1343
+ })); } catch (_) { /* WS may have closed */ }
1344
+ } catch (err) {
1345
+ try { ws.send(JSON.stringify({
1346
+ type: "screenshot_response",
1347
+ id: requestId,
1348
+ error: err.message,
1349
+ })); } catch (_) { /* WS may have closed */ }
1350
+ }
1351
+ }, 100);
1352
+ }
1353
+ };
1354
+
1355
+ ws.onerror = (err) => {
1356
+ console.error("[viewer] WebSocket error:", err);
1357
+ };
1358
+
1359
+ // Reconnect with exponential backoff (no full page reload)
1360
+ ws.onclose = () => {
1361
+ // Don't reconnect if user navigated away from this room
1362
+ if (currentWs !== ws) return;
1363
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 15000);
1364
+ reconnectAttempts++;
1365
+ console.log(`[viewer] WebSocket closed, reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
1366
+ showToast(`Disconnected. Reconnecting in ${Math.round(delay/1000)}s...`, "info", delay + 1000);
1367
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; if (currentWs === ws) start(); }, delay);
1368
+ };
1369
+
1370
+ } catch (err) {
1371
+ showError(err.message);
1372
+ }
1373
+ }
1374
+
1375
+ let reconnectAttempts = 0;
1376
+ let reconnectTimer = null; // Stored so we can clear on navigation
1377
+ let reactRoot = null; // Persists across reconnects to avoid duplicate roots
1378
+
1379
+ // Initial routing: if path has a room ID (e.g. /room-abc), connect to it.
1380
+ // Otherwise show the library.
1381
+ if (ROOM_ID) {
1382
+ start();
1383
+ } else {
1384
+ showLibraryView();
1385
+ }
1386
+ </script>
1387
+ </body>
1388
+ </html>