opencroc 0.6.0 โ†’ 1.3.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,734 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
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
+ :root {
9
+ --bg-dark: #0a0a1a;
10
+ --bg-panel: #12122a;
11
+ --bg-card: #1a1a3e;
12
+ --accent: #4ecca3;
13
+ --accent-dim: #2a8a6a;
14
+ --red: #e94560;
15
+ --orange: #f39c12;
16
+ --blue: #3498db;
17
+ --text: #e0e0e0;
18
+ --text-dim: #888;
19
+ --pixel-border: 2px solid #333;
20
+ }
21
+
22
+ * { margin: 0; padding: 0; box-sizing: border-box; }
23
+
24
+ body {
25
+ background: var(--bg-dark);
26
+ color: var(--text);
27
+ font-family: 'Courier New', 'Consolas', monospace;
28
+ overflow: hidden;
29
+ height: 100vh;
30
+ }
31
+
32
+ /* ===== Layout ===== */
33
+ .app {
34
+ display: grid;
35
+ grid-template-rows: 48px 1fr 200px;
36
+ grid-template-columns: 240px 1fr;
37
+ height: 100vh;
38
+ }
39
+
40
+ .header {
41
+ grid-column: 1 / -1;
42
+ background: var(--bg-panel);
43
+ border-bottom: var(--pixel-border);
44
+ display: flex;
45
+ align-items: center;
46
+ padding: 0 16px;
47
+ gap: 16px;
48
+ }
49
+
50
+ .header h1 {
51
+ font-size: 16px;
52
+ color: var(--accent);
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ }
57
+
58
+ .header .logo {
59
+ font-size: 24px;
60
+ image-rendering: pixelated;
61
+ }
62
+
63
+ .header .stats {
64
+ margin-left: auto;
65
+ display: flex;
66
+ gap: 16px;
67
+ font-size: 12px;
68
+ color: var(--text-dim);
69
+ }
70
+
71
+ .header .stats span {
72
+ color: var(--accent);
73
+ font-weight: bold;
74
+ }
75
+
76
+ .header .conn-status {
77
+ width: 8px; height: 8px;
78
+ border-radius: 50%;
79
+ background: var(--red);
80
+ transition: background 0.3s;
81
+ }
82
+
83
+ .header .conn-status.connected { background: var(--accent); }
84
+
85
+ .sidebar {
86
+ background: var(--bg-panel);
87
+ border-right: var(--pixel-border);
88
+ overflow-y: auto;
89
+ padding: 8px;
90
+ }
91
+
92
+ .sidebar h3 {
93
+ font-size: 11px;
94
+ text-transform: uppercase;
95
+ color: var(--text-dim);
96
+ padding: 8px 4px 4px;
97
+ letter-spacing: 1px;
98
+ }
99
+
100
+ .sidebar .module-item {
101
+ padding: 6px 8px;
102
+ border-radius: 4px;
103
+ font-size: 12px;
104
+ cursor: pointer;
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 6px;
108
+ transition: background 0.15s;
109
+ }
110
+
111
+ .sidebar .module-item:hover { background: var(--bg-card); }
112
+ .sidebar .module-item .dot {
113
+ width: 6px; height: 6px;
114
+ border-radius: 50%;
115
+ background: var(--text-dim);
116
+ }
117
+ .sidebar .module-item .dot.idle { background: var(--text-dim); }
118
+ .sidebar .module-item .dot.testing { background: var(--orange); animation: blink 0.8s infinite; }
119
+ .sidebar .module-item .dot.passed { background: var(--accent); }
120
+ .sidebar .module-item .dot.failed { background: var(--red); }
121
+
122
+ @keyframes blink { 50% { opacity: 0.3; } }
123
+
124
+ /* ===== Main Canvas Area ===== */
125
+ .main {
126
+ position: relative;
127
+ overflow: hidden;
128
+ }
129
+
130
+ #graph-canvas {
131
+ width: 100%;
132
+ height: 100%;
133
+ display: block;
134
+ }
135
+
136
+ /* ===== Bottom Panel: Croc Office ===== */
137
+ .office-panel {
138
+ grid-column: 1 / -1;
139
+ background: var(--bg-panel);
140
+ border-top: var(--pixel-border);
141
+ display: flex;
142
+ overflow-x: auto;
143
+ padding: 8px;
144
+ gap: 8px;
145
+ }
146
+
147
+ .croc-desk {
148
+ flex: 0 0 200px;
149
+ background: var(--bg-card);
150
+ border: 1px solid #333;
151
+ border-radius: 4px;
152
+ padding: 8px;
153
+ display: flex;
154
+ flex-direction: column;
155
+ align-items: center;
156
+ gap: 4px;
157
+ position: relative;
158
+ }
159
+
160
+ .croc-desk .croc-sprite {
161
+ font-size: 48px;
162
+ image-rendering: pixelated;
163
+ transition: transform 0.3s;
164
+ position: relative;
165
+ }
166
+
167
+ .croc-desk.working .croc-sprite {
168
+ animation: croc-work 0.6s infinite alternate;
169
+ }
170
+
171
+ .croc-desk.thinking .croc-sprite {
172
+ animation: croc-think 1s infinite;
173
+ }
174
+
175
+ @keyframes croc-work {
176
+ from { transform: translateY(0) rotate(-2deg); }
177
+ to { transform: translateY(-4px) rotate(2deg); }
178
+ }
179
+
180
+ @keyframes croc-think {
181
+ 0%, 100% { transform: scale(1); }
182
+ 50% { transform: scale(1.05); }
183
+ }
184
+
185
+ .croc-desk .croc-name {
186
+ font-size: 11px;
187
+ font-weight: bold;
188
+ color: var(--accent);
189
+ }
190
+
191
+ .croc-desk .croc-role {
192
+ font-size: 9px;
193
+ color: var(--text-dim);
194
+ text-transform: uppercase;
195
+ }
196
+
197
+ .croc-desk .croc-task {
198
+ font-size: 9px;
199
+ color: var(--orange);
200
+ text-align: center;
201
+ max-width: 180px;
202
+ overflow: hidden;
203
+ text-overflow: ellipsis;
204
+ white-space: nowrap;
205
+ min-height: 14px;
206
+ }
207
+
208
+ .croc-desk .status-badge {
209
+ position: absolute;
210
+ top: 4px;
211
+ right: 4px;
212
+ width: 8px;
213
+ height: 8px;
214
+ border-radius: 50%;
215
+ background: var(--text-dim);
216
+ }
217
+
218
+ .croc-desk .status-badge.idle { background: var(--text-dim); }
219
+ .croc-desk .status-badge.working { background: var(--orange); animation: blink 0.6s infinite; }
220
+ .croc-desk .status-badge.thinking { background: var(--blue); animation: blink 1s infinite; }
221
+ .croc-desk .status-badge.done { background: var(--accent); }
222
+ .croc-desk .status-badge.error { background: var(--red); }
223
+
224
+ /* ===== Tooltip ===== */
225
+ .tooltip {
226
+ position: absolute;
227
+ background: var(--bg-card);
228
+ border: 1px solid var(--accent-dim);
229
+ border-radius: 4px;
230
+ padding: 8px 12px;
231
+ font-size: 11px;
232
+ pointer-events: none;
233
+ z-index: 100;
234
+ display: none;
235
+ max-width: 300px;
236
+ }
237
+
238
+ .tooltip.visible { display: block; }
239
+
240
+ /* ===== Action Buttons ===== */
241
+ .actions {
242
+ display: flex;
243
+ gap: 8px;
244
+ align-items: center;
245
+ }
246
+
247
+ .btn {
248
+ background: var(--accent-dim);
249
+ color: #fff;
250
+ border: none;
251
+ padding: 6px 14px;
252
+ font-family: inherit;
253
+ font-size: 11px;
254
+ border-radius: 4px;
255
+ cursor: pointer;
256
+ transition: background 0.2s;
257
+ }
258
+
259
+ .btn:hover { background: var(--accent); }
260
+ .btn.danger { background: #8b2035; }
261
+ .btn.danger:hover { background: var(--red); }
262
+
263
+ /* Pixel art decorations */
264
+ .pixel-floor {
265
+ position: absolute;
266
+ bottom: 0;
267
+ left: 0;
268
+ right: 0;
269
+ height: 4px;
270
+ background: repeating-linear-gradient(90deg, #2a2a4a 0, #2a2a4a 8px, #1a1a3a 8px, #1a1a3a 16px);
271
+ }
272
+ </style>
273
+ </head>
274
+ <body>
275
+
276
+ <div class="app">
277
+ <!-- Header -->
278
+ <header class="header">
279
+ <div class="logo">๐ŸŠ</div>
280
+ <h1>OpenCroc Studio</h1>
281
+ <div class="actions">
282
+ <button class="btn" id="btn-scan" title="Re-scan project">๐Ÿ”„ Scan</button>
283
+ <button class="btn" id="btn-test" title="Run all tests">โ–ถ Test</button>
284
+ </div>
285
+ <div class="stats" id="header-stats">
286
+ <div>Modules: <span id="stat-modules">-</span></div>
287
+ <div>Models: <span id="stat-models">-</span></div>
288
+ <div>APIs: <span id="stat-endpoints">-</span></div>
289
+ </div>
290
+ <div class="conn-status" id="conn-status" title="WebSocket"></div>
291
+ </header>
292
+
293
+ <!-- Sidebar: Module List -->
294
+ <aside class="sidebar" id="sidebar">
295
+ <h3>๐Ÿ—‚ Modules</h3>
296
+ <div id="module-list"></div>
297
+ <h3 style="margin-top:16px">๐ŸŠ Agents</h3>
298
+ <div id="agent-list-sidebar"></div>
299
+ </aside>
300
+
301
+ <!-- Main: Knowledge Graph Canvas -->
302
+ <main class="main">
303
+ <canvas id="graph-canvas"></canvas>
304
+ <div class="tooltip" id="tooltip"></div>
305
+ </main>
306
+
307
+ <!-- Bottom: Pixel Croc Office -->
308
+ <section class="office-panel" id="croc-office">
309
+ <!-- Populated by JS -->
310
+ </section>
311
+ </div>
312
+
313
+ <script>
314
+ // ============================================
315
+ // OpenCroc Studio โ€” M1 Frontend
316
+ // ============================================
317
+
318
+ const state = {
319
+ project: null,
320
+ graph: { nodes: [], edges: [] },
321
+ agents: [],
322
+ ws: null,
323
+ selectedNode: null,
324
+ // Canvas rendering state
325
+ pan: { x: 0, y: 0 },
326
+ zoom: 1,
327
+ dragging: false,
328
+ dragStart: { x: 0, y: 0 },
329
+ nodePositions: new Map(),
330
+ hoveredNode: null,
331
+ };
332
+
333
+ // ============ API ============
334
+
335
+ async function fetchProject() {
336
+ try {
337
+ const res = await fetch('/api/project');
338
+ state.project = await res.json();
339
+ state.graph = state.project.graph || { nodes: [], edges: [] };
340
+ state.agents = state.project.agents || [];
341
+ layoutGraph();
342
+ updateUI();
343
+ } catch (e) {
344
+ console.error('Failed to fetch project:', e);
345
+ }
346
+ }
347
+
348
+ async function refreshProject() {
349
+ try {
350
+ await fetch('/api/project/refresh', { method: 'POST' });
351
+ await fetchProject();
352
+ } catch (e) {
353
+ console.error('Failed to refresh:', e);
354
+ }
355
+ }
356
+
357
+ // ============ WebSocket ============
358
+
359
+ function connectWS() {
360
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
361
+ state.ws = new WebSocket(`${protocol}//${location.host}/ws`);
362
+
363
+ state.ws.onopen = () => {
364
+ document.getElementById('conn-status').classList.add('connected');
365
+ };
366
+
367
+ state.ws.onmessage = (e) => {
368
+ try {
369
+ const msg = JSON.parse(e.data);
370
+ if (msg.type === 'agent:update' && Array.isArray(msg.payload)) {
371
+ state.agents = msg.payload;
372
+ renderCrocOffice();
373
+ renderAgentSidebar();
374
+ } else if (msg.type === 'graph:update') {
375
+ state.graph = msg.payload;
376
+ layoutGraph();
377
+ renderCanvas();
378
+ }
379
+ } catch {}
380
+ };
381
+
382
+ state.ws.onclose = () => {
383
+ document.getElementById('conn-status').classList.remove('connected');
384
+ // Reconnect after 3s
385
+ setTimeout(connectWS, 3000);
386
+ };
387
+ }
388
+
389
+ // ============ Graph Layout (Force-directed) ============
390
+
391
+ function layoutGraph() {
392
+ const nodes = state.graph.nodes;
393
+ if (!nodes.length) return;
394
+
395
+ const canvas = document.getElementById('graph-canvas');
396
+ const w = canvas.width;
397
+ const h = canvas.height;
398
+ const cx = w / 2;
399
+ const cy = h / 2;
400
+
401
+ // Group by module
402
+ const modules = new Map();
403
+ for (const node of nodes) {
404
+ const m = node.module || node.id;
405
+ if (!modules.has(m)) modules.set(m, []);
406
+ modules.get(m).push(node);
407
+ }
408
+
409
+ const moduleKeys = [...modules.keys()];
410
+ const moduleCount = moduleKeys.length || 1;
411
+
412
+ for (let mi = 0; mi < moduleKeys.length; mi++) {
413
+ const modNodes = modules.get(moduleKeys[mi]);
414
+ const angle = (mi / moduleCount) * Math.PI * 2 - Math.PI / 2;
415
+ const radius = Math.min(w, h) * 0.3;
416
+ const modCx = cx + Math.cos(angle) * radius;
417
+ const modCy = cy + Math.sin(angle) * radius;
418
+
419
+ for (let ni = 0; ni < modNodes.length; ni++) {
420
+ const nAngle = (ni / modNodes.length) * Math.PI * 2;
421
+ const nRadius = 40 + modNodes.length * 10;
422
+ state.nodePositions.set(modNodes[ni].id, {
423
+ x: modCx + Math.cos(nAngle) * nRadius,
424
+ y: modCy + Math.sin(nAngle) * nRadius,
425
+ });
426
+ }
427
+ }
428
+ }
429
+
430
+ // ============ Canvas Rendering ============
431
+
432
+ function renderCanvas() {
433
+ const canvas = document.getElementById('graph-canvas');
434
+ const ctx = canvas.getContext('2d');
435
+ const dpr = window.devicePixelRatio || 1;
436
+
437
+ canvas.width = canvas.clientWidth * dpr;
438
+ canvas.height = canvas.clientHeight * dpr;
439
+ ctx.scale(dpr, dpr);
440
+
441
+ const w = canvas.clientWidth;
442
+ const h = canvas.clientHeight;
443
+
444
+ ctx.clearRect(0, 0, w, h);
445
+ ctx.save();
446
+ ctx.translate(state.pan.x, state.pan.y);
447
+ ctx.scale(state.zoom, state.zoom);
448
+
449
+ // Draw grid (pixel art style)
450
+ ctx.strokeStyle = '#1a1a3a';
451
+ ctx.lineWidth = 0.5;
452
+ const gridSize = 32;
453
+ for (let x = 0; x < w * 2; x += gridSize) {
454
+ ctx.beginPath(); ctx.moveTo(x, -h); ctx.lineTo(x, h * 2); ctx.stroke();
455
+ }
456
+ for (let y = 0; y < h * 2; y += gridSize) {
457
+ ctx.beginPath(); ctx.moveTo(-w, y); ctx.lineTo(w * 2, y); ctx.stroke();
458
+ }
459
+
460
+ const edges = state.graph.edges || [];
461
+ const nodes = state.graph.nodes || [];
462
+
463
+ // Draw edges
464
+ ctx.lineWidth = 1.5;
465
+ for (const edge of edges) {
466
+ const s = state.nodePositions.get(edge.source);
467
+ const t = state.nodePositions.get(edge.target);
468
+ if (!s || !t) continue;
469
+
470
+ const gradient = ctx.createLinearGradient(s.x, s.y, t.x, t.y);
471
+ gradient.addColorStop(0, 'rgba(78, 204, 163, 0.4)');
472
+ gradient.addColorStop(1, 'rgba(78, 204, 163, 0.1)');
473
+ ctx.strokeStyle = gradient;
474
+
475
+ ctx.beginPath();
476
+ ctx.moveTo(s.x, s.y);
477
+ ctx.lineTo(t.x, t.y);
478
+ ctx.stroke();
479
+
480
+ // Arrow head
481
+ const angle = Math.atan2(t.y - s.y, t.x - s.x);
482
+ const arrowLen = 8;
483
+ const ax = t.x - Math.cos(angle) * 20;
484
+ const ay = t.y - Math.sin(angle) * 20;
485
+ ctx.fillStyle = 'rgba(78, 204, 163, 0.5)';
486
+ ctx.beginPath();
487
+ ctx.moveTo(ax, ay);
488
+ ctx.lineTo(ax - arrowLen * Math.cos(angle - 0.4), ay - arrowLen * Math.sin(angle - 0.4));
489
+ ctx.lineTo(ax - arrowLen * Math.cos(angle + 0.4), ay - arrowLen * Math.sin(angle + 0.4));
490
+ ctx.closePath();
491
+ ctx.fill();
492
+ }
493
+
494
+ // Draw nodes (pixel art style โ€” square blocks)
495
+ const typeColors = {
496
+ model: '#4ecca3',
497
+ controller: '#e94560',
498
+ api: '#f39c12',
499
+ dto: '#3498db',
500
+ module: '#9b59b6',
501
+ };
502
+
503
+ const statusOutline = {
504
+ idle: '#555',
505
+ testing: '#f39c12',
506
+ passed: '#4ecca3',
507
+ failed: '#e94560',
508
+ };
509
+
510
+ for (const node of nodes) {
511
+ const pos = state.nodePositions.get(node.id);
512
+ if (!pos) continue;
513
+
514
+ const size = node.type === 'module' ? 24 : 16;
515
+ const color = typeColors[node.type] || '#888';
516
+ const outline = statusOutline[node.status] || '#555';
517
+ const isHovered = state.hoveredNode === node.id;
518
+
519
+ // Shadow
520
+ ctx.fillStyle = 'rgba(0,0,0,0.3)';
521
+ ctx.fillRect(pos.x - size + 2, pos.y - size + 2, size * 2, size * 2);
522
+
523
+ // Main block
524
+ ctx.fillStyle = color;
525
+ ctx.fillRect(pos.x - size, pos.y - size, size * 2, size * 2);
526
+
527
+ // Pixel highlight (top-left)
528
+ ctx.fillStyle = 'rgba(255,255,255,0.2)';
529
+ ctx.fillRect(pos.x - size, pos.y - size, size * 2, 3);
530
+ ctx.fillRect(pos.x - size, pos.y - size, 3, size * 2);
531
+
532
+ // Status outline
533
+ ctx.strokeStyle = isHovered ? '#fff' : outline;
534
+ ctx.lineWidth = isHovered ? 3 : 2;
535
+ ctx.strokeRect(pos.x - size, pos.y - size, size * 2, size * 2);
536
+
537
+ // Icon (pixel emoji)
538
+ ctx.font = `${size}px serif`;
539
+ ctx.textAlign = 'center';
540
+ ctx.textBaseline = 'middle';
541
+ const icons = { model: '๐Ÿ“ฆ', controller: '๐ŸŽฎ', api: '๐Ÿ”Œ', dto: '๐Ÿ“‹', module: '๐Ÿ“' };
542
+ ctx.fillText(icons[node.type] || 'โฌœ', pos.x, pos.y);
543
+
544
+ // Label
545
+ ctx.font = '10px "Courier New"';
546
+ ctx.fillStyle = isHovered ? '#fff' : '#ccc';
547
+ ctx.textAlign = 'center';
548
+ ctx.textBaseline = 'top';
549
+ const label = node.label || node.id.split(':').pop();
550
+ ctx.fillText(label, pos.x, pos.y + size + 4);
551
+ }
552
+
553
+ ctx.restore();
554
+ }
555
+
556
+ // ============ Canvas Interaction ============
557
+
558
+ function setupCanvasInteraction() {
559
+ const canvas = document.getElementById('graph-canvas');
560
+
561
+ canvas.addEventListener('mousedown', (e) => {
562
+ state.dragging = true;
563
+ state.dragStart = { x: e.clientX - state.pan.x, y: e.clientY - state.pan.y };
564
+ });
565
+
566
+ canvas.addEventListener('mousemove', (e) => {
567
+ if (state.dragging) {
568
+ state.pan.x = e.clientX - state.dragStart.x;
569
+ state.pan.y = e.clientY - state.dragStart.y;
570
+ renderCanvas();
571
+ }
572
+
573
+ // Hit-test nodes
574
+ const rect = canvas.getBoundingClientRect();
575
+ const mx = (e.clientX - rect.left - state.pan.x) / state.zoom;
576
+ const my = (e.clientY - rect.top - state.pan.y) / state.zoom;
577
+
578
+ let hit = null;
579
+ for (const node of state.graph.nodes) {
580
+ const pos = state.nodePositions.get(node.id);
581
+ if (!pos) continue;
582
+ const size = node.type === 'module' ? 24 : 16;
583
+ if (mx >= pos.x - size && mx <= pos.x + size && my >= pos.y - size && my <= pos.y + size) {
584
+ hit = node;
585
+ break;
586
+ }
587
+ }
588
+
589
+ const tooltip = document.getElementById('tooltip');
590
+ if (hit) {
591
+ state.hoveredNode = hit.id;
592
+ tooltip.innerHTML = `<b>${hit.label || hit.id}</b><br>Type: ${hit.type}<br>Status: ${hit.status}${hit.module ? '<br>Module: ' + hit.module : ''}`;
593
+ tooltip.style.left = (e.clientX + 12) + 'px';
594
+ tooltip.style.top = (e.clientY + 12) + 'px';
595
+ tooltip.classList.add('visible');
596
+ canvas.style.cursor = 'pointer';
597
+ } else {
598
+ if (state.hoveredNode) {
599
+ state.hoveredNode = null;
600
+ renderCanvas();
601
+ }
602
+ tooltip.classList.remove('visible');
603
+ canvas.style.cursor = state.dragging ? 'grabbing' : 'grab';
604
+ }
605
+ if (hit) renderCanvas();
606
+ });
607
+
608
+ canvas.addEventListener('mouseup', () => { state.dragging = false; });
609
+ canvas.addEventListener('mouseleave', () => {
610
+ state.dragging = false;
611
+ document.getElementById('tooltip').classList.remove('visible');
612
+ });
613
+
614
+ canvas.addEventListener('wheel', (e) => {
615
+ e.preventDefault();
616
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
617
+ state.zoom = Math.max(0.2, Math.min(3, state.zoom * delta));
618
+ renderCanvas();
619
+ }, { passive: false });
620
+ }
621
+
622
+ // ============ UI Updates ============
623
+
624
+ function updateUI() {
625
+ if (state.project) {
626
+ document.getElementById('stat-modules').textContent = state.project.stats?.modules || 0;
627
+ document.getElementById('stat-models').textContent = state.project.stats?.models || 0;
628
+ document.getElementById('stat-endpoints').textContent = state.project.stats?.endpoints || 0;
629
+ }
630
+
631
+ renderModuleList();
632
+ renderCrocOffice();
633
+ renderAgentSidebar();
634
+ renderCanvas();
635
+ }
636
+
637
+ function renderModuleList() {
638
+ const list = document.getElementById('module-list');
639
+ const modules = state.graph.nodes.filter(n => n.type === 'module');
640
+
641
+ if (!modules.length) {
642
+ list.innerHTML = '<div style="padding:8px;color:#666;font-size:11px">No modules found</div>';
643
+ return;
644
+ }
645
+
646
+ list.innerHTML = modules.map(m => `
647
+ <div class="module-item" data-id="${esc(m.id)}">
648
+ <div class="dot ${m.status}"></div>
649
+ ${esc(m.label || m.id.replace('module:', ''))}
650
+ </div>
651
+ `).join('');
652
+ }
653
+
654
+ function renderAgentSidebar() {
655
+ const list = document.getElementById('agent-list-sidebar');
656
+ list.innerHTML = state.agents.map(a => `
657
+ <div class="module-item">
658
+ <div class="dot ${a.status}"></div>
659
+ ${esc(a.name)} <span style="color:#666;font-size:9px">${a.status}</span>
660
+ </div>
661
+ `).join('');
662
+ }
663
+
664
+ function renderCrocOffice() {
665
+ const office = document.getElementById('croc-office');
666
+
667
+ const sprites = {
668
+ parser: '๐ŸŠ',
669
+ analyzer: '๐ŸŠ',
670
+ tester: '๐ŸŠ',
671
+ healer: '๐ŸŠ',
672
+ planner: '๐ŸŠ',
673
+ reporter: '๐ŸŠ',
674
+ };
675
+
676
+ // Desk decorations per role
677
+ const deskDecor = {
678
+ parser: '๐Ÿ’ป',
679
+ analyzer: '๐Ÿ“Š',
680
+ tester: '๐Ÿงช',
681
+ healer: '๐Ÿ”ง',
682
+ planner: '๐Ÿ“‹',
683
+ reporter: '๐Ÿ“',
684
+ };
685
+
686
+ office.innerHTML = state.agents.map(a => `
687
+ <div class="croc-desk ${a.status}">
688
+ <div class="status-badge ${a.status}"></div>
689
+ <div class="croc-sprite">${sprites[a.role] || '๐ŸŠ'}</div>
690
+ <div class="croc-name">${esc(a.name)}</div>
691
+ <div class="croc-role">${esc(a.role)} ${deskDecor[a.role] || ''}</div>
692
+ <div class="croc-task">${a.currentTask ? esc(a.currentTask) : ''}</div>
693
+ <div class="pixel-floor"></div>
694
+ </div>
695
+ `).join('');
696
+ }
697
+
698
+ function esc(s) {
699
+ if (!s) return '';
700
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
701
+ }
702
+
703
+ // ============ Event Handlers ============
704
+
705
+ document.getElementById('btn-scan').addEventListener('click', refreshProject);
706
+ document.getElementById('btn-test').addEventListener('click', async () => {
707
+ // M2: will trigger real test pipeline
708
+ try {
709
+ await fetch('/api/agents/tester-croc/task', {
710
+ method: 'POST',
711
+ headers: { 'Content-Type': 'application/json' },
712
+ body: JSON.stringify({ task: 'Running E2E tests...' }),
713
+ });
714
+ } catch (e) {
715
+ console.error('Failed to start test:', e);
716
+ }
717
+ });
718
+
719
+ // ============ Init ============
720
+
721
+ (async () => {
722
+ setupCanvasInteraction();
723
+ await fetchProject();
724
+ connectWS();
725
+
726
+ // Handle resize
727
+ window.addEventListener('resize', () => {
728
+ layoutGraph();
729
+ renderCanvas();
730
+ });
731
+ })();
732
+ </script>
733
+ </body>
734
+ </html>