File size: 16,850 Bytes
94c4245
471b2cd
 
94c4245
 
4f032fd
94c4245
4f032fd
 
 
471b2cd
4f032fd
 
 
471b2cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94c4245
 
 
 
 
471b2cd
94c4245
 
 
471b2cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94c4245
 
 
 
471b2cd
 
94c4245
471b2cd
 
 
 
 
 
 
 
 
 
 
 
94c4245
471b2cd
 
 
 
 
94c4245
 
471b2cd
 
94c4245
 
471b2cd
 
 
94c4245
 
471b2cd
 
94c4245
471b2cd
 
 
 
 
 
 
 
94c4245
 
 
471b2cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94c4245
 
 
 
 
2fa6c59
471b2cd
 
94c4245
471b2cd
 
 
 
 
 
 
 
 
94c4245
471b2cd
 
 
94c4245
471b2cd
 
 
 
 
 
 
 
 
 
 
 
 
 
94c4245
471b2cd
 
 
 
 
94c4245
 
471b2cd
 
 
 
 
 
 
 
 
 
 
 
 
94c4245
 
 
471b2cd
 
 
 
 
 
94c4245
 
471b2cd
94c4245
471b2cd
94c4245
 
471b2cd
 
94c4245
 
471b2cd
 
 
94c4245
 
471b2cd
 
94c4245
 
 
471b2cd
 
 
 
 
 
94c4245
 
 
471b2cd
94c4245
471b2cd
 
 
 
94c4245
 
 
471b2cd
94c4245
 
471b2cd
94c4245
 
471b2cd
 
94c4245
 
 
 
471b2cd
 
94c4245
 
 
 
 
 
da5077d
471b2cd
da5077d
 
471b2cd
94c4245
471b2cd
 
 
94c4245
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
"""
Self-contained SVG isometric studio scene for Gradio — zero external dependencies.
No Three.js, no CDN. Pure SVG + HTML + vanilla JS.
"""
import json
from pathlib import Path


def _load_characters_js() -> str:
    p = Path(__file__).parent / "sandbox_cache" / "characters.js"
    return p.read_text() if p.exists() else ""


_CHARACTERS_JS = _load_characters_js()


# ---------------------------------------------------------------------------
# Isometric helpers
# ---------------------------------------------------------------------------

def _pts(pairs, ox=0, oy=0):
    return " ".join(f"{x+ox},{y+oy}" for x, y in pairs)


def _darken(hex_color: str, factor: float = 0.65) -> str:
    h = hex_color.lstrip("#")
    if len(h) != 6:
        return hex_color
    r, g, b = int(h[:2], 16), int(h[2:4], 16), int(h[4:], 16)
    return f"#{int(r*factor):02x}{int(g*factor):02x}{int(b*factor):02x}"


# Base desk polygon coordinates (desk slot 0, no offset)
_DESK_TABLETOP  = [(148,268),(240,220),(306,252),(214,300)]
_DESK_LEFT      = [(148,268),(148,288),(214,320),(214,300)]
_DESK_RIGHT     = [(214,300),(214,320),(306,270),(306,252)]
_DESK_MAT       = [(158,262),(238,218),(298,248),(218,292)]
_MON_BACK       = [(196,208),(218,198),(242,210),(220,220)]
_MON_SCREEN     = [(200,210),(218,202),(238,212),(220,220)]
_MON_LINES      = [(200,211,228,203),(200,215,224,207),(200,219,230,211)]

# Base character polygons — head anchor at (174, 264) relative to slot 0
_CHAR_HEAD_TOP   = [(174,264),(192,255),(202,259),(184,268)]
_CHAR_HEAD_SIDE  = [(184,268),(202,259),(203,261),(185,270)]
_CHAR_BODY_TOP   = [(176,276),(196,267),(204,271),(184,280)]
_CHAR_BODY_SIDE  = [(184,280),(202,271),(204,273),(186,282)]
_CHAR_LEG_L      = [(177,288),(185,284),(189,286),(181,290)]
_CHAR_LEG_R      = [(184,285),(192,281),(196,283),(188,287)]
_CHAR_ARM_L      = (186,270, 174,264)
_CHAR_ARM_R      = (196,266, 210,260)
_CHAR_EYE_L      = (178,258)
_CHAR_EYE_R      = (189,255)

# Five desk slot offsets (ox, oy)
_SLOTS = [
    (0,   0),    # back-left
    (188, 34),   # back-center
    (350, 2),    # back-right
    (0,   95),   # front-left
    (188, 129),  # front-center
]


def _svg_desk(ox: int, oy: int) -> str:
    lines = "".join(
        f'<line stroke="#00ff88" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
        if i == 0 else
        f'<line stroke="#44aaff" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
        if i == 1 else
        f'<line stroke="#ffcc00" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
        for i, (x1, y1, x2, y2) in enumerate(_MON_LINES)
    )
    return (
        f'<polygon fill="#8a6038" stroke="#aa8050" stroke-width="1" points="{_pts(_DESK_TABLETOP,ox,oy)}"/>'
        f'<polygon fill="#6a4020" points="{_pts(_DESK_LEFT,ox,oy)}"/>'
        f'<polygon fill="#5a3010" points="{_pts(_DESK_RIGHT,ox,oy)}"/>'
        f'<polygon fill="#5a8a3a" opacity=".7" points="{_pts(_DESK_MAT,ox,oy)}"/>'
        f'<polygon fill="#222" stroke="#555" stroke-width=".9" points="{_pts(_MON_BACK,ox,oy)}"/>'
        f'<polygon fill="#001812" points="{_pts(_MON_SCREEN,ox,oy)}"/>'
        + lines
    )


def _svg_char(ox: int, oy: int, color: str) -> str:
    dark = _darken(color)
    al = _CHAR_ARM_L
    ar = _CHAR_ARM_R
    el = _CHAR_EYE_L
    er = _CHAR_EYE_R
    return (
        f'<line stroke="{color}" stroke-width="3.5" x1="{al[0]+ox}" y1="{al[1]+oy}" x2="{al[2]+ox}" y2="{al[3]+oy}"/>'
        f'<line stroke="{color}" stroke-width="3.5" x1="{ar[0]+ox}" y1="{ar[1]+oy}" x2="{ar[2]+ox}" y2="{ar[3]+oy}"/>'
        f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_L,ox,oy)}"/>'
        f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_R,ox,oy)}"/>'
        f'<polygon fill="{color}" stroke="{dark}" stroke-width=".5" points="{_pts(_CHAR_BODY_TOP,ox,oy)}"/>'
        f'<polygon fill="{dark}" points="{_pts(_CHAR_BODY_SIDE,ox,oy)}"/>'
        f'<polygon fill="{color}" stroke="{dark}" stroke-width=".6" points="{_pts(_CHAR_HEAD_TOP,ox,oy)}"/>'
        f'<polygon fill="{dark}" points="{_pts(_CHAR_HEAD_SIDE,ox,oy)}"/>'
        f'<rect fill="#1a1a1a" x="{el[0]+ox}" y="{el[1]+oy}" width="5" height="5" rx=".8"/>'
        f'<rect fill="#1a1a1a" x="{er[0]+ox}" y="{er[1]+oy}" width="5" height="5" rx=".8"/>'
    )


def build_studio_html(model_assignments: list[dict]) -> str:
    """
    model_assignments: list of dicts:
        {model_id, role, character_fn, color, desk (1-5)}
    Returns self-contained HTML string.
    """
    assignments_json = json.dumps(model_assignments)

    # Build SVG for each assigned desk + character
    desk_svg_parts = []
    char_svg_parts = []
    for i, a in enumerate(model_assignments[:5]):
        ox, oy = _SLOTS[i]
        desk_svg_parts.append(_svg_desk(ox, oy))
        char_svg_parts.append(_svg_char(ox, oy, a.get("color", "#aaaaaa")))

    desks_svg  = "\n".join(desk_svg_parts)
    chars_svg  = "\n".join(char_svg_parts)

    # Speech bubble anchor positions (SVG coords) per slot — above character head
    bubble_anchors = json.dumps([
        {"svgX": 174 + ox, "svgY": 255 + oy}
        for ox, oy in _SLOTS
    ])

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Studio</title>
<style>
* {{ box-sizing:border-box; margin:0; padding:0; }}
body {{ background:#1a1a1a; font-family:system-ui,sans-serif; overflow:hidden; }}
.scene-wrap {{ position:relative; width:100vw; height:calc(100vh - 28px); }}
svg.iso {{ width:100%; height:100%; display:block; }}
#phase-bar {{
  position:fixed; bottom:0; left:0; right:0; height:28px;
  background:#111; display:flex; align-items:center; gap:12px;
  padding:0 14px; font-size:11px; color:#eee; z-index:20;
}}
#phase-label {{ flex:1; }}
#phase-progress {{ width:180px; height:7px; background:#333; border-radius:4px; overflow:hidden; }}
#phase-fill {{ height:100%; background:#7c3aed; border-radius:4px; transition:width .5s; }}
.speech-bubble {{
  position:absolute; background:rgba(255,255,255,.95);
  border:2px solid #333; border-radius:6px; padding:5px 9px;
  font-size:11px; max-width:180px; line-height:1.35;
  box-shadow:2px 2px 6px rgba(0,0,0,.4); pointer-events:none;
  transition:opacity .5s; display:none;
}}
.speech-bubble::after {{
  content:''; position:absolute; bottom:-9px; left:16px;
  border:5px solid transparent; border-top-color:#333;
}}
.floating-popup {{
  position:absolute; padding:3px 9px; border-radius:6px;
  font-size:11px; font-weight:700; color:#fff;
  animation:floatUp 2.5s ease-out forwards; pointer-events:none;
}}
@keyframes floatUp {{
  0%   {{ transform:translateY(0);   opacity:1; }}
  100% {{ transform:translateY(-60px); opacity:0; }}
}}
/* floor tiles */
.f0 {{ fill:#d4a060; stroke:#b07838; stroke-width:.7; }}
.f1 {{ fill:#c8924a; stroke:#a06830; stroke-width:.7; }}
/* walls */
.wb {{ fill:#dce8c4; stroke:#bccca4; stroke-width:.8; }}
.wl {{ fill:#ccdcb0; stroke:#accc90; stroke-width:.8; }}
.wr {{ fill:#c4d4a8; stroke:#a4c488; stroke-width:.8; }}
.sk {{ fill:#a09070; stroke:#807050; stroke-width:.5; }}
</style>
</head>
<body>
<div class="scene-wrap" id="scene-wrap">

<svg class="iso" viewBox="0 0 860 540" xmlns="http://www.w3.org/2000/svg">

<!-- WALLS -->
<polygon class="wb" points="60,30 430,215 800,30 430,-155"/>
<polygon class="wl" points="60,30 60,250 430,435 430,215"/>
<polygon class="wr" points="430,215 430,435 800,250 800,30"/>
<polygon class="sk" points="60,242 430,427 430,435 60,250"/>
<polygon class="sk" style="fill:#908060" points="430,427 800,242 800,250 430,435"/>

<!-- FLOOR -->
<polygon class="f0" points="60,215 245,120 430,215 245,310"/>
<polygon class="f1" points="245,120 430,25 615,120 430,215"/>
<polygon class="f0" points="430,25 615,-70 800,25 615,120"/>
<polygon class="f1" points="152,167 337,72 430,120 245,215"/>
<polygon class="f0" points="337,72 522,-23 615,25 430,120"/>
<polygon class="f1" points="522,-23 707,-118 800,-70 615,25"/>
<polygon class="f1" points="60,310 245,215 430,310 245,405"/>
<polygon class="f0" points="245,215 430,120 615,215 430,310"/>
<polygon class="f1" points="430,120 615,25 800,120 615,215"/>
<polygon class="f0" points="152,262 337,167 430,215 245,310"/>
<polygon class="f1" points="337,167 522,72 615,120 430,215"/>
<polygon class="f0" points="522,72 707,-23 800,25 615,120"/>
<polygon class="f0" points="60,405 245,310 430,405 245,500"/>
<polygon class="f1" points="245,310 430,215 615,310 430,405"/>
<polygon class="f0" points="430,215 615,120 800,215 615,310"/>
<polygon class="f1" points="152,357 337,262 430,310 245,405"/>
<polygon class="f0" points="337,262 522,167 615,215 430,310"/>
<polygon class="f1" points="522,167 707,72 800,120 615,215"/>

<!-- WHITEBOARDS -->
<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="70" y="10" width="115" height="82" rx="3"/>
<rect fill="#e5e5dc" x="70" y="10" width="115" height="10" rx="3"/>
<text fill="#5a7a3a" font-size="7" font-weight="700" x="75" y="18">ASSET MANIFEST</text>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="28" x2="178" y2="28"/>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="39" x2="165" y2="39"/>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="50" x2="172" y2="50"/>
<rect fill="#298" x="78" y="56" width="13" height="17" rx="1.5"/>
<rect fill="#f5c542" x="81" y="52" width="9" height="8" rx="1.5"/>

<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="298" y="-5" width="136" height="72" rx="3"/>
<rect fill="#e5e5dc" x="298" y="-5" width="136" height="10" rx="3"/>
<text fill="#5a7a3a" font-size="7" font-weight="700" x="303" y="4">DESIGN SPEC</text>
<line stroke="#cc8888" stroke-width="1.5" x1="303" y1="10" x2="426" y2="10"/>
<text fill="#333" font-size="6" x="303" y="22">gravity: 18    jumpForce: 9.5</text>
<text fill="#333" font-size="6" x="303" y="33">laneWidth: 2   snapSpeed: 8</text>
<text fill="#4488cc" font-size="6.5" font-weight="700" x="303" y="46">AI · COLLAB · BUILD</text>

<!-- VENDING MACHINE -->
<polygon fill="#cc2222" stroke="#ee4444" stroke-width="1.2" points="60,176 60,296 102,273 102,153"/>
<polygon fill="#aa1111" stroke="#cc2222" stroke-width="1" points="60,153 102,130 102,153 60,176"/>
<rect fill="#ffcc00" x="64" y="196" width="30" height="10" rx="2.5"/>
<rect fill="#00ccff" x="64" y="209" width="30" height="10" rx="2.5"/>
<rect fill="#ff8800" x="64" y="222" width="30" height="10" rx="2.5"/>
<rect fill="#111" x="64" y="167" width="30" height="12" rx="2"/>
<rect fill="#00ff88" opacity=".55" x="66" y="169" width="26" height="8" rx="1.5"/>

<!-- PLANTS -->
<polygon fill="#c8681a" stroke="#a04808" stroke-width="1" points="660,100 680,90 680,110 660,120"/>
<ellipse fill="#3a8a1a" cx="676" cy="78" rx="16" ry="13"/>
<ellipse fill="#2a7a0a" cx="664" cy="73" rx="11" ry="10"/>
<ellipse fill="#4a9a2a" cx="685" cy="70" rx="11" ry="10"/>
<ellipse fill="#5aaa3a" cx="676" cy="62" rx="8" ry="7"/>

<!-- RESULT MONITOR — glowing purple -->
<polygon fill="#201030" stroke="#9944dd" stroke-width="2.5" points="646,76 728,33 756,48 674,91">
  <animate attributeName="stroke-opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite"/>
</polygon>
<polygon fill="#050d05" points="650,78 724,37 752,51 678,92"/>
<line stroke="#3aaa3a" stroke-width="3" x1="658" y1="82" x2="746" y2="40"/>
<text fill="#bb77ff" font-size="7" font-weight="700" x="665" y="108">GENERATING...</text>
<ellipse fill="none" stroke="#9944dd" stroke-width="2" opacity=".45" cx="700" cy="64" rx="40" ry="22">
  <animate attributeName="opacity" values="0.3;0.6;0.3" dur="1.8s" repeatCount="indefinite"/>
</ellipse>

<!-- PIZZA BOX (hidden initially, shown at phase 7+) -->
<polygon id="pizza-box" fill="#c8a060" stroke="#a88040" stroke-width=".8" visibility="hidden"
  points="464,278 498,260 514,268 480,286"/>
<polygon id="pizza-box2" fill="#e0b878" stroke="#c09848" stroke-width=".6" visibility="hidden"
  points="464,274 498,256 514,264 480,282"/>
<text id="pizza-emoji" visibility="hidden" fill="#aa6622" font-size="10" x="477" y="272" transform="rotate(-20,477,272)">🍕</text>

<!-- DESKS (back-to-front for correct z-order) -->
{desks_svg}

<!-- CHARACTERS -->
{chars_svg}

</svg>

<!-- Speech bubble overlay (absolute-positioned HTML) -->
<div id="bubble-layer" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none">
</div>

</div>

<div id="phase-bar">
  <span id="phase-label">Waiting…</span>
  <div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
</div>

<script>
const assignments  = {assignments_json};
const bubbleAnchors = {bubble_anchors};

// Pre-create one speech bubble per slot
const bubbleLayer = document.getElementById('bubble-layer');
const bubbles = bubbleAnchors.map((_, i) => {{
  const d = document.createElement('div');
  d.className = 'speech-bubble';
  d.id = 'bubble-' + i;
  bubbleLayer.appendChild(d);
  return d;
}});

// Map role → slot index
const roleToSlot = {{}};
assignments.forEach((a, i) => {{ roleToSlot[a.role] = i; }});

const phaseLabel = document.getElementById('phase-label');
const phaseFill  = document.getElementById('phase-fill');
const svgEl      = document.querySelector('svg.iso');
const sceneWrap  = document.getElementById('scene-wrap');

function svgToHtml(svgX, svgY) {{
  const vbW = 860, vbH = 540;
  const bbox  = svgEl.getBoundingClientRect();
  const swRect = sceneWrap.getBoundingClientRect();
  return {{
    x: (svgX / vbW) * bbox.width  + (bbox.left - swRect.left),
    y: (svgY / vbH) * bbox.height + (bbox.top  - swRect.top),
  }};
}}

function positionBubble(bubble, slotIdx) {{
  const anchor = bubbleAnchors[slotIdx];
  const pos = svgToHtml(anchor.svgX, anchor.svgY);
  bubble.style.left = (pos.x - 90) + 'px';
  bubble.style.top  = (pos.y - 70) + 'px';
}}

function showBubble(slotIdx, text, borderColor) {{
  const b = bubbles[slotIdx];
  if (!b) return;
  b.textContent = text;
  b.style.display = 'block';
  b.style.opacity = '1';
  b.style.borderColor = borderColor || '#333';
  positionBubble(b, slotIdx);
  clearTimeout(b._timer);
  b._timer = setTimeout(() => {{
    b.style.opacity = '0';
    setTimeout(() => {{ b.style.display = 'none'; }}, 500);
  }}, 2500);
}}

const popupPool = [];
for (let i = 0; i < 8; i++) {{
  const d = document.createElement('div');
  d.className = 'floating-popup';
  d.style.display = 'none';
  bubbleLayer.appendChild(d);
  popupPool.push(d);
}}

function spawnPopup(text, color, svgX, svgY) {{
  const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
  const pos = svgToHtml(svgX, svgY);
  popup.textContent = text;
  popup.style.backgroundColor = color;
  popup.style.left = pos.x + 'px';
  popup.style.top  = pos.y + 'px';
  popup.style.display = 'block';
  popup.style.animation = 'none';
  void popup.offsetWidth; // reflow
  popup.style.animation = 'floatUp 2.5s ease-out forwards';
  setTimeout(() => {{ popup.style.display = 'none'; }}, 2500);
}}

// typeBuffers per slot
const typeBuffers = assignments.map(() => '');

window.studioUpdate = function(msg) {{
  if (msg.type === 'text') {{
    const slot = roleToSlot[msg.role];
    if (slot === undefined) return;
    typeBuffers[slot] += msg.text;
    if (typeBuffers[slot].length > 160) typeBuffers[slot] = typeBuffers[slot].slice(-160);
    const lines = typeBuffers[slot].split('\\n').slice(-2).join('\\n');
    showBubble(slot, lines);
  }}
  else if (msg.type === 'phase_start') {{
    phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
    phaseFill.style.width = Math.round((msg.phase / 9) * 100) + '%';
    if (msg.phase >= 7) {{
      ['pizza-box','pizza-box2','pizza-emoji'].forEach(id => {{
        const el = document.getElementById(id);
        if (el) el.setAttribute('visibility','visible');
      }});
    }}
  }}
  else if (msg.type === 'phase_complete') {{
    spawnPopup(`✓ ${{msg.name}}`, '#f39c12', 430, 200);
  }}
  else if (msg.type === 'commit') {{
    spawnPopup(`📁 ${{msg.file}}`, '#27ae60', 430, 240);
  }}
  else if (msg.type === 'error') {{
    const slot = roleToSlot[msg.role];
    if (slot !== undefined) showBubble(slot, `❌ ${{msg.text}}`, '#e74c3c');
  }}
  else if (msg.type === 'done') {{
    phaseLabel.textContent = '✅ Generation Complete!';
    phaseFill.style.width = '100%';
    spawnPopup('🎉 Done!', '#7c3aed', 430, 180);
    if (window.onStudioDone) window.onStudioDone();
  }}
  else if (msg.type === 'cancelled') {{
    phaseLabel.textContent = '⛔ Cancelled';
  }}
}};

window.addEventListener('message', function(e) {{
  if (e.data && e.data.type) window.studioUpdate(e.data);
}});

// Keep bubbles in position on resize
window.addEventListener('resize', () => {{
  bubbles.forEach((b, i) => {{
    if (b.style.display !== 'none') positionBubble(b, i);
  }});
}});
</script>
</body>
</html>"""