@henryz2004/agency 1.0.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.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
@@ -0,0 +1,1484 @@
1
+ // office.js — the fully-procedural office (the app's sole renderer): lays mock
2
+ // agents into project desk clusters interleaved with cozy decor zones (lounge,
3
+ // meeting table, kitchen counter, plant beds, pets), then composites the whole
4
+ // scene. Renders into a buffer canvas that the page scales up crisply.
5
+
6
+ import { makeAgents } from './mock-agents.js';
7
+ import {
8
+ px, shade, hashInt, rng,
9
+ drawWall, drawFloor, drawDaylight, moodForHour,
10
+ drawCubicle, drawCrown, drawNeedsYou, drawWalker, drawCat, drawDog, CELL_W, CELL_H,
11
+ } from './sprites.js';
12
+ import { createAvatar } from './avatar.js';
13
+
14
+ const WALL_H = 70; // cream wall band; fills from the top of canvas
15
+ const TOP = WALL_H; // floor starts here (no sky)
16
+ const MARGIN = 16;
17
+ const CLUSTER_PAD = 6; // inset around a cluster's cells
18
+ const HEADER_H = 14; // repo-label clearance atop each cluster (DOM tab sits here)
19
+ const GAP_X = 16; // gap between blocks on a row
20
+ const GAP_Y = 20; // gap between rows
21
+ const UPSCALE = 3; // canvas is CSS-scaled this much; the DOM label layer matches
22
+
23
+ let canvas, ctx, bufW = 0, bufH = 0, frame = 0;
24
+ let agents = [];
25
+ let clusters = []; // {x, y, w, h, count, firstAgent, project, seed}
26
+ let decor = []; // {type, x, y, w, h, seed}
27
+ let cells = []; // {x, y, agent} — flattened, in draw order
28
+
29
+ // ---- floor entities: the walkable user avatar + a wandering cat -------------
30
+ let avatar = null; // the player (created in initOffice, off by default)
31
+ let lastT = 0; // last loop timestamp → real dt for motion
32
+ let walkBtn = null, walkHint = null; // on-screen walk-mode affordances (built in JS)
33
+ let nameBtn = null; // on-screen nametag-visibility toggle
34
+ let labelsHidden = false; // hide all agent name chips when true ('n' / button)
35
+ // roaming pet: dir +1 faces right; sit→sleep when it rests a while; `petted` is
36
+ // set while the walking avatar is right next to it (→ wakes + hearts in drawCat).
37
+ const cat = { x: 0, y: 0, tx: 0, ty: 0, sit: true, until: 0, init: false, dir: 1, sleeping: false, petted: false };
38
+ // the dog lounges in a fixed spot but is PETTABLE like the cat (walk up → hearts);
39
+ // a floor entity so it z-sorts + animates. Position set in layout().
40
+ const dog = { x: 0, y: 0, init: false, petted: false };
41
+ if (typeof window !== 'undefined') { window.__cat = cat; window.__dog = dog; } // reel hooks: frame/control the pets
42
+ // Idle-wander: settled-idle workers leave their desk and amble around the floor —
43
+ // to a JITTERED point inside a lounge/kitchen zone (so they spread out instead of
44
+ // stacking on a fixed spot), often hopping between spots before heading home, and
45
+ // rushing back fast when resumed. Per-agent state keyed by keyOf(agent); each tick
46
+ // re-syncs the agent's home to its (possibly re-laid) desk.
47
+ const wanderers = new Map(); // key -> { phase, x, y, tx, ty, until, homeX, homeY, speed, rush, legDist }
48
+ let wanderZones = []; // [{x, y, w, h}] destination zones (lounge / meeting / plants / kitchen)
49
+
50
+ // DOM overlay layer: real (crisp) HTML text for the name chips + repo labels,
51
+ // instead of canvas pixel text. One wrapper div over the canvas, scaled by
52
+ // UPSCALE so children are positioned in BUFFER coordinates (matching the canvas
53
+ // art); a future camera can transform this one wrapper as a group.
54
+ let labelWrap = null; // the single group wrapper
55
+ const nameNodes = []; // pooled name-chip elements, reused between frames
56
+ const repoNodes = []; // pooled repo-label elements, reused between frames
57
+ const leadNodes = []; // pooled "▸ leadName" captions tying teammates to a lead
58
+
59
+ // ---- layout ----------------------------------------------------------------
60
+
61
+ function clusterDims(count) {
62
+ // Lay a team's desks as a LANDSCAPE block (wider than tall) so a big team grows
63
+ // SIDEWAYS instead of into a tall column. A cell is 58w×78h (taller than wide),
64
+ // so to read landscape we need cols well above rows: cols ≈ √(count · 2.4).
65
+ const cols = Math.min(count, Math.max(1, Math.ceil(Math.sqrt(count * 2.4))));
66
+ const rows = Math.ceil(count / cols);
67
+ return {
68
+ cols, rows,
69
+ w: cols * CELL_W + CLUSTER_PAD * 2,
70
+ h: HEADER_H + rows * CELL_H + CLUSTER_PAD,
71
+ };
72
+ }
73
+
74
+ // One project = ONE team = ONE cluster (one rug, one label). The list arrives
75
+ // project-sorted (see setAgents), so each project is a single contiguous run.
76
+ // A big team is NOT split into pods — clusterDims() wraps it across rows WITHIN
77
+ // the one cluster, so it never reads as two separate teams.
78
+ function clusterRuns(list) {
79
+ const runs = [];
80
+ let i = 0;
81
+ while (i < list.length) {
82
+ const key = list[i].project;
83
+ let j = i;
84
+ while (j < list.length && list[j].project === key) j++;
85
+ runs.push({ project: key, start: i, count: j - i });
86
+ i = j;
87
+ }
88
+ return runs;
89
+ }
90
+
91
+ // Pack the desk clusters into a TIGHT central grid (clusters directly adjacent,
92
+ // small gaps, wrapping into rows) — like the reference's cubicle block — then lay
93
+ // a dedicated cozy LOUNGE BAND of decor along the floor beneath the desks, plus
94
+ // fixed amenities against the back wall. Decor is NOT interleaved between desks
95
+ // (that's what made the floor read sparse); it lives in its own band so the
96
+ // bullpen stays dense.
97
+ const CLUSTER_GAP_X = 12; // tight gap between adjacent desk clusters on a row
98
+ const CLUSTER_GAP_Y = 10; // gap between rows of desk clusters (tight, like the ref)
99
+ const LOUNGE_BAND_H = 60; // height of the decor band beneath the bullpen
100
+ // Reserved band at the top of the floor for back-wall amenities (the kitchen
101
+ // counter ends at TOP+25) and the hanging ceiling pendants (end ~TOP+18). The
102
+ // bullpen starts below this so decor never clips a cluster rug — count-independent.
103
+ const AMENITY_BAND_H = 16;
104
+ // Target bullpen aspect (width:height). >1 packs the desk clusters LANDSCAPE so
105
+ // the floor fills a desktop viewport instead of a tall portrait column. Biased
106
+ // well above 1 because the fixed amenity + lounge bands add height the bullpen
107
+ // must out-widen. Tuned by eye against the floor-frame.
108
+ const LANDSCAPE_ASPECT = 2.6;
109
+
110
+ function plan() {
111
+ clusters = []; decor = []; cells = [];
112
+ const runs = clusterRuns(agents);
113
+
114
+ // --- size each cluster, then pick a columns-per-row that keeps the bullpen
115
+ // roughly landscape (wider than tall) without overflowing a sane width. ---
116
+ const sized = runs.map((run, ci) => {
117
+ const d = clusterDims(run.count);
118
+ return { count: run.count, project: run.project, firstAgent: run.start, w: d.w, h: d.h, dims: d, seed: 101 + ci * 7 };
119
+ });
120
+ // --- LANDSCAPE packing: wrap rows at a TARGET WIDTH derived from total cluster
121
+ // area × the aspect bias, so the bullpen reads wider-than-tall and fills a
122
+ // desktop viewport instead of a narrow column. Adapts to any team count: few
123
+ // teams → one wide row; many → a wide grid. The widest cluster sets a floor so
124
+ // a single big team never overflows its own row. ---
125
+ const bx0 = MARGIN, by0 = TOP + AMENITY_BAND_H;
126
+ const totalArea = sized.reduce((s, b) => s + (b.w + CLUSTER_GAP_X) * b.h, 0);
127
+ const widest = sized.reduce((m, b) => Math.max(m, b.w), 0);
128
+ const targetRowW = Math.max(widest, Math.sqrt(totalArea * LANDSCAPE_ASPECT));
129
+
130
+ let rowW = 0, rowTopH = 0;
131
+ let cx = bx0, cy = by0;
132
+ const rows = [[]];
133
+ sized.forEach((b) => {
134
+ // wrap once the current row has REACHED the target width (the triggering
135
+ // cluster overhangs, which packs rows wider → more landscape). Greedy by
136
+ // width, not by a fixed cluster count.
137
+ if (rows[rows.length - 1].length && (cx - bx0) >= targetRowW) {
138
+ cy += rowTopH + CLUSTER_GAP_Y;
139
+ cx = bx0; rowTopH = 0; rows.push([]);
140
+ }
141
+ b.x = cx; b.y = cy;
142
+ rows[rows.length - 1].push(b);
143
+ cx += b.w + CLUSTER_GAP_X;
144
+ rowTopH = Math.max(rowTopH, b.h);
145
+ rowW = Math.max(rowW, cx - CLUSTER_GAP_X - bx0);
146
+ });
147
+ const bullpenBottom = cy + rowTopH;
148
+
149
+ // centre each row within the widest row so ragged tails read balanced
150
+ rows.forEach((row) => {
151
+ if (!row.length) return;
152
+ const last = row[row.length - 1];
153
+ const used = last.x + last.w - bx0;
154
+ const shift = Math.round((rowW - used) / 2);
155
+ if (shift > 0) row.forEach((b) => { b.x += shift; });
156
+ });
157
+
158
+ // --- realise clusters + cells ---
159
+ sized.forEach((b) => {
160
+ clusters.push({ x: b.x, y: b.y, w: b.w, h: b.h, count: b.count, firstAgent: b.firstAgent, project: b.project, seed: b.seed });
161
+ const { cols } = b.dims;
162
+ for (let k = 0; k < b.count; k++) {
163
+ const c = k % cols, r = Math.floor(k / cols);
164
+ cells.push({
165
+ x: b.x + CLUSTER_PAD + c * CELL_W,
166
+ y: b.y + HEADER_H + r * CELL_H,
167
+ agent: agents[b.firstAgent + k],
168
+ });
169
+ }
170
+ });
171
+
172
+ // --- DECOR PLACEMENT: the room HUGS its content so there's no cavern of dead floor.
173
+ // Two regimes by floor size:
174
+ // SNUG (≤4 desks): desks high under the wall + a plant bed in each upper corner —
175
+ // a small office, not a hall.
176
+ // FULL (>4 desks): desks high + ONE coherent lounge band directly below them +
177
+ // a kitchen against the wall. Compact; no scattered fill.
178
+ // The camera (fitView) centres on the desks, so a compact/portrait room still frames
179
+ // them well, and the soft zoom-out lets you pull back to the whole floor.
180
+ const nAgents = cells.length;
181
+ const minRoomW = 320;
182
+
183
+ if (nAgents <= 4) {
184
+ // ---- SNUG: a cozy small office — desks high under the wall, a plant bed in each
185
+ // UPPER CORNER tucked against the back wall (head-on, so it overlaps the wall's
186
+ // base) instead of at desk height where it clipped the rug. ----
187
+ const flankW = nAgents <= 2 ? 40 : 52; // slim flanks for 1–2 desks
188
+ const gap = 10;
189
+ bufW = rowW + 2 * (flankW + gap) + MARGIN * 2;
190
+ bufH = bullpenBottom + 16 + MARGIN;
191
+ clusters.forEach((c) => { c.x += flankW + gap; });
192
+ cells.forEach((c) => { c.x += flankW + gap; });
193
+ // a plant bed in each upper corner, standing AGAINST the wall (overlaps its base)
194
+ decor.push({ type: 'plants', x: MARGIN, y: TOP - 6, w: flankW, h: 44, seed: hashInt('pl') });
195
+ decor.push({ type: 'plants', x: bufW - MARGIN - flankW, y: TOP - 6, w: flankW, h: 44, seed: hashInt('pr') });
196
+ dog.x = bufW - MARGIN - flankW / 2; dog.y = bullpenBottom - 4; dog.init = true;
197
+ } else {
198
+ // ---- FULL: desks high under the wall + ONE coherent lounge band directly below.
199
+ // The room HUGS (desks + lounge) — no cavern of dead floor, and the decor reads as
200
+ // a single sensible lounge instead of furniture scattered across an empty hall.
201
+ bufW = Math.max(minRoomW, rowW + MARGIN * 2);
202
+ // centre the bullpen horizontally
203
+ const bullShift = Math.round((bufW - MARGIN * 2 - rowW) / 2);
204
+ if (bullShift > 0) {
205
+ clusters.forEach((c) => { c.x += bullShift; });
206
+ cells.forEach((c) => { c.x += bullShift; });
207
+ }
208
+
209
+ // one lounge band beneath the desks: grouped, coherent pieces (couch+table,
210
+ // meeting table, plant beds) scaled to the floor width.
211
+ const bandY = bullpenBottom + 10;
212
+ const floorW = bufW - MARGIN * 2;
213
+ const wOf = (t) => (t === 'lounge' ? 104 : t === 'meeting' ? 96 : 44);
214
+ const pieces = floorW > 560 ? ['plants', 'lounge', 'meeting', 'lounge', 'plants']
215
+ : floorW > 380 ? ['plants', 'lounge', 'meeting', 'plants']
216
+ : floorW > 230 ? ['lounge', 'plants']
217
+ : ['lounge'];
218
+ const used = pieces.reduce((s, t) => s + wOf(t), 0);
219
+ const gap = pieces.length > 1 ? Math.max(16, (floorW - used) / (pieces.length - 1)) : 0;
220
+ let lx = MARGIN + Math.max(0, (floorW - used - gap * (pieces.length - 1)) / 2);
221
+ pieces.forEach((t) => {
222
+ decor.push({ type: t, x: Math.round(lx), y: bandY, w: wOf(t), h: LOUNGE_BAND_H, seed: hashInt('b' + lx) });
223
+ lx += wOf(t) + gap;
224
+ });
225
+
226
+ bufH = bandY + LOUNGE_BAND_H + MARGIN;
227
+ // kitchen counter tucked UP against the back wall, far right (base above the desk
228
+ // line so it never dips into a team rug)
229
+ decor.push({ type: 'kitchen', x: bufW - 84, y: TOP - 12, w: 78, h: 26, seed: 999 });
230
+ // the dog naps at the end of the lounge band
231
+ dog.x = bufW - MARGIN - 30; dog.y = bandY + LOUNGE_BAND_H - 4; dog.init = true;
232
+ }
233
+
234
+ // idle-wander destination ZONES: the lounge spots (couch / meeting / plants) plus
235
+ // the kitchen/vending bar, so workers actually visit the amenities. Stored as
236
+ // zones — updateWanderers picks a JITTERED point inside one, so workers spread
237
+ // out instead of stacking on a fixed point. (Empty floor → none → everyone stays.)
238
+ wanderZones = decor
239
+ .filter((z) => z.type === 'lounge' || z.type === 'meeting' || z.type === 'plants' || z.type === 'kitchen')
240
+ .map((z) => ({ x: z.x, y: z.y, w: z.w, h: z.h, kind: z.type }));
241
+
242
+ // focal point = centre of the desk bullpen, so fitView() frames the desks on any
243
+ // room shape (not the empty back wall).
244
+ if (clusters.length) {
245
+ let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
246
+ clusters.forEach((c) => {
247
+ minx = Math.min(minx, c.x); maxx = Math.max(maxx, c.x + c.w);
248
+ miny = Math.min(miny, c.y); maxy = Math.max(maxy, c.y + c.h);
249
+ });
250
+ focusBX = (minx + maxx) / 2; focusBY = (miny + maxy) / 2;
251
+ } else { focusBX = bufW / 2; focusBY = bufH / 2; }
252
+ }
253
+
254
+ // ---- decor drawing ---------------------------------------------------------
255
+
256
+ function drawDecor() {
257
+ for (const z of decor) {
258
+ if (z.type === 'lounge') drawLounge(z);
259
+ else if (z.type === 'meeting') drawMeeting(z);
260
+ else if (z.type === 'plants') drawPlants(z);
261
+ else if (z.type === 'kitchen') drawKitchen(z);
262
+ // (the cat + dog are floor entities now — drawn in the actor loop, not as decor)
263
+ }
264
+ }
265
+
266
+ function drawAreaRug(z, fill, edge) {
267
+ const rx = z.x + 4, ry = z.y + z.h - 40, rw = z.w - 8, rh = 36;
268
+ px(ctx, rx + 3, ry, rw - 6, rh, fill);
269
+ px(ctx, rx, ry + 3, rw, rh - 6, fill);
270
+ px(ctx, rx + 3, ry, rw - 6, 1, edge);
271
+ px(ctx, rx + 3, ry + rh - 1, rw - 6, 1, edge);
272
+ px(ctx, rx + 8, ry + 4, rw - 16, 1, 'rgba(255,255,255,0.08)');
273
+ return { rx, ry, rw, rh, floorY: ry + rh - 4 };
274
+ }
275
+
276
+ function drawLounge(z) {
277
+ const { floorY, rx } = drawAreaRug(z, '#caa46c', '#b58f56'); // warm sand rug
278
+ // two-seat couch on the left, FRONT-FACING (tall backrest at the back, seat
279
+ // cushions toward the viewer, arms framing the sides) so it reads as a sofa you
280
+ // sit into — not an ambiguous top-down slab.
281
+ const couchCols = ['#d9694f', '#5d9ce0', '#5dc98a', '#a87de0'];
282
+ const couch = couchCols[hashInt('couch' + z.seed) % couchCols.length];
283
+ const lite = shade(couch, 20), dark = shade(couch, -22), arm = shade(couch, -8);
284
+ const cxx = rx + 4, cyy = floorY - 16;
285
+ // backrest (tall block at the back)
286
+ px(ctx, cxx + 3, cyy - 8, 30, 9, couch);
287
+ px(ctx, cxx + 3, cyy - 8, 30, 1, lite); // top highlight
288
+ px(ctx, cxx + 17, cyy - 7, 1, 7, dark); // back split between the seats
289
+ // seat cushions in front of the backrest
290
+ px(ctx, cxx, cyy + 1, 36, 7, couch);
291
+ px(ctx, cxx, cyy + 1, 36, 1, lite); // cushion top edge
292
+ px(ctx, cxx + 17, cyy + 1, 1, 7, dark); // seam between the two seats
293
+ // front base / skirt + little feet
294
+ px(ctx, cxx + 2, cyy + 8, 32, 2, dark);
295
+ px(ctx, cxx + 3, cyy + 10, 2, 2, '#2c2018');
296
+ px(ctx, cxx + 31, cyy + 10, 2, 2, '#2c2018');
297
+ // arms framing the seat (a touch taller than the cushions)
298
+ px(ctx, cxx - 2, cyy - 5, 5, 14, arm);
299
+ px(ctx, cxx + 33, cyy - 5, 5, 14, arm);
300
+ px(ctx, cxx - 2, cyy - 5, 5, 1, lite);
301
+ px(ctx, cxx + 33, cyy - 5, 5, 1, lite);
302
+ // a throw pillow on the left cushion
303
+ px(ctx, cxx + 4, cyy + 2, 7, 5, '#f4d35e');
304
+ // coffee table in front
305
+ const tx = cxx + 40, ty = floorY - 8;
306
+ px(ctx, tx, ty, 18, 5, '#8a5e34');
307
+ px(ctx, tx, ty, 18, 1, '#a5743f');
308
+ px(ctx, tx + 1, ty + 5, 2, 3, '#5a3d22');
309
+ px(ctx, tx + 15, ty + 5, 2, 3, '#5a3d22');
310
+ px(ctx, tx + 6, ty - 2, 4, 2, '#d8d2c8'); // mug on the table
311
+ // a plant at the back-right
312
+ drawPlant(ctx, tx + 22, floorY - 3);
313
+ }
314
+
315
+ function drawMeeting(z) {
316
+ const { floorY, rx, rw } = drawAreaRug(z, '#54707b', '#46606a'); // slate task rug
317
+ // a long meeting table centred
318
+ const tw = rw - 24, tx = rx + 12, ty = floorY - 12;
319
+ px(ctx, tx, ty, tw, 7, '#a5743f');
320
+ px(ctx, tx, ty, tw, 2, '#c08a52');
321
+ px(ctx, tx, ty + 7, tw, 2, '#6f4a2a');
322
+ px(ctx, tx + 2, ty + 9, 2, 4, '#5a3d22'); // legs
323
+ px(ctx, tx + tw - 4, ty + 9, 2, 4, '#5a3d22');
324
+ // laptops / a coffee on top
325
+ px(ctx, tx + 6, ty - 3, 8, 4, '#1c2230');
326
+ px(ctx, tx + 7, ty - 2, 6, 2, '#5cd0ff');
327
+ px(ctx, tx + tw - 16, ty - 3, 8, 4, '#1c2230');
328
+ px(ctx, tx + tw - 15, ty - 2, 6, 2, '#ffd166');
329
+ px(ctx, tx + (tw >> 1) - 2, ty - 2, 3, 2, '#c0473a'); // mug
330
+ // accent chairs pulled up
331
+ const chairCols = ['#e0855d', '#5dc98a', '#e0b05d', '#5d9ce0'];
332
+ const c1 = chairCols[hashInt('ch' + z.seed) % chairCols.length];
333
+ const c2 = chairCols[hashInt('ch' + z.seed + 3) % chairCols.length];
334
+ px(ctx, tx - 8, floorY - 12, 6, 12, c1);
335
+ px(ctx, tx - 8, floorY - 12, 6, 2, shade(c1, 20));
336
+ px(ctx, tx + tw + 2, floorY - 12, 6, 12, c2);
337
+ px(ctx, tx + tw + 2, floorY - 12, 6, 2, shade(c2, 20));
338
+ }
339
+
340
+ function drawPlants(z) {
341
+ const floorY = z.y + z.h - 4;
342
+ // a low wood planter box with plants TILED across it, so a wide bed reads as a
343
+ // full planter — not two lonely plants at the ends with an empty box between.
344
+ const bx = z.x + 2, bw = z.w - 4, by = floorY - 6;
345
+ px(ctx, bx, by, bw, 7, '#6f4d2b');
346
+ px(ctx, bx, by, bw, 2, '#8a6238');
347
+ px(ctx, bx, by + 6, bw, 1, '#5a3d22');
348
+ const slot = 14; // a single plant's footprint
349
+ const n = Math.max(1, Math.floor(bw / 18)); // ~one plant per 18px of box
350
+ const gap = n > 1 ? (bw - n * slot) / (n - 1) : 0;
351
+ const x0 = bx + (n > 1 ? 0 : (bw - slot) / 2); // centre a lone plant
352
+ for (let i = 0; i < n; i++) {
353
+ drawPlant(ctx, Math.round(x0 + i * (slot + gap)), floorY - (i % 2 ? 1 : 5));
354
+ }
355
+ }
356
+
357
+ function drawPlant(ctx, x, baseY) {
358
+ // a leafy potted plant ~14 tall
359
+ px(ctx, x + 3, baseY - 5, 8, 6, '#b5602e'); // pot
360
+ px(ctx, x + 3, baseY - 5, 8, 1, '#cd7438'); // pot rim
361
+ px(ctx, x + 4, baseY - 11, 6, 7, '#3f9a55'); // foliage core
362
+ px(ctx, x + 2, baseY - 9, 3, 5, '#4cb364'); // left frond
363
+ px(ctx, x + 9, baseY - 9, 3, 5, '#4cb364'); // right frond
364
+ px(ctx, x + 5, baseY - 14, 4, 4, '#5cc873'); // top frond
365
+ px(ctx, x + 5, baseY - 13, 2, 2, '#74dd8c'); // highlight
366
+ }
367
+
368
+ function drawKitchen(z) {
369
+ const x = z.x, y = z.y, w = z.w;
370
+ const floorY = y + 26;
371
+ // counter cabinet
372
+ px(ctx, x, y + 8, w, 18, '#cfd5de');
373
+ px(ctx, x, y + 8, w, 2, '#e2e7ee'); // counter top light
374
+ px(ctx, x, y + 24, w, 2, 'rgba(0,0,0,0.12)');
375
+ // cabinet doors
376
+ for (let dx = x + 3; dx < x + w - 6; dx += 16) {
377
+ px(ctx, dx, y + 12, 13, 11, '#bcc3cd');
378
+ px(ctx, dx + 11, y + 16, 1, 3, '#8a93a0'); // handle
379
+ }
380
+ // a coffee machine on the counter
381
+ px(ctx, x + 5, y + 1, 9, 8, '#2b2f38');
382
+ px(ctx, x + 6, y + 2, 7, 3, '#3a414c');
383
+ px(ctx, x + 7, y + 5, 5, 2, '#c0473a'); // hot plate glow
384
+ // two mugs
385
+ px(ctx, x + 17, y + 4, 4, 4, '#e4ded3');
386
+ px(ctx, x + 17, y + 4, 4, 1, '#f2ede4');
387
+ px(ctx, x + 23, y + 5, 4, 3, '#5dc98a');
388
+ // a vending machine on the right end
389
+ const vx = x + w - 18;
390
+ px(ctx, vx, y - 6, 16, 32, '#c0473a');
391
+ px(ctx, vx + 2, y - 4, 9, 24, '#1a2a3a'); // glass
392
+ ctx.fillStyle = '#5cd0ff';
393
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 3; c++) px(ctx, vx + 3 + c * 3, y - 3 + r * 6, 2, 4, ['#5cd0ff', '#ffd166', '#6cff9a', '#e07db0'][(r + c) % 4]);
394
+ px(ctx, vx + 12, y + 4, 3, 6, '#0d1018'); // dispense slot
395
+ }
396
+
397
+ // A muted area rug under each desk cluster — grounds the team neighbourhood and
398
+ // lets the warm wood read as the circulation path around it. Rug tone rotates
399
+ // across a small dusty palette so adjacent teams read as distinct zones.
400
+ const RUG_TONES = [
401
+ { fill: '#4a6360', edge: '#3b504e' }, // dusty teal
402
+ { fill: '#5c5168', edge: '#473f53' }, // muted plum
403
+ { fill: '#6b5642', edge: '#564536' }, // warm taupe
404
+ { fill: '#4a5a6e', edge: '#3c4a5b' }, // slate blue
405
+ ];
406
+ function drawClusterRugs() {
407
+ clusters.forEach((c) => {
408
+ const tone = RUG_TONES[hashInt('rug' + c.seed) % RUG_TONES.length];
409
+ // Rug now starts near the top of the cluster (was below the header band) so
410
+ // it includes a top strip where the repo label is "sewn into" the carpet.
411
+ const rx = c.x - 4, ry = c.y + 2, rw = c.w + 8, rh = c.h + 2;
412
+ px(ctx, rx, ry, rw, rh, tone.fill);
413
+ px(ctx, rx, ry, rw, 2, tone.edge);
414
+ px(ctx, rx, ry + rh - 2, rw, 2, tone.edge);
415
+ px(ctx, rx, ry, 2, rh, tone.edge);
416
+ px(ctx, rx + rw - 2, ry, 2, rh, tone.edge);
417
+ px(ctx, rx + 4, ry + 3, rw - 8, 1, 'rgba(255,255,255,0.05)'); // faint sheen
418
+ });
419
+ }
420
+
421
+ // ---- DOM label overlay (real crisp text) ------------------------------------
422
+ // Name chips + repo labels are HTML, not canvas pixels — sharp DOM text scaled
423
+ // by the camera, which is far more legible than any bitmap font at this buffer
424
+ // resolution. The wrapper is sized
425
+ // in BUFFER coords and scaled by UPSCALE, so node positions reuse the same
426
+ // cell/cluster coords the canvas art uses. Nodes are pooled and reused between
427
+ // updates so nothing leaks. A future camera can transform `labelWrap` as a group.
428
+
429
+ // Build (once) the group wrapper, parented to the canvas's container so it
430
+ // overlays the canvas exactly. Inline styles only — we don't own index.html/css.
431
+ function ensureLabelWrap() {
432
+ if (labelWrap || !canvas || !canvas.parentElement) return;
433
+ // inject the "just finished / unread" glow keyframe once (scoped <style>; we
434
+ // don't own css). A calm CYAN breathe on the whole name chip — matches the
435
+ // topbar's cyan "new for you" language and the wave the worker does; replaces
436
+ // the old hot-magenta corner pip.
437
+ if (!document.getElementById('proc-kf')) {
438
+ const s = document.createElement('style');
439
+ s.id = 'proc-kf';
440
+ s.textContent =
441
+ '@keyframes procUnreadGlow{0%,100%{box-shadow:0 0 4px 1px rgba(92,208,255,0.5)}' +
442
+ '50%{box-shadow:0 0 10px 3px rgba(92,208,255,0.85)}}';
443
+ document.head.appendChild(s);
444
+ }
445
+ labelWrap = document.createElement('div');
446
+ labelWrap.className = 'proc-label-layer';
447
+ labelWrap.style.cssText =
448
+ 'position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none;z-index:5;';
449
+ canvas.parentElement.appendChild(labelWrap);
450
+ }
451
+
452
+ // Floor label face: IBM Plex Mono (clean, readable mono) for names/teams, small —
453
+ // the pixel identity lives in the art + Press Start 2P headers/numbers, not body text.
454
+ const NAME_FONT = "7px 'IBM Plex Mono', ui-monospace, monospace";
455
+ const REPO_FONT = "9px 'IBM Plex Mono', ui-monospace, monospace";
456
+
457
+ function statusColor(act) {
458
+ return act === 'working' ? '#39d98a' : act === 'shell' ? '#ffb454' : '#5a6478';
459
+ }
460
+
461
+ // Position one name chip per agent (under its desk) and one repo label per
462
+ // cluster (in the clearance band above the pod). Reuses pooled nodes; hides any
463
+ // surplus from a previous, larger frame.
464
+ function syncLabels() {
465
+ ensureLabelWrap();
466
+ if (!labelWrap) return;
467
+ // labelWrap's size + scale are owned by applyCam() (it scales with the camera,
468
+ // matching the canvas). Here we only (re)position the child labels in buffer coords.
469
+
470
+ // --- name chips (one per agent/cell) ---
471
+ cells.forEach((cell, i) => {
472
+ const a = cell.agent;
473
+ const first = String(a.name || '').split(/\s+/)[0] || a.name || '—';
474
+ let el = nameNodes[i];
475
+ if (!el) {
476
+ el = document.createElement('div');
477
+ el.style.cssText =
478
+ 'position:absolute;transform:translateX(-50%);display:inline-flex;' +
479
+ 'align-items:center;gap:2px;max-width:58px;box-sizing:border-box;' +
480
+ 'padding:0 3px 0 2px;height:8px;white-space:nowrap;' +
481
+ 'background:rgba(14,20,32,0.9);border:1px solid rgba(255,255,255,0.16);' +
482
+ 'border-radius:3px;line-height:1;';
483
+ const dot = document.createElement('span');
484
+ dot.dataset.role = 'dot';
485
+ dot.style.cssText = 'width:3px;height:3px;border-radius:50%;flex:0 0 auto;';
486
+ const name = document.createElement('span');
487
+ name.dataset.role = 'name';
488
+ name.style.cssText =
489
+ `font:${NAME_FONT};color:#eef3fb;line-height:1;` +
490
+ 'overflow:hidden;text-overflow:ellipsis;';
491
+ // "just finished / unread" is shown as a cyan glow on the whole chip (see
492
+ // syncLabels below) + a wave from the worker — no separate corner badge.
493
+ el.appendChild(dot); el.appendChild(name);
494
+ labelWrap.appendChild(el);
495
+ nameNodes[i] = el;
496
+ }
497
+ el.style.display = (labelsHidden || a.placeholder) ? 'none' : 'inline-flex';
498
+ if (a.placeholder) return; // a vacant desk has no name chip
499
+ // Pin the chip: FOLLOW a wandering worker, else sit under the desk. This
500
+ // per-poll pass uses the SAME rule as the per-frame draw(), so the chip never
501
+ // flickers back to the desk for a frame when a poll lands mid-stroll.
502
+ const w = wanderers.get(keyOf(a));
503
+ if (w && w.phase !== 'seated') { el.style.left = Math.round(w.x) + 'px'; el.style.top = Math.round(w.y - 46) + 'px'; }
504
+ else { el.style.left = (cell.x + CELL_W / 2) + 'px'; el.style.top = (cell.y + CELL_H - 10) + 'px'; }
505
+ const dot = el.firstChild, name = el.children[1];
506
+ const col = statusColor(a.activity);
507
+ dot.style.background = col;
508
+ dot.style.boxShadow = a.activity === 'idle' ? 'none' : `0 0 4px ${col}`;
509
+ name.textContent = first;
510
+ // unread = agent just finished + not yet viewed (app.js owns the set). Shown
511
+ // as a cyan glow on the chip (the worker also waves, see drawCubicle/unread).
512
+ const unread = !!(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
513
+ el.style.borderColor = unread ? 'rgba(92,208,255,0.85)' : 'rgba(255,255,255,0.18)';
514
+ el.style.animation = unread ? 'procUnreadGlow 1.8s ease-in-out infinite' : 'none';
515
+ });
516
+ for (let i = cells.length; i < nameNodes.length; i++) nameNodes[i].style.display = 'none';
517
+
518
+ // --- repo labels (one per cluster) ---
519
+ clusters.forEach((c, i) => {
520
+ let el = repoNodes[i];
521
+ if (!el) {
522
+ el = document.createElement('div');
523
+ // "Stamped INTO the carpet": a DEBOSSED label, not text floating on top. The
524
+ // glyphs are a dark recess (semi-transparent black, so it darkens whatever rug
525
+ // tone it sits on) with a 1px light lower lip — the classic engraved/letterpress
526
+ // cue, so the team name reads as pressed into the fabric. Lowercase + trailing
527
+ // slash so it reads plainly as the folder.
528
+ el.style.cssText =
529
+ 'position:absolute;transform:translateX(-50%);white-space:nowrap;' +
530
+ "font:600 7px 'IBM Plex Mono', ui-monospace, monospace;letter-spacing:0.2px;" +
531
+ 'color:rgba(232,224,205,0.66);text-align:center;' +
532
+ 'text-shadow:0 1px 2px rgba(0,0,0,0.6);' +
533
+ 'overflow:hidden;text-overflow:ellipsis;box-sizing:border-box;';
534
+ labelWrap.appendChild(el);
535
+ repoNodes[i] = el;
536
+ }
537
+ el.style.display = 'block';
538
+ // Allow the label to spill ~half the inter-cluster gap on each side so a
539
+ // tiny (1-agent) rug doesn't ellipsis a readable name; neighbours are also
540
+ // centred so the boxes meet at the gap midpoint without overlapping text.
541
+ el.style.maxWidth = (c.w + CLUSTER_GAP_X) + 'px';
542
+ el.style.left = (c.x + c.w / 2) + 'px';
543
+ el.style.top = (c.y + 2) + 'px'; // on the rug's top strip
544
+ const proj = String(c.project || '');
545
+ el.textContent = proj ? proj + '/' : '';
546
+ });
547
+ for (let i = clusters.length; i < repoNodes.length; i++) repoNodes[i].style.display = 'none';
548
+
549
+ // --- teammate → lead tie: a small "▸ leadName" caption just under a teammate's
550
+ // chip so a sub-agent reads as "X's helper", not a peer (pm1 sets kind/leadName). ---
551
+ cells.forEach((cell, i) => {
552
+ const a = cell.agent;
553
+ const tie = a.kind === 'teammate' && a.leadName ? '↳ ' + a.leadName + "'s helper" : '';
554
+ let el = leadNodes[i];
555
+ if (!el) {
556
+ el = document.createElement('div');
557
+ el.style.cssText =
558
+ 'position:absolute;transform:translateX(-50%);white-space:nowrap;' +
559
+ "font:8px 'IBM Plex Mono', ui-monospace, monospace;color:#9fb3cf;" +
560
+ 'text-shadow:0 1px 2px rgba(0,0,0,0.65);';
561
+ labelWrap.appendChild(el);
562
+ leadNodes[i] = el;
563
+ }
564
+ if (!tie) { el.style.display = 'none'; return; }
565
+ el.style.display = 'block';
566
+ el.textContent = tie;
567
+ el.style.left = (cell.x + CELL_W / 2) + 'px';
568
+ el.style.top = (cell.y + CELL_H + 1) + 'px'; // just beneath the name chip
569
+ });
570
+ for (let i = cells.length; i < leadNodes.length; i++) leadNodes[i].style.display = 'none';
571
+
572
+ syncHiddenChip();
573
+ }
574
+
575
+ // A small floor control showing how many desks the user has hidden, toggling
576
+ // them back into view. The hidden STATE is owned by app.js (agent.hidden); proc
577
+ // just collapses them by default and offers a local reveal (no poll needed —
578
+ // rebuild() re-filters lastAll). Anchored to the floor-frame, not the scaled
579
+ // label layer, so it stays a crisp, fixed-size control.
580
+ let hiddenChip = null;
581
+ function syncHiddenChip() {
582
+ const host = world && world.parentElement;
583
+ if (!host) return;
584
+ if (!hiddenChip) {
585
+ hiddenChip = document.createElement('button');
586
+ hiddenChip.type = 'button';
587
+ hiddenChip.style.cssText =
588
+ 'position:absolute;left:12px;top:12px;z-index:8;cursor:pointer;' +
589
+ 'appearance:none;-webkit-appearance:none;outline:none;' +
590
+ 'padding:3px 9px;border-radius:11px;white-space:nowrap;' +
591
+ 'background:rgba(14,20,32,0.9);border:1px solid rgba(255,255,255,0.22);' +
592
+ "font:11px 'IBM Plex Mono', ui-monospace, monospace;color:#cdd8ea;letter-spacing:.3px;";
593
+ hiddenChip.addEventListener('click', () => { showHidden = !showHidden; rebuild(); });
594
+ host.appendChild(hiddenChip);
595
+ }
596
+ // count emptied → also drop the reveal flag, else a future hide would stay
597
+ // visible with no chip to re-collapse it (the chip is gone at count 0).
598
+ if (!hiddenCount) { showHidden = false; hiddenChip.style.display = 'none'; return; }
599
+ hiddenChip.style.display = 'inline-block';
600
+ hiddenChip.textContent = showHidden
601
+ ? `▾ hide ${hiddenCount} hidden`
602
+ : `▸ ${hiddenCount} away · show`;
603
+ }
604
+
605
+ // ---- camera: pan / zoom / click-to-select -----------------------------------
606
+ // The office uses a `.world` wrapper (absolute, origin 0,0, the canvas's parent).
607
+ // Pan = a translate on `.world` so the canvas AND the DOM label group (both
608
+ // children of `.world`) move together. Zoom scales the canvas crisply via CSS
609
+ // width (a transform-scale on the canvas would blur it) and the label group by a
610
+ // matching factor. A click (a press with no drag) hit-tests the desk cells and
611
+ // dispatches the `agency:select` event the chat panel listens for.
612
+ let world = null;
613
+ let recenterBtn = null; // shared #recenter button (CSS hides it until .show)
614
+ const cam = { x: 0, y: 0, s: 1 }; // s multiplies the UPSCALE base
615
+ let focusBX = 0, focusBY = 0; // centre of the desk bullpen (the default camera focus)
616
+ let userMoved = false; // once the user pans/zooms, stop auto-fitting
617
+ let reservedRight = 0; // px reserved on the right for the hovering panel
618
+ const clampN = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
619
+
620
+ // ui.js calls this when the stats panel opens/closes/resizes so the office fits
621
+ // in the free area LEFT of the panel (0 when collapsed). fitView() subtracts it
622
+ // from the available width; re-fit unless the user has taken over the camera.
623
+ export function setReservedRight(px) {
624
+ reservedRight = Math.max(0, px || 0);
625
+ if (!canvas) return; // office not initialized yet
626
+ // Don't re-fit during a walk (it would reset cam.s and drop the immersive zoom);
627
+ // just re-confine to the new available width. Mirrors the guard in the loop.
628
+ if (!userMoved && !(avatar && avatar.enabled)) fitView();
629
+ else { clampPan(); applyCam(); }
630
+ }
631
+
632
+ // Selection + hover, tracked by a STABLE key so the selection survives the 3s
633
+ // /api/state polls (the agent objects are replaced each poll).
634
+ let selectedKey = null; // the clicked desk (persistent highlight)
635
+ let hoverKey = null; // the desk under the cursor (transient)
636
+ let lastInspectAgent = null; // agent the avatar is walking past (E opens it)
637
+ const keyOf = (a) => a ? (a.sessionId || (a.pid != null ? `pid:${a.pid}` : null)) : null;
638
+
639
+ function viewportRect() {
640
+ const host = world && world.parentElement; // .floor-frame
641
+ return host ? host.getBoundingClientRect() : { left: 0, top: 0, width: 800, height: 600 };
642
+ }
643
+
644
+ function applyCam() {
645
+ if (!canvas) return;
646
+ const eff = UPSCALE * cam.s;
647
+ canvas.style.position = 'absolute';
648
+ canvas.style.left = '0';
649
+ canvas.style.top = '0';
650
+ canvas.style.width = bufW * eff + 'px'; // crisp CSS-width zoom
651
+ canvas.style.height = bufH * eff + 'px';
652
+ if (labelWrap) {
653
+ labelWrap.style.width = bufW + 'px';
654
+ labelWrap.style.height = bufH + 'px';
655
+ labelWrap.style.transformOrigin = '0 0';
656
+ labelWrap.style.transform = `scale(${eff})`;
657
+ }
658
+ // translate3d (not translate) forces a GPU compositor layer so the camera
659
+ // follow glides; rounding keeps the pixel art crisp under the 3x CSS upscale.
660
+ if (world) world.style.transform = `translate3d(${Math.round(cam.x)}px, ${Math.round(cam.y)}px, 0)`;
661
+ // CSS hides #recenter until .show; reveal it once the user has panned/zoomed
662
+ // so the affordance isn't permanently invisible.
663
+ if (recenterBtn) recenterBtn.classList.toggle('show', userMoved);
664
+ positionOverlays(); // re-pin tooltip/card so they track pan/zoom/follow immediately
665
+ }
666
+
667
+ // The camera always CONFINES the office to the visible floor (the area left of the
668
+ // hovering panel) so we never see void around it — in walk mode AND free pan/zoom.
669
+ // Centres the office on an axis where it's smaller than the frame.
670
+ function clampPan() {
671
+ const vp = viewportRect();
672
+ const availW = Math.max(40, vp.width - reservedRight);
673
+ const w = bufW * UPSCALE * cam.s, h = bufH * UPSCALE * cam.s;
674
+ cam.x = w >= availW ? clampN(cam.x, availW - w, 0) : Math.round((availW - w) / 2);
675
+ cam.y = h >= vp.height ? clampN(cam.y, vp.height - h, 0) : Math.round((vp.height - h) / 2);
676
+ }
677
+
678
+ // The COVER fit — the zoom at which the office FILLS the visible floor on BOTH axes,
679
+ // cropping the longer one. This is the DEFAULT framing (a full, no-void view).
680
+ // Capped at native 3x so a tiny floor isn't blown up absurdly.
681
+ function coverScale() {
682
+ const vp = viewportRect();
683
+ const availW = Math.max(40, vp.width - reservedRight);
684
+ return clampN(Math.max(availW / (bufW * UPSCALE), vp.height / (bufH * UPSCALE)), 0.05, 3);
685
+ }
686
+
687
+ // The CONTAIN fit — the zoom at which the WHOLE office is visible (the smaller of the
688
+ // two ratios → limited by the office's LARGER dimension), with void around the short
689
+ // axis. This is the soft zoom-OUT floor: from the cover default you can keep pulling
690
+ // back past it (×0.85 leaves a little breathing margin) until the entire floor fits,
691
+ // instead of being hard-stopped at cover. clampPan centres the office in the void.
692
+ function containScale() {
693
+ const vp = viewportRect();
694
+ const availW = Math.max(40, vp.width - reservedRight);
695
+ return clampN(Math.min(availW / (bufW * UPSCALE), vp.height / (bufH * UPSCALE)) * 0.85, 0.04, 3);
696
+ }
697
+
698
+ // Default view = the cover fit, CENTRED ON THE DESKS. Centering on the bullpen (not
699
+ // the buffer's top-left) means a tall/portrait small office frames the desks in the
700
+ // middle of the screen instead of pinning the empty back wall to the top and cropping
701
+ // the desks off the bottom. clampPan still prevents any void.
702
+ function fitView() {
703
+ cam.s = coverScale();
704
+ const vp = viewportRect();
705
+ const availW = Math.max(40, vp.width - reservedRight);
706
+ const eff = UPSCALE * cam.s;
707
+ cam.x = availW / 2 - focusBX * eff;
708
+ cam.y = vp.height / 2 - focusBY * eff;
709
+ clampPan();
710
+ applyCam();
711
+ }
712
+
713
+ function zoomAt(clientX, clientY, factor) {
714
+ const vp = viewportRect();
715
+ const pxp = clientX - vp.left, pyp = clientY - vp.top;
716
+ const eff0 = UPSCALE * cam.s;
717
+ const bx = (pxp - cam.x) / eff0, by = (pyp - cam.y) / eff0;
718
+ // zoom-out floor is the CONTAIN fit (whole office visible), not cover — so you can
719
+ // pull back to see the entire floor instead of being hard-stopped at the fill view.
720
+ cam.s = clampN(cam.s * factor, containScale(), 3);
721
+ const eff1 = UPSCALE * cam.s;
722
+ cam.x = pxp - bx * eff1;
723
+ cam.y = pyp - by * eff1;
724
+ userMoved = true;
725
+ clampPan();
726
+ applyCam();
727
+ }
728
+
729
+ // Map a screen point to a desk cell (or null). The canvas rect already reflects
730
+ // the CSS scale + world translate, so dividing by the effective scale gives
731
+ // buffer-space px.
732
+ function cellAt(clientX, clientY) {
733
+ if (!canvas) return null;
734
+ const rect = canvas.getBoundingClientRect();
735
+ const eff = UPSCALE * cam.s;
736
+ const bx = (clientX - rect.left) / eff, by = (clientY - rect.top) / eff;
737
+ // A wandering worker is away from its desk — hit-test the walker sprite (around
738
+ // its feet) FIRST, so you can hover/click the agent itself, not just the desk.
739
+ for (const cell of cells) {
740
+ const w = wanderers.get(keyOf(cell.agent));
741
+ if (w && w.phase !== 'seated' && bx >= w.x - 9 && bx <= w.x + 9 && by >= w.y - 38 && by <= w.y + 3) return cell;
742
+ }
743
+ for (const cell of cells) {
744
+ if (bx >= cell.x && bx <= cell.x + CELL_W && by >= cell.y && by <= cell.y + CELL_H) return cell;
745
+ }
746
+ return null;
747
+ }
748
+ const agentAt = (clientX, clientY) => { const c = cellAt(clientX, clientY); return c ? c.agent : null; };
749
+
750
+ // ---- hover affordance + tooltip --------------------------------------------
751
+ // Hovering a desk rings it (handled in draw via hoverKey) and pops a light DOM
752
+ // tooltip of what that agent is doing — a minimal hover-ring + info-card feel
753
+ // (name · project, activity, current task). All fields come from
754
+ // the agent object already in /api/state — no backend dependency.
755
+ let tooltip = null;
756
+ const ACTIVITY_TEXT = { working: 'shipping', shell: 'running a command', idle: 'idle' };
757
+ const escHtml = (s) => String(s == null ? '' : s).replace(/[&<>"]/g,
758
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
759
+
760
+ function ensureTooltip() {
761
+ if (tooltip) return;
762
+ // position:fixed on <body> → viewport coords straight from getBoundingClientRect,
763
+ // no offset-parent guesswork; pointer-events:none so it never eats clicks.
764
+ tooltip = document.createElement('div');
765
+ tooltip.className = 'proc-tooltip';
766
+ tooltip.style.cssText =
767
+ 'position:fixed;z-index:30;pointer-events:none;transform:translateX(-50%);' +
768
+ 'padding:4px 7px;border-radius:5px;white-space:nowrap;' +
769
+ 'background:rgba(14,20,32,0.95);border:1px solid rgba(255,255,255,0.22);' +
770
+ "font:12px 'IBM Plex Mono', ui-monospace, monospace;color:#eef3fb;line-height:1.25;" +
771
+ 'box-shadow:0 4px 14px rgba(0,0,0,0.45);display:none;';
772
+ document.body.appendChild(tooltip);
773
+ }
774
+
775
+ function hideTooltip() { if (tooltip) tooltip.style.display = 'none'; }
776
+
777
+ // The agent's LIVE buffer anchor: a wandering worker (its walker) when away from
778
+ // the desk, else the desk itself. This is what makes hover/inspect info appear
779
+ // beside the AGENT — following them as they roam — instead of over an empty desk.
780
+ function agentAnchor(cell) {
781
+ const w = wanderers.get(keyOf(cell.agent));
782
+ if (w && w.phase !== 'seated') return { cx: w.x, top: w.y - 36, bot: w.y + 3 };
783
+ return { cx: cell.x + CELL_W / 2, top: cell.y, bot: cell.y + CELL_H };
784
+ }
785
+
786
+ // Pin the tooltip beside the agent's live anchor (above it, flipping below if
787
+ // cramped), clamped to the viewport. Split from showTooltip so the per-frame loop
788
+ // can re-pin it cheaply as a walker moves or the camera pans/zooms.
789
+ function positionTooltip(cell) {
790
+ if (!tooltip || tooltip.style.display === 'none' || !canvas) return;
791
+ const an = agentAnchor(cell);
792
+ const rect = canvas.getBoundingClientRect();
793
+ const eff = UPSCALE * cam.s;
794
+ const cx = rect.left + an.cx * eff;
795
+ const topY = rect.top + an.top * eff;
796
+ const botY = rect.top + an.bot * eff;
797
+ const tw = tooltip.offsetWidth, th = tooltip.offsetHeight;
798
+ const left = clampN(cx, tw / 2 + 4, window.innerWidth - tw / 2 - 4);
799
+ let top = topY - th - 8;
800
+ if (top < 4) top = botY + 8;
801
+ tooltip.style.left = Math.round(left) + 'px';
802
+ tooltip.style.top = Math.round(top) + 'px';
803
+ }
804
+
805
+ function showTooltip(cell) {
806
+ ensureTooltip();
807
+ const a = cell.agent;
808
+ if (a.placeholder) {
809
+ // a vacant desk → invite the user to hire their first agent
810
+ tooltip.innerHTML =
811
+ '<div style="font-weight:600;letter-spacing:.3px">An empty desk</div>' +
812
+ '<div style="opacity:.85;margin-top:2px;max-width:180px;white-space:normal">' +
813
+ 'Start a Claude agent to see your first employee.</div>';
814
+ tooltip.style.display = 'block';
815
+ positionTooltip(cell);
816
+ return;
817
+ }
818
+ const col = statusColor(a.activity);
819
+ const act = ACTIVITY_TEXT[a.activity] || a.activity || 'idle';
820
+ const task = a.task || a.chatName || a.lastPrompt || '';
821
+ tooltip.innerHTML =
822
+ `<div style="font-weight:600;letter-spacing:.3px">${escHtml(a.name)}` +
823
+ `<span style="opacity:.55;font-weight:400"> · ${escHtml(a.project)}</span></div>` +
824
+ '<div style="display:flex;align-items:center;gap:4px;margin-top:1px">' +
825
+ `<span style="width:6px;height:6px;border-radius:50%;background:${col};` +
826
+ `box-shadow:${a.activity === 'idle' ? 'none' : `0 0 5px ${col}`}"></span>` +
827
+ `<span style="color:${col}">${escHtml(act)}</span></div>` +
828
+ (a.model ? `<div style="opacity:.6;margin-top:1px;font-size:12px">` +
829
+ `${escHtml(String(a.model).replace(/^claude-/, ''))}</div>` : '') +
830
+ (task ? '<div style="opacity:.85;margin-top:1px;max-width:206px;overflow:hidden;' +
831
+ `text-overflow:ellipsis">“${escHtml(task)}”</div>` : '') +
832
+ (avatar && avatar.enabled ? '<div style="opacity:.5;margin-top:3px;font-size:11px">' +
833
+ 'press E to open</div>' : '');
834
+ tooltip.style.display = 'block';
835
+ positionTooltip(cell);
836
+ }
837
+
838
+ // The detail card (chat-panel #chatPanel) floats BESIDE the selected agent rather
839
+ // than as a screen-blocking rail. office.js owns its position (it knows the camera
840
+ // + the agent's live buffer pos); chat-panel.js owns its content. Re-pinned every
841
+ // frame so it tracks the agent + camera. Prefers the agent's right; flips left near
842
+ // the edge; clamps under the topbar.
843
+ function positionDetailCard() {
844
+ const card = document.getElementById('chatPanel');
845
+ if (!card || !card.classList.contains('open')) return;
846
+ const cell = cells.find((c) => keyOf(c.agent) === selectedKey);
847
+ if (!cell) return;
848
+ const an = agentAnchor(cell);
849
+ const rect = canvas.getBoundingClientRect();
850
+ const eff = UPSCALE * cam.s;
851
+ const ax = rect.left + an.cx * eff;
852
+ const atop = rect.top + an.top * eff;
853
+ const cw = card.offsetWidth || 300, ch = card.offsetHeight || 240;
854
+ const margin = 12, gap = 22;
855
+ let left = ax + gap;
856
+ if (left + cw > window.innerWidth - margin) left = ax - gap - cw; // flip to the left
857
+ left = clampN(left, margin, Math.max(margin, window.innerWidth - cw - margin));
858
+ let top = clampN(atop - 8, 80, Math.max(80, window.innerHeight - ch - margin));
859
+ card.style.left = Math.round(left) + 'px';
860
+ card.style.top = Math.round(top) + 'px';
861
+ }
862
+
863
+ // Keep the hover tooltip + the selected detail card pinned to their agents as the
864
+ // camera moves and walkers roam. Called from draw() (walker motion) and applyCam()
865
+ // (pan/zoom/follow) so neither lags behind.
866
+ function positionOverlays() {
867
+ if (!cells.length) return;
868
+ if (hoverKey) {
869
+ const cell = cells.find((c) => keyOf(c.agent) === hoverKey);
870
+ if (cell) positionTooltip(cell); else hideTooltip();
871
+ }
872
+ if (selectedKey) positionDetailCard();
873
+ }
874
+
875
+ // A highlight disc under a wandering worker who is hovered/selected — the walker
876
+ // analogue of the desk's hover/selection ring, so the AGENT lights up (not just
877
+ // their empty desk). Drawn before the walker so it stands on the disc.
878
+ function drawWalkerRing(x, y, color) {
879
+ ctx.save();
880
+ ctx.globalAlpha = 0.18; ctx.fillStyle = color;
881
+ ctx.beginPath(); ctx.ellipse(x, y + 1, 11, 4, 0, 0, Math.PI * 2); ctx.fill();
882
+ ctx.globalAlpha = 0.95; ctx.strokeStyle = color; ctx.lineWidth = 1;
883
+ ctx.beginPath(); ctx.ellipse(x, y + 1, 10, 3.4, 0, 0, Math.PI * 2); ctx.stroke();
884
+ ctx.restore();
885
+ }
886
+
887
+ // Recompute the hovered desk from a cursor point: update the cursor, the tooltip,
888
+ // and (only on change) the hover key + an immediate repaint so the ring tracks
889
+ // the cursor without waiting for the slow (~7.5fps) animation tick.
890
+ function updateHover(clientX, clientY) {
891
+ if (avatar && avatar.enabled) return; // walk mode: avatar proximity drives the tooltip
892
+ const cell = cellAt(clientX, clientY);
893
+ const k = cell ? keyOf(cell.agent) : null;
894
+ if (canvas) canvas.style.cursor = cell ? 'pointer' : 'grab';
895
+ if (k === hoverKey) return;
896
+ hoverKey = k;
897
+ if (cell) showTooltip(cell); else hideTooltip();
898
+ draw();
899
+ }
900
+
901
+ // Attach pan/zoom/click once. A press that doesn't move is a click (select); a
902
+ // press that drags pans the floor. Trackpad: two-finger scroll pans, pinch (⌘
903
+ // /ctrl-wheel) zooms — matching the floor hint.
904
+ function attachInput() {
905
+ if (!canvas || canvas.dataset.procInput) return;
906
+ canvas.dataset.procInput = '1';
907
+ let down = false, moved = 0, sx = 0, sy = 0;
908
+ canvas.style.cursor = 'grab';
909
+ canvas.addEventListener('mousedown', (e) => {
910
+ down = true; moved = 0; sx = e.clientX; sy = e.clientY;
911
+ canvas.style.cursor = 'grabbing';
912
+ hoverKey = null; hideTooltip(); // dragging: drop the hover affordance
913
+ });
914
+ window.addEventListener('mousemove', (e) => {
915
+ if (!down) { updateHover(e.clientX, e.clientY); return; } // not dragging → hover
916
+ const dx = e.clientX - sx, dy = e.clientY - sy;
917
+ moved += Math.abs(dx) + Math.abs(dy);
918
+ cam.x += dx; cam.y += dy; sx = e.clientX; sy = e.clientY;
919
+ userMoved = true; clampPan(); applyCam();
920
+ });
921
+ window.addEventListener('mouseup', (e) => {
922
+ if (!down) return;
923
+ down = false; canvas.style.cursor = 'grab';
924
+ if (moved < 5) { // a clean tap selects; tapping the selected desk deselects
925
+ const agent = agentAt(e.clientX, e.clientY);
926
+ if (agent && agent.placeholder) {
927
+ // a vacant desk isn't a real agent — show its hover hint, don't open a card
928
+ const cell = cellAt(e.clientX, e.clientY);
929
+ if (cell) showTooltip(cell);
930
+ } else {
931
+ const k = keyOf(agent);
932
+ selectedKey = (k && k === selectedKey) ? null : k;
933
+ draw(); // paint the selection ring now — don't wait for the slow loop tick
934
+ window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent: selectedKey ? agent : null } }));
935
+ }
936
+ }
937
+ updateHover(e.clientX, e.clientY); // re-evaluate hover at rest
938
+ });
939
+ canvas.addEventListener('mouseleave', () => {
940
+ if (hoverKey != null) { hoverKey = null; draw(); }
941
+ hideTooltip(); canvas.style.cursor = 'grab';
942
+ });
943
+ const host = (world && world.parentElement) || canvas;
944
+ host.addEventListener('wheel', (e) => {
945
+ e.preventDefault();
946
+ hideTooltip();
947
+ if (e.ctrlKey || e.metaKey) { // pinch / ⌘ → zoom around the cursor
948
+ // Magnitude-proportional zoom. A fixed per-event factor felt hair-trigger
949
+ // on trackpads, which fire many
950
+ // tiny wheel events; exp(-step·0.01) scales with gesture size. Normalize
951
+ // line-mode (mouse wheel) deltas to px first. zoomAt still clamps the scale.
952
+ const step = e.deltaMode !== 0 ? e.deltaY * 16 : e.deltaY;
953
+ zoomAt(e.clientX, e.clientY, Math.exp(-step * 0.01));
954
+ } else { // plain scroll → pan
955
+ cam.x -= e.deltaX; cam.y -= e.deltaY;
956
+ userMoved = true; clampPan(); applyCam();
957
+ }
958
+ }, { passive: false });
959
+ recenterBtn = document.getElementById('recenter');
960
+ if (recenterBtn) recenterBtn.addEventListener('click', () => { userMoved = false; fitView(); });
961
+ window.addEventListener('resize', () => { if (!userMoved) fitView(); else { clampPan(); applyCam(); } });
962
+ // app.js mutates window.agencyUnread between polls and fires this; re-sync the
963
+ // per-desk unread pips (syncLabels reads window.agencyUnread directly).
964
+ window.addEventListener('agency:unread', () => syncLabels());
965
+ // Selection can come from a click (here), the avatar's E key, or anywhere; keep
966
+ // selectedKey in sync and pin the detail card beside the agent once it has opened
967
+ // + measured (rAF), so it never flashes at its default corner.
968
+ window.addEventListener('agency:select', (e) => {
969
+ const a = e && e.detail && e.detail.agent;
970
+ selectedKey = keyOf(a);
971
+ requestAnimationFrame(positionDetailCard);
972
+ });
973
+ // Walk mode: the avatar fires agency:inspect as it passes desks. Surface a LIGHT
974
+ // tooltip beside that agent (NOT the heavy detail card) so the floor isn't blocked
975
+ // while wandering; E (see initOffice keydown) opens the full card for it.
976
+ window.addEventListener('agency:inspect', (e) => {
977
+ const a = e && e.detail && e.detail.agent;
978
+ lastInspectAgent = a || null;
979
+ const k = keyOf(a);
980
+ if (k === hoverKey) return;
981
+ hoverKey = k;
982
+ if (a) {
983
+ const cell = cells.find((c) => keyOf(c.agent) === k);
984
+ if (cell) showTooltip(cell); else hideTooltip();
985
+ } else hideTooltip();
986
+ draw();
987
+ });
988
+ }
989
+
990
+ // ---- floor entities: avatar (walkable player) + wandering cat ---------------
991
+
992
+ // Desks in the avatar's expected shape: pod top-left (cell x,y) + the agent.
993
+ function buildPods() {
994
+ return cells.map((c, i) => ({ x: c.x, y: c.y, i, agent: c.agent }));
995
+ }
996
+
997
+ // Walkable floor extents in buffer coords (inside the walls + margins).
998
+ function floorBounds() {
999
+ return { minX: MARGIN + 8, maxX: bufW - MARGIN - 8, minY: TOP + 14, maxY: bufH - MARGIN - 6 };
1000
+ }
1001
+
1002
+ // Center the camera on the avatar (lerped catch-up) so it follows while walking.
1003
+ // Mutates cam WITHOUT setting userMoved, so toggling walk off restores fit/pan.
1004
+ function followAvatar(dt = 16) {
1005
+ if (!avatar) return;
1006
+ const vp = viewportRect();
1007
+ const availW = Math.max(40, vp.width - reservedRight);
1008
+ const eff = UPSCALE * cam.s;
1009
+ const tx = availW / 2 - avatar.pos.x * eff;
1010
+ const ty = vp.height / 2 - avatar.pos.y * eff;
1011
+ // Frame-rate-independent catch-up (~0.18 per frame at 60fps) so the follow
1012
+ // feels identical at 30 / 60 / 120 Hz instead of speeding up with frame rate.
1013
+ const k = 1 - Math.pow(0.82, dt / 16.67);
1014
+ cam.x += (tx - cam.x) * k;
1015
+ cam.y += (ty - cam.y) * k;
1016
+ clampPan();
1017
+ applyCam();
1018
+ }
1019
+
1020
+ // Toggle walk mode (the 'g' key / the button). On ENTER: drop the avatar where
1021
+ // the user is looking, then zoom IN for an immersive first-person feel and snap
1022
+ // the camera onto it. On EXIT: restore the pre-walk camera.
1023
+ const WALK_ZOOM = 1.8; // cam.s in walk mode → eff ≈ 5.4x: a close, immersive view
1024
+ let preWalkCam = null; // camera saved on walk-enter, restored on walk-exit
1025
+ function toggleWalk(on) {
1026
+ if (!avatar) return;
1027
+ const was = avatar.enabled;
1028
+ avatar.enabled = on == null ? !avatar.enabled : !!on;
1029
+ if (avatar.enabled && !was) {
1030
+ const vp = viewportRect();
1031
+ const effOld = UPSCALE * cam.s;
1032
+ const b = floorBounds();
1033
+ // drop the avatar at the current view centre, in world coords at the OLD zoom
1034
+ avatar.setPos(
1035
+ clampN((vp.width / 2 - cam.x) / effOld, b.minX, b.maxX),
1036
+ clampN((vp.height / 2 - cam.y) / effOld, b.minY, b.maxY)
1037
+ );
1038
+ preWalkCam = { s: cam.s, x: cam.x, y: cam.y, userMoved };
1039
+ cam.s = Math.max(WALK_ZOOM, coverScale()); // zoom in for immersion, but never below cover (no void)
1040
+ // snap (no lerp) so the camera doesn't lurch from the old framing
1041
+ const effNew = UPSCALE * cam.s;
1042
+ const availW = Math.max(40, vp.width - reservedRight);
1043
+ cam.x = availW / 2 - avatar.pos.x * effNew;
1044
+ cam.y = vp.height / 2 - avatar.pos.y * effNew;
1045
+ clampPan();
1046
+ applyCam();
1047
+ } else if (!avatar.enabled && was && preWalkCam) {
1048
+ // restore the pre-walk zoom + pan + auto-fit state
1049
+ cam.s = preWalkCam.s; cam.x = preWalkCam.x; cam.y = preWalkCam.y;
1050
+ userMoved = preWalkCam.userMoved; preWalkCam = null;
1051
+ clampPan(); applyCam();
1052
+ }
1053
+ updateWalkUI();
1054
+ }
1055
+
1056
+ function updateWalkUI() {
1057
+ const on = !!(avatar && avatar.enabled);
1058
+ if (walkBtn) {
1059
+ walkBtn.textContent = on ? '🚶 walking · G to exit' : '🚶 walk (G)';
1060
+ walkBtn.style.borderColor = on ? '#ff2e88' : 'rgba(255,255,255,0.14)';
1061
+ walkBtn.style.color = on ? '#ff8fc4' : '#cdd6e6';
1062
+ }
1063
+ if (walkHint) walkHint.style.display = on ? 'block' : 'none';
1064
+ }
1065
+
1066
+ // Toggle agent name-tag visibility (the 'n' key / the button). syncLabels + draw()
1067
+ // both honor `labelsHidden`; we flip the active chips here too for an instant response.
1068
+ function toggleLabels(on) {
1069
+ labelsHidden = on == null ? !labelsHidden : !!on;
1070
+ for (let i = 0; i < cells.length; i++) if (nameNodes[i]) nameNodes[i].style.display = labelsHidden ? 'none' : 'inline-flex';
1071
+ updateLabelBtn();
1072
+ }
1073
+ function updateLabelBtn() {
1074
+ if (!nameBtn) return;
1075
+ nameBtn.textContent = labelsHidden ? '🏷 names off' : '🏷 names';
1076
+ nameBtn.style.opacity = labelsHidden ? '0.6' : '1';
1077
+ }
1078
+
1079
+ // Build the small walk affordances (a toggle button + a hint) over the floor
1080
+ // frame, styled inline (index.html / style.css aren't in this lane).
1081
+ function ensureWalkUI() {
1082
+ const host = world && world.parentElement; // .floor-frame
1083
+ if (!host || walkBtn) return;
1084
+ walkBtn = document.createElement('button');
1085
+ walkBtn.type = 'button';
1086
+ walkBtn.id = 'walkToggle';
1087
+ Object.assign(walkBtn.style, {
1088
+ position: 'absolute', left: '12px', bottom: '12px', zIndex: '6',
1089
+ font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
1090
+ border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
1091
+ padding: '5px 9px', cursor: 'pointer',
1092
+ });
1093
+ walkBtn.addEventListener('click', () => toggleWalk());
1094
+ host.appendChild(walkBtn);
1095
+ // nametag-visibility toggle (top-left, clear of the bottom controls)
1096
+ nameBtn = document.createElement('button');
1097
+ nameBtn.type = 'button';
1098
+ nameBtn.id = 'nameToggle';
1099
+ nameBtn.title = 'Show / hide agent name tags (N)';
1100
+ Object.assign(nameBtn.style, {
1101
+ // sits just below the top-left "N away · show" hidden-agents chip (which also
1102
+ // anchors top-left), so the two stack instead of overlapping.
1103
+ position: 'absolute', left: '12px', top: '40px', zIndex: '6',
1104
+ font: '11px ui-monospace, monospace', background: 'rgba(16,20,28,0.82)',
1105
+ border: '1px solid rgba(255,255,255,0.14)', borderRadius: '6px',
1106
+ padding: '5px 9px', cursor: 'pointer', color: '#cdd6e6',
1107
+ });
1108
+ nameBtn.addEventListener('click', () => toggleLabels());
1109
+ host.appendChild(nameBtn);
1110
+ updateLabelBtn();
1111
+ walkHint = document.createElement('div');
1112
+ Object.assign(walkHint.style, {
1113
+ position: 'absolute', left: '12px', bottom: '40px', zIndex: '6',
1114
+ font: '11px ui-monospace, monospace', color: '#9aa6ba',
1115
+ background: 'rgba(16,20,28,0.82)', border: '1px solid rgba(255,255,255,0.10)',
1116
+ borderRadius: '6px', padding: '4px 8px', display: 'none', maxWidth: '250px',
1117
+ });
1118
+ walkHint.textContent = 'WASD / arrows to move · walk up to a desk to open it · G to exit';
1119
+ host.appendChild(walkHint);
1120
+ updateWalkUI();
1121
+ }
1122
+
1123
+ // Gently roam a cat across the lower floor (the lounge): pick a target, amble to
1124
+ // it, sit a while, repeat. Subtle ambient life; runs every loop tick.
1125
+ function updateCat(dt, now) {
1126
+ if (!bufW || !bufH) return;
1127
+ const minX = MARGIN + 12, maxX = bufW - MARGIN - 14;
1128
+ const minY = Math.round(bufH * 0.66), maxY = bufH - MARGIN - 6;
1129
+ if (!cat.init) {
1130
+ cat.x = minX + 8; cat.y = maxY - 2; cat.tx = cat.x; cat.ty = cat.y;
1131
+ cat.sit = true; cat.until = now + 1500; cat.init = true;
1132
+ }
1133
+ cat.x = clampN(cat.x, minX, maxX); cat.y = clampN(cat.y, minY, maxY); // re-clamp after a reshape
1134
+
1135
+ // Petting: while the walking avatar is right next to the cat, it wakes, sits
1136
+ // happily, and emits hearts (drawn in drawCat). Re-checked every tick so a
1137
+ // lingering pet keeps it awake; stepping away lets it drift back to napping.
1138
+ cat.petted = (typeof window !== 'undefined' && window.__pet) || false; // reel: force the happy/hearts state
1139
+ dog.petted = (typeof window !== 'undefined' && window.__pet) || false;
1140
+ if (avatar && avatar.enabled) {
1141
+ const pd = Math.hypot(avatar.pos.x - cat.x, avatar.pos.y - cat.y);
1142
+ if (pd < 22) {
1143
+ cat.petted = true;
1144
+ cat.sleeping = false;
1145
+ cat.sit = true;
1146
+ if (cat.until < now + 900) cat.until = now + 900; // stay put a beat to be petted
1147
+ }
1148
+ // the dog is stationary — just light up when the avatar is beside it
1149
+ if (dog.init && Math.hypot(avatar.pos.x - dog.x, avatar.pos.y - dog.y) < 22) dog.petted = true;
1150
+ }
1151
+
1152
+ if (now >= cat.until) {
1153
+ if (cat.sleeping) {
1154
+ // wake from a nap → sit a moment, then resume roaming
1155
+ cat.sleeping = false; cat.sit = true; cat.until = now + 1400 + Math.random() * 2200;
1156
+ } else if (cat.sit) {
1157
+ // resting done → usually curl up for a nap, otherwise wander to a new spot
1158
+ if (!cat.petted && Math.random() < 0.45) {
1159
+ cat.sleeping = true; cat.until = now + 9000 + Math.random() * 9000; // a good nap
1160
+ } else {
1161
+ cat.sit = false;
1162
+ cat.tx = minX + Math.round(Math.random() * (maxX - minX));
1163
+ cat.ty = minY + Math.round(Math.random() * (maxY - minY));
1164
+ cat.until = now + 5000 + Math.random() * 4000; // safety cap before it must rest
1165
+ }
1166
+ } else {
1167
+ cat.sit = true; cat.until = now + 2200 + Math.random() * 4500; // arrived → rest a while
1168
+ }
1169
+ }
1170
+ if (!cat.sit) {
1171
+ const dx = cat.tx - cat.x, dy = cat.ty - cat.y, d = Math.hypot(dx, dy);
1172
+ if (d > 1.2) {
1173
+ const step = Math.min(14 * dt / 1000, d); // ~14 buffer px/s amble
1174
+ // Face the direction of travel so it never moonwalks (deadzone avoids
1175
+ // flicker on near-vertical paths; keeps the last facing otherwise).
1176
+ if (dx < -0.4) cat.dir = -1; else if (dx > 0.4) cat.dir = 1;
1177
+ cat.x += (dx / d) * step; cat.y += (dy / d) * step;
1178
+ } else { cat.sit = true; cat.until = now + 1500 + Math.random() * 3000; }
1179
+ }
1180
+ }
1181
+
1182
+ // Idle-wander: settled-idle workers leave their desk and amble around the floor —
1183
+ // to a JITTERED point inside a random destination zone (so they spread out instead
1184
+ // of stacking on a fixed spot), usually hopping between spots a while before heading
1185
+ // home (they don't camp at the desk), and RUSHING home fast when resumed. Runs every
1186
+ // tick. ≤2 wander at once so the floor reads calm, and they cycle so it's not always
1187
+ // the same two.
1188
+ const WANDER_SPEED = 24; // fallback amble speed (buffer px/s); each wanderer varies its own
1189
+ const RETURN_RUSH = 64; // fast speed home when the agent is resumed / goes busy
1190
+ function updateWanderers(dt, now) {
1191
+ if (!cells.length) return;
1192
+ const live = new Set();
1193
+ let away = 0;
1194
+ for (const [, w] of wanderers) if (w.phase !== 'seated') away++;
1195
+ cells.forEach((cell) => {
1196
+ const a = cell.agent;
1197
+ if (a.placeholder) return; // a vacant desk never wanders
1198
+ const key = keyOf(a);
1199
+ if (key == null) return;
1200
+ live.add(key);
1201
+ const homeX = cell.x + CELL_W / 2, homeY = cell.y + CELL_H - 6;
1202
+ let w = wanderers.get(key);
1203
+ if (!w) {
1204
+ w = { phase: 'seated', x: homeX, y: homeY, tx: homeX, ty: homeY, legDist: 1, rush: false,
1205
+ until: now + 8000 + Math.random() * 30000, homeX, homeY,
1206
+ speed: 19 + Math.random() * 13 }; // its own amble speed → motion isn't uniform
1207
+ wanderers.set(key, w);
1208
+ } else { w.homeX = homeX; w.homeY = homeY; } // re-sync home to the (re-laid) desk
1209
+ const settled = a.activity === 'idle' && !a.needsYou && !a.awaitingReply
1210
+ && !(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
1211
+ // resumed / went busy while out → RUSH straight home, fast
1212
+ if (!settled && w.phase !== 'seated' && !(w.phase === 'returning' && w.rush)) {
1213
+ w.phase = 'returning'; w.rush = true; setTarget(w, w.homeX, w.homeY);
1214
+ }
1215
+ switch (w.phase) {
1216
+ case 'seated':
1217
+ if (now >= w.until) {
1218
+ // only SOMETIMES actually get up when the timer fires → irregular rhythm
1219
+ if (settled && away < 2 && wanderZones.length && Math.random() < 0.7) {
1220
+ w.x = w.homeX; w.y = w.homeY; w.rush = false; startStroll(w); away++;
1221
+ } else {
1222
+ w.until = now + 6000 + Math.random() * 18000;
1223
+ }
1224
+ }
1225
+ break;
1226
+ case 'walking':
1227
+ if (stepToward(w, dt)) { w.phase = 'lingering'; w.until = now + 3000 + Math.random() * 9000; }
1228
+ break;
1229
+ case 'lingering':
1230
+ if (now >= w.until) {
1231
+ // keep wandering: usually hop to ANOTHER spot; only sometimes head home
1232
+ if (settled && wanderZones.length && Math.random() < 0.7) startStroll(w);
1233
+ else { w.phase = 'returning'; w.rush = false; setTarget(w, w.homeX, w.homeY); }
1234
+ }
1235
+ break;
1236
+ case 'returning':
1237
+ if (stepToward(w, dt)) {
1238
+ w.phase = 'seated'; w.rush = false;
1239
+ // if still idle, don't camp — head out again soon; if it came back because
1240
+ // it's busy, sit (it's working) until it goes idle again.
1241
+ w.until = now + (settled ? 4000 + Math.random() * 12000 : 25000 + Math.random() * 40000);
1242
+ }
1243
+ break;
1244
+ }
1245
+ });
1246
+ for (const k of wanderers.keys()) if (!live.has(k)) wanderers.delete(k); // agent left the floor
1247
+ }
1248
+
1249
+ // Aim w at (tx,ty), recording the leg distance so stepToward can ease in + out.
1250
+ function setTarget(w, tx, ty) {
1251
+ w.tx = tx; w.ty = ty;
1252
+ w.legDist = Math.max(1, Math.hypot(tx - w.x, ty - w.y));
1253
+ }
1254
+
1255
+ // Start ambling to a JITTERED point inside a random destination zone. The jitter
1256
+ // (a random spot in the lower part of the zone) keeps workers from stacking on the
1257
+ // same pixel and makes each visit look a little different.
1258
+ function startStroll(w) {
1259
+ const z = wanderZones[Math.floor(Math.random() * wanderZones.length)];
1260
+ const jx = z.x + 6 + Math.random() * Math.max(1, z.w - 12);
1261
+ const jy = z.y + Math.max(0, z.h - 14) + Math.random() * 10; // lower part — in front of the zone
1262
+ w.phase = 'walking';
1263
+ setTarget(w, Math.round(jx), Math.round(jy));
1264
+ }
1265
+
1266
+ // Amble w toward its target. Eases IN at the start and OUT near arrival for natural
1267
+ // motion — unless RUSHING home (resumed), where it goes full speed straight back.
1268
+ function stepToward(w, dt) {
1269
+ const dx = w.tx - w.x, dy = w.ty - w.y, d = Math.hypot(dx, dy);
1270
+ if (d <= 1.2) return true;
1271
+ const sp = w.rush ? RETURN_RUSH : (w.speed || WANDER_SPEED);
1272
+ const ease = w.rush ? 1
1273
+ : Math.max(0.4, Math.min(Math.min(1, (w.legDist - d) / 12 + 0.5), Math.min(1, d / 16)));
1274
+ const step = Math.min(sp * ease * dt / 1000, d);
1275
+ w.x += (dx / d) * step; w.y += (dy / d) * step;
1276
+ return false;
1277
+ }
1278
+
1279
+ // ---- scene -----------------------------------------------------------------
1280
+ function draw() {
1281
+ ctx.clearRect(0, 0, bufW, bufH);
1282
+ const mood = moodForHour(new Date().getHours()); // time-of-day mood (browser-local)
1283
+ drawWall(ctx, bufW, WALL_H, frame, mood);
1284
+ drawFloor(ctx, bufW, TOP, bufH);
1285
+ drawDaylight(ctx, bufW, TOP, bufH, mood);
1286
+ drawDecor();
1287
+ drawClusterRugs();
1288
+ // --- depth-sorted floor actors: every desk (the seated worker is HIDDEN while
1289
+ // its agent is away wandering), each wandering worker, the avatar, and the cat —
1290
+ // painted in baseline-y order so they pass correctly in front of / behind each
1291
+ // other. Names are DOM chips that FOLLOW a wandering worker (else sit at the desk).
1292
+ const actors = [];
1293
+ cells.forEach((cell, i) => {
1294
+ const a = cell.agent;
1295
+ const isEmpty = a.placeholder; // a vacant desk: draw the desk, no worker
1296
+ const k = keyOf(a);
1297
+ const selected = k != null && k === selectedKey;
1298
+ const hovered = k != null && k === hoverKey && !selected;
1299
+ // just-finished + unviewed → the worker waves to catch your eye (app.js owns the set)
1300
+ const unread = !!(a.sessionId && window.agencyUnread && window.agencyUnread.has(a.sessionId));
1301
+ const w = wanderers.get(k);
1302
+ const away = !!(w && w.phase !== 'seated');
1303
+ // sort the desk by its FLOOR-CONTACT line (front edge ≈ DESK_TOP+11 = CELL_H-15),
1304
+ // NOT the cell bottom — otherwise a walker whose feet are in front of the desk
1305
+ // front but above the cell bottom wrongly sorts behind it. Characters sort by
1306
+ // their feet (w.y / avatar.pos.y / cat.y), so this makes occlusion feet-based.
1307
+ actors.push({ y: cell.y + CELL_H - 15, fn: () => {
1308
+ // a vacant desk hides the worker (away=true) — an empty chair + dark monitor.
1309
+ drawCubicle(ctx, cell.x, cell.y, a, frame, selected, hovered, unread, away || isEmpty);
1310
+ if (!isEmpty && a.role === 'lead') drawCrown(ctx, cell.x + CELL_W / 2 - 6, cell.y + 22, frame);
1311
+ } });
1312
+ if (away) {
1313
+ const seed = (a.pid | 0) || hashInt(a.sessionId || 'a'); // SAME look as the seated worker
1314
+ const moving = w.phase === 'walking' || w.phase === 'returning';
1315
+ const ring = selected ? '#ffd166' : hovered ? '#5cd0ff' : null; // gold = selected, cyan = hover
1316
+ actors.push({ y: w.y, fn: () => {
1317
+ if (ring) drawWalkerRing(Math.round(w.x), Math.round(w.y), ring);
1318
+ drawWalker(ctx, Math.round(w.x), Math.round(w.y), { seed, frame, walking: moving });
1319
+ } });
1320
+ }
1321
+ // the name chip follows a wandering worker; otherwise it sits under the desk
1322
+ const node = nameNodes[i];
1323
+ if (node) {
1324
+ node.style.display = (labelsHidden || isEmpty) ? 'none' : 'inline-flex';
1325
+ if (!labelsHidden && !isEmpty) {
1326
+ if (away) { node.style.left = Math.round(w.x) + 'px'; node.style.top = Math.round(w.y - 46) + 'px'; }
1327
+ else { node.style.left = (cell.x + CELL_W / 2) + 'px'; node.style.top = (cell.y + CELL_H - 10) + 'px'; }
1328
+ }
1329
+ }
1330
+ });
1331
+ if (avatar && avatar.enabled) actors.push({ y: avatar.pos.y, fn: () => avatar.draw(ctx) });
1332
+ if (cat.init) actors.push({ y: cat.y, fn: () => drawCat(ctx, cat.x, cat.y, frame, cat.dir, { sleeping: cat.sleeping, petted: cat.petted, walking: !cat.sit && !cat.sleeping }) });
1333
+ if (dog.init) actors.push({ y: dog.y, fn: () => drawDog(ctx, dog.x, dog.y, { frame, petted: dog.petted }) });
1334
+ actors.sort((p, q) => p.y - q.y);
1335
+ for (const act of actors) act.fn();
1336
+
1337
+ // "Waiting on you" — highest-signal state, painted LAST so it floats above every
1338
+ // desk. agent.needsYou is the live "blocked on you" field; awaitingReply is an
1339
+ // older alias for the same thing, kept as a harmless fallback. Falsy → not waiting.
1340
+ for (const cell of cells) {
1341
+ const a = cell.agent;
1342
+ if (a.needsYou || a.awaitingReply) drawNeedsYou(ctx, cell.x, cell.y, frame); // whole-desk amber treatment
1343
+ }
1344
+
1345
+ positionOverlays(); // keep the hover tooltip + detail card pinned beside their agents
1346
+ }
1347
+
1348
+ // ---- public API ------------------------------------------------------------
1349
+ // initOffice(canvas, labels) + setAgents(agents) are the entry points app.js
1350
+ // drives off the /api/state agent shape. Starts empty; the /api/state poll feeds
1351
+ // real agents via setAgents. (labels is an unused HTML overlay arg kept for the
1352
+ // call signature; this office draws its own DOM label group instead.)
1353
+ export function initOffice(canvasEl, _labels) {
1354
+ // Only wire up canvas/ctx — DON'T paint anything yet. Painting an empty,
1355
+ // furnished room before the first /api/state poll arrives caused a visible
1356
+ // "load flash" (a fully-built office with zero agents flickering in before the
1357
+ // real data). The animation loop starts on the first setAgents() call instead,
1358
+ // so the first painted frame already reflects real agent data (or an
1359
+ // intentionally-empty room when the poll genuinely reports zero agents).
1360
+ // (_labels is the legacy #labels overlay arg; this office owns its own DOM
1361
+ // label group instead — see ensureLabelWrap.)
1362
+ canvas = canvasEl;
1363
+ ctx = canvas.getContext('2d');
1364
+ ctx.imageSmoothingEnabled = false;
1365
+ world = canvas.parentElement; // the .world wrapper (absolute, transform-origin 0,0)
1366
+ attachInput(); // pan / zoom / click-to-open-chat
1367
+
1368
+ // Floor entities: the walkable player avatar (off by default) + its affordances.
1369
+ avatar = createAvatar({ enabled: false });
1370
+ ensureWalkUI();
1371
+ window.addEventListener('keydown', (e) => {
1372
+ const k = e.key.toLowerCase();
1373
+ const isOpen = k === 'e' || e.key === 'Enter';
1374
+ if (k !== 'g' && k !== 'n' && !isOpen) return;
1375
+ const t = e.target; // don't fire while typing in the chat panel
1376
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
1377
+ if (k === 'g') toggleWalk();
1378
+ else if (k === 'n') toggleLabels();
1379
+ else if (isOpen && avatar && avatar.enabled && lastInspectAgent) {
1380
+ // open the full detail card for the agent we're standing next to
1381
+ selectedKey = keyOf(lastInspectAgent);
1382
+ draw();
1383
+ window.dispatchEvent(new CustomEvent('agency:select', { detail: { agent: lastInspectAgent } }));
1384
+ }
1385
+ });
1386
+ }
1387
+
1388
+ export function init(canvasEl, n, seed) {
1389
+ canvas = canvasEl;
1390
+ ctx = canvas.getContext('2d');
1391
+ ctx.imageSmoothingEnabled = false;
1392
+ setAgents(makeAgents(n, seed));
1393
+ }
1394
+
1395
+ let started = false;
1396
+ let lastAll = []; // last full agent set (pre hidden-filter), so the "show"
1397
+ let showHidden = false; // toggle can re-reveal hidden desks without a fresh poll
1398
+ // A single VACANT desk shown when the floor is empty (no agents). Flows through the
1399
+ // normal layout as one cell; the renderer draws an empty desk + a hover hint.
1400
+ const EMPTY_DESK = { placeholder: true, project: '', name: '', activity: 'idle', model: '', sessionId: '__vacant__', pid: null, subagents: [], role: null };
1401
+ let hiddenCount = 0;
1402
+
1403
+ const isActive = (a) => a.activity === 'working' || a.activity === 'shell';
1404
+ // Sort: group by project (one rug/label per team — clusterRuns needs each team
1405
+ // contiguous), then ACTIVE-first and LEAD-first WITHIN the team so the eye lands
1406
+ // on what's live. Stable + deterministic so teams don't reshuffle between polls.
1407
+ const agentOrder = (a, b) =>
1408
+ String(a.project || '').localeCompare(String(b.project || '')) ||
1409
+ (isActive(b) - isActive(a)) ||
1410
+ ((b.role === 'lead') - (a.role === 'lead'));
1411
+
1412
+ // Rebuild the scene from lastAll, honoring the hidden filter + showHidden toggle.
1413
+ // Shared by setAgents (new poll) and the "N away · show" toggle (no poll).
1414
+ function rebuild() {
1415
+ hiddenCount = lastAll.reduce((n, a) => n + (a.hidden ? 1 : 0), 0);
1416
+ const visible = showHidden ? lastAll.slice() : lastAll.filter((a) => !a.hidden);
1417
+ // No agents → show ONE vacant desk (the snug 1-desk office, just empty) so an empty
1418
+ // floor reads as "your office, awaiting your first hire" instead of a bare room.
1419
+ // The placeholder flows through the normal layout; the draw loop + tooltip special-
1420
+ // case it (no worker, a hover hint).
1421
+ agents = visible.length ? visible : [EMPTY_DESK];
1422
+ plan();
1423
+ canvas.width = bufW;
1424
+ canvas.height = bufH;
1425
+ canvas.style.width = bufW * UPSCALE + 'px'; // crisp CSS upscale
1426
+ canvas.style.height = bufH * UPSCALE + 'px';
1427
+ ctx.imageSmoothingEnabled = false;
1428
+ draw(); // repaint now — setting canvas.width cleared it (avoids a blank frame)
1429
+ syncLabels(); // (re)position the DOM name + repo + lead-tie labels + hidden chip
1430
+ // fit-to-view until the user pans/zooms, then respect their view.
1431
+ // While walking, the camera is driven by followAvatar(); a poll-time re-fit
1432
+ // would snap it away for one frame, so skip the auto-fit in walk mode.
1433
+ if (!userMoved && !(avatar && avatar.enabled)) fitView(); else applyCam();
1434
+ }
1435
+
1436
+ export function setAgents(next) {
1437
+ lastAll = (next || []).slice().sort(agentOrder);
1438
+ rebuild();
1439
+ if (!started) { started = true; loop(); } // first data → kick off the animation loop
1440
+ }
1441
+
1442
+ let raf, timer;
1443
+ function loop() {
1444
+ if (!started) return; // cancelled (reset) between frames
1445
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
1446
+ // Animation phase is derived from wall-clock at a FIXED ~7.5fps cadence, NOT
1447
+ // incremented per loop — so sprites animate at the same speed regardless of the
1448
+ // loop rate. (Walk mode runs the loop at native refresh for smooth avatar/camera
1449
+ // motion; without this, every typing/blink/tail-wag cycle would run ~8x faster.)
1450
+ frame = Math.floor(now / 130);
1451
+ const dt = lastT ? Math.min(now - lastT, 100) : 16; // clamp big tab-out gaps
1452
+ lastT = now;
1453
+ updateCat(dt, now); // ambient — roams whether or not walk mode is on
1454
+ updateWanderers(dt, now); // ambient — idle workers stroll the lower floor
1455
+ const walking = !!(avatar && avatar.enabled);
1456
+ if (walking) {
1457
+ avatar.update(dt, { podPositions: buildPods(), bounds: floorBounds() });
1458
+ followAvatar(dt);
1459
+ }
1460
+ draw();
1461
+ // Walking: glide at the display's native refresh — no setTimeout throttle, so
1462
+ // motion is smooth and frame-aligned (the old ~30fps setTimeout-in-rAF was the
1463
+ // choppiness). Idle: the calm ~7.5fps pixel cadence (cheap + the retro tick).
1464
+ if (walking) {
1465
+ raf = requestAnimationFrame(loop);
1466
+ } else {
1467
+ raf = requestAnimationFrame(() => {
1468
+ if (!started) return; // cancelled (reset) while the RAF was pending
1469
+ timer = setTimeout(loop, 130);
1470
+ });
1471
+ }
1472
+ }
1473
+
1474
+ export function reset(n, seed) {
1475
+ cancelAnimationFrame(raf);
1476
+ clearTimeout(timer); // kill the tick the RAF may have already scheduled
1477
+ started = false; // makes a pending RAF callback bail instead of rescheduling
1478
+ userMoved = false; // re-fit the fresh layout
1479
+ wanderers.clear(); // fresh floor → nobody mid-stroll
1480
+ setAgents(makeAgents(n, seed));
1481
+ }
1482
+
1483
+ // expose for the harness
1484
+ window.__office = { init, reset };