BolyosCsaba Claude Sonnet 4.6 commited on
Commit
471b2cd
·
1 Parent(s): 4f032fd

fix: replace Three.js WebGL studio with pure SVG — zero dependencies

Browse files

Three.js r167/r184 CDN URLs all 404'd; the new build dropped the UMD
global bundle entirely. SVG isometric studio (adapted from studio-v4)
renders instantly in any browser and inside sandboxed srcdoc iframes
with no external requests, no module loading, and 16 KB total HTML.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. studio.py +323 -453
studio.py CHANGED
@@ -1,543 +1,413 @@
1
  """
2
- Self-contained Three.js r167 isometric game-dev studio scene for Gradio.
3
- Three.js and characters.js are inlined at module load so the HTML is fully
4
- self-contained and works in srcdoc iframes without any external requests.
5
  """
6
  import json
7
- import urllib.request
8
  from pathlib import Path
9
 
10
 
11
- def _fetch_threejs() -> str:
12
- # Try local file first (committed to repo)
13
- local = Path(__file__).parent / "sandbox_cache" / "three.min.js"
14
- if local.exists() and local.stat().st_size > 100_000:
15
- print("[studio] Using local three.min.js")
16
- return local.read_text()
17
- # Try CDNs
18
- cdns = [
19
- "https://cdnjs.cloudflare.com/ajax/libs/three.js/r167/three.min.js",
20
- "https://unpkg.com/three@0.167.0/build/three.min.js",
21
- "https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.min.js",
22
- ]
23
- for url in cdns:
24
- try:
25
- with urllib.request.urlopen(url, timeout=15) as r:
26
- data = r.read().decode("utf-8")
27
- if len(data) > 100_000:
28
- # Cache locally for next startup
29
- try:
30
- local.write_text(data)
31
- except Exception:
32
- pass
33
- print(f"[studio] Fetched Three.js from {url} ({len(data):,} bytes)")
34
- return data
35
- except Exception as e:
36
- print(f"[studio] CDN {url} failed: {e}")
37
- print("[studio] ERROR: All Three.js sources failed")
38
- return "console.error('[IVDS] Three.js failed to load — studio will not render');"
39
-
40
-
41
  def _load_characters_js() -> str:
42
  p = Path(__file__).parent / "sandbox_cache" / "characters.js"
43
- if p.exists():
44
- return p.read_text()
45
- return "/* characters.js not found */"
46
 
47
 
48
- print("[studio] Loading Three.js from CDN...")
49
- _THREEJS_JS = _fetch_threejs()
50
  _CHARACTERS_JS = _load_characters_js()
51
- print(f"[studio] Three.js: {len(_THREEJS_JS):,} bytes | characters.js: {len(_CHARACTERS_JS):,} bytes")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  def build_studio_html(model_assignments: list[dict]) -> str:
55
  """
56
  model_assignments: list of dicts:
57
- {{model_id: str, role: str, character_fn: str, color: str, desk: int (1-5)}}
58
  Returns self-contained HTML string.
59
  """
60
  assignments_json = json.dumps(model_assignments)
61
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  return f"""<!DOCTYPE html>
63
  <html lang="en">
64
  <head>
65
  <meta charset="UTF-8">
66
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
67
- <title>Three.js Studio</title>
68
  <style>
69
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
70
- body {{ background: #1a1a2e; overflow: hidden; font-family: 'Courier New', monospace; }}
71
- #studio-canvas {{ display: block; width: 100%; height: 100vh; }}
72
- #bubble-layer {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }}
 
 
 
 
 
 
 
 
73
  .speech-bubble {{
74
- position: absolute; background: rgba(255,255,255,0.95);
75
- border: 2px solid #333; border-radius: 8px; padding: 6px 10px;
76
- font-size: 12px; max-width: 200px; line-height: 1.4;
77
- box-shadow: 2px 2px 6px rgba(0,0,0,0.4); transition: opacity 0.5s;
78
- display: none;
79
  }}
80
  .speech-bubble::after {{
81
- content: ''; position: absolute; bottom: -10px; left: 20px;
82
- border: 5px solid transparent; border-top-color: #333;
83
  }}
84
  .floating-popup {{
85
- position: absolute; padding: 4px 10px; border-radius: 12px;
86
- font-size: 11px; font-weight: bold; color: white;
87
- animation: floatUp 2s ease-out forwards; pointer-events: none;
88
  }}
89
  @keyframes floatUp {{
90
- 0% {{ transform: translateY(0); opacity: 1; }}
91
- 100% {{ transform: translateY(-60px); opacity: 0; }}
92
  }}
93
- #phase-bar {{
94
- position: fixed; bottom: 0; left: 0; right: 0;
95
- background: rgba(0,0,0,0.7); color: #fff;
96
- padding: 6px 16px; font-size: 12px;
97
- display: flex; align-items: center; gap: 12px; z-index: 20;
98
- }}
99
- #phase-label {{ flex: 1; }}
100
- #phase-progress {{ width: 200px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; }}
101
- #phase-fill {{ height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.5s; }}
102
  </style>
103
  </head>
104
  <body>
105
- <canvas id="studio-canvas"></canvas>
106
- <div id="bubble-layer"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  <div id="phase-bar">
108
  <span id="phase-label">Waiting…</span>
109
  <div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
110
  </div>
111
 
112
- <script>{_THREEJS_JS}</script>
113
- <script>{_CHARACTERS_JS}</script>
114
  <script>
115
- const assignments = {assignments_json};
116
-
117
- const DESK_POSITIONS = [
118
- [-2.5, 0, -1.5],
119
- [-0.8, 0, -1.5],
120
- [ 0.8, 0, -1.5],
121
- [ 2.0, 0, 0.5],
122
- [-1.5, 0, 1.0],
123
- ];
124
-
125
- // Scene setup
126
- const canvas = document.getElementById('studio-canvas');
127
- const renderer = new THREE.WebGLRenderer({{ canvas, antialias: false }});
128
- renderer.toneMapping = THREE.NoToneMapping;
129
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
130
- renderer.setSize(window.innerWidth, window.innerHeight);
131
-
132
- const scene = new THREE.Scene();
133
- scene.background = new THREE.Color(0x1a1a2e);
134
-
135
- // Isometric camera
136
- const aspect = window.innerWidth / window.innerHeight;
137
- const zoom = 8;
138
- const camera = new THREE.OrthographicCamera(
139
- -aspect * zoom, aspect * zoom,
140
- zoom, -zoom,
141
- 0.1, 100
142
- );
143
- camera.position.set(10, 8.165, 10);
144
- camera.lookAt(0, 0, 0);
145
-
146
- // Lighting
147
- const ambientLight = new THREE.AmbientLight(0xffeedd, 0.8);
148
- scene.add(ambientLight);
149
-
150
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
151
- dirLight.position.set(8, 12, 6);
152
- scene.add(dirLight);
153
-
154
- // Floor (InstancedMesh)
155
- const floorGeo = new THREE.PlaneGeometry(2, 2);
156
- floorGeo.rotateX(-Math.PI / 2);
157
- const floorMat = new THREE.MeshLambertMaterial({{ color: 0xc8924a }});
158
- const floorMesh = new THREE.InstancedMesh(floorGeo, floorMat, 16);
159
-
160
- let idx = 0;
161
- const matrix = new THREE.Matrix4();
162
- for (let x = 0; x < 4; x++) {{
163
- for (let z = 0; z < 4; z++) {{
164
- matrix.setPosition(x * 2 - 3, 0, z * 2 - 3);
165
- floorMesh.setMatrixAt(idx++, matrix);
166
- }}
167
- }}
168
- floorMesh.instanceMatrix.needsUpdate = true;
169
- scene.add(floorMesh);
170
-
171
- // Walls
172
- const wallMat = new THREE.MeshToonMaterial({{ color: 0xdce8c4 }});
173
-
174
- const backWall = new THREE.Mesh(
175
- new THREE.BoxGeometry(8, 4, 0.2),
176
- wallMat
177
- );
178
- backWall.position.set(0, 2, -4);
179
- scene.add(backWall);
180
-
181
- const rightWall = new THREE.Mesh(
182
- new THREE.BoxGeometry(0.2, 4, 8),
183
- wallMat
184
- );
185
- rightWall.position.set(4, 2, 0);
186
- scene.add(rightWall);
187
-
188
- // Helper to add boxes
189
- function addBox(w, h, d, color, x, y, z) {{
190
- const mesh = new THREE.Mesh(
191
- new THREE.BoxGeometry(w, h, d),
192
- new THREE.MeshToonMaterial({{ color }})
193
- );
194
- mesh.position.set(x, y, z);
195
- scene.add(mesh);
196
- return mesh;
197
- }}
198
 
199
- // Props
200
- // Vending machine
201
- addBox(0.5, 1.5, 0.5, 0xe74c3c, -3.5, 0.75, -3.5);
202
- addBox(0.3, 0.2, 0.02, 0x2c3e50, -3.5, 1.2, -3.25);
203
-
204
- // Plants
205
- const pot1 = addBox(0.2, 0.15, 0.2, 0x8b4513, 3.0, 0.075, -3.5);
206
- const foliage1 = new THREE.Mesh(
207
- new THREE.SphereGeometry(0.2, 8, 8),
208
- new THREE.MeshToonMaterial({{ color: 0x27ae60 }})
209
- );
210
- foliage1.position.set(3.0, 0.35, -3.5);
211
- scene.add(foliage1);
212
-
213
- const pot2 = addBox(0.2, 0.15, 0.2, 0x8b4513, 3.5, 0.075, -2.8);
214
- const foliage2 = new THREE.Mesh(
215
- new THREE.SphereGeometry(0.2, 8, 8),
216
- new THREE.MeshToonMaterial({{ color: 0x27ae60 }})
217
- );
218
- foliage2.position.set(3.5, 0.35, -2.8);
219
- scene.add(foliage2);
220
-
221
- // Whiteboards
222
- addBox(0.8, 0.5, 0.05, 0xf5f5f5, -1.0, 2.0, -3.95);
223
- addBox(0.8, 0.5, 0.05, 0xf5f5f5, 1.5, 2.0, -3.95);
224
-
225
- // Result monitor
226
- addBox(0.4, 0.6, 0.1, 0x2c3e50, 3.5, 1.8, -3.5);
227
- const monitorScreen = addBox(0.35, 0.5, 0.02, 0x8e44ad, 3.5, 1.8, -3.42);
228
- monitorScreen.material.emissive = new THREE.Color(0x8e44ad);
229
- monitorScreen.material.emissiveIntensity = 0.3;
230
-
231
- // Pizza box (hidden initially)
232
- const pizzaBox = addBox(0.3, 0.05, 0.3, 0xf39c12, DESK_POSITIONS[1][0], 1.0, DESK_POSITIONS[1][2]);
233
- pizzaBox.visible = false;
234
-
235
- // Build desks and characters
236
- const characters = [];
237
-
238
- function buildDesk(x, y, z) {{
239
- const deskGroup = new THREE.Group();
240
-
241
- // Tabletop
242
- const top = new THREE.Mesh(
243
- new THREE.BoxGeometry(1.0, 0.08, 0.6),
244
- new THREE.MeshToonMaterial({{ color: 0x8b6f47 }})
245
- );
246
- top.position.set(x, 0.9, z);
247
- deskGroup.add(top);
248
-
249
- // Legs
250
- const legGeo = new THREE.BoxGeometry(0.05, 0.9, 0.05);
251
- const legMat = new THREE.MeshToonMaterial({{ color: 0x5a4a2a }});
252
- const offsets = [
253
- [-0.45, 0, -0.25],
254
- [ 0.45, 0, -0.25],
255
- [-0.45, 0, 0.25],
256
- [ 0.45, 0, 0.25],
257
- ];
258
- offsets.forEach(([ox, oy, oz]) => {{
259
- const leg = new THREE.Mesh(legGeo, legMat);
260
- leg.position.set(x + ox, 0.45, z + oz);
261
- deskGroup.add(leg);
262
- }});
263
-
264
- // Small monitor on desk
265
- const mon = new THREE.Mesh(
266
- new THREE.BoxGeometry(0.15, 0.12, 0.02),
267
- new THREE.MeshToonMaterial({{ color: 0x2c3e50 }})
268
- );
269
- mon.position.set(x, 1.0, z - 0.15);
270
- deskGroup.add(mon);
271
-
272
- scene.add(deskGroup);
273
- return deskGroup;
274
- }}
275
 
276
- function buildFallbackCharacter(color) {{
277
- const charGroup = new THREE.Group();
278
-
279
- const body = new THREE.Mesh(
280
- new THREE.BoxGeometry(0.35, 0.4, 0.25),
281
- new THREE.MeshToonMaterial({{ color }})
282
- );
283
- body.position.y = 1.1;
284
- charGroup.add(body);
285
-
286
- const head = new THREE.Mesh(
287
- new THREE.BoxGeometry(0.28, 0.28, 0.28),
288
- new THREE.MeshToonMaterial({{ color }})
289
- );
290
- head.position.y = 1.45;
291
- charGroup.add(head);
292
-
293
- return charGroup;
294
- }}
295
 
296
- assignments.forEach((assignment, i) => {{
297
- if (i >= 5) return;
298
-
299
- const deskIdx = assignment.desk - 1;
300
- const [x, y, z] = DESK_POSITIONS[deskIdx];
301
-
302
- buildDesk(x, y, z);
303
-
304
- let charMesh;
305
- try {{
306
- if (window[assignment.character_fn]) {{
307
- charMesh = window[assignment.character_fn]();
308
- }} else {{
309
- charMesh = buildFallbackCharacter(assignment.color);
310
- }}
311
- }} catch (e) {{
312
- console.warn('Character fn failed:', e);
313
- charMesh = buildFallbackCharacter(assignment.color);
314
- }}
315
-
316
- charMesh.position.set(x + 0.3, 0, z + 0.2);
317
- scene.add(charMesh);
318
-
319
- characters.push({{
320
- mesh: charMesh,
321
- role: assignment.role,
322
- color: assignment.color,
323
- bubbleEl: null,
324
- _bobPhase: Math.random() * Math.PI * 2,
325
- activePhase: false,
326
- _typeBuffer: ''
327
- }});
328
- }});
329
 
330
- // Speech bubble pool
331
- const bubbleLayer = document.getElementById('bubble-layer');
332
- const bubblePool = [];
333
- for (let i = 0; i < 6; i++) {{
334
- const bubble = document.createElement('div');
335
- bubble.className = 'speech-bubble';
336
- bubbleLayer.appendChild(bubble);
337
- bubblePool.push(bubble);
338
  }}
339
 
340
- function getBubble() {{
341
- return bubblePool.find(b => b.style.display === 'none') || bubblePool[0];
 
 
 
 
 
 
 
 
 
 
 
342
  }}
343
 
344
- // Floating popup pool
345
  const popupPool = [];
346
- for (let i = 0; i < 6; i++) {{
347
- const popup = document.createElement('div');
348
- popup.className = 'floating-popup';
349
- popup.style.display = 'none';
350
- bubbleLayer.appendChild(popup);
351
- popupPool.push(popup);
352
  }}
353
 
354
- function spawnPopup(text, color, x, y) {{
355
  const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
 
356
  popup.textContent = text;
357
  popup.style.backgroundColor = color;
358
- popup.style.left = x + 'px';
359
- popup.style.top = y + 'px';
360
  popup.style.display = 'block';
361
  popup.style.animation = 'none';
362
- setTimeout(() => {{
363
- popup.style.animation = 'floatUp 2s ease-out forwards';
364
- }}, 10);
365
- setTimeout(() => {{
366
- popup.style.display = 'none';
367
- }}, 2000);
368
  }}
369
 
370
- // Phase bar elements
371
- const phaseLabel = document.getElementById('phase-label');
372
- const phaseFill = document.getElementById('phase-fill');
373
-
374
- // Project to screen coords
375
- function projectToScreen(worldPos) {{
376
- const vector = worldPos.clone();
377
- vector.project(camera);
378
- return {{
379
- x: (vector.x * 0.5 + 0.5) * window.innerWidth,
380
- y: (-vector.y * 0.5 + 0.5) * window.innerHeight
381
- }};
382
- }}
383
 
384
- // studioUpdate event router
385
  window.studioUpdate = function(msg) {{
386
  if (msg.type === 'text') {{
387
- const char = characters.find(c => c.role === msg.role);
388
- if (!char) return;
389
-
390
- char._typeBuffer += msg.text;
391
- if (char._typeBuffer.length > 160) {{
392
- char._typeBuffer = char._typeBuffer.slice(-160);
393
- }}
394
-
395
- if (!char.bubbleEl) {{
396
- char.bubbleEl = getBubble();
397
- }}
398
-
399
- const lines = char._typeBuffer.split('\\n').slice(-2);
400
- char.bubbleEl.textContent = lines.join('\\n');
401
- char.bubbleEl.style.display = 'block';
402
- char.bubbleEl.style.opacity = '1';
403
-
404
- clearTimeout(char._fadeTimer);
405
- char._fadeTimer = setTimeout(() => {{
406
- char.bubbleEl.style.opacity = '0';
407
- setTimeout(() => {{
408
- char.bubbleEl.style.display = 'none';
409
- }}, 500);
410
- }}, 1500);
411
  }}
412
-
413
  else if (msg.type === 'phase_start') {{
414
  phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
415
- const progress = Math.round((msg.phase / 9) * 100);
416
- phaseFill.style.width = progress + '%';
417
-
418
- if (msg.role) {{
419
- characters.forEach(c => c.activePhase = false);
420
- const char = characters.find(c => c.role === msg.role);
421
- if (char) char.activePhase = true;
422
- }}
423
-
424
  if (msg.phase >= 7) {{
425
- pizzaBox.visible = true;
 
 
 
426
  }}
427
  }}
428
-
429
  else if (msg.type === 'phase_complete') {{
430
- const pos = projectToScreen(new THREE.Vector3(0, 2, -2));
431
- spawnPopup(`✓ ${{msg.name}}`, '#f39c12', pos.x, pos.y);
432
  }}
433
-
434
  else if (msg.type === 'commit') {{
435
- const pos = projectToScreen(new THREE.Vector3(0, 1.5, 0));
436
- spawnPopup(`📁 ${{msg.file}}`, '#27ae60', pos.x, pos.y);
437
  }}
438
-
439
  else if (msg.type === 'error') {{
440
- const char = characters.find(c => c.role === msg.role);
441
- if (char) {{
442
- if (!char.bubbleEl) char.bubbleEl = getBubble();
443
- char.bubbleEl.textContent = `❌ ${{msg.text}}`;
444
- char.bubbleEl.style.display = 'block';
445
- char.bubbleEl.style.opacity = '1';
446
- char.bubbleEl.style.borderColor = '#e74c3c';
447
- }}
448
  }}
449
-
450
  else if (msg.type === 'done') {{
451
  phaseLabel.textContent = '✅ Generation Complete!';
452
  phaseFill.style.width = '100%';
453
-
454
- // Celebrate jump
455
- characters.forEach((char, i) => {{
456
- setTimeout(() => {{
457
- const startY = char.mesh.position.y;
458
- const jumpDuration = 500;
459
- const jumpHeight = 0.5;
460
- const startTime = Date.now();
461
-
462
- function jumpAnim() {{
463
- const elapsed = Date.now() - startTime;
464
- const progress = Math.min(elapsed / jumpDuration, 1);
465
- const eased = Math.sin(progress * Math.PI);
466
- char.mesh.position.y = startY + eased * jumpHeight;
467
-
468
- if (progress < 1) {{
469
- requestAnimationFrame(jumpAnim);
470
- }} else {{
471
- char.mesh.position.y = startY;
472
- }}
473
- }}
474
- jumpAnim();
475
- }}, i * 100);
476
- }});
477
-
478
- if (window.onStudioDone) {{
479
- window.onStudioDone();
480
- }}
481
  }}
482
-
483
  else if (msg.type === 'cancelled') {{
484
  phaseLabel.textContent = '⛔ Cancelled';
485
  }}
486
  }};
487
 
488
- // Listen for messages from parent (Gradio bridge)
489
  window.addEventListener('message', function(e) {{
490
- if (e.data && e.data.type) {{
491
- window.studioUpdate(e.data);
492
- }}
493
  }});
494
 
495
- // Animation loop
496
- const clock = new THREE.Clock();
497
- const tmpVec = new THREE.Vector3();
498
-
499
- function animate() {{
500
- requestAnimationFrame(animate);
501
- const t = clock.getElapsedTime();
502
-
503
- // Monitor glow pulse
504
- monitorScreen.material.emissiveIntensity = 0.2 + 0.2 * Math.sin(t * 2);
505
-
506
- // Character idle bob and wobble
507
- characters.forEach((ch, i) => {{
508
- ch._bobPhase += 0.03;
509
- ch.mesh.position.y = Math.sin(ch._bobPhase) * 0.04;
510
-
511
- if (ch.activePhase) {{
512
- ch.mesh.rotation.z = Math.sin(t * 3 + i) * 0.06;
513
- }} else {{
514
- ch.mesh.rotation.z *= 0.9;
515
- }}
516
-
517
- // Update bubble position
518
- if (ch.bubbleEl && ch.bubbleEl.style.display !== 'none') {{
519
- ch.mesh.getWorldPosition(tmpVec);
520
- tmpVec.y += 0.8;
521
- const s = projectToScreen(tmpVec);
522
- ch.bubbleEl.style.left = (s.x - 100) + 'px';
523
- ch.bubbleEl.style.top = (s.y - 80) + 'px';
524
- }}
525
- }});
526
-
527
- renderer.render(scene, camera);
528
- }}
529
- animate();
530
-
531
- // Resize handler
532
  window.addEventListener('resize', () => {{
533
- const w = window.innerWidth, h = window.innerHeight;
534
- renderer.setSize(w, h);
535
- const a = w / h;
536
- camera.left = -a * zoom;
537
- camera.right = a * zoom;
538
- camera.top = zoom;
539
- camera.bottom = -zoom;
540
- camera.updateProjectionMatrix();
541
  }});
542
  </script>
543
  </body>
 
1
  """
2
+ Self-contained SVG isometric studio scene for Gradio — zero external dependencies.
3
+ No Three.js, no CDN. Pure SVG + HTML + vanilla JS.
 
4
  """
5
  import json
 
6
  from pathlib import Path
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  def _load_characters_js() -> str:
10
  p = Path(__file__).parent / "sandbox_cache" / "characters.js"
11
+ return p.read_text() if p.exists() else ""
 
 
12
 
13
 
 
 
14
  _CHARACTERS_JS = _load_characters_js()
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Isometric helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _pts(pairs, ox=0, oy=0):
22
+ return " ".join(f"{x+ox},{y+oy}" for x, y in pairs)
23
+
24
+
25
+ def _darken(hex_color: str, factor: float = 0.65) -> str:
26
+ h = hex_color.lstrip("#")
27
+ if len(h) != 6:
28
+ return hex_color
29
+ r, g, b = int(h[:2], 16), int(h[2:4], 16), int(h[4:], 16)
30
+ return f"#{int(r*factor):02x}{int(g*factor):02x}{int(b*factor):02x}"
31
+
32
+
33
+ # Base desk polygon coordinates (desk slot 0, no offset)
34
+ _DESK_TABLETOP = [(148,268),(240,220),(306,252),(214,300)]
35
+ _DESK_LEFT = [(148,268),(148,288),(214,320),(214,300)]
36
+ _DESK_RIGHT = [(214,300),(214,320),(306,270),(306,252)]
37
+ _DESK_MAT = [(158,262),(238,218),(298,248),(218,292)]
38
+ _MON_BACK = [(196,208),(218,198),(242,210),(220,220)]
39
+ _MON_SCREEN = [(200,210),(218,202),(238,212),(220,220)]
40
+ _MON_LINES = [(200,211,228,203),(200,215,224,207),(200,219,230,211)]
41
+
42
+ # Base character polygons — head anchor at (174, 264) relative to slot 0
43
+ _CHAR_HEAD_TOP = [(174,264),(192,255),(202,259),(184,268)]
44
+ _CHAR_HEAD_SIDE = [(184,268),(202,259),(203,261),(185,270)]
45
+ _CHAR_BODY_TOP = [(176,276),(196,267),(204,271),(184,280)]
46
+ _CHAR_BODY_SIDE = [(184,280),(202,271),(204,273),(186,282)]
47
+ _CHAR_LEG_L = [(177,288),(185,284),(189,286),(181,290)]
48
+ _CHAR_LEG_R = [(184,285),(192,281),(196,283),(188,287)]
49
+ _CHAR_ARM_L = (186,270, 174,264)
50
+ _CHAR_ARM_R = (196,266, 210,260)
51
+ _CHAR_EYE_L = (178,258)
52
+ _CHAR_EYE_R = (189,255)
53
+
54
+ # Five desk slot offsets (ox, oy)
55
+ _SLOTS = [
56
+ (0, 0), # back-left
57
+ (188, 34), # back-center
58
+ (350, 2), # back-right
59
+ (0, 95), # front-left
60
+ (188, 129), # front-center
61
+ ]
62
+
63
+
64
+ def _svg_desk(ox: int, oy: int) -> str:
65
+ lines = "".join(
66
+ f'<line stroke="#00ff88" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
67
+ if i == 0 else
68
+ f'<line stroke="#44aaff" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
69
+ if i == 1 else
70
+ f'<line stroke="#ffcc00" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
71
+ for i, (x1, y1, x2, y2) in enumerate(_MON_LINES)
72
+ )
73
+ return (
74
+ f'<polygon fill="#8a6038" stroke="#aa8050" stroke-width="1" points="{_pts(_DESK_TABLETOP,ox,oy)}"/>'
75
+ f'<polygon fill="#6a4020" points="{_pts(_DESK_LEFT,ox,oy)}"/>'
76
+ f'<polygon fill="#5a3010" points="{_pts(_DESK_RIGHT,ox,oy)}"/>'
77
+ f'<polygon fill="#5a8a3a" opacity=".7" points="{_pts(_DESK_MAT,ox,oy)}"/>'
78
+ f'<polygon fill="#222" stroke="#555" stroke-width=".9" points="{_pts(_MON_BACK,ox,oy)}"/>'
79
+ f'<polygon fill="#001812" points="{_pts(_MON_SCREEN,ox,oy)}"/>'
80
+ + lines
81
+ )
82
+
83
+
84
+ def _svg_char(ox: int, oy: int, color: str) -> str:
85
+ dark = _darken(color)
86
+ al = _CHAR_ARM_L
87
+ ar = _CHAR_ARM_R
88
+ el = _CHAR_EYE_L
89
+ er = _CHAR_EYE_R
90
+ return (
91
+ f'<line stroke="{color}" stroke-width="3.5" x1="{al[0]+ox}" y1="{al[1]+oy}" x2="{al[2]+ox}" y2="{al[3]+oy}"/>'
92
+ f'<line stroke="{color}" stroke-width="3.5" x1="{ar[0]+ox}" y1="{ar[1]+oy}" x2="{ar[2]+ox}" y2="{ar[3]+oy}"/>'
93
+ f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_L,ox,oy)}"/>'
94
+ f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_R,ox,oy)}"/>'
95
+ f'<polygon fill="{color}" stroke="{dark}" stroke-width=".5" points="{_pts(_CHAR_BODY_TOP,ox,oy)}"/>'
96
+ f'<polygon fill="{dark}" points="{_pts(_CHAR_BODY_SIDE,ox,oy)}"/>'
97
+ f'<polygon fill="{color}" stroke="{dark}" stroke-width=".6" points="{_pts(_CHAR_HEAD_TOP,ox,oy)}"/>'
98
+ f'<polygon fill="{dark}" points="{_pts(_CHAR_HEAD_SIDE,ox,oy)}"/>'
99
+ f'<rect fill="#1a1a1a" x="{el[0]+ox}" y="{el[1]+oy}" width="5" height="5" rx=".8"/>'
100
+ f'<rect fill="#1a1a1a" x="{er[0]+ox}" y="{er[1]+oy}" width="5" height="5" rx=".8"/>'
101
+ )
102
 
103
 
104
  def build_studio_html(model_assignments: list[dict]) -> str:
105
  """
106
  model_assignments: list of dicts:
107
+ {model_id, role, character_fn, color, desk (1-5)}
108
  Returns self-contained HTML string.
109
  """
110
  assignments_json = json.dumps(model_assignments)
111
+
112
+ # Build SVG for each assigned desk + character
113
+ desk_svg_parts = []
114
+ char_svg_parts = []
115
+ for i, a in enumerate(model_assignments[:5]):
116
+ ox, oy = _SLOTS[i]
117
+ desk_svg_parts.append(_svg_desk(ox, oy))
118
+ char_svg_parts.append(_svg_char(ox, oy, a.get("color", "#aaaaaa")))
119
+
120
+ desks_svg = "\n".join(desk_svg_parts)
121
+ chars_svg = "\n".join(char_svg_parts)
122
+
123
+ # Speech bubble anchor positions (SVG coords) per slot — above character head
124
+ bubble_anchors = json.dumps([
125
+ {"svgX": 174 + ox, "svgY": 255 + oy}
126
+ for ox, oy in _SLOTS
127
+ ])
128
+
129
  return f"""<!DOCTYPE html>
130
  <html lang="en">
131
  <head>
132
  <meta charset="UTF-8">
133
+ <meta name="viewport" content="width=device-width,initial-scale=1">
134
+ <title>Studio</title>
135
  <style>
136
+ * {{ box-sizing:border-box; margin:0; padding:0; }}
137
+ body {{ background:#1a1a1a; font-family:system-ui,sans-serif; overflow:hidden; }}
138
+ .scene-wrap {{ position:relative; width:100vw; height:calc(100vh - 28px); }}
139
+ svg.iso {{ width:100%; height:100%; display:block; }}
140
+ #phase-bar {{
141
+ position:fixed; bottom:0; left:0; right:0; height:28px;
142
+ background:#111; display:flex; align-items:center; gap:12px;
143
+ padding:0 14px; font-size:11px; color:#eee; z-index:20;
144
+ }}
145
+ #phase-label {{ flex:1; }}
146
+ #phase-progress {{ width:180px; height:7px; background:#333; border-radius:4px; overflow:hidden; }}
147
+ #phase-fill {{ height:100%; background:#7c3aed; border-radius:4px; transition:width .5s; }}
148
  .speech-bubble {{
149
+ position:absolute; background:rgba(255,255,255,.95);
150
+ border:2px solid #333; border-radius:6px; padding:5px 9px;
151
+ font-size:11px; max-width:180px; line-height:1.35;
152
+ box-shadow:2px 2px 6px rgba(0,0,0,.4); pointer-events:none;
153
+ transition:opacity .5s; display:none;
154
  }}
155
  .speech-bubble::after {{
156
+ content:''; position:absolute; bottom:-9px; left:16px;
157
+ border:5px solid transparent; border-top-color:#333;
158
  }}
159
  .floating-popup {{
160
+ position:absolute; padding:3px 9px; border-radius:6px;
161
+ font-size:11px; font-weight:700; color:#fff;
162
+ animation:floatUp 2.5s ease-out forwards; pointer-events:none;
163
  }}
164
  @keyframes floatUp {{
165
+ 0% {{ transform:translateY(0); opacity:1; }}
166
+ 100% {{ transform:translateY(-60px); opacity:0; }}
167
  }}
168
+ /* floor tiles */
169
+ .f0 {{ fill:#d4a060; stroke:#b07838; stroke-width:.7; }}
170
+ .f1 {{ fill:#c8924a; stroke:#a06830; stroke-width:.7; }}
171
+ /* walls */
172
+ .wb {{ fill:#dce8c4; stroke:#bccca4; stroke-width:.8; }}
173
+ .wl {{ fill:#ccdcb0; stroke:#accc90; stroke-width:.8; }}
174
+ .wr {{ fill:#c4d4a8; stroke:#a4c488; stroke-width:.8; }}
175
+ .sk {{ fill:#a09070; stroke:#807050; stroke-width:.5; }}
 
176
  </style>
177
  </head>
178
  <body>
179
+ <div class="scene-wrap" id="scene-wrap">
180
+
181
+ <svg class="iso" viewBox="0 0 860 540" xmlns="http://www.w3.org/2000/svg">
182
+
183
+ <!-- WALLS -->
184
+ <polygon class="wb" points="60,30 430,215 800,30 430,-155"/>
185
+ <polygon class="wl" points="60,30 60,250 430,435 430,215"/>
186
+ <polygon class="wr" points="430,215 430,435 800,250 800,30"/>
187
+ <polygon class="sk" points="60,242 430,427 430,435 60,250"/>
188
+ <polygon class="sk" style="fill:#908060" points="430,427 800,242 800,250 430,435"/>
189
+
190
+ <!-- FLOOR -->
191
+ <polygon class="f0" points="60,215 245,120 430,215 245,310"/>
192
+ <polygon class="f1" points="245,120 430,25 615,120 430,215"/>
193
+ <polygon class="f0" points="430,25 615,-70 800,25 615,120"/>
194
+ <polygon class="f1" points="152,167 337,72 430,120 245,215"/>
195
+ <polygon class="f0" points="337,72 522,-23 615,25 430,120"/>
196
+ <polygon class="f1" points="522,-23 707,-118 800,-70 615,25"/>
197
+ <polygon class="f1" points="60,310 245,215 430,310 245,405"/>
198
+ <polygon class="f0" points="245,215 430,120 615,215 430,310"/>
199
+ <polygon class="f1" points="430,120 615,25 800,120 615,215"/>
200
+ <polygon class="f0" points="152,262 337,167 430,215 245,310"/>
201
+ <polygon class="f1" points="337,167 522,72 615,120 430,215"/>
202
+ <polygon class="f0" points="522,72 707,-23 800,25 615,120"/>
203
+ <polygon class="f0" points="60,405 245,310 430,405 245,500"/>
204
+ <polygon class="f1" points="245,310 430,215 615,310 430,405"/>
205
+ <polygon class="f0" points="430,215 615,120 800,215 615,310"/>
206
+ <polygon class="f1" points="152,357 337,262 430,310 245,405"/>
207
+ <polygon class="f0" points="337,262 522,167 615,215 430,310"/>
208
+ <polygon class="f1" points="522,167 707,72 800,120 615,215"/>
209
+
210
+ <!-- WHITEBOARDS -->
211
+ <rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="70" y="10" width="115" height="82" rx="3"/>
212
+ <rect fill="#e5e5dc" x="70" y="10" width="115" height="10" rx="3"/>
213
+ <text fill="#5a7a3a" font-size="7" font-weight="700" x="75" y="18">ASSET MANIFEST</text>
214
+ <line stroke="#99aacc" stroke-width="1" x1="78" y1="28" x2="178" y2="28"/>
215
+ <line stroke="#99aacc" stroke-width="1" x1="78" y1="39" x2="165" y2="39"/>
216
+ <line stroke="#99aacc" stroke-width="1" x1="78" y1="50" x2="172" y2="50"/>
217
+ <rect fill="#298" x="78" y="56" width="13" height="17" rx="1.5"/>
218
+ <rect fill="#f5c542" x="81" y="52" width="9" height="8" rx="1.5"/>
219
+
220
+ <rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="298" y="-5" width="136" height="72" rx="3"/>
221
+ <rect fill="#e5e5dc" x="298" y="-5" width="136" height="10" rx="3"/>
222
+ <text fill="#5a7a3a" font-size="7" font-weight="700" x="303" y="4">DESIGN SPEC</text>
223
+ <line stroke="#cc8888" stroke-width="1.5" x1="303" y1="10" x2="426" y2="10"/>
224
+ <text fill="#333" font-size="6" x="303" y="22">gravity: 18 jumpForce: 9.5</text>
225
+ <text fill="#333" font-size="6" x="303" y="33">laneWidth: 2 snapSpeed: 8</text>
226
+ <text fill="#4488cc" font-size="6.5" font-weight="700" x="303" y="46">AI · COLLAB · BUILD</text>
227
+
228
+ <!-- VENDING MACHINE -->
229
+ <polygon fill="#cc2222" stroke="#ee4444" stroke-width="1.2" points="60,176 60,296 102,273 102,153"/>
230
+ <polygon fill="#aa1111" stroke="#cc2222" stroke-width="1" points="60,153 102,130 102,153 60,176"/>
231
+ <rect fill="#ffcc00" x="64" y="196" width="30" height="10" rx="2.5"/>
232
+ <rect fill="#00ccff" x="64" y="209" width="30" height="10" rx="2.5"/>
233
+ <rect fill="#ff8800" x="64" y="222" width="30" height="10" rx="2.5"/>
234
+ <rect fill="#111" x="64" y="167" width="30" height="12" rx="2"/>
235
+ <rect fill="#00ff88" opacity=".55" x="66" y="169" width="26" height="8" rx="1.5"/>
236
+
237
+ <!-- PLANTS -->
238
+ <polygon fill="#c8681a" stroke="#a04808" stroke-width="1" points="660,100 680,90 680,110 660,120"/>
239
+ <ellipse fill="#3a8a1a" cx="676" cy="78" rx="16" ry="13"/>
240
+ <ellipse fill="#2a7a0a" cx="664" cy="73" rx="11" ry="10"/>
241
+ <ellipse fill="#4a9a2a" cx="685" cy="70" rx="11" ry="10"/>
242
+ <ellipse fill="#5aaa3a" cx="676" cy="62" rx="8" ry="7"/>
243
+
244
+ <!-- RESULT MONITOR — glowing purple -->
245
+ <polygon fill="#201030" stroke="#9944dd" stroke-width="2.5" points="646,76 728,33 756,48 674,91">
246
+ <animate attributeName="stroke-opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite"/>
247
+ </polygon>
248
+ <polygon fill="#050d05" points="650,78 724,37 752,51 678,92"/>
249
+ <line stroke="#3aaa3a" stroke-width="3" x1="658" y1="82" x2="746" y2="40"/>
250
+ <text fill="#bb77ff" font-size="7" font-weight="700" x="665" y="108">GENERATING...</text>
251
+ <ellipse fill="none" stroke="#9944dd" stroke-width="2" opacity=".45" cx="700" cy="64" rx="40" ry="22">
252
+ <animate attributeName="opacity" values="0.3;0.6;0.3" dur="1.8s" repeatCount="indefinite"/>
253
+ </ellipse>
254
+
255
+ <!-- PIZZA BOX (hidden initially, shown at phase 7+) -->
256
+ <polygon id="pizza-box" fill="#c8a060" stroke="#a88040" stroke-width=".8" visibility="hidden"
257
+ points="464,278 498,260 514,268 480,286"/>
258
+ <polygon id="pizza-box2" fill="#e0b878" stroke="#c09848" stroke-width=".6" visibility="hidden"
259
+ points="464,274 498,256 514,264 480,282"/>
260
+ <text id="pizza-emoji" visibility="hidden" fill="#aa6622" font-size="10" x="477" y="272" transform="rotate(-20,477,272)">🍕</text>
261
+
262
+ <!-- DESKS (back-to-front for correct z-order) -->
263
+ {desks_svg}
264
+
265
+ <!-- CHARACTERS -->
266
+ {chars_svg}
267
+
268
+ </svg>
269
+
270
+ <!-- Speech bubble overlay (absolute-positioned HTML) -->
271
+ <div id="bubble-layer" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none">
272
+ </div>
273
+
274
+ </div>
275
+
276
  <div id="phase-bar">
277
  <span id="phase-label">Waiting…</span>
278
  <div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
279
  </div>
280
 
 
 
281
  <script>
282
+ const assignments = {assignments_json};
283
+ const bubbleAnchors = {bubble_anchors};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ // Pre-create one speech bubble per slot
286
+ const bubbleLayer = document.getElementById('bubble-layer');
287
+ const bubbles = bubbleAnchors.map((_, i) => {{
288
+ const d = document.createElement('div');
289
+ d.className = 'speech-bubble';
290
+ d.id = 'bubble-' + i;
291
+ bubbleLayer.appendChild(d);
292
+ return d;
293
+ }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
+ // Map role → slot index
296
+ const roleToSlot = {{}};
297
+ assignments.forEach((a, i) => {{ roleToSlot[a.role] = i; }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
+ const phaseLabel = document.getElementById('phase-label');
300
+ const phaseFill = document.getElementById('phase-fill');
301
+ const svgEl = document.querySelector('svg.iso');
302
+ const sceneWrap = document.getElementById('scene-wrap');
303
+
304
+ function svgToHtml(svgX, svgY) {{
305
+ const vbW = 860, vbH = 540;
306
+ const bbox = svgEl.getBoundingClientRect();
307
+ const swRect = sceneWrap.getBoundingClientRect();
308
+ return {{
309
+ x: (svgX / vbW) * bbox.width + (bbox.left - swRect.left),
310
+ y: (svgY / vbH) * bbox.height + (bbox.top - swRect.top),
311
+ }};
312
+ }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ function positionBubble(bubble, slotIdx) {{
315
+ const anchor = bubbleAnchors[slotIdx];
316
+ const pos = svgToHtml(anchor.svgX, anchor.svgY);
317
+ bubble.style.left = (pos.x - 90) + 'px';
318
+ bubble.style.top = (pos.y - 70) + 'px';
 
 
 
319
  }}
320
 
321
+ function showBubble(slotIdx, text, borderColor) {{
322
+ const b = bubbles[slotIdx];
323
+ if (!b) return;
324
+ b.textContent = text;
325
+ b.style.display = 'block';
326
+ b.style.opacity = '1';
327
+ b.style.borderColor = borderColor || '#333';
328
+ positionBubble(b, slotIdx);
329
+ clearTimeout(b._timer);
330
+ b._timer = setTimeout(() => {{
331
+ b.style.opacity = '0';
332
+ setTimeout(() => {{ b.style.display = 'none'; }}, 500);
333
+ }}, 2500);
334
  }}
335
 
 
336
  const popupPool = [];
337
+ for (let i = 0; i < 8; i++) {{
338
+ const d = document.createElement('div');
339
+ d.className = 'floating-popup';
340
+ d.style.display = 'none';
341
+ bubbleLayer.appendChild(d);
342
+ popupPool.push(d);
343
  }}
344
 
345
+ function spawnPopup(text, color, svgX, svgY) {{
346
  const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
347
+ const pos = svgToHtml(svgX, svgY);
348
  popup.textContent = text;
349
  popup.style.backgroundColor = color;
350
+ popup.style.left = pos.x + 'px';
351
+ popup.style.top = pos.y + 'px';
352
  popup.style.display = 'block';
353
  popup.style.animation = 'none';
354
+ void popup.offsetWidth; // reflow
355
+ popup.style.animation = 'floatUp 2.5s ease-out forwards';
356
+ setTimeout(() => {{ popup.style.display = 'none'; }}, 2500);
 
 
 
357
  }}
358
 
359
+ // typeBuffers per slot
360
+ const typeBuffers = assignments.map(() => '');
 
 
 
 
 
 
 
 
 
 
 
361
 
 
362
  window.studioUpdate = function(msg) {{
363
  if (msg.type === 'text') {{
364
+ const slot = roleToSlot[msg.role];
365
+ if (slot === undefined) return;
366
+ typeBuffers[slot] += msg.text;
367
+ if (typeBuffers[slot].length > 160) typeBuffers[slot] = typeBuffers[slot].slice(-160);
368
+ const lines = typeBuffers[slot].split('\\n').slice(-2).join('\\n');
369
+ showBubble(slot, lines);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  }}
 
371
  else if (msg.type === 'phase_start') {{
372
  phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
373
+ phaseFill.style.width = Math.round((msg.phase / 9) * 100) + '%';
 
 
 
 
 
 
 
 
374
  if (msg.phase >= 7) {{
375
+ ['pizza-box','pizza-box2','pizza-emoji'].forEach(id => {{
376
+ const el = document.getElementById(id);
377
+ if (el) el.setAttribute('visibility','visible');
378
+ }});
379
  }}
380
  }}
 
381
  else if (msg.type === 'phase_complete') {{
382
+ spawnPopup(`✓ ${{msg.name}}`, '#f39c12', 430, 200);
 
383
  }}
 
384
  else if (msg.type === 'commit') {{
385
+ spawnPopup(`📁 ${{msg.file}}`, '#27ae60', 430, 240);
 
386
  }}
 
387
  else if (msg.type === 'error') {{
388
+ const slot = roleToSlot[msg.role];
389
+ if (slot !== undefined) showBubble(slot, `❌ ${{msg.text}}`, '#e74c3c');
 
 
 
 
 
 
390
  }}
 
391
  else if (msg.type === 'done') {{
392
  phaseLabel.textContent = '✅ Generation Complete!';
393
  phaseFill.style.width = '100%';
394
+ spawnPopup('🎉 Done!', '#7c3aed', 430, 180);
395
+ if (window.onStudioDone) window.onStudioDone();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  }}
 
397
  else if (msg.type === 'cancelled') {{
398
  phaseLabel.textContent = '⛔ Cancelled';
399
  }}
400
  }};
401
 
 
402
  window.addEventListener('message', function(e) {{
403
+ if (e.data && e.data.type) window.studioUpdate(e.data);
 
 
404
  }});
405
 
406
+ // Keep bubbles in position on resize
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  window.addEventListener('resize', () => {{
408
+ bubbles.forEach((b, i) => {{
409
+ if (b.style.display !== 'none') positionBubble(b, i);
410
+ }});
 
 
 
 
 
411
  }});
412
  </script>
413
  </body>