agmon 0.0.0-stage → 0.1.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,519 @@
1
+ import { Application, Container, FillGradient, Graphics, Text } from "pixi.js";
2
+ import { buildOffice, OFFICE, STATE_COLORS } from "./scene/office-layout";
3
+ import { createCharacter, createDesk, createBadge } from "./scene/characters";
4
+ import { createToolBadge, STATUS_KEYS, STATUS_TYPES, TOOL_KEYS } from "./scene/tool-icons";
5
+ import { createTokenStreams } from "./scene/token-streams";
6
+
7
+ const MAX_ZOOM = 1.6;
8
+ const MIN_ZOOM = 0.2;
9
+
10
+ // World rectangle the camera frames (floor).
11
+ const FLOOR = { x: -60, y: 40, w: 2150, h: 980 };
12
+
13
+ const DESK_W = 150;
14
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
15
+
16
+ const MODE_INTENSITY = { streaming: 0.8, pending: 0.3, happy: 0.2, sleeping: 0, error: 1 };
17
+
18
+ /**
19
+ * Mount the office scene on `canvas`.
20
+ *
21
+ * Everything visible is a function of the trace records:
22
+ * - robot pose <- account state (typing / waving / happy / asleep / error)
23
+ * - badge over head <- queued, done, or the specific failure reason
24
+ * - badge by monitor <- tool type currently running
25
+ * - desk face <- latency bar, cost, cache share, request pile
26
+ * - packets <- input (round), output (diamond), cached (square)
27
+ * - pods <- load meter, siren when saturated, operator robot
28
+ * - amber lanes <- fallback: courier drone carries the request to another provider
29
+ */
30
+ export async function mountOfficeScene(canvas, traces, options = {}) {
31
+ let selectedId = options.selectedId ?? null;
32
+ let onSelect = options.onSelect;
33
+
34
+ const app = new Application();
35
+ await app.init({
36
+ canvas,
37
+ preference: "webgl",
38
+ resizeTo: canvas.parentElement,
39
+ background: 0x0b0f16,
40
+ antialias: true,
41
+ resolution: Math.min(globalThis.devicePixelRatio || 1, 2),
42
+ autoDensity: true,
43
+ });
44
+
45
+ const world = new Container();
46
+ const floorLayer = new Container();
47
+ const laneLayer = new Container();
48
+ const deskLayer = new Container();
49
+ const actorLayer = new Container();
50
+ const toolLayer = new Container();
51
+ const hudLayer = new Container();
52
+ const streams = createTokenStreams();
53
+ world.addChild(floorLayer, laneLayer, streams.container, deskLayer, actorLayer, toolLayer, hudLayer);
54
+ app.stage.addChild(world);
55
+
56
+ // ---- floor -------------------------------------------------------------
57
+ const floor = new Graphics();
58
+ floor.roundRect(FLOOR.x, FLOOR.y, FLOOR.w, FLOOR.h, 28).fill(
59
+ new FillGradient({
60
+ type: "linear",
61
+ start: { x: 0, y: 0 },
62
+ end: { x: 0, y: 1 },
63
+ colorStops: [{ offset: 0, color: 0x0b1018 }, { offset: 1, color: 0x161f2c }],
64
+ textureSpace: "local",
65
+ }),
66
+ );
67
+ for (let x = FLOOR.x + 100; x < FLOOR.x + FLOOR.w; x += 100) {
68
+ floor.moveTo(x, FLOOR.y + 14).lineTo(x, FLOOR.y + FLOOR.h - 14).stroke({ width: 1, color: 0x1a2330, alpha: 0.7 });
69
+ }
70
+ for (let y = FLOOR.y + 100; y < FLOOR.y + FLOOR.h; y += 100) {
71
+ floor.moveTo(FLOOR.x + 14, y).lineTo(FLOOR.x + FLOOR.w - 14, y).stroke({ width: 1, color: 0x1a2330, alpha: 0.7 });
72
+ }
73
+ floor.roundRect(FLOOR.x, FLOOR.y, FLOOR.w, FLOOR.h, 28).stroke({ width: 2, color: 0x263244, alpha: 0.9 });
74
+ // Zone panels: agents | router | providers
75
+ floor.roundRect(OFFICE.rackX - 130, FLOOR.y + 120, 260, FLOOR.h - 240, 22).fill({ color: 0xfde047, alpha: 0.035 });
76
+ floor.roundRect(OFFICE.podX - 110, FLOOR.y + 40, 220, FLOOR.h - 150, 22).fill({ color: 0x22d3ee, alpha: 0.035 });
77
+ floor.eventMode = "static";
78
+ floor.on("pointertap", () => select(null));
79
+ floorLayer.addChild(floor);
80
+
81
+ // ---- central rack (Core Hub) with a dispatcher robot behind it -----------
82
+ const rack = new Container();
83
+ const dispatcher = createCharacter({ color: 0xfde047, trimColor: 0xf59e0b, seed: 3, scale: 0.95, mode: "sleeping" });
84
+ dispatcher.root.baseX = 0;
85
+ dispatcher.root.baseY = -156;
86
+ dispatcher.root.x = 0;
87
+ dispatcher.root.y = -156;
88
+ rack.addChild(dispatcher.root);
89
+
90
+ const rackBody = new Graphics();
91
+ rackBody.roundRect(-70, -170, 140, 230, 10).fill({ color: 0x1a222d });
92
+ rackBody.roundRect(-62, -162, 124, 60, 6).fill({ color: 0x223040 });
93
+ rackBody.roundRect(-58, 40, 116, 96, 6).fill({ color: 0x223040 });
94
+ rack.addChild(rackBody);
95
+
96
+ const rackGlow = new Graphics();
97
+ rackGlow.roundRect(-80, -180, 160, 250, 14).fill({ color: 0xfde047, alpha: 0.12 });
98
+ rack.addChildAt(rackGlow, 0);
99
+
100
+ const leds = new Graphics();
101
+ rack.addChild(leds);
102
+
103
+ const rackLabel = new Text({
104
+ text: "CORE HUB",
105
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 14, fill: 0x38bdf8, fontWeight: "800", letterSpacing: 2 },
106
+ });
107
+ rackLabel.anchor.set(0.5, 0);
108
+ rackLabel.y = -92;
109
+ rack.addChild(rackLabel);
110
+
111
+ const rackCount = createBadge("0", { color: 0xfde047, size: 13 });
112
+ rackCount.anchor.set(0.5, 0);
113
+ rackCount.y = 4;
114
+ rack.addChild(rackCount);
115
+ deskLayer.addChild(rack);
116
+
117
+ // ---- build / rebuild on data change ----------------------------------
118
+ // Everything created per data refresh is tracked here and destroyed together.
119
+ let dyn = [];
120
+ let deskEntries = [];
121
+ let podEntries = [];
122
+ let couriers = [];
123
+ let rackActive = false;
124
+
125
+ const track = (layer, obj) => {
126
+ layer.addChild(obj);
127
+ dyn.push(obj);
128
+ return obj;
129
+ };
130
+
131
+ function refreshSelection() {
132
+ for (const e of deskEntries) e.desk.setSelected(e.ws.connectionId === selectedId);
133
+ }
134
+
135
+ function select(id) {
136
+ selectedId = id;
137
+ refreshSelection();
138
+ onSelect?.(id);
139
+ }
140
+
141
+ function dashedLine(g, x, y1, y2, color) {
142
+ const dir = Math.sign(y2 - y1) || 1;
143
+ for (let y = y1; dir > 0 ? y < y2 - 8 : y > y2 + 8; y += dir * 14) {
144
+ g.moveTo(x, y).lineTo(x, y + dir * 8).stroke({ width: 2, color, alpha: 0.8 });
145
+ }
146
+ }
147
+
148
+ function rebuild(nextTraces) {
149
+ for (const o of dyn) o.destroy({ children: true });
150
+ dyn = [];
151
+ deskEntries = [];
152
+ podEntries = [];
153
+ couriers = [];
154
+
155
+ const office = buildOffice(nextTraces);
156
+ const podByProvider = new Map(office.pods.map((p) => [p.provider, p]));
157
+ const routes = [];
158
+
159
+ // Back-to-front so nearer desks overlap further ones.
160
+ const ordered = [...office.workstations].sort((a, b) => a.y - b.y || a.x - b.x);
161
+
162
+ for (const [i, ws] of ordered.entries()) {
163
+ const desk = createDesk({
164
+ color: ws.color,
165
+ label: ws.account,
166
+ meta: `${ws.model || ""} · ${ws.totalLabel}`,
167
+ elapsedMs: ws.elapsedMs,
168
+ cost: ws.cost,
169
+ cachedPct: ws.cachedPct,
170
+ queued: ws.queued,
171
+ depth: ws.depth,
172
+ });
173
+ desk.root.x = ws.x;
174
+ desk.root.y = ws.y;
175
+ desk.root.on("pointertap", (e) => {
176
+ e.stopPropagation();
177
+ select(ws.connectionId === selectedId ? null : ws.connectionId);
178
+ });
179
+
180
+ // Agent first: it sits behind the desk so the desk hides its torso.
181
+ const character = createCharacter({ color: ws.color, depth: ws.depth, seed: i, mode: ws.mode });
182
+ character.root.x = ws.x + 108;
183
+ character.root.baseX = ws.x + 108;
184
+ character.root.baseY = ws.y + 34;
185
+ character.root.y = character.root.baseY;
186
+ track(deskLayer, character.root);
187
+ track(deskLayer, desk.root);
188
+
189
+ // Tool-call badges cycle by the monitor, one glyph per tool type.
190
+ const toolBadges = [];
191
+ if (ws.busy > 0 || ws.tools.length > 0) {
192
+ for (const tool of ws.tools) {
193
+ const badge = createToolBadge(tool, { size: 1.05 });
194
+ badge.root.x = ws.x + 28;
195
+ badge.root.y = ws.y - 50;
196
+ badge.root.visible = false;
197
+ track(toolLayer, badge.root);
198
+ toolBadges.push(badge);
199
+ }
200
+ }
201
+
202
+ // Status badge over the head: queued / done / the exact failure reason.
203
+ let statusBadge = null;
204
+ const statusKey =
205
+ ws.mode === "pending" ? "pending"
206
+ : ws.mode === "happy" ? "done"
207
+ : ws.mode === "error" ? (STATUS_TYPES[ws.errorReason] ? ws.errorReason : "rate_limited")
208
+ : null;
209
+ if (statusKey) {
210
+ statusBadge = createToolBadge(statusKey, { size: 1.05 });
211
+ statusBadge.root.x = ws.x + DESK_W + 14;
212
+ statusBadge.root.y = ws.y - 34;
213
+ track(toolLayer, statusBadge.root);
214
+ }
215
+
216
+ // Fallback marker: this request already failed over from another provider.
217
+ if (ws.fallbackFrom) {
218
+ const fb = createToolBadge("fallback", { size: 0.85, label: `from ${ws.fallbackFrom}` });
219
+ fb.root.x = ws.x + DESK_W + 14;
220
+ fb.root.y = ws.y + 40;
221
+ track(toolLayer, fb.root);
222
+ }
223
+
224
+ // Cost: a coin pops off the desk of a finished account.
225
+ let coin = null;
226
+ if (ws.mode === "happy" && ws.cost > 0) {
227
+ coin = new Container();
228
+ const disc = new Graphics();
229
+ disc.circle(0, 0, 6).fill({ color: 0xfacc15 });
230
+ disc.circle(0, 0, 6).stroke({ width: 1.2, color: 0xa16207 });
231
+ const amount = new Text({
232
+ text: `+$${ws.cost.toFixed(2)}`,
233
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 11, fill: 0xfacc15, fontWeight: "800" },
234
+ });
235
+ amount.x = 10;
236
+ amount.anchor.set(0, 0.5);
237
+ coin.addChild(disc, amount);
238
+ coin.x = ws.x + 16;
239
+ coin.visible = false;
240
+ track(toolLayer, coin);
241
+ }
242
+
243
+ deskEntries.push({ desk, character, ws, toolBadges, statusBadge, coin });
244
+
245
+ const pod = podByProvider.get(ws.provider);
246
+ if (ws.busy > 0) {
247
+ const cap = (v) => Math.max(0.15, clamp01(v));
248
+ const wIn = cap(ws.tokens.input / 16000);
249
+ const wOut = cap(ws.tokens.output / 3200);
250
+ const wCache = ws.tokens.cached > 0 ? cap(ws.tokens.cached / 6500) : 0;
251
+ const deskPt = { x: ws.x + 75, y: ws.y - 10 };
252
+ const rackIn = { x: OFFICE.rackX, y: OFFICE.rackY - 90 };
253
+ // input: desk -> rack -> provider
254
+ routes.push({ from: deskPt, to: rackIn, color: ws.color, weight: wIn });
255
+ if (pod) {
256
+ routes.push({ from: { x: OFFICE.rackX, y: OFFICE.rackY - 40 }, to: { x: pod.x - 46, y: pod.y }, color: pod.color, weight: wIn });
257
+ // output: provider -> rack -> desk, arcing underneath
258
+ routes.push({ from: { x: pod.x - 46, y: pod.y + 14 }, to: { x: OFFICE.rackX + 20, y: OFFICE.rackY - 10 }, color: 0x86efac, weight: wOut, shape: "diamond", arc: -22 });
259
+ }
260
+ routes.push({ from: { x: OFFICE.rackX - 20, y: OFFICE.rackY + 10 }, to: { x: ws.x + 100, y: ws.y + 6 }, color: 0x86efac, weight: wOut, shape: "diamond", arc: -22 });
261
+ // cached: served at the rack, never reaches a provider
262
+ if (wCache) {
263
+ routes.push({ from: { x: ws.x + 90, y: ws.y - 20 }, to: { x: OFFICE.rackX - 30, y: OFFICE.rackY - 120 }, color: 0xcbd5e1, weight: wCache, shape: "square", arc: 44 });
264
+ }
265
+ }
266
+ }
267
+
268
+ // Provider pods: load meter, siren when saturated, operator robot behind.
269
+ for (const pod of office.pods) {
270
+ const operator = createCharacter({
271
+ color: pod.color,
272
+ trimColor: pod.color,
273
+ scale: 0.55,
274
+ seed: pod.provider.length,
275
+ mode: pod.overloaded ? "pending" : pod.busy > 0 ? "streaming" : "sleeping",
276
+ });
277
+ operator.root.x = pod.x + 2;
278
+ operator.root.baseX = pod.x + 2;
279
+ operator.root.baseY = pod.y + 10;
280
+ operator.root.y = pod.y + 10;
281
+ track(deskLayer, operator.root);
282
+
283
+ const g = new Graphics();
284
+ g.ellipse(0, 34, 52, 10).fill({ color: 0x000000, alpha: 0.35 });
285
+ g.roundRect(-46, -30, 92, 62, 12).fill({ color: 0x1c2531 });
286
+ g.roundRect(-46, -30, 92, 6, 5).fill({ color: 0xffffff, alpha: 0.06 });
287
+ // Load meter: three rows fill in turn as the provider saturates.
288
+ const meterColor = pod.overloaded ? STATE_COLORS.error : pod.load > 0.5 ? 0xfbbf24 : 0x34d399;
289
+ for (let r = 0; r < 3; r += 1) {
290
+ const fill = clamp01(pod.load * 3 - r);
291
+ g.roundRect(-38, -18 + r * 14, 76, 9, 3).fill({ color: 0x0f141c });
292
+ if (fill > 0) g.roundRect(-38, -18 + r * 14, 76 * fill, 9, 3).fill({ color: meterColor, alpha: 0.9 });
293
+ }
294
+ g.x = pod.x;
295
+ g.y = pod.y;
296
+ track(deskLayer, g);
297
+
298
+ const siren = new Graphics();
299
+ siren.x = pod.x + 34;
300
+ siren.y = pod.y - 35;
301
+ track(deskLayer, siren);
302
+
303
+ const label = new Text({
304
+ text: pod.provider.toUpperCase(),
305
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 12, fill: 0xdce6f2, fontWeight: "800", letterSpacing: 1 },
306
+ });
307
+ label.anchor.set(0.5, 0);
308
+ label.x = pod.x;
309
+ label.y = pod.y + 38;
310
+ track(deskLayer, label);
311
+
312
+ const sub = createBadge(`${pod.busy} in flight${pod.overloaded ? " · FULL" : ""}`, {
313
+ color: pod.overloaded ? STATE_COLORS.error : pod.color,
314
+ size: 10,
315
+ });
316
+ sub.anchor.set(0.5, 0);
317
+ sub.x = pod.x;
318
+ sub.y = pod.y + 53;
319
+ track(deskLayer, sub);
320
+
321
+ podEntries.push({ pod, operator, siren });
322
+ }
323
+
324
+ // Fallback lanes + courier drones: from the failed provider to the one that served it.
325
+ office.fallbacks.forEach((lane, k) => {
326
+ const from = podByProvider.get(lane.from);
327
+ const to = podByProvider.get(lane.to);
328
+ if (!from || !to) return;
329
+ const laneX = OFFICE.podX - 82 - (k % 4) * 14;
330
+
331
+ const g = new Graphics();
332
+ g.moveTo(from.x - 46, from.y).lineTo(laneX, from.y).stroke({ width: 2, color: 0xfbbf24, alpha: 0.5 });
333
+ g.moveTo(to.x - 46, to.y).lineTo(laneX, to.y).stroke({ width: 2, color: 0xfbbf24, alpha: 0.5 });
334
+ dashedLine(g, laneX, from.y, to.y, 0xfbbf24);
335
+ const dir = Math.sign(to.y - from.y) || 1;
336
+ g.poly([laneX - 6, to.y - dir * 10, laneX + 6, to.y - dir * 10, laneX, to.y]).fill({ color: 0xfbbf24 });
337
+ track(laneLayer, g);
338
+
339
+ const tag = new Text({
340
+ text: `FALLBACK ×${lane.count}`,
341
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 9, fill: 0xfbbf24, fontWeight: "800", letterSpacing: 1 },
342
+ });
343
+ tag.anchor.set(1, 0.5);
344
+ tag.x = laneX - 9;
345
+ tag.y = (from.y + to.y) / 2;
346
+ track(laneLayer, tag);
347
+
348
+ const drone = createCharacter({ color: 0xfbbf24, trimColor: 0xf59e0b, scale: 0.5, seed: k + 5, mode: "streaming" });
349
+ const parcel = new Graphics();
350
+ parcel.roundRect(-30, -22, 14, 12, 2).fill({ color: 0xd6a15c });
351
+ parcel.moveTo(-30, -16).lineTo(-16, -16).stroke({ width: 1.2, color: 0x7c4a1d });
352
+ const flame = new Graphics();
353
+ drone.root.addChild(parcel, flame);
354
+ track(actorLayer, drone.root);
355
+ couriers.push({ drone, flame, laneX, y1: from.y, y2: to.y, offset: k * 900 });
356
+ });
357
+
358
+ rack.x = OFFICE.rackX;
359
+ rack.y = OFFICE.rackY;
360
+ rackCount.text = String(office.rack.active);
361
+ rackActive = office.rack.active > 0;
362
+ dispatcher.setMode(rackActive ? "streaming" : "sleeping");
363
+ streams.setRoutes(routes);
364
+ refreshSelection();
365
+ }
366
+
367
+ // ---- camera -----------------------------------------------------------
368
+ function fit() {
369
+ if (app.screen.width <= 0 || app.screen.height <= 0) return;
370
+ const scale = Math.min(
371
+ app.screen.width / (FLOOR.w + 40),
372
+ app.screen.height / (FLOOR.h + 40),
373
+ MAX_ZOOM,
374
+ );
375
+ world.scale.set(Math.max(MIN_ZOOM, scale));
376
+ world.x = (app.screen.width - FLOOR.w * world.scale.x) / 2 - FLOOR.x * world.scale.x;
377
+ world.y = (app.screen.height - FLOOR.h * world.scale.y) / 2 - FLOOR.y * world.scale.y;
378
+ }
379
+
380
+ app.renderer.on("resize", fit);
381
+
382
+ // ---- animation --------------------------------------------------------
383
+ let t = 0;
384
+ app.ticker.add((ticker) => {
385
+ const dt = ticker.deltaMS;
386
+ t += dt;
387
+
388
+ streams.animate(dt);
389
+
390
+ for (const entry of deskEntries) {
391
+ const { ws } = entry;
392
+ const intensity = ws.mode === "streaming" ? Math.min(1, ws.busy / 2) : MODE_INTENSITY[ws.mode] ?? 0;
393
+ entry.desk.animate(t, intensity);
394
+ entry.character.animate(t, intensity);
395
+
396
+ // Tool badges: pop in, float, shrink out; one tool at a time.
397
+ const n = entry.toolBadges.length;
398
+ if (n > 0) {
399
+ const slot = 2200;
400
+ const cycle = Math.floor((t + ws.x * 3) / slot) % n;
401
+ const local = ((t + ws.x * 3) % slot) / slot;
402
+ entry.toolBadges.forEach((b, idx) => {
403
+ const active = idx === cycle;
404
+ b.root.visible = active;
405
+ if (!active) return;
406
+ const k = Math.min(Math.min(1, local * 6), Math.min(1, (1 - local) * 6));
407
+ b.root.scale.set(1.05 * (0.35 + 0.65 * k));
408
+ b.root.alpha = k;
409
+ b.root.y = ws.y - 50 + Math.sin(t * 0.005 + idx) * 2.5;
410
+ b.halo.scale.set(1 + Math.sin(t * 0.01) * 0.12);
411
+ });
412
+ }
413
+
414
+ if (entry.statusBadge) {
415
+ const err = ws.mode === "error";
416
+ entry.statusBadge.root.y = ws.y - 34 + Math.sin(t * (err ? 0.02 : 0.004) + ws.x) * (err ? 3 : 2);
417
+ entry.statusBadge.halo.scale.set(1 + Math.sin(t * (err ? 0.016 : 0.008)) * (err ? 0.3 : 0.12));
418
+ }
419
+
420
+ if (entry.coin) {
421
+ const p = ((t + ws.x * 5) % 3400) / 3400;
422
+ entry.coin.visible = p < 0.8;
423
+ entry.coin.y = ws.y - 24 - p * 30;
424
+ entry.coin.alpha = Math.sin(Math.min(1, p / 0.8) * Math.PI);
425
+ }
426
+ }
427
+
428
+ for (const { pod, operator, siren } of podEntries) {
429
+ operator.animate(t, pod.overloaded ? 1 : Math.min(1, pod.load));
430
+ siren.clear();
431
+ if (pod.overloaded) {
432
+ const on = Math.sin(t * 0.014) > 0;
433
+ siren.circle(0, 0, 5).fill({ color: 0xef4444, alpha: on ? 1 : 0.35 });
434
+ siren.circle(0, 0, 13).fill({ color: 0xef4444, alpha: on ? 0.28 : 0.05 });
435
+ } else {
436
+ siren.circle(0, 0, 3).fill({ color: pod.busy > 0 ? 0x34d399 : 0x475569, alpha: 0.9 });
437
+ }
438
+ }
439
+
440
+ for (const c of couriers) {
441
+ const p = ((t + c.offset) % 4500) / 4500;
442
+ const eased = p * p * (3 - 2 * p);
443
+ c.drone.root.baseX = c.laneX;
444
+ c.drone.root.baseY = c.y1 + (c.y2 - c.y1) * eased + 6;
445
+ c.drone.animate(t, 1);
446
+ c.flame.clear();
447
+ c.flame.ellipse(0, 16, 7 + Math.sin(t * 0.04) * 1.5, 10 + Math.sin(t * 0.05) * 3).fill({ color: 0xfbbf24, alpha: 0.55 });
448
+ c.flame.ellipse(0, 14, 4, 6).fill({ color: 0xfff1b8, alpha: 0.85 });
449
+ c.drone.root.alpha = Math.min(1, p * 8) * Math.min(1, (1 - p) * 8);
450
+ }
451
+
452
+ dispatcher.animate(t, rackActive ? 1 : 0);
453
+
454
+ // Rack activity LEDs
455
+ leds.clear();
456
+ for (let row = 0; row < 5; row += 1) {
457
+ for (let col = 0; col < 6; col += 1) {
458
+ const on = Math.sin(t * 0.006 + row * 1.7 + col * 0.9) > (rackActive ? -0.2 : 0.6);
459
+ if (!on) continue;
460
+ leds.circle(-40 + col * 16, 56 + row * 14, 2.4).fill({
461
+ color: row % 3 === 0 ? 0x34d399 : 0x22d3ee,
462
+ alpha: 0.9,
463
+ });
464
+ }
465
+ }
466
+ rackGlow.alpha = rackActive ? 0.5 + Math.sin(t * 0.005) * 0.3 : 0.15;
467
+ });
468
+
469
+ // ---- legend ------------------------------------------------------------
470
+ const legendTitle = (text, x) => {
471
+ const title = new Text({
472
+ text,
473
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 11, fill: 0x7d8aa0, fontWeight: "800", letterSpacing: 2 },
474
+ });
475
+ title.x = x;
476
+ title.y = FLOOR.y + FLOOR.h - 52;
477
+ hudLayer.addChild(title);
478
+ };
479
+ legendTitle("TOOL CALLS", 35);
480
+ TOOL_KEYS.forEach((tool, i) => {
481
+ const chip = createToolBadge(tool, { size: 0.72 });
482
+ chip.root.x = 160 + i * 65;
483
+ chip.root.y = FLOOR.y + FLOOR.h - 40;
484
+ hudLayer.addChild(chip.root);
485
+ });
486
+ legendTitle("STATUS", 965);
487
+ STATUS_KEYS.forEach((key, i) => {
488
+ const chip = createToolBadge(key, { size: 0.72 });
489
+ chip.root.x = 1050 + i * 68;
490
+ chip.root.y = FLOOR.y + FLOOR.h - 40;
491
+ hudLayer.addChild(chip.root);
492
+ });
493
+ const packetKey = new Text({
494
+ text: "● input ◆ output ■ cached",
495
+ style: { fontFamily: "Inter, system-ui, sans-serif", fontSize: 11, fill: 0x7d8aa0, fontWeight: "700" },
496
+ });
497
+ packetKey.x = 1560;
498
+ packetKey.y = FLOOR.y + FLOOR.h - 50;
499
+ hudLayer.addChild(packetKey);
500
+
501
+ rebuild(traces);
502
+ fit();
503
+
504
+ return {
505
+ app,
506
+ rebuild,
507
+ fit,
508
+ setSelected(id) {
509
+ selectedId = id;
510
+ refreshSelection();
511
+ },
512
+ setOnSelect(fn) {
513
+ onSelect = fn;
514
+ },
515
+ destroy: () => app.destroy(true, { children: true }),
516
+ };
517
+ }
518
+
519
+ export { STATE_COLORS };