opencroc 1.6.9 → 1.7.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,1571 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>OpenCroc Studio</title>
7
+ <style>
8
+ /* ═══════════════════════════════════════════════════════
9
+ Design System — CSS Variables (borrowed from OpenClaw)
10
+ ═══════════════════════════════════════════════════════ */
11
+ :root {
12
+ --bg: #0a0f1a;
13
+ --bg-panel: #111827;
14
+ --bg-card: #1a2332;
15
+ --bg-hover: #243044;
16
+ --bg-soft: rgba(30, 41, 59, 0.5);
17
+ --accent: #34d399;
18
+ --accent-dim: #059669;
19
+ --accent-bg: rgba(52, 211, 153, 0.12);
20
+ --red: #f87171;
21
+ --red-bg: rgba(248, 113, 113, 0.12);
22
+ --orange: #fbbf24;
23
+ --orange-bg: rgba(251, 191, 36, 0.12);
24
+ --blue: #60a5fa;
25
+ --blue-bg: rgba(96, 165, 250, 0.12);
26
+ --purple: #a78bfa;
27
+ --purple-bg: rgba(167, 139, 250, 0.12);
28
+ --text: #f1f5f9;
29
+ --text-dim: #94a3b8;
30
+ --text-subtle:#64748b;
31
+ --border: rgba(148, 163, 184, 0.15);
32
+ --border-accent: rgba(52, 211, 153, 0.3);
33
+ --shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.4);
34
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.3);
35
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
36
+ --radius-lg: 14px;
37
+ --radius-md: 10px;
38
+ --radius-sm: 6px;
39
+ --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
40
+ }
41
+ [data-theme="light"] {
42
+ --bg: #f1f5f9;
43
+ --bg-panel: #ffffff;
44
+ --bg-card: #f8fafc;
45
+ --bg-hover: #e2e8f0;
46
+ --bg-soft: rgba(241, 245, 249, 0.8);
47
+ --accent: #059669;
48
+ --accent-dim: #047857;
49
+ --accent-bg: rgba(5, 150, 105, 0.1);
50
+ --red: #dc2626;
51
+ --red-bg: rgba(220, 38, 38, 0.08);
52
+ --orange: #d97706;
53
+ --orange-bg: rgba(217, 119, 6, 0.08);
54
+ --blue: #2563eb;
55
+ --blue-bg: rgba(37, 99, 235, 0.08);
56
+ --purple: #7c3aed;
57
+ --purple-bg: rgba(124, 58, 237, 0.08);
58
+ --text: #0f172a;
59
+ --text-dim: #475569;
60
+ --text-subtle:#94a3b8;
61
+ --border: rgba(100, 116, 139, 0.2);
62
+ --border-accent: rgba(5, 150, 105, 0.35);
63
+ --shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.08);
64
+ --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.06);
65
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
66
+ }
67
+
68
+ /* ═══════════════════════════════════════════════════════
69
+ Base Reset & Typography (system font stack)
70
+ ═══════════════════════════════════════════════════════ */
71
+ * { margin:0; padding:0; box-sizing:border-box; }
72
+ body {
73
+ background: var(--bg);
74
+ color: var(--text);
75
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", "PingFang SC", sans-serif;
76
+ overflow: hidden;
77
+ height: 100vh;
78
+ line-height: 1.5;
79
+ -webkit-font-smoothing: antialiased;
80
+ }
81
+
82
+ /* Scrollbar styling */
83
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
84
+ ::-webkit-scrollbar-track { background: transparent; }
85
+ ::-webkit-scrollbar-thumb { background: var(--text-subtle); border-radius: 4px; }
86
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
87
+
88
+ /* ═══════════════════════════════════════════════════════
89
+ Layout Grid — Responsive
90
+ ═══════════════════════════════════════════════════════ */
91
+ .app {
92
+ display: grid;
93
+ grid-template-rows: 56px 1fr 220px;
94
+ grid-template-columns: 240px 1fr 320px;
95
+ grid-template-areas:
96
+ "header header header"
97
+ "sidebar main panel"
98
+ "office office office";
99
+ height: 100vh;
100
+ gap: 6px;
101
+ padding: 6px;
102
+ }
103
+ @media (max-width: 1200px) {
104
+ .app {
105
+ grid-template-columns: 200px 1fr 280px;
106
+ }
107
+ }
108
+ @media (max-width: 960px) {
109
+ .app {
110
+ grid-template-columns: 1fr;
111
+ grid-template-rows: 56px 1fr 200px;
112
+ grid-template-areas:
113
+ "header"
114
+ "main"
115
+ "office";
116
+ }
117
+ .sidebar, .log-panel { display: none; }
118
+ .sidebar.mobile-open, .log-panel.mobile-open {
119
+ display: flex;
120
+ position: fixed;
121
+ top: 62px; bottom: 0;
122
+ width: 280px;
123
+ z-index: 150;
124
+ }
125
+ .sidebar.mobile-open { left: 0; }
126
+ .log-panel.mobile-open { right: 0; }
127
+ }
128
+
129
+ /* ═══════════════════════════════════════════════════════
130
+ Header
131
+ ═══════════════════════════════════════════════════════ */
132
+ .header {
133
+ grid-area: header;
134
+ background: var(--bg-panel);
135
+ border: 1px solid var(--border);
136
+ border-radius: var(--radius-lg);
137
+ display: flex;
138
+ align-items: center;
139
+ padding: 0 14px;
140
+ gap: 10px;
141
+ box-shadow: var(--shadow-sm);
142
+ }
143
+ .header .logo {
144
+ width: 36px; height: 36px;
145
+ border-radius: var(--radius-md);
146
+ display: grid; place-items: center;
147
+ background: var(--accent-bg);
148
+ border: 1px solid var(--border-accent);
149
+ flex-shrink: 0;
150
+ }
151
+ .header .logo svg { width: 22px; height: 22px; }
152
+ .header .title-wrap { display:flex; flex-direction:column; gap:1px; min-width:0; }
153
+ .header h1 { font-size:14px; font-weight:700; color:var(--accent); letter-spacing:.3px; white-space:nowrap; }
154
+ .header .subtitle { font-size:10px; color:var(--text-subtle); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
155
+ .header .actions { display:flex; gap:5px; flex-shrink:0; }
156
+ .header .spacer { flex:1; }
157
+
158
+ /* View Switch */
159
+ .view-switch {
160
+ display: flex;
161
+ background: var(--bg-card);
162
+ border: 1px solid var(--border);
163
+ border-radius: var(--radius-md);
164
+ padding: 2px;
165
+ margin-right: 2px;
166
+ }
167
+ .view-switch button {
168
+ background: transparent;
169
+ color: var(--text-dim);
170
+ border: none;
171
+ font-family: inherit;
172
+ font-size: 11px;
173
+ padding: 5px 12px;
174
+ border-radius: var(--radius-sm);
175
+ cursor: pointer;
176
+ transition: all var(--transition);
177
+ display: flex; align-items: center; gap: 4px;
178
+ }
179
+ .view-switch button:hover { color: var(--text); background: var(--bg-hover); }
180
+ .view-switch button.active {
181
+ background: var(--accent-bg);
182
+ color: var(--accent);
183
+ box-shadow: 0 0 0 1px var(--border-accent);
184
+ }
185
+
186
+ /* Buttons */
187
+ .btn {
188
+ background: var(--accent-dim);
189
+ color: #fff;
190
+ border: 1px solid var(--border-accent);
191
+ padding: 5px 12px;
192
+ font-family: inherit;
193
+ font-size: 11px;
194
+ font-weight: 500;
195
+ border-radius: var(--radius-sm);
196
+ cursor: pointer;
197
+ transition: all var(--transition);
198
+ display: flex; align-items: center; gap: 4px;
199
+ white-space: nowrap;
200
+ }
201
+ .btn:hover { background: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
202
+ .btn:active { transform: translateY(0); }
203
+ .btn:disabled { opacity: .35; cursor: not-allowed; transform: none; }
204
+ .btn.danger { background: #991b1b; border-color: rgba(248,113,113,.3); }
205
+ .btn.danger:hover { background: #b91c1c; }
206
+ .btn svg { width: 14px; height: 14px; }
207
+ .mode-select {
208
+ background: var(--bg-card);
209
+ color: var(--text);
210
+ border: 1px solid var(--border);
211
+ border-radius: var(--radius-sm);
212
+ font-family: inherit;
213
+ font-size: 11px;
214
+ padding: 5px 8px;
215
+ min-width: 80px;
216
+ cursor: pointer;
217
+ }
218
+
219
+ /* Stats */
220
+ .header .stats { display:flex; gap:6px; font-size:10px; color:var(--text-dim); flex-shrink:0; }
221
+ .header .stats > div {
222
+ background: var(--bg-card);
223
+ border: 1px solid var(--border);
224
+ border-radius: var(--radius-sm);
225
+ padding: 4px 8px;
226
+ min-width: 60px;
227
+ text-align: center;
228
+ }
229
+ .header .stats .label { font-size:9px; color:var(--text-subtle); text-transform:uppercase; letter-spacing:.5px; }
230
+ .header .stats .value { display:block; color:var(--accent); font-weight:700; font-size:13px; margin-top:1px; }
231
+ .conn-dot {
232
+ width: 10px; height: 10px;
233
+ border-radius: 50%;
234
+ background: var(--red);
235
+ box-shadow: 0 0 0 3px var(--red-bg);
236
+ transition: all .3s;
237
+ flex-shrink: 0;
238
+ }
239
+ .conn-dot.on { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); animation: conn-pulse 2s infinite; }
240
+ @keyframes conn-pulse { 0%,100%{ box-shadow: 0 0 0 3px var(--accent-bg) } 50%{ box-shadow: 0 0 0 6px var(--accent-bg) } }
241
+
242
+ /* Theme toggle */
243
+ .theme-toggle {
244
+ background: none; border: 1px solid var(--border); color: var(--text-dim);
245
+ width: 32px; height: 32px; border-radius: var(--radius-sm);
246
+ display: grid; place-items: center; cursor: pointer;
247
+ transition: all var(--transition);
248
+ }
249
+ .theme-toggle:hover { background: var(--bg-hover); color: var(--text); }
250
+ .theme-toggle svg { width: 16px; height: 16px; }
251
+
252
+ /* ═══════════════════════════════════════════════════════
253
+ Sidebar
254
+ ═══════════════════════════════════════════════════════ */
255
+ .sidebar {
256
+ grid-area: sidebar;
257
+ background: var(--bg-panel);
258
+ border: 1px solid var(--border);
259
+ border-radius: var(--radius-lg);
260
+ overflow-y: auto;
261
+ padding: 10px;
262
+ box-shadow: var(--shadow-sm);
263
+ }
264
+ .sidebar h3 {
265
+ font-size: 10px; text-transform: uppercase; color: var(--text-subtle);
266
+ padding: 10px 4px 6px; letter-spacing: 1.2px; font-weight: 600;
267
+ display: flex; align-items: center; gap: 6px;
268
+ }
269
+ .sidebar h3 svg { width: 14px; height: 14px; opacity: 0.6; }
270
+ .mod-item {
271
+ padding: 7px 10px;
272
+ border-radius: var(--radius-sm);
273
+ font-size: 12px;
274
+ cursor: pointer;
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 8px;
278
+ transition: all var(--transition);
279
+ margin-bottom: 2px;
280
+ border: 1px solid transparent;
281
+ color: var(--text-dim);
282
+ }
283
+ .mod-item:hover {
284
+ background: var(--bg-hover);
285
+ border-color: var(--border);
286
+ color: var(--text);
287
+ transform: translateX(2px);
288
+ }
289
+ .mod-count {
290
+ margin-left: auto; font-size: 9px; color: var(--text-subtle);
291
+ background: var(--bg-card); padding: 1px 6px; border-radius: 8px;
292
+ }
293
+ .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; transition: background .3s; }
294
+ .dot.idle { background: var(--text-subtle); }
295
+ .dot.testing, .dot.working { background: var(--orange); animation: dot-pulse 1s infinite; }
296
+ .dot.thinking { background: var(--blue); animation: dot-pulse 1.4s infinite; }
297
+ .dot.passed, .dot.done { background: var(--accent); }
298
+ .dot.failed, .dot.error { background: var(--red); animation: dot-shake .3s infinite; }
299
+ @keyframes dot-pulse { 0%,100%{ opacity:1; transform:scale(1) } 50%{ opacity:.5; transform:scale(.85) } }
300
+ @keyframes dot-shake { 0%,100%{ transform:translateX(0) } 25%{ transform:translateX(-1px) } 75%{ transform:translateX(1px) } }
301
+
302
+ /* ═══════════════════════════════════════════════════════
303
+ Main Content
304
+ ═══════════════════════════════════════════════════════ */
305
+ .main {
306
+ grid-area: main;
307
+ position: relative;
308
+ overflow: hidden;
309
+ background: var(--bg-panel);
310
+ border: 1px solid var(--border);
311
+ border-radius: var(--radius-lg);
312
+ box-shadow: var(--shadow-md);
313
+ }
314
+ #graph-canvas { width:100%; height:100%; display:block; cursor:grab; }
315
+ .view { position:absolute; inset:0; }
316
+ .view.hidden { display:none; }
317
+
318
+ /* Pixel Office Stage */
319
+ .pixel-stage {
320
+ position: relative; width:100%; height:100%; overflow:hidden;
321
+ background:
322
+ linear-gradient(rgba(8,12,20,.15), rgba(8,12,20,.4)),
323
+ url('./assets/star/office_bg_small.webp') center/cover no-repeat;
324
+ }
325
+ .pixel-stage::before {
326
+ content:''; position:absolute; inset:0;
327
+ background-image:
328
+ linear-gradient(rgba(148,163,184,.08) 1px, transparent 1px),
329
+ linear-gradient(90deg, rgba(148,163,184,.08) 1px, transparent 1px);
330
+ background-size: 32px 32px;
331
+ pointer-events:none;
332
+ }
333
+ .pixel-stage .asset {
334
+ position:absolute; image-rendering:pixelated; z-index:2;
335
+ filter: drop-shadow(0 8px 16px rgba(0,0,0,.4));
336
+ transition: opacity .3s;
337
+ }
338
+ .pixel-stage .desk-asset { width:180px; bottom:90px; left:40px; }
339
+ .pixel-stage .server-asset { width:110px; top:24px; right:34px; }
340
+ .pixel-stage .coffee-asset { width:72px; top:100px; right:180px; }
341
+ .pixel-stage .walls-asset { width:220px; top:10px; left:46%; transform:translateX(-50%); opacity:.4; }
342
+ .pixel-agent-layer { position:absolute; inset:0; z-index:3; }
343
+ .pixel-agent {
344
+ position:absolute; width:44px;
345
+ transition: left .6s cubic-bezier(.4,0,.2,1), top .6s cubic-bezier(.4,0,.2,1);
346
+ image-rendering:pixelated;
347
+ filter: drop-shadow(0 6px 10px rgba(0,0,0,.4));
348
+ }
349
+ /* Sprite-canvas replaces static img for animated agents */
350
+ .pixel-sprite-canvas {
351
+ position:absolute; width:48px; height:48px;
352
+ image-rendering:pixelated;
353
+ transition: left .6s cubic-bezier(.4,0,.2,1), top .6s cubic-bezier(.4,0,.2,1);
354
+ filter: drop-shadow(0 6px 10px rgba(0,0,0,.4));
355
+ }
356
+ .pixel-agent.working,.pixel-agent.testing { animation: agent-bob .5s ease-in-out infinite alternate; }
357
+ .pixel-agent.thinking { animation: agent-think 1.2s ease-in-out infinite; }
358
+ .pixel-agent.error,.pixel-agent.failed { animation: agent-shake .25s ease-in-out infinite; }
359
+ .pixel-agent.done,.pixel-agent.passed { animation: agent-pulse .9s ease-in-out infinite; }
360
+ .pixel-sprite-canvas.working,.pixel-sprite-canvas.testing { animation: agent-bob .5s ease-in-out infinite alternate; }
361
+ .pixel-sprite-canvas.thinking { animation: agent-think 1.2s ease-in-out infinite; }
362
+ .pixel-sprite-canvas.error,.pixel-sprite-canvas.failed { animation: agent-shake .25s ease-in-out infinite; }
363
+ .pixel-sprite-canvas.done,.pixel-sprite-canvas.passed { animation: agent-pulse .9s ease-in-out infinite; }
364
+ @keyframes agent-bob { from{transform:translateY(0)} to{transform:translateY(-4px)} }
365
+ @keyframes agent-think { 0%,100%{transform:rotate(0) scale(1)} 50%{transform:rotate(-3deg) scale(1.02)} }
366
+ @keyframes agent-shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-3px)} 75%{transform:translateX(3px)} }
367
+ @keyframes agent-pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }
368
+
369
+ /* Pixel Agent Label — enhanced with status bubble */
370
+ .pixel-label {
371
+ position:absolute; transform:translate(-50%,-50%);
372
+ background: var(--bg-panel);
373
+ border: 1px solid var(--border-accent);
374
+ border-radius: var(--radius-sm);
375
+ padding: 3px 8px;
376
+ font-size: 10px;
377
+ color: var(--text);
378
+ white-space:nowrap;
379
+ z-index:4;
380
+ box-shadow: var(--shadow-sm);
381
+ backdrop-filter: blur(4px);
382
+ }
383
+ .pixel-label .status-tag {
384
+ display:inline-block; font-size:8px; padding:1px 4px; border-radius:4px; margin-left:4px;
385
+ font-weight:600; text-transform:uppercase; letter-spacing:.3px;
386
+ }
387
+ .pixel-label .status-tag.working { background:var(--orange-bg); color:var(--orange); }
388
+ .pixel-label .status-tag.thinking { background:var(--blue-bg); color:var(--blue); }
389
+ .pixel-label .status-tag.done { background:var(--accent-bg); color:var(--accent); }
390
+ .pixel-label .status-tag.error { background:var(--red-bg); color:var(--red); }
391
+
392
+ /* Pixel KPIs */
393
+ .pixel-kpis { position:absolute; left:14px; top:14px; display:flex; gap:6px; z-index:4; }
394
+ .pixel-kpi {
395
+ min-width:80px;
396
+ background: var(--bg-panel);
397
+ border: 1px solid var(--border);
398
+ border-radius: var(--radius-md);
399
+ padding: 8px 10px;
400
+ box-shadow: var(--shadow-md);
401
+ backdrop-filter: blur(8px);
402
+ }
403
+ .pixel-kpi .t { font-size:9px; color:var(--text-subtle); text-transform:uppercase; letter-spacing:.5px; }
404
+ .pixel-kpi .v { font-size:16px; color:var(--accent); font-weight:700; margin-top:2px; }
405
+ .pixel-kpi.errors .v { color:var(--red); }
406
+ .pixel-kpi.done .v { color:var(--accent); }
407
+
408
+ /* Bubble messages (Star-Office style) */
409
+ .pixel-bubble {
410
+ position:absolute; z-index:5; pointer-events:none;
411
+ background: var(--bg-panel);
412
+ border: 1px solid var(--border-accent);
413
+ border-radius: var(--radius-sm) var(--radius-sm) var(--radius-sm) 2px;
414
+ padding: 4px 10px;
415
+ font-size: 10px;
416
+ color: var(--text);
417
+ box-shadow: var(--shadow-sm);
418
+ animation: bubble-in .3s ease-out, bubble-out .3s ease-in 2.7s forwards;
419
+ white-space: nowrap;
420
+ }
421
+ @keyframes bubble-in { from{opacity:0;transform:translateY(6px) scale(.9)} to{opacity:1;transform:translateY(0) scale(1)} }
422
+ @keyframes bubble-out { from{opacity:1} to{opacity:0;transform:translateY(-4px)} }
423
+
424
+ /* Tooltip */
425
+ .tooltip {
426
+ position:absolute;
427
+ background: var(--bg-panel);
428
+ border: 1px solid var(--border-accent);
429
+ border-radius: var(--radius-md);
430
+ padding: 10px 14px;
431
+ font-size: 12px;
432
+ pointer-events:none;
433
+ z-index:100;
434
+ display:none;
435
+ max-width:300px;
436
+ box-shadow: var(--shadow-lg);
437
+ line-height: 1.6;
438
+ }
439
+ .tooltip.visible { display:block; }
440
+ .tooltip b { color: var(--accent); }
441
+
442
+ /* ═══════════════════════════════════════════════════════
443
+ Right Panel (Log/Tests/Results/Reports)
444
+ ═══════════════════════════════════════════════════════ */
445
+ .log-panel {
446
+ grid-area: panel;
447
+ background: var(--bg-panel);
448
+ border: 1px solid var(--border);
449
+ border-radius: var(--radius-lg);
450
+ display: flex; flex-direction: column;
451
+ overflow: hidden;
452
+ box-shadow: var(--shadow-sm);
453
+ }
454
+ .panel-tabs {
455
+ display: flex;
456
+ border-bottom: 1px solid var(--border);
457
+ background: var(--bg-card);
458
+ flex-shrink: 0;
459
+ }
460
+ .panel-tabs .tab {
461
+ background: none; border: none;
462
+ color: var(--text-dim);
463
+ font-family: inherit;
464
+ font-size: 11px;
465
+ padding: 10px 12px;
466
+ cursor: pointer;
467
+ border-bottom: 2px solid transparent;
468
+ transition: all var(--transition);
469
+ display: flex; align-items: center; gap: 4px;
470
+ }
471
+ .panel-tabs .tab:hover { color: var(--text); background: var(--bg-hover); }
472
+ .panel-tabs .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
473
+ .panel-tabs .tab svg { width: 14px; height: 14px; }
474
+ .tab-badge {
475
+ display:inline-block; background:var(--accent); color:var(--bg);
476
+ border-radius:8px; padding:0 5px; font-size:9px; font-weight:700;
477
+ }
478
+ .tab-badge.alert { background:var(--red); color:#fff; }
479
+
480
+ .file-list { flex:1; overflow-y:auto; padding:6px 8px; font-size:11px; }
481
+ .file-item {
482
+ padding: 8px 10px;
483
+ border-bottom: 1px solid var(--border);
484
+ cursor: pointer;
485
+ border-radius: var(--radius-sm);
486
+ transition: all var(--transition);
487
+ margin-bottom:2px;
488
+ }
489
+ .file-item:hover { background: var(--bg-hover); }
490
+ .file-item .fname { color: var(--accent); font-weight: 600; font-size:12px; }
491
+ .file-item .fmeta { color: var(--text-dim); font-size: 10px; margin-top: 3px; }
492
+ .file-preview {
493
+ position: fixed; top: 50%; left: 50%;
494
+ transform: translate(-50%, -50%);
495
+ width: min(800px, 90vw);
496
+ max-height: 80vh;
497
+ background: var(--bg-panel);
498
+ border: 1px solid var(--border-accent);
499
+ border-radius: var(--radius-lg);
500
+ z-index: 200;
501
+ display: none; flex-direction: column;
502
+ box-shadow: var(--shadow-lg);
503
+ }
504
+ .file-preview.visible { display: flex; }
505
+ .file-preview .fp-header {
506
+ display: flex; justify-content: space-between; align-items: center;
507
+ padding: 12px 16px;
508
+ border-bottom: 1px solid var(--border);
509
+ }
510
+ .file-preview .fp-header h4 { font-size: 13px; color: var(--accent); font-weight: 600; }
511
+ .file-preview .fp-close {
512
+ background: none; border: none; color: var(--text-dim);
513
+ font-size: 20px; cursor: pointer; padding: 4px 8px; border-radius: var(--radius-sm);
514
+ transition: all var(--transition);
515
+ }
516
+ .file-preview .fp-close:hover { background: var(--bg-hover); color: var(--text); }
517
+ .file-preview pre {
518
+ flex:1; overflow:auto; padding:16px; margin:0;
519
+ font-size: 12px; line-height: 1.6;
520
+ font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace;
521
+ color: var(--text); background: var(--bg);
522
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
523
+ }
524
+ .log-list { flex:1; overflow-y:auto; padding:6px 10px; font-size:11px; line-height:1.7; }
525
+ .log-list .log-entry {
526
+ padding: 4px 0;
527
+ border-bottom: 1px solid var(--border);
528
+ word-break: break-word;
529
+ font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
530
+ font-size: 10px;
531
+ }
532
+ .log-list .log-entry .timestamp { color: var(--text-subtle); margin-right: 6px; }
533
+ .log-list .log-entry.warn { color: var(--orange); }
534
+ .log-list .log-entry.error { color: var(--red); }
535
+ .log-list .log-entry.info { color: var(--text-dim); }
536
+
537
+ /* ═══════════════════════════════════════════════════════
538
+ Bottom: Croc Office (Agent Desks)
539
+ ═══════════════════════════════════════════════════════ */
540
+ .office {
541
+ grid-area: office;
542
+ background: var(--bg-panel);
543
+ border: 1px solid var(--border);
544
+ border-radius: var(--radius-lg);
545
+ display: flex;
546
+ overflow-x: auto;
547
+ padding: 10px;
548
+ gap: 8px;
549
+ box-shadow: var(--shadow-sm);
550
+ }
551
+ .desk {
552
+ flex: 0 0 180px;
553
+ background: var(--bg-card);
554
+ border: 1px solid var(--border);
555
+ border-radius: var(--radius-md);
556
+ padding: 12px 10px;
557
+ display: flex;
558
+ flex-direction: column;
559
+ align-items: center;
560
+ gap: 4px;
561
+ position: relative;
562
+ overflow: hidden;
563
+ transition: all var(--transition);
564
+ box-shadow: var(--shadow-sm);
565
+ }
566
+ .desk:hover { border-color: var(--border-accent); transform: translateY(-2px); box-shadow: var(--shadow-md); }
567
+ .desk .badge { position:absolute; top:8px; right:8px; width:8px; height:8px; border-radius:50%; }
568
+
569
+ /* SVG Croc sprites replace emoji */
570
+ .desk .croc-sprite {
571
+ width: 48px; height: 48px;
572
+ position: relative; z-index: 1;
573
+ display: grid; place-items: center;
574
+ }
575
+ .desk .croc-sprite svg { width:44px; height:44px; }
576
+ .desk.idle .croc-sprite { animation: croc-idle 3s ease-in-out infinite; }
577
+ .desk.working .croc-sprite, .desk.testing .croc-sprite { animation: croc-work .5s ease-in-out infinite alternate; }
578
+ .desk.thinking .croc-sprite { animation: croc-think 1.2s ease-in-out infinite; }
579
+ .desk.done .croc-sprite, .desk.passed .croc-sprite { animation: croc-done .8s ease-out 1; }
580
+ .desk.error .croc-sprite, .desk.failed .croc-sprite { animation: croc-error .3s ease-in-out 3; }
581
+ @keyframes croc-idle { 0%,90%,100%{transform:translateY(0)} 95%{transform:translateY(-3px)} }
582
+ @keyframes croc-work { from{transform:translateY(0) rotate(-2deg)} to{transform:translateY(-5px) rotate(2deg)} }
583
+ @keyframes croc-think { 0%,100%{transform:scale(1) rotate(0)} 25%{transform:scale(1.04) rotate(-2deg)} 75%{transform:scale(1.04) rotate(2deg)} }
584
+ @keyframes croc-done { 0%{transform:scale(1)} 50%{transform:scale(1.15) translateY(-6px)} 100%{transform:scale(1)} }
585
+ @keyframes croc-error { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-3px)} 75%{transform:translateX(3px)} }
586
+ .desk .croc-name { font-size: 12px; font-weight: 600; color: var(--accent); }
587
+ .desk .croc-role {
588
+ font-size: 9px; color: var(--text-subtle); text-transform: uppercase;
589
+ letter-spacing: .5px; font-weight: 500;
590
+ }
591
+ .desk .croc-task {
592
+ font-size: 10px; color: var(--orange); text-align: center;
593
+ max-width: 160px; overflow: hidden; text-overflow: ellipsis;
594
+ white-space: nowrap; min-height: 14px;
595
+ }
596
+ .desk .progress-bar {
597
+ width: 90%; height: 3px; background: var(--bg-hover);
598
+ border-radius: 2px; margin-top: 3px; overflow: hidden;
599
+ }
600
+ .desk .progress-bar .fill {
601
+ height: 100%; background: linear-gradient(90deg, var(--accent-dim), var(--accent));
602
+ transition: width .4s cubic-bezier(.4,0,.2,1); border-radius: 2px;
603
+ }
604
+ .desk .desk-icon {
605
+ position: absolute; bottom: 6px; right: 8px; opacity: .25;
606
+ }
607
+ .desk .desk-icon svg { width: 16px; height: 16px; }
608
+
609
+ /* Loading skeleton */
610
+ .skeleton { background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-hover) 50%, var(--bg-card) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm); }
611
+ @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
612
+
613
+ /* Typewriter cursor */
614
+ .typewriter-cursor {
615
+ display:inline-block; width:1px; height:1em; background:var(--accent);
616
+ margin-left:2px; vertical-align:text-bottom;
617
+ animation: cursor-blink .6s steps(1) infinite;
618
+ }
619
+ @keyframes cursor-blink { 0%,100%{opacity:1} 50%{opacity:0} }
620
+
621
+ /* Keyboard shortcuts legend */
622
+ .shortcut-legend {
623
+ position:fixed; bottom:12px; right:12px; z-index:90;
624
+ background:var(--bg-panel); border:1px solid var(--border);
625
+ border-radius:var(--radius-md); padding:10px 14px;
626
+ font-size:10px; color:var(--text-dim);
627
+ box-shadow:var(--shadow-md); opacity:0; pointer-events:none;
628
+ transition:opacity .2s;
629
+ display:grid; grid-template-columns:auto auto; gap:3px 12px;
630
+ }
631
+ .shortcut-legend.visible { opacity:1; pointer-events:auto; }
632
+ .shortcut-legend kbd {
633
+ display:inline-block; min-width:18px; text-align:center;
634
+ background:var(--bg-card); border:1px solid var(--border);
635
+ border-radius:3px; padding:1px 5px; font-family:inherit;
636
+ font-size:9px; font-weight:600; color:var(--text);
637
+ }
638
+ </style>
639
+ </head>
640
+ <body>
641
+ <div class="app">
642
+ <header class="header">
643
+ <div class="logo">
644
+ <svg viewBox="0 0 16 16" fill="none"><rect x="4" y="2" width="8" height="3" rx="1" fill="var(--accent)"/><rect x="3" y="5" width="10" height="6" rx="2" fill="var(--accent)" opacity=".8"/><rect x="5" y="11" width="2" height="2" fill="var(--accent)" opacity=".6"/><rect x="9" y="11" width="2" height="2" fill="var(--accent)" opacity=".6"/><rect x="5" y="6" width="2" height="2" rx="1" fill="var(--bg)"/><rect x="9" y="6" width="2" height="2" rx="1" fill="var(--bg)"/><rect x="6" y="9" width="4" height="1" fill="var(--bg)" opacity=".5"/></svg>
645
+ </div>
646
+ <div class="title-wrap">
647
+ <h1>OpenCroc Studio</h1>
648
+ <div class="subtitle">Pixel Ops Dashboard · Real-time Multi-Agent Runtime</div>
649
+ </div>
650
+ <div class="actions">
651
+ <div class="view-switch">
652
+ <button id="view-dashboard" class="active">
653
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="3" rx="1"/><rect x="9" y="6" width="6" height="6" rx="1" opacity=".6"/><rect x="1" y="9" width="6" height="3" rx="1" opacity=".6"/></svg>
654
+ Dashboard
655
+ </button>
656
+ <button id="view-office">
657
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="2" y="8" width="12" height="6" rx="1"/><rect x="5" y="3" width="6" height="5" rx="1" opacity=".6"/><rect x="4" y="14" width="2" height="1"/><rect x="10" y="14" width="2" height="1"/></svg>
658
+ Pixel Office
659
+ </button>
660
+ </div>
661
+ <button class="btn" id="btn-scan" title="Scan project">
662
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
663
+ Scan
664
+ </button>
665
+ <button class="btn" id="btn-pipeline" title="Run full pipeline">
666
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><polygon points="4,2 14,8 4,14"/></svg>
667
+ Pipeline
668
+ </button>
669
+ <select id="run-mode" class="mode-select" title="Test run mode">
670
+ <option value="auto">Auto</option>
671
+ <option value="reuse">Reuse</option>
672
+ <option value="managed">Managed</option>
673
+ </select>
674
+ <button class="btn" id="btn-run-tests" title="Run generated tests" disabled>
675
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8l3 3 7-7"/></svg>
676
+ Tests
677
+ </button>
678
+ <button class="btn" id="btn-reports" title="Generate reports" disabled>
679
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="1" width="12" height="14" rx="2"/><line x1="5" y1="5" x2="11" y2="5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="11" x2="9" y2="11"/></svg>
680
+ Reports
681
+ </button>
682
+ <button class="btn danger" id="btn-reset" title="Reset agents">
683
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="2"/></svg>
684
+ Reset
685
+ </button>
686
+ </div>
687
+ <div class="spacer"></div>
688
+ <div class="stats">
689
+ <div><div class="label">Modules</div><span class="value" id="s-mod">-</span></div>
690
+ <div><div class="label">Models</div><span class="value" id="s-mdl">-</span></div>
691
+ <div><div class="label">APIs</div><span class="value" id="s-api">-</span></div>
692
+ <div><div class="label">Tests</div><span class="value" id="s-files">-</span></div>
693
+ <div id="s-results-wrap" style="display:none"><div class="label">Results</div><span class="value" id="s-results">-</span></div>
694
+ </div>
695
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme">
696
+ <svg id="theme-icon-dark" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 1 0 0 14A5 5 0 0 1 8 1z"/></svg>
697
+ <svg id="theme-icon-light" viewBox="0 0 16 16" fill="currentColor" style="display:none"><circle cx="8" cy="8" r="3"/><g stroke="currentColor" stroke-width="1.2"><line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/><line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="11.54" x2="12.95" y2="12.95"/><line x1="3.05" y1="12.95" x2="4.46" y2="11.54"/><line x1="11.54" y1="4.46" x2="12.95" y2="3.05"/></g></svg>
698
+ </button>
699
+ <div class="conn-dot" id="conn-dot" title="WebSocket"></div>
700
+ </header>
701
+
702
+ <aside class="sidebar">
703
+ <h3>
704
+ <svg viewBox="0 0 16 16" fill="currentColor"><path d="M1 2h14v2H1zm0 4h10v2H1zm0 4h12v2H1zm0 4h8v2H1z"/></svg>
705
+ Modules
706
+ </h3>
707
+ <div id="mod-list"></div>
708
+ <h3 style="margin-top:12px">
709
+ <svg viewBox="0 0 16 16" fill="var(--accent)"><rect x="4" y="2" width="8" height="3" rx="1"/><rect x="3" y="5" width="10" height="6" rx="2" opacity=".8"/><rect x="5" y="11" width="2" height="2" opacity=".6"/><rect x="9" y="11" width="2" height="2" opacity=".6"/></svg>
710
+ Agents
711
+ </h3>
712
+ <div id="agent-sidebar"></div>
713
+ </aside>
714
+
715
+ <main class="main">
716
+ <div class="view" id="graph-view">
717
+ <canvas id="graph-canvas"></canvas>
718
+ </div>
719
+ <div class="view hidden" id="pixel-view">
720
+ <div class="pixel-stage">
721
+ <img class="asset desk-asset" src="./assets/star/desk-v3.webp" alt="desk">
722
+ <img class="asset server-asset" src="./assets/botreview/server.gif" alt="server">
723
+ <img class="asset coffee-asset" src="./assets/botreview/coffee-machine.gif" alt="coffee">
724
+ <img class="asset walls-asset" src="./assets/botreview/walls.png" alt="walls">
725
+ <div class="pixel-kpis">
726
+ <div class="pixel-kpi"><div class="t">Working</div><div class="v" id="kpi-working">0</div></div>
727
+ <div class="pixel-kpi errors"><div class="t">Errors</div><div class="v" id="kpi-errors">0</div></div>
728
+ <div class="pixel-kpi done"><div class="t">Done</div><div class="v" id="kpi-done">0</div></div>
729
+ </div>
730
+ <div class="pixel-agent-layer" id="pixel-agent-layer"></div>
731
+ </div>
732
+ </div>
733
+ <div class="tooltip" id="tooltip"></div>
734
+ </main>
735
+
736
+ <div class="log-panel">
737
+ <div class="panel-tabs">
738
+ <button class="tab active" data-tab="log">
739
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="2" width="14" height="12" rx="2" opacity=".2"/><line x1="4" y1="5" x2="12" y2="5" stroke="currentColor" stroke-width="1.2"/><line x1="4" y1="8" x2="10" y2="8" stroke="currentColor" stroke-width="1.2"/><line x1="4" y1="11" x2="8" y2="11" stroke="currentColor" stroke-width="1.2"/></svg>
740
+ Log
741
+ </button>
742
+ <button class="tab" data-tab="files">
743
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M3 1h7l3 3v11H3V1z" opacity=".3"/><path d="M10 1v3h3" fill="none" stroke="currentColor" stroke-width="1"/></svg>
744
+ Tests <span id="file-badge" class="tab-badge" style="display:none">0</span>
745
+ </button>
746
+ <button class="tab" data-tab="results">
747
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3 8l3 3 7-7"/></svg>
748
+ Results <span id="result-badge" class="tab-badge" style="display:none">0</span>
749
+ </button>
750
+ <button class="tab" data-tab="reports">
751
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" opacity=".7"><rect x="1" y="3" width="5" height="10" rx="1"/><rect x="7" y="1" width="8" height="14" rx="1"/></svg>
752
+ Reports
753
+ </button>
754
+ </div>
755
+ <div class="log-list" id="log-list"></div>
756
+ <div class="file-list" id="file-list" style="display:none"></div>
757
+ <div class="file-list" id="results-panel" style="display:none"></div>
758
+ <div class="file-list" id="reports-panel" style="display:none"></div>
759
+ </div>
760
+
761
+ <section class="office" id="croc-office"></section>
762
+ </div>
763
+
764
+ <div class="file-preview" id="file-preview">
765
+ <div class="fp-header"><h4 id="fp-title">file.spec.ts</h4><button class="fp-close" id="fp-close">&times;</button></div>
766
+ <pre id="fp-code"></pre>
767
+ </div>
768
+
769
+ <!-- Keyboard shortcut legend -->
770
+ <div class="shortcut-legend" id="shortcut-legend">
771
+ <kbd>1</kbd><span>Dashboard</span>
772
+ <kbd>2</kbd><span>Pixel Office</span>
773
+ <kbd>S</kbd><span>Scan</span>
774
+ <kbd>P</kbd><span>Pipeline</span>
775
+ <kbd>T</kbd><span>Run Tests</span>
776
+ <kbd>R</kbd><span>Reports</span>
777
+ <kbd>X</kbd><span>Reset</span>
778
+ <kbd>D</kbd><span>Dark/Light</span>
779
+ <kbd>?</kbd><span>Show shortcuts</span>
780
+ <kbd>Esc</kbd><span>Close panel</span>
781
+ </div>
782
+
783
+ <script>
784
+ /* ═══════════════════════════════════════════════════════
785
+ SVG Icon Library (replaces emoji) — inspired by OpenClaw PixelSvg
786
+ ═══════════════════════════════════════════════════════ */
787
+ const ICONS = {
788
+ croc: `<svg viewBox="0 0 16 16" fill="none"><rect x="4" y="1" width="8" height="3" rx="1" fill="var(--accent)"/><rect x="3" y="4" width="10" height="7" rx="2" fill="var(--accent)" opacity=".85"/><rect x="5" y="5" width="2" height="2" rx="1" fill="var(--bg)"/><rect x="9" y="5" width="2" height="2" rx="1" fill="var(--bg)"/><rect x="6" y="8" width="4" height="1" fill="var(--bg)" opacity=".4"/><rect x="5" y="11" width="2" height="3" rx="1" fill="var(--accent)" opacity=".6"/><rect x="9" y="11" width="2" height="3" rx="1" fill="var(--accent)" opacity=".6"/></svg>`,
789
+ parser: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.3"><rect x="2" y="2" width="12" height="12" rx="2"/><line x1="5" y1="6" x2="11" y2="6"/><line x1="5" y1="9" x2="9" y2="9"/></svg>`,
790
+ analyzer: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--purple)" stroke-width="1.3"><rect x="1" y="11" width="3" height="4"/><rect x="5" y="7" width="3" height="8"/><rect x="9" y="3" width="3" height="12"/><rect x="13" y="1" width="2" height="14"/></svg>`,
791
+ tester: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.3"><path d="M6 2v4l-3 8h10l-3-8V2"/><line x1="4" y1="2" x2="12" y2="2"/></svg>`,
792
+ healer: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--orange)" stroke-width="1.5"><path d="M5 3l-2 5h4l-2 5 7-7h-4l2-3z"/></svg>`,
793
+ planner: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.3"><rect x="2" y="1" width="12" height="14" rx="2"/><line x1="5" y1="5" x2="11" y2="5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="11" x2="8" y2="11"/></svg>`,
794
+ reporter: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.3"><rect x="1" y="2" width="14" height="12" rx="2"/><polyline points="4,10 6,6 8,9 10,4 12,7"/></svg>`,
795
+ };
796
+ const ROLE_ICONS = { parser:ICONS.parser, analyzer:ICONS.analyzer, tester:ICONS.tester, healer:ICONS.healer, planner:ICONS.planner, reporter:ICONS.reporter };
797
+
798
+ /* Bubble messages (Star-Office style) */
799
+ const BUBBLE_TEXTS = {
800
+ working: ['正在执行...', '快了快了', '处理中...', '加油 💪'],
801
+ testing: ['跑测试中...', '验证 API...', '等结果...'],
802
+ thinking: ['让我想想...', '分析中...', '推理...', '🤔'],
803
+ error: ['出错了!', '修复中...', '糟糕...', '排查问题...'],
804
+ idle: ['摸鱼中~', '等任务...', '☕ 喝咖啡', 'zzZ'],
805
+ done: ['搞定!', '完成 ✓', '下一个!'],
806
+ passed: ['全绿 ✓', '测试通过!'],
807
+ failed: ['有失败...', '需要修复'],
808
+ };
809
+
810
+ const S = {
811
+ project:null, graph:{nodes:[],edges:[]}, agents:[], ws:null,
812
+ pan:{x:0,y:0}, zoom:1, dragging:false, dragStart:{x:0,y:0},
813
+ nodePos:new Map(), hoveredNode:null, running:false, _userPanned:false,
814
+ generatedFiles:[], testMetrics:null, testQuality:null, reports:[], runMode:'auto',
815
+ currentView:'office', theme:'light', bubbleTimers:new Map(),
816
+ spriteSheets:new Map(), spriteAnimFrames:new Map(), spriteAnimTimers:new Map()
817
+ };
818
+
819
+ /* ═══════════════════════════════════════════════════════
820
+ Spritesheet Animation Engine (inspired by Star-Office Phaser)
821
+ ═══════════════════════════════════════════════════════ */
822
+ const SPRITE_CONFIG = {
823
+ // Each character sprite: image src, frame grid, frame count, FPS per status
824
+ chars: [
825
+ { key:'char_0', src:'./assets/botreview/char_0.png', cols:1, rows:1, total:1 },
826
+ { key:'char_1', src:'./assets/botreview/char_1.png', cols:1, rows:1, total:1 },
827
+ { key:'char_2', src:'./assets/botreview/char_2.png', cols:1, rows:1, total:1 },
828
+ ],
829
+ // FPS by status — active states get faster animation
830
+ fps: { idle:2, working:8, testing:8, thinking:4, error:10, failed:10, done:4, passed:4 },
831
+ };
832
+
833
+ // Preload sprite images
834
+ function preloadSprites(){
835
+ SPRITE_CONFIG.chars.forEach(cfg=>{
836
+ if(S.spriteSheets.has(cfg.key)) return;
837
+ const img=new Image(); img.src=cfg.src;
838
+ img.onload=()=>{ S.spriteSheets.set(cfg.key, {img,cols:cfg.cols,rows:cfg.rows,total:cfg.total}); };
839
+ });
840
+ }
841
+
842
+ // Render a single frame of a sprite to a canvas element
843
+ function renderSpriteFrame(canvas, spriteKey, frameIdx){
844
+ const sheet=S.spriteSheets.get(spriteKey);
845
+ if(!sheet||!canvas) return;
846
+ const ctx=canvas.getContext('2d');
847
+ const fw=sheet.img.naturalWidth/sheet.cols;
848
+ const fh=sheet.img.naturalHeight/sheet.rows;
849
+ const col=frameIdx%sheet.cols;
850
+ const row=Math.floor(frameIdx/sheet.cols);
851
+ ctx.clearRect(0,0,canvas.width,canvas.height);
852
+ ctx.imageSmoothingEnabled=false; // pixel-art crisp
853
+ ctx.drawImage(sheet.img, col*fw,row*fh,fw,fh, 0,0,canvas.width,canvas.height);
854
+ }
855
+
856
+ // Start/manage sprite animation loop for an agent
857
+ function startSpriteAnim(canvasId, spriteKey, status){
858
+ // Stop existing timer
859
+ if(S.spriteAnimTimers.has(canvasId)){
860
+ clearInterval(S.spriteAnimTimers.get(canvasId));
861
+ }
862
+ const sheet=S.spriteSheets.get(spriteKey);
863
+ if(!sheet) return;
864
+ const fps=SPRITE_CONFIG.fps[status]||SPRITE_CONFIG.fps.idle;
865
+ let frame=S.spriteAnimFrames.get(canvasId)||0;
866
+ const canvas=document.getElementById(canvasId);
867
+ if(!canvas) return;
868
+ renderSpriteFrame(canvas, spriteKey, frame);
869
+ if(sheet.total<=1){ S.spriteAnimTimers.delete(canvasId); return; }
870
+ const timer=setInterval(()=>{
871
+ frame=(frame+1)%sheet.total;
872
+ S.spriteAnimFrames.set(canvasId, frame);
873
+ const c=document.getElementById(canvasId);
874
+ if(c) renderSpriteFrame(c, spriteKey, frame);
875
+ else { clearInterval(timer); S.spriteAnimTimers.delete(canvasId); }
876
+ }, 1000/fps);
877
+ S.spriteAnimTimers.set(canvasId, timer);
878
+ }
879
+
880
+ /* ═══════════════════════════════════════════════════════
881
+ Typewriter Effect (inspired by Star-Office)
882
+ ═══════════════════════════════════════════════════════ */
883
+ function typewriterLog(msg, level, charDelay){
884
+ level=level||'info';
885
+ charDelay=charDelay||35;
886
+ const el=document.getElementById('log-list');
887
+ const d=document.createElement('div'); d.className='log-entry '+level;
888
+ const ts=document.createElement('span'); ts.className='timestamp';
889
+ ts.textContent=new Date().toLocaleTimeString();
890
+ d.appendChild(ts);
891
+ const textSpan=document.createElement('span');
892
+ const cursor=document.createElement('span'); cursor.className='typewriter-cursor';
893
+ d.appendChild(textSpan); d.appendChild(cursor);
894
+ el.appendChild(d); el.scrollTop=el.scrollHeight;
895
+ let i=0;
896
+ const interval=setInterval(()=>{
897
+ if(i<msg.length){
898
+ textSpan.textContent+=msg[i]; i++;
899
+ el.scrollTop=el.scrollHeight;
900
+ }else{
901
+ clearInterval(interval); cursor.remove();
902
+ }
903
+ }, charDelay);
904
+ return d;
905
+ }
906
+
907
+ async function fetchProject(){
908
+ try{
909
+ const r=await fetch('/api/project'); S.project=await r.json();
910
+ S.graph=S.project.graph||{nodes:[],edges:[]}; S.agents=S.project.agents||[];
911
+ layoutGraph(); updateAll();
912
+ }catch(e){addLog('Failed to load: '+e.message,'error');}
913
+ }
914
+ async function doScan(){
915
+ if(S.running)return; S.running=true; updateBtns();
916
+ addLog('🔍 Starting scan...','info',true);
917
+ try{await fetch('/api/scan',{method:'POST'});}
918
+ catch(e){addLog('Scan failed: '+e.message,'error');S.running=false;updateBtns();}
919
+ }
920
+ async function doPipeline(){
921
+ if(S.running)return; S.running=true; updateBtns();
922
+ addLog('▶ Starting pipeline...','info',true);
923
+ try{await fetch('/api/pipeline',{method:'POST'});}
924
+ catch(e){addLog('Pipeline failed: '+e.message,'error');S.running=false;updateBtns();}
925
+ }
926
+ async function doReset(){
927
+ try{await fetch('/api/reset',{method:'POST'});}catch(e){addLog('Reset failed','error');}
928
+ S.running=false; updateBtns(); addLog('⏹ Agents reset','info',true);
929
+ }
930
+ async function doRunTests(){
931
+ if(S.running)return; S.running=true; updateBtns();
932
+ addLog('🧪 Starting test execution ('+S.runMode+')...','info',true);
933
+ try{
934
+ const r=await fetch('/api/run-tests',{
935
+ method:'POST',
936
+ headers:{'content-type':'application/json'},
937
+ body:JSON.stringify({mode:S.runMode}),
938
+ });
939
+ if(!r.ok){
940
+ const body=await r.json().catch(()=>({error:'unknown error'}));
941
+ throw new Error(body.error||('HTTP '+r.status));
942
+ }
943
+ }
944
+ catch(e){addLog('Run tests failed: '+e.message,'error');S.running=false;updateBtns();}
945
+ }
946
+ async function doReports(){
947
+ if(S.running)return; S.running=true; updateBtns();
948
+ addLog('📊 Generating reports...','info',true);
949
+ try{await fetch('/api/reports/generate',{method:'POST'});}
950
+ catch(e){addLog('Report gen failed: '+e.message,'error');S.running=false;updateBtns();}
951
+ }
952
+ function updateBtns(){
953
+ document.getElementById('btn-scan').disabled=S.running;
954
+ document.getElementById('btn-pipeline').disabled=S.running;
955
+ document.getElementById('run-mode').disabled=S.running||S.generatedFiles.length===0;
956
+ document.getElementById('btn-run-tests').disabled=S.running||S.generatedFiles.length===0;
957
+ document.getElementById('btn-reports').disabled=S.running||S.generatedFiles.length===0;
958
+ }
959
+
960
+ function connectWS(){
961
+ const p=location.protocol==='https:'?'wss:':'ws:';
962
+ S.ws=new WebSocket(p+'//'+location.host+'/ws');
963
+ S.ws.onopen=()=>{document.getElementById('conn-dot').classList.add('on');};
964
+ S.ws.onmessage=(e)=>{
965
+ try{
966
+ const m=JSON.parse(e.data);
967
+ if(m.type==='agent:update'&&Array.isArray(m.payload)){
968
+ S.agents=m.payload; renderOffice(); renderAgentSB(); renderPixelOffice();
969
+ }else if(m.type==='graph:update'){
970
+ S.graph=m.payload; layoutGraph(); renderCanvas(); renderModList(); updateStats();
971
+ }else if(m.type==='log'){
972
+ addLog(m.payload.message, m.payload.level);
973
+ }else if(m.type==='files:generated'){
974
+ S.generatedFiles=m.payload||[];
975
+ renderFileList();
976
+ document.getElementById('s-files').textContent=S.generatedFiles.length;
977
+ const badge=document.getElementById('file-badge');
978
+ badge.textContent=S.generatedFiles.length;badge.style.display='inline';
979
+ }else if(m.type==='pipeline:complete'){
980
+ S.running=false; updateBtns();
981
+ if(m.payload.status==='success') addLog('✅ Pipeline complete!','info',true);
982
+ else addLog('❌ Pipeline failed: '+(m.payload.error||''),'error',true);
983
+ setTimeout(fetchProject,500);
984
+ }else if(m.type==='test:complete'){
985
+ S.running=false; S.testMetrics=m.payload.metrics||null; S.testQuality=m.payload.quality||null; updateBtns(); renderResults();
986
+ const met=m.payload.metrics;
987
+ document.getElementById('s-results-wrap').style.display='';
988
+ if(met){
989
+ document.getElementById('s-results').textContent=met.passed+'✓ '+met.failed+'✗';
990
+ document.getElementById('s-results').style.color=met.failed>0?'var(--red)':'var(--accent)';
991
+ }else{
992
+ document.getElementById('s-results').textContent='setup fail';
993
+ document.getElementById('s-results').style.color='var(--red)';
994
+ }
995
+ const rb=document.getElementById('result-badge');
996
+ rb.textContent=m.payload.total||0; rb.style.display='inline';
997
+ rb.className='tab-badge'+((met&&met.failed>0)?' alert':'');
998
+ }else if(m.type==='reports:generated'){
999
+ S.running=false; S.reports=m.payload||[]; updateBtns(); renderReports();
1000
+ addLog('📊 '+S.reports.length+' reports generated');
1001
+ }
1002
+ }catch{}
1003
+ };
1004
+ S.ws.onclose=()=>{document.getElementById('conn-dot').classList.remove('on');setTimeout(connectWS,3000);};
1005
+ }
1006
+
1007
+ function addLog(msg,level,useTypewriter){
1008
+ level=level||'info';
1009
+ // Use typewriter for important messages (pipeline, scan, reset, test)
1010
+ if(useTypewriter){
1011
+ typewriterLog(msg,level,30);
1012
+ return;
1013
+ }
1014
+ const el=document.getElementById('log-list');
1015
+ const d=document.createElement('div'); d.className='log-entry '+level;
1016
+ const ts=document.createElement('span'); ts.className='timestamp'; ts.textContent=new Date().toLocaleTimeString();
1017
+ d.appendChild(ts); d.appendChild(document.createTextNode(msg));
1018
+ el.appendChild(d); el.scrollTop=el.scrollHeight;
1019
+ }
1020
+ function updateStats(){
1021
+ document.getElementById('s-mod').textContent=S.graph.nodes.filter(n=>n.type==='module').length;
1022
+ document.getElementById('s-mdl').textContent=S.graph.nodes.filter(n=>n.type==='model').length;
1023
+ document.getElementById('s-api').textContent=S.graph.nodes.filter(n=>n.type==='controller'||n.type==='api').length;
1024
+ }
1025
+
1026
+ function layoutGraph(){
1027
+ const nodes=S.graph.nodes; if(!nodes.length)return;
1028
+ const c=document.getElementById('graph-canvas');
1029
+ const w=c.clientWidth||800,h=c.clientHeight||500;
1030
+ // Group non-module nodes by module
1031
+ const mods=new Map();
1032
+ S.modMeta=new Map();
1033
+ for(const n of nodes){
1034
+ if(n.type==='module') continue;
1035
+ const m=n.module||'other';
1036
+ if(!mods.has(m)) mods.set(m,[]);
1037
+ mods.get(m).push(n);
1038
+ }
1039
+ // Sort by size descending
1040
+ const keys=[...mods.keys()].sort((a,b)=>(mods.get(b).length-mods.get(a).length));
1041
+ const nMods=keys.length||1;
1042
+ const palette=['#4ecca3','#e94560','#f39c12','#3498db','#9b59b6','#1abc9c','#e67e22','#2ecc71','#e84393','#00cec9'];
1043
+ const RING_CAP=24; // max nodes per ring layer
1044
+
1045
+ // Calculate each module's outer radius (for spacing)
1046
+ const modRadii=[];
1047
+ for(let i=0;i<keys.length;i++){
1048
+ const cnt=mods.get(keys[i]).length;
1049
+ const rings=Math.ceil(cnt/RING_CAP);
1050
+ const outerR=rings===1?Math.max(40,cnt*6):rings*32+20;
1051
+ modRadii.push(outerR);
1052
+ }
1053
+
1054
+ // Adaptive base ring: sum of all module diameters / (2*PI) gives minimum perimeter
1055
+ const totalDiam=modRadii.reduce((s,r)=>s+r*2+60,0); // 60px gap between modules
1056
+ const baseRadius=Math.max(400, totalDiam/(Math.PI*2));
1057
+
1058
+ // Place modules with angular spacing proportional to their size
1059
+ const totalWeight=modRadii.reduce((s,r)=>s+r+30,0);
1060
+ let angle=-Math.PI/2;
1061
+ for(let i=0;i<keys.length;i++){
1062
+ const mn=mods.get(keys[i]); if(!mn)continue;
1063
+ const cnt=mn.length;
1064
+ const col=palette[i%palette.length];
1065
+ const rings=Math.ceil(cnt/RING_CAP);
1066
+ const outerR=modRadii[i];
1067
+
1068
+ // Angular span for this module proportional to its size
1069
+ const span=((outerR+30)/totalWeight)*Math.PI*2;
1070
+ const modAngle=angle+span/2;
1071
+ angle+=span;
1072
+
1073
+ const mcx=w/2+Math.cos(modAngle)*baseRadius;
1074
+ const mcy=h/2+Math.sin(modAngle)*baseRadius;
1075
+
1076
+ const modNodeId='module:'+keys[i];
1077
+ S.nodePos.set(modNodeId,{x:mcx,y:mcy});
1078
+ S.modMeta.set(keys[i],{cx:mcx,cy:mcy,radius:outerR+20,color:col,count:cnt});
1079
+
1080
+ // Spread nodes across concentric rings
1081
+ let placed=0;
1082
+ for(let ring=0;ring<rings;ring++){
1083
+ const nodesInRing=Math.min(RING_CAP,cnt-placed);
1084
+ const r=rings===1?Math.max(40,nodesInRing*6):(ring+1)*32;
1085
+ for(let j=0;j<nodesInRing;j++){
1086
+ const na=(j/nodesInRing)*Math.PI*2;
1087
+ S.nodePos.set(mn[placed].id,{x:mcx+Math.cos(na)*r,y:mcy+Math.sin(na)*r});
1088
+ placed++;
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ // Auto-center
1094
+ if(nodes.length>0&&!S._userPanned){
1095
+ let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity;
1096
+ for(const[,p] of S.nodePos){minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y);}
1097
+ const gw=maxX-minX+300,gh=maxY-minY+300,gcx=minX+(maxX-minX)/2,gcy=minY+(maxY-minY)/2;
1098
+ S.zoom=Math.min(1, Math.min(w/gw, h/gh));
1099
+ S.pan.x=w/2-gcx*S.zoom;S.pan.y=h/2-gcy*S.zoom;
1100
+ }
1101
+ }
1102
+
1103
+ function renderCanvas(){
1104
+ const canvas=document.getElementById('graph-canvas'),ctx=canvas.getContext('2d');
1105
+ const dpr=window.devicePixelRatio||1;
1106
+ canvas.width=canvas.clientWidth*dpr; canvas.height=canvas.clientHeight*dpr;
1107
+ ctx.scale(dpr,dpr);
1108
+ const w=canvas.clientWidth,h=canvas.clientHeight;
1109
+ const isDark=S.theme==='dark';
1110
+ ctx.clearRect(0,0,w,h);
1111
+ // Background fill
1112
+ ctx.fillStyle=isDark?'#0a0f1a':'#f8fafc';
1113
+ ctx.fillRect(0,0,w,h);
1114
+ ctx.save(); ctx.translate(S.pan.x,S.pan.y); ctx.scale(S.zoom,S.zoom);
1115
+ // Subtle grid
1116
+ ctx.strokeStyle=isDark?'rgba(148,163,184,.06)':'rgba(100,116,139,.08)';ctx.lineWidth=.5;
1117
+ const gridStep=40;
1118
+ for(let x=-2000;x<4000;x+=gridStep){ctx.beginPath();ctx.moveTo(x,-2000);ctx.lineTo(x,4000);ctx.stroke();}
1119
+ for(let y=-2000;y<4000;y+=gridStep){ctx.beginPath();ctx.moveTo(-2000,y);ctx.lineTo(4000,y);ctx.stroke();}
1120
+
1121
+ const edges=S.graph.edges||[],nodes=S.graph.nodes||[];
1122
+
1123
+ // Draw module cluster backgrounds
1124
+ if(S.modMeta){
1125
+ for(const[name,m] of S.modMeta){
1126
+ ctx.beginPath(); ctx.arc(m.cx,m.cy,m.radius,0,Math.PI*2);
1127
+ ctx.fillStyle=m.color+(isDark?'0a':'08'); ctx.fill();
1128
+ ctx.strokeStyle=m.color+(isDark?'25':'20'); ctx.lineWidth=1.5; ctx.setLineDash([6,4]); ctx.stroke(); ctx.setLineDash([]);
1129
+ ctx.font='600 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1130
+ ctx.fillStyle=m.color+(isDark?'cc':'aa'); ctx.textAlign='center'; ctx.textBaseline='bottom';
1131
+ ctx.fillText(name+' ('+m.count+')',m.cx,m.cy-m.radius-8);
1132
+ }
1133
+ }
1134
+
1135
+ // Draw edges with curved lines + gradient
1136
+ for(const e of edges){
1137
+ if(e.relation==='contains') continue;
1138
+ const s=S.nodePos.get(e.source),t=S.nodePos.get(e.target);if(!s||!t)continue;
1139
+ const dx=t.x-s.x,dy=t.y-s.y;
1140
+ const mx=(s.x+t.x)/2+dy*0.15, my=(s.y+t.y)/2-dx*0.15;
1141
+ const grad=ctx.createLinearGradient(s.x,s.y,t.x,t.y);
1142
+ grad.addColorStop(0,isDark?'rgba(248,113,113,.35)':'rgba(220,38,38,.25)');
1143
+ grad.addColorStop(1,isDark?'rgba(52,211,153,.35)':'rgba(5,150,105,.25)');
1144
+ ctx.strokeStyle=grad;ctx.lineWidth=1.5;
1145
+ ctx.beginPath();ctx.moveTo(s.x,s.y);ctx.quadraticCurveTo(mx,my,t.x,t.y);ctx.stroke();
1146
+ const t2=0.95,at2x=(1-t2)*(1-t2)*s.x+2*(1-t2)*t2*mx+t2*t2*t.x,at2y=(1-t2)*(1-t2)*s.y+2*(1-t2)*t2*my+t2*t2*t.y;
1147
+ const a=Math.atan2(t.y-at2y,t.x-at2x),al=7;
1148
+ ctx.fillStyle=isDark?'rgba(52,211,153,.5)':'rgba(5,150,105,.4)';ctx.beginPath();ctx.moveTo(t.x,t.y);
1149
+ ctx.lineTo(t.x-al*Math.cos(a-.4),t.y-al*Math.sin(a-.4));
1150
+ ctx.lineTo(t.x-al*Math.cos(a+.4),t.y-al*Math.sin(a+.4));ctx.closePath();ctx.fill();
1151
+ }
1152
+
1153
+ // Draw nodes
1154
+ const tc={model:'#34d399',controller:'#f87171',api:'#fbbf24',dto:'#60a5fa',module:'#a78bfa'};
1155
+ const sc={idle:isDark?'#475569':'#94a3b8',testing:'#fbbf24',passed:'#34d399',failed:'#f87171'};
1156
+ for(const n of nodes){
1157
+ if(n.type==='module') continue;
1158
+ const p=S.nodePos.get(n.id);if(!p)continue;
1159
+ const sz=9,c=tc[n.type]||'#888',ol=sc[n.status]||sc.idle,hov=S.hoveredNode===n.id;
1160
+ if(n.status==='testing'){ctx.shadowColor=sc.testing;ctx.shadowBlur=12;}
1161
+ else if(n.status==='passed'){ctx.shadowColor=sc.passed;ctx.shadowBlur=8;}
1162
+ else if(n.status==='failed'){ctx.shadowColor=sc.failed;ctx.shadowBlur=10;}
1163
+ ctx.beginPath();ctx.arc(p.x,p.y,sz,0,Math.PI*2);
1164
+ ctx.fillStyle=c;ctx.fill();
1165
+ ctx.shadowBlur=0;
1166
+ ctx.strokeStyle=hov?(isDark?'#fff':'#0f172a'):ol;ctx.lineWidth=hov?2.5:1.2;ctx.stroke();
1167
+ // Label
1168
+ if(S.zoom>0.4||hov){
1169
+ ctx.font='500 9px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
1170
+ ctx.fillStyle=hov?(isDark?'#fff':'#0f172a'):(isDark?'#94a3b8':'#64748b');
1171
+ ctx.textAlign='center';ctx.textBaseline='top';
1172
+ ctx.fillText((n.label||n.id.split(':').pop()).substring(0,18),p.x,p.y+sz+4);
1173
+ }
1174
+ }
1175
+ ctx.restore();
1176
+
1177
+ // HUD legend
1178
+ ctx.font='500 10px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';ctx.textAlign='left';
1179
+ const legBg=isDark?'rgba(10,15,26,.8)':'rgba(255,255,255,.85)';
1180
+ ctx.fillStyle=legBg; ctx.beginPath(); ctx.roundRect(6,h-56,120,48,6); ctx.fill();
1181
+ ctx.strokeStyle=isDark?'rgba(148,163,184,.15)':'rgba(100,116,139,.2)';ctx.lineWidth=1;ctx.stroke();
1182
+ const leg=[['● Model','#34d399'],['● Controller','#f87171'],['─ uses',isDark?'rgba(200,150,160,.7)':'rgba(150,80,100,.6)']];
1183
+ for(let i=0;i<leg.length;i++){ctx.fillStyle=leg[i][1];ctx.fillText(leg[i][0],14,h-42+i*14);}
1184
+ }
1185
+
1186
+ function setupCanvas(){
1187
+ const c=document.getElementById('graph-canvas');
1188
+ c.addEventListener('mousedown',e=>{S.dragging=true;S._userPanned=true;S.dragStart={x:e.clientX-S.pan.x,y:e.clientY-S.pan.y};c.style.cursor='grabbing';});
1189
+ c.addEventListener('mousemove',e=>{
1190
+ if(S.dragging){S.pan.x=e.clientX-S.dragStart.x;S.pan.y=e.clientY-S.dragStart.y;renderCanvas();}
1191
+ const rect=c.getBoundingClientRect(),mx=(e.clientX-rect.left-S.pan.x)/S.zoom,my=(e.clientY-rect.top-S.pan.y)/S.zoom;
1192
+ let hit=null;
1193
+ for(const n of S.graph.nodes){const p=S.nodePos.get(n.id);if(!p)continue;const sz=n.type==='module'?22:14;if(mx>=p.x-sz&&mx<=p.x+sz&&my>=p.y-sz&&my<=p.y+sz){hit=n;break;}}
1194
+ const tt=document.getElementById('tooltip');
1195
+ if(hit){
1196
+ S.hoveredNode=hit.id;
1197
+ const sc={idle:'#888',testing:'#f39c12',passed:'#4ecca3',failed:'#e94560'};
1198
+ tt.innerHTML='<b>'+esc(hit.label||hit.id)+'</b><br>Type: '+hit.type+'<br>Status: <span style="color:'+(sc[hit.status]||'#888')+'">'+hit.status+'</span>'+(hit.module?'<br>Module: '+esc(hit.module):'');
1199
+ tt.style.left=(e.clientX+12)+'px';tt.style.top=(e.clientY+12)+'px';tt.classList.add('visible');c.style.cursor='pointer';
1200
+ }else{if(S.hoveredNode)S.hoveredNode=null;tt.classList.remove('visible');if(!S.dragging)c.style.cursor='grab';}
1201
+ renderCanvas();
1202
+ });
1203
+ c.addEventListener('mouseup',()=>{S.dragging=false;c.style.cursor='grab';});
1204
+ c.addEventListener('mouseleave',()=>{S.dragging=false;document.getElementById('tooltip').classList.remove('visible');});
1205
+ c.addEventListener('wheel',e=>{e.preventDefault();S.zoom=Math.max(.2,Math.min(3,S.zoom*(e.deltaY>0?.92:1.08)));renderCanvas();},{passive:false});
1206
+ }
1207
+
1208
+ function updateAll(){
1209
+ if(S.project){
1210
+ document.getElementById('s-mod').textContent=S.project.stats?.modules||0;
1211
+ document.getElementById('s-mdl').textContent=S.project.stats?.models||0;
1212
+ document.getElementById('s-api').textContent=S.project.stats?.endpoints||0;
1213
+ }
1214
+ renderModList();renderOffice();renderAgentSB();renderPixelOffice();renderCanvas();
1215
+ }
1216
+ function renderModList(){
1217
+ const el=document.getElementById('mod-list'),mods=S.graph.nodes.filter(n=>n.type==='module');
1218
+ if(!mods.length){el.innerHTML='<div style="padding:12px;color:var(--text-subtle);font-size:11px">No modules found</div>';return;}
1219
+ const sorted=mods.sort((a,b)=>{
1220
+ const ca=S.modMeta&&S.modMeta.get(a.label)?S.modMeta.get(a.label).count:0;
1221
+ const cb=S.modMeta&&S.modMeta.get(b.label)?S.modMeta.get(b.label).count:0;
1222
+ return cb-ca;
1223
+ });
1224
+ el.innerHTML=sorted.map(m=>{
1225
+ const meta=S.modMeta&&S.modMeta.get(m.label);
1226
+ const cnt=meta?meta.count:'';
1227
+ const col=meta?meta.color:'var(--text-subtle)';
1228
+ return '<div class="mod-item" data-mod="'+esc(m.label)+'" style="border-left:3px solid '+col+'">'
1229
+ +'<div class="dot '+m.status+'"></div>'
1230
+ +esc(m.label)+'<span class="mod-count">'+cnt+'</span></div>';
1231
+ }).join('');
1232
+ // Click to navigate
1233
+ el.querySelectorAll('.mod-item').forEach(item=>{
1234
+ item.addEventListener('click',()=>{
1235
+ const name=item.getAttribute('data-mod');
1236
+ const meta=S.modMeta&&S.modMeta.get(name);
1237
+ if(meta){
1238
+ const c=document.getElementById('graph-canvas');
1239
+ S.zoom=0.8;S._userPanned=true;
1240
+ S.pan.x=c.clientWidth/2-meta.cx*S.zoom;
1241
+ S.pan.y=c.clientHeight/2-meta.cy*S.zoom;
1242
+ renderCanvas();
1243
+ }
1244
+ });
1245
+ });
1246
+ }
1247
+ function renderAgentSB(){
1248
+ document.getElementById('agent-sidebar').innerHTML=S.agents.map(a=>
1249
+ '<div class="mod-item"><div class="dot '+a.status+'"></div>'+esc(a.name)+'<span class="mod-count">'+a.status+'</span></div>'
1250
+ ).join('');
1251
+ }
1252
+ function renderOffice(){
1253
+ document.getElementById('croc-office').innerHTML=S.agents.map(a=>{
1254
+ const prog=typeof a.progress==='number'?a.progress:0;
1255
+ const roleIcon=ROLE_ICONS[a.role]||ICONS.croc;
1256
+ return '<div class="desk '+a.status+'"><div class="badge dot '+a.status+'"></div>'+
1257
+ '<div class="croc-sprite">'+ICONS.croc+'</div><div class="croc-name">'+esc(a.name)+'</div>'+
1258
+ '<div class="croc-role">'+esc(a.role)+'</div>'+
1259
+ '<div class="croc-task">'+(a.currentTask?esc(a.currentTask):'')+'</div>'+
1260
+ '<div class="progress-bar"><div class="fill" style="width:'+prog+'%"></div></div>'+
1261
+ '<div class="desk-icon">'+roleIcon+'</div></div>';
1262
+ }).join('');
1263
+ }
1264
+ function renderPixelOffice(){
1265
+ const el=document.getElementById('pixel-agent-layer');
1266
+ const stageWidth=(document.getElementById('pixel-view').clientWidth||900)-80;
1267
+ const stageHeight=(document.getElementById('pixel-view').clientHeight||520)-120;
1268
+ const presets=[
1269
+ {x:.18,y:.64},{x:.3,y:.66},{x:.42,y:.62},{x:.56,y:.6},{x:.72,y:.63},{x:.82,y:.52},
1270
+ {x:.22,y:.45},{x:.48,y:.42},{x:.66,y:.4},{x:.78,y:.72}
1271
+ ];
1272
+ const roles=['char_0','char_1','char_2'];
1273
+ const working=S.agents.filter(a=>a.status==='working'||a.status==='testing').length;
1274
+ const errors=S.agents.filter(a=>a.status==='error'||a.status==='failed').length;
1275
+ const done=S.agents.filter(a=>a.status==='done'||a.status==='passed').length;
1276
+ document.getElementById('kpi-working').textContent=String(working);
1277
+ document.getElementById('kpi-errors').textContent=String(errors);
1278
+ document.getElementById('kpi-done').textContent=String(done);
1279
+ if(!S.agents.length){el.innerHTML='';return;}
1280
+
1281
+ // Stop all existing sprite timers
1282
+ for(const [id,timer] of S.spriteAnimTimers){ clearInterval(timer); }
1283
+ S.spriteAnimTimers.clear();
1284
+
1285
+ el.innerHTML=S.agents.map((a,i)=>{
1286
+ const p=presets[i%presets.length];
1287
+ const x=Math.max(16,Math.round(stageWidth*p.x));
1288
+ const y=Math.max(20,Math.round(stageHeight*p.y));
1289
+ const roleSprite=roles[i%roles.length];
1290
+ const labelY=Math.max(16,y-12);
1291
+ const statusClass=(['working','testing'].includes(a.status)?'working':
1292
+ ['done','passed'].includes(a.status)?'done':
1293
+ ['error','failed'].includes(a.status)?'error':
1294
+ a.status==='thinking'?'thinking':'');
1295
+ const canvasId='sprite-'+i;
1296
+ // Use canvas element for spritesheet-based rendering
1297
+ return '<canvas id="'+canvasId+'" class="pixel-sprite-canvas '+a.status+'" width="48" height="48" style="left:'+x+'px;top:'+y+'px"></canvas>'
1298
+ +'<div class="pixel-label" style="left:'+(x+24)+'px;top:'+labelY+'px">'+esc(a.name)
1299
+ +(statusClass?'<span class="status-tag '+statusClass+'">'+esc(a.status)+'</span>':'')+'</div>';
1300
+ }).join('');
1301
+
1302
+ // Start sprite animations for each agent
1303
+ S.agents.forEach((a,i)=>{
1304
+ const roleSprite=roles[i%roles.length];
1305
+ const canvasId='sprite-'+i;
1306
+ // Delay to ensure DOM is ready
1307
+ requestAnimationFrame(()=>startSpriteAnim(canvasId, roleSprite, a.status));
1308
+ });
1309
+
1310
+ // Trigger bubbles for active agents (Star-Office style)
1311
+ scheduleBubbles();
1312
+ }
1313
+ function scheduleBubbles(){
1314
+ S.agents.forEach((a,i)=>{
1315
+ if(S.bubbleTimers.has(a.name)) return;
1316
+ const texts=BUBBLE_TEXTS[a.status]||BUBBLE_TEXTS.idle;
1317
+ const show=()=>{
1318
+ const el=document.getElementById('pixel-agent-layer');
1319
+ if(!el||S.currentView!=='office') return;
1320
+ const presets=[
1321
+ {x:.18,y:.64},{x:.3,y:.66},{x:.42,y:.62},{x:.56,y:.6},{x:.72,y:.63},{x:.82,y:.52},
1322
+ {x:.22,y:.45},{x:.48,y:.42},{x:.66,y:.4},{x:.78,y:.72}
1323
+ ];
1324
+ const stageW=(document.getElementById('pixel-view').clientWidth||900)-80;
1325
+ const stageH=(document.getElementById('pixel-view').clientHeight||520)-120;
1326
+ const p=presets[i%presets.length];
1327
+ const x=Math.max(16,Math.round(stageW*p.x));
1328
+ const y=Math.max(20,Math.round(stageH*p.y));
1329
+ const bubble=document.createElement('div');
1330
+ bubble.className='pixel-bubble';
1331
+ bubble.textContent=texts[Math.floor(Math.random()*texts.length)];
1332
+ bubble.style.left=(x+50)+'px'; bubble.style.top=(y-20)+'px';
1333
+ el.appendChild(bubble);
1334
+ setTimeout(()=>bubble.remove(),3000);
1335
+ };
1336
+ const interval=setInterval(show, 6000+Math.random()*8000);
1337
+ S.bubbleTimers.set(a.name, interval);
1338
+ setTimeout(show, 1000+Math.random()*3000);
1339
+ });
1340
+ // Clear timers for agents that no longer exist
1341
+ for(const [name,timer] of S.bubbleTimers){
1342
+ if(!S.agents.find(a=>a.name===name)){clearInterval(timer);S.bubbleTimers.delete(name);}
1343
+ }
1344
+ }
1345
+ function setView(view){
1346
+ S.currentView=view;
1347
+ const dashboard=view==='dashboard';
1348
+ document.getElementById('graph-view').classList.toggle('hidden',!dashboard);
1349
+ document.getElementById('pixel-view').classList.toggle('hidden',dashboard);
1350
+ document.getElementById('view-dashboard').classList.toggle('active',dashboard);
1351
+ document.getElementById('view-office').classList.toggle('active',!dashboard);
1352
+ document.getElementById('tooltip').classList.remove('visible');
1353
+ if(dashboard){
1354
+ setTimeout(()=>{layoutGraph();renderCanvas();},0);
1355
+ }else{
1356
+ renderPixelOffice();
1357
+ }
1358
+ }
1359
+ function esc(s){return s?s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';}
1360
+
1361
+ document.getElementById('btn-scan').addEventListener('click',doScan);
1362
+ document.getElementById('btn-pipeline').addEventListener('click',doPipeline);
1363
+ document.getElementById('btn-reset').addEventListener('click',doReset);
1364
+ document.getElementById('btn-run-tests').addEventListener('click',doRunTests);
1365
+ document.getElementById('btn-reports').addEventListener('click',doReports);
1366
+ document.getElementById('run-mode').addEventListener('change',e=>{S.runMode=e.target.value;});
1367
+ document.getElementById('view-dashboard').addEventListener('click',()=>setView('dashboard'));
1368
+ document.getElementById('view-office').addEventListener('click',()=>setView('office'));
1369
+
1370
+ // Tab switching
1371
+ document.querySelectorAll('.panel-tabs .tab').forEach(tab=>{
1372
+ tab.addEventListener('click',()=>{
1373
+ document.querySelectorAll('.panel-tabs .tab').forEach(t=>t.classList.remove('active'));
1374
+ tab.classList.add('active');
1375
+ const t=tab.getAttribute('data-tab');
1376
+ document.getElementById('log-list').style.display=t==='log'?'':'none';
1377
+ document.getElementById('file-list').style.display=t==='files'?'':'none';
1378
+ document.getElementById('results-panel').style.display=t==='results'?'':'none';
1379
+ document.getElementById('reports-panel').style.display=t==='reports'?'':'none';
1380
+ });
1381
+ });
1382
+
1383
+ // File list rendering
1384
+ function renderFileList(){
1385
+ const el=document.getElementById('file-list');
1386
+ if(!S.generatedFiles.length){el.innerHTML='<div style="padding:12px;color:var(--text-subtle);font-size:11px">No test files generated yet. Run Pipeline first.</div>';return;}
1387
+ el.innerHTML=S.generatedFiles.map((f,i)=>
1388
+ '<div class="file-item" data-idx="'+i+'"><div class="fname">'+esc(f.filePath.split('/').pop()||f.filePath)+'</div>'+
1389
+ '<div class="fmeta">'+esc(f.module)+' / '+esc(f.chain)+' — '+f.lines+' lines</div></div>'
1390
+ ).join('');
1391
+ el.querySelectorAll('.file-item').forEach(item=>{
1392
+ item.addEventListener('click',async()=>{
1393
+ const idx=item.getAttribute('data-idx');
1394
+ try{
1395
+ const r=await fetch('/api/files/'+idx); const file=await r.json();
1396
+ document.getElementById('fp-title').textContent=file.filePath;
1397
+ document.getElementById('fp-code').textContent=file.content;
1398
+ document.getElementById('file-preview').classList.add('visible');
1399
+ }catch(e){addLog('Failed to load file: '+e.message,'error');}
1400
+ });
1401
+ });
1402
+ }
1403
+
1404
+ // File preview close
1405
+ document.getElementById('fp-close').addEventListener('click',()=>{
1406
+ document.getElementById('file-preview').classList.remove('visible');
1407
+ });
1408
+ document.addEventListener('keydown',e=>{
1409
+ if(e.key==='Escape') document.getElementById('file-preview').classList.remove('visible');
1410
+ });
1411
+
1412
+ // Test Results rendering
1413
+ function renderResults(){
1414
+ const el=document.getElementById('results-panel');
1415
+ if(!S.testMetrics&&!S.testQuality){el.innerHTML='<div style="padding:12px;color:var(--text-subtle);font-size:11px">No test results yet. Run Tests first.</div>';return;}
1416
+ const q=S.testQuality;
1417
+ if(!S.testMetrics&&q){
1418
+ el.innerHTML='<div style="padding:10px">'
1419
+ +'<div style="font-size:13px;font-weight:bold;margin-bottom:8px">🧪 Test Execution Results</div>'
1420
+ +'<div style="padding:10px;border-radius:var(--radius-sm);background:var(--red-bg);color:var(--red);font-size:11px;margin-bottom:10px">Setup failed before test execution.</div>'
1421
+ +'<div style="font-size:11px;margin-bottom:6px">Gate Level: <span style="color:'+(q.level==='fail'?'var(--red)':q.level==='warn'?'var(--orange)':'var(--accent)')+';font-weight:bold">'+q.level.toUpperCase()+'</span></div>'
1422
+ +'<div style="font-size:10px;color:var(--text-dim)">Auth: '+q.authStatus+' | Backend: '+q.backendStatus+'</div>'
1423
+ +'<div style="font-size:10px;color:var(--text-dim);margin-top:4px">Reasons: '+(q.reasons&&q.reasons.length?q.reasons.join(', '):'-')+'</div>'
1424
+ +'</div>';
1425
+ return;
1426
+ }
1427
+ const m=S.testMetrics, total=m.passed+m.failed+m.skipped+m.timedOut;
1428
+ const passRate=total>0?Math.round(m.passed/total*100):0;
1429
+ const barColor=m.failed>0?'var(--red)':'var(--accent)';
1430
+ let qualityHtml='';
1431
+ if(q){
1432
+ const gateColor=q.level==='fail'?'var(--red)':q.level==='warn'?'var(--orange)':'var(--accent)';
1433
+ qualityHtml='<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">'
1434
+ +'<div style="font-size:12px;font-weight:bold;margin-bottom:8px">🧭 Execution Quality</div>'
1435
+ +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:11px">'
1436
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Gate: <span style="color:'+gateColor+';font-weight:600">'+q.level.toUpperCase()+'</span></div>'
1437
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Setup Fail: '+q.setupFail+'</div>'
1438
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Skip Ratio: '+Math.round((q.skipRatio||0)*100)+'%</div>'
1439
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Auth Fail Ratio: '+Math.round((q.authFailRatio||0)*100)+'%</div>'
1440
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Effective Rate: '+Math.round((q.effectiveExecutionRate||0)*100)+'%</div>'
1441
+ +'<div style="background:var(--bg-card);padding:8px;border-radius:var(--radius-sm);border:1px solid var(--border)">Auth/Backend: '+q.authStatus+' / '+q.backendStatus+'</div>'
1442
+ +'</div>'
1443
+ +'<div style="font-size:10px;color:var(--text-dim);margin-top:6px">Reasons: '+(q.reasons&&q.reasons.length?q.reasons.join(', '):'-')+'</div>'
1444
+ +'</div>';
1445
+ }
1446
+ el.innerHTML='<div style="padding:10px">'
1447
+ +'<div style="font-size:13px;font-weight:bold;margin-bottom:8px">🧪 Test Execution Results</div>'
1448
+ +'<div style="display:flex;gap:12px;margin-bottom:10px">'
1449
+ +'<div style="flex:1;text-align:center;background:var(--accent-bg);padding:10px;border-radius:var(--radius-sm);border:1px solid var(--border)"><div style="font-size:22px;font-weight:700;color:var(--accent)">'+m.passed+'</div><div style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-top:2px">PASSED</div></div>'
1450
+ +'<div style="flex:1;text-align:center;background:var(--red-bg);padding:10px;border-radius:var(--radius-sm);border:1px solid var(--border)"><div style="font-size:22px;font-weight:700;color:var(--red)">'+m.failed+'</div><div style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-top:2px">FAILED</div></div>'
1451
+ +'<div style="flex:1;text-align:center;background:var(--orange-bg);padding:10px;border-radius:var(--radius-sm);border:1px solid var(--border)"><div style="font-size:22px;font-weight:700;color:var(--orange)">'+m.skipped+'</div><div style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-top:2px">SKIPPED</div></div>'
1452
+ +'<div style="flex:1;text-align:center;background:var(--blue-bg);padding:10px;border-radius:var(--radius-sm);border:1px solid var(--border)"><div style="font-size:22px;font-weight:700;color:var(--blue)">'+m.timedOut+'</div><div style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-top:2px">TIMEOUT</div></div>'
1453
+ +'</div>'
1454
+ +'<div style="background:var(--bg-hover);border-radius:4px;height:8px;overflow:hidden">'
1455
+ +'<div style="height:100%;width:'+passRate+'%;background:'+barColor+';transition:width .5s"></div></div>'
1456
+ +'<div style="text-align:center;font-size:10px;color:var(--text-dim);margin-top:4px">Pass Rate: '+passRate+'% ('+total+' total)</div>'
1457
+ +qualityHtml
1458
+ +'</div>';
1459
+ }
1460
+
1461
+ // Reports rendering
1462
+ function renderReports(){
1463
+ const el=document.getElementById('reports-panel');
1464
+ if(!S.reports.length){el.innerHTML='<div style="padding:12px;color:var(--text-subtle);font-size:11px">No reports generated yet. Click Reports to generate.</div>';return;}
1465
+ el.innerHTML='<div style="padding:10px"><div style="font-size:13px;font-weight:bold;margin-bottom:8px">📊 Generated Reports</div>'
1466
+ +S.reports.map(r=>{
1467
+ const icon=r.format==='html'?'🌐':r.format==='json'?'📋':'📝';
1468
+ const sizeKB=(r.size/1024).toFixed(1);
1469
+ return '<div class="file-item" data-format="'+esc(r.format)+'" style="cursor:pointer">'
1470
+ +'<div class="fname">'+icon+' '+esc(r.filename)+'</div>'
1471
+ +'<div class="fmeta">'+r.format.toUpperCase()+' — '+sizeKB+' KB</div></div>';
1472
+ }).join('')+'</div>';
1473
+ el.querySelectorAll('.file-item').forEach(item=>{
1474
+ item.addEventListener('click',async()=>{
1475
+ const fmt=item.getAttribute('data-format');
1476
+ try{
1477
+ const r=await fetch('/api/reports/'+fmt);
1478
+ const content=await r.text();
1479
+ if(fmt==='html'){
1480
+ const w=window.open('','_blank','width=900,height=700');
1481
+ w.document.write(content);w.document.close();
1482
+ }else{
1483
+ document.getElementById('fp-title').textContent='report.'+fmt;
1484
+ document.getElementById('fp-code').textContent=content;
1485
+ document.getElementById('file-preview').classList.add('visible');
1486
+ }
1487
+ }catch(e){addLog('Failed to load report: '+e.message,'error');}
1488
+ });
1489
+ });
1490
+ }
1491
+
1492
+ // Theme toggle (borrowed from OpenClaw)
1493
+ function toggleTheme(){
1494
+ S.theme=S.theme==='dark'?'light':'dark';
1495
+ document.documentElement.setAttribute('data-theme',S.theme);
1496
+ localStorage.setItem('opencroc-theme',S.theme);
1497
+ document.getElementById('theme-icon-dark').style.display=S.theme==='dark'?'':'none';
1498
+ document.getElementById('theme-icon-light').style.display=S.theme==='light'?'':'none';
1499
+ renderCanvas();
1500
+ }
1501
+ document.getElementById('theme-toggle').addEventListener('click',toggleTheme);
1502
+
1503
+ // Restore saved theme
1504
+ (function initTheme(){
1505
+ const saved=localStorage.getItem('opencroc-theme');
1506
+ if(saved&&saved==='dark'){S.theme='dark';document.documentElement.setAttribute('data-theme','dark');
1507
+ document.getElementById('theme-icon-dark').style.display='';
1508
+ document.getElementById('theme-icon-light').style.display='none';
1509
+ } else {
1510
+ document.getElementById('theme-icon-dark').style.display='none';
1511
+ document.getElementById('theme-icon-light').style.display='';
1512
+ }
1513
+ })();
1514
+
1515
+ /* ═══════════════════════════════════════════════════════
1516
+ Keyboard Shortcuts System
1517
+ ═══════════════════════════════════════════════════════ */
1518
+ let shortcutLegendTimer=null;
1519
+ function showShortcutLegend(){
1520
+ const el=document.getElementById('shortcut-legend');
1521
+ el.classList.add('visible');
1522
+ if(shortcutLegendTimer) clearTimeout(shortcutLegendTimer);
1523
+ shortcutLegendTimer=setTimeout(()=>el.classList.remove('visible'), 4000);
1524
+ }
1525
+
1526
+ document.addEventListener('keydown',e=>{
1527
+ // Don't capture when typing in inputs/selects
1528
+ const tag=e.target.tagName.toLowerCase();
1529
+ if(tag==='input'||tag==='textarea'||tag==='select') return;
1530
+
1531
+ const key=e.key.toLowerCase();
1532
+
1533
+ // Escape — close file preview, close shortcut legend
1534
+ if(e.key==='Escape'){
1535
+ document.getElementById('file-preview').classList.remove('visible');
1536
+ document.getElementById('shortcut-legend').classList.remove('visible');
1537
+ return;
1538
+ }
1539
+ // ? — Show shortcut legend
1540
+ if(key==='?'||e.key==='/'&&e.shiftKey){
1541
+ e.preventDefault(); showShortcutLegend(); return;
1542
+ }
1543
+ // 1 — Dashboard view
1544
+ if(key==='1'){ e.preventDefault(); setView('dashboard'); return; }
1545
+ // 2 — Pixel Office view
1546
+ if(key==='2'){ e.preventDefault(); setView('office'); return; }
1547
+ // S — Scan
1548
+ if(key==='s'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); doScan(); return; }
1549
+ // P — Pipeline
1550
+ if(key==='p'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); doPipeline(); return; }
1551
+ // T — Tests
1552
+ if(key==='t'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); doRunTests(); return; }
1553
+ // R — Reports
1554
+ if(key==='r'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); doReports(); return; }
1555
+ // X — Reset
1556
+ if(key==='x'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); doReset(); return; }
1557
+ // D — Dark/Light toggle
1558
+ if(key==='d'&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); toggleTheme(); return; }
1559
+ });
1560
+
1561
+ (async()=>{
1562
+ preloadSprites();
1563
+ setupCanvas();
1564
+ await fetchProject();
1565
+ connectWS();
1566
+ addLog('OpenCroc Studio ready — press ? for shortcuts','info',true);
1567
+ window.addEventListener('resize',()=>{layoutGraph();renderCanvas();renderPixelOffice();});
1568
+ })();
1569
+ </script>
1570
+ </body>
1571
+ </html>