@bill10/agent-007 0.6.2

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
@@ -0,0 +1,2154 @@
1
+ // Pixel office rendering — canvas drawing, click handling, animation
2
+ import { agents, jobs, activeSessionId, canControlAgent, setView } from './state.js';
3
+ import { switchToSession } from './terminal.js';
4
+ import { showJobBoard } from './jobs.js';
5
+
6
+ const Z = 3;
7
+
8
+ const WALL_H = 36 * Z;
9
+ const WALL_BOTTOM = WALL_H + 2 * Z;
10
+ const WS_W = 32, WS_H = 36, WS_GAP_X = 12, WS_GAP_Y = 18;
11
+ // Shared by the agent name labels and the pod rug labels so they stay in step.
12
+ const LABEL_FONT = 'bold 11px monospace';
13
+
14
+ // Persistent dust mote positions (stable across frames)
15
+ const DUST_MOTES = [
16
+ { baseX: 0.15, baseY: 0.35, phaseX: 0, phaseY: 0.5 },
17
+ { baseX: 0.45, baseY: 0.25, phaseX: 1.2, phaseY: 2.1 },
18
+ { baseX: 0.70, baseY: 0.40, phaseX: 2.5, phaseY: 0.8 },
19
+ { baseX: 0.30, baseY: 0.30, phaseX: 3.8, phaseY: 3.2 },
20
+ { baseX: 0.85, baseY: 0.20, phaseX: 5.0, phaseY: 1.5 },
21
+ ];
22
+
23
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
24
+
25
+ // --- Furniture + character sprites ---
26
+ // Desks/bookshelf: Free Furniture Office Equipment Set by Antea (CC-BY 4.0)
27
+ // Characters: pixel-agents (MIT), based on JIK-A-4 "Metro City" pack (CC0).
28
+ // Each char sheet is 112×96: 7 columns × 3 rows of 16×32 frames.
29
+ // Rows: 0 = facing down (viewer), 1 = facing up (back), 2 = facing right.
30
+ // Columns: 0-2 walk cycle (1 = standing), 3-4 seated typing, 5-6 reading.
31
+ export const SPRITE_PATHS = {
32
+ desk: 'assets/furniture/desk.png', // 32×32 computer desk with monitor
33
+ desk2: 'assets/furniture/desk2.png', // 32×32 alt desk layout
34
+ bookshelf: 'assets/furniture/bookshelf.png', // 16×16 short bookcase, art in x 2–14 (Antea CC-BY 4.0)
35
+ // Ambient decor from pixel-agents (MIT). 16px-tile art, drawn at CHAR_SCALE
36
+ // like the characters.
37
+ cactus: 'assets/furniture/cactus.png', // 16×32
38
+ plant2: 'assets/furniture/plant_2.png', // 16×32
39
+ sofa: 'assets/furniture/sofa_side.png', // 16×32, side view facing right, art in x 0–12, y 1–31
40
+ sofafront: 'assets/furniture/sofa_front.png', // 32×16, front view facing down, art fills the box
41
+ table: 'assets/furniture/coffee_table.png', // 32×32, art in x 2–29, y 6–30
42
+ coffee: 'assets/furniture/coffee.png', // 16×16, art in the top-right 8×7
43
+ conftable: 'assets/furniture/table_front.png', // 48×64 wooden table, art in rows 11–63
44
+ confchair: 'assets/furniture/chair_side.png', // 16×16 cushioned chair facing right, art in x 0–11
45
+ confchairback: 'assets/furniture/chair_back.png', // 16×16 cushioned chair seen from behind, art in x 2–13
46
+ confchairfront: 'assets/furniture/chair_front.png', // 16×16 the same chair from the front: lit seat below the back, art in x 2–13
47
+ char0: 'assets/characters/char_0.png',
48
+ char1: 'assets/characters/char_1.png',
49
+ char2: 'assets/characters/char_2.png',
50
+ char3: 'assets/characters/char_3.png',
51
+ char4: 'assets/characters/char_4.png',
52
+ char5: 'assets/characters/char_5.png',
53
+ };
54
+ // Derived from the charN keys above; exported for the character-variant test.
55
+ export const CHAR_VARIANTS = Object.keys(SPRITE_PATHS).filter((k) => k.startsWith('char')).length;
56
+ export const CHAR_FRAME_W = 16;
57
+ const CHAR_FRAME_H = 32;
58
+ const CHAR_SCALE = 2; // 16px art in a 32px-tile office — 2x keeps pixels square
59
+ const CHAR_ROW_DOWN = 0, CHAR_ROW_UP = 1, CHAR_ROW_RIGHT = 2;
60
+ // Sprite sheet columns: 1 is the standing frame (legs and feet), 3-6 are the
61
+ // seated frames (torso only, flat bottom) — the desk pass animates 3/4 as
62
+ // typing, and a sitter on any furniture uses 3 for the same reason: a standing
63
+ // frame on a chair reads as standing ON it.
64
+ const CHAR_COL_STAND = 1;
65
+ export const CHAR_COL_SIT = 3;
66
+ const CHAR_W = CHAR_FRAME_W * CHAR_SCALE, CHAR_H = CHAR_FRAME_H * CHAR_SCALE;
67
+ // The left-edge lane the walk in/out entrance and its strip occupy: furniture
68
+ // that can yield (spare desks, the conference set) keeps out of it.
69
+ const ENTRY_STRIP_W = Z + CHAR_W;
70
+
71
+ // Deterministic variant per agent id, stable across renders and reorders.
72
+ export function charVariant(id) {
73
+ let h = 0;
74
+ const s = String(id);
75
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
76
+ return Math.abs(h) % CHAR_VARIANTS;
77
+ }
78
+ // Desk sprite: monitor part is in top 12 rows — we skip it and draw our own
79
+ const DESK_CROP_Y = 13;
80
+ // Per-desk-variant positions (in sprite pixels)
81
+ const DESK_MON_X = 3; // Desk.png: monitor at x=3-16, center ~9
82
+ const DESK2_MON_X = 13; // Desk-2.png: monitor at x=13-26, center ~19
83
+ const DESK_CHAR_X = 5; // character X offset for Desk.png (centered on keyboard)
84
+ const DESK2_CHAR_X = 15; // character X offset for Desk-2.png
85
+ const DESK_CHAR_Y = 13; // character Y offset below the workstation top (both desks)
86
+ const SPRITES = {};
87
+ let spritesLoaded = false;
88
+
89
+ function loadSprites() {
90
+ if (spritesLoaded) return Promise.resolve();
91
+ const promises = Object.entries(SPRITE_PATHS).map(([key, path]) => {
92
+ return new Promise((resolve) => {
93
+ const img = new Image();
94
+ img.onload = () => { SPRITES[key] = img; resolve(); };
95
+ img.onerror = () => { console.warn(`office: sprite failed to load: ${path}`); resolve(); };
96
+ img.src = path;
97
+ });
98
+ });
99
+ return Promise.all(promises).then(() => { spritesLoaded = true; });
100
+ }
101
+ loadSprites();
102
+
103
+ function getThemeColors() {
104
+ const style = getComputedStyle(document.documentElement);
105
+ const get = (prop) => style.getPropertyValue(prop).trim();
106
+ const accent = get('--accent') || '#b8bfcc';
107
+ const bgOffice = get('--bg-office') || '#131519';
108
+ const bgDark = get('--bg-dark') || '#090a0c';
109
+ const border = get('--border') || '#1f2228';
110
+ const textDim = get('--text-dim') || '#6b7280';
111
+ const text = get('--text') || '#d8dce4';
112
+ const ar = parseInt(accent.slice(1, 3), 16);
113
+ const ag = parseInt(accent.slice(3, 5), 16);
114
+ const ab = parseInt(accent.slice(5, 7), 16);
115
+ const tr = parseInt(text.slice(1, 3), 16);
116
+ const tg = parseInt(text.slice(3, 5), 16);
117
+ const tb = parseInt(text.slice(5, 7), 16);
118
+ return { accent, text, bgOffice, bgDark, border, textDim, ar, ag, ab, tr, tg, tb };
119
+ }
120
+
121
+ // --- Day/night cycle ---
122
+ function getTimeOfDay() {
123
+ const h = new Date().getHours();
124
+ if (h >= 6 && h < 8) return 'dawn';
125
+ if (h >= 8 && h < 17) return 'day';
126
+ if (h >= 17 && h < 19) return 'dusk';
127
+ return 'night';
128
+ }
129
+
130
+ function getWindowColors(tod) {
131
+ switch (tod) {
132
+ case 'dawn': return { glass1: '#5a4060', glass2: '#7a5080', beam: 'rgba(255, 200, 150, 0.06)', hasStars: false, hasClouds: false };
133
+ case 'day': return { glass1: '#7ab5e5', glass2: '#a0d0f5', beam: 'rgba(255, 240, 200, 0.08)', hasStars: false, hasClouds: true };
134
+ case 'dusk': return { glass1: '#5a3060', glass2: '#3a1a50', beam: 'rgba(255, 180, 100, 0.06)', hasStars: false, hasClouds: false };
135
+ default: return { glass1: '#1a2a4a', glass2: '#2a3a5a', beam: 'rgba(180, 200, 230, 0.06)', hasStars: true, hasClouds: false };
136
+ }
137
+ }
138
+
139
+ // --- Pod layout: per-repo desk clusters anchored near the top of the open
140
+ // floor (below the windows + job board zone). Agents on the same repo sit
141
+ // together in a pod on a shared rug; different repos are separated by extra
142
+ // gap. A single repo yields exactly the old uniform grid. ---
143
+ // The boards end well short of FLOOR_TOP (their shadows reach 8 Z below the
144
+ // baseboard); the rest is walking room before the first row of desks.
145
+ const FLOOR_TOP = WALL_BOTTOM + 26 * Z;
146
+ const POD_GAP_X = 20; // Z units between pods in a row (vs WS_GAP_X=12 within)
147
+ const POD_GAP_Y = 26; // Z units between pod rows (vs WS_GAP_Y=18 within)
148
+ const RUG_PAD_X = 4; // Z units the rug extends past the desks each side
149
+ const RUG_PAD_TOP = 7; // Z units above the desks — the repo label sits here
150
+ const RUG_PAD_BOTTOM = 12; // Z units below — covers the agent name labels
151
+ const POD_TOP_MARGIN = 8; // Z units of bare floor between FLOOR_TOP and the first rug
152
+
153
+ // Pure layout: agentInfos is [{ id, repoPath, slug, isBillion }] in spawn (map) order.
154
+ // Billion sits alone at the top center, on a row of its own above every pod.
155
+ // Returns { pods, positions } — positions maps id -> { x, y } (top-left px of
156
+ // its desk); pods carry the desk-block rect in px plus the repo label.
157
+ // Agents with no repo land in a final unlabeled pod. Within a pod agents keep
158
+ // their input order, so an unrelated spawn/exit never reshuffles desks inside
159
+ // a pod.
160
+ // Desks sit in a plain classroom grid, all facing front: every screen stays
161
+ // visible to the viewer and the character's facing reads as state (back to
162
+ // viewer = working). Agent i takes col i % cols, row floor(i / cols); the row
163
+ // pitch keeps a full aisle so a back row never occludes the one in front.
164
+ // The rug-padding allowance mirrors panelZ in computePodLayout, so a
165
+ // max-width pod's rug borders don't clip at the panel edges.
166
+ function maxColsFor(panelWidth) {
167
+ return Math.max(1, Math.min(4, Math.floor((panelWidth / Z - 2 * RUG_PAD_X + WS_GAP_X) / (WS_W + WS_GAP_X))));
168
+ }
169
+
170
+ export function computePodLayout(agentInfos, panelWidth, panelHeight) {
171
+ const maxCols = maxColsFor(panelWidth);
172
+
173
+ // Group by repo, first-appearance order; no-repo pod goes last.
174
+ const BILLION = '\0billion'; // cannot collide with a repo path
175
+ const groups = new Map();
176
+ for (const a of agentInfos) {
177
+ const key = a.isBillion ? BILLION : (a.repoPath || '');
178
+ // filter(Boolean) survives trailing slashes; slice caps fillText re-shaping cost
179
+ if (!groups.has(key)) groups.set(key, { label: String(a.slug || (key ? key.split('/').filter(Boolean).pop() : '') || '').slice(0, 40) || null, ids: [] });
180
+ groups.get(key).ids.push(a.id);
181
+ }
182
+ const keys = [...groups.keys()].filter(k => k !== '' && k !== BILLION);
183
+ if (groups.has('')) keys.push('');
184
+ if (groups.has(BILLION)) keys.unshift(BILLION);
185
+
186
+ const pods = keys.map(key => {
187
+ const g = groups.get(key);
188
+ const cols = Math.min(g.ids.length, maxCols);
189
+ const rows = Math.ceil(g.ids.length / cols);
190
+ const own = key === BILLION;
191
+ return {
192
+ repoPath: own ? null : key || null, label: key && !own ? g.label : null, ids: g.ids, cols, own,
193
+ w: cols * WS_W + (cols - 1) * WS_GAP_X,
194
+ h: rows * WS_H + (rows - 1) * WS_GAP_Y,
195
+ };
196
+ });
197
+
198
+ // Flow pods left-to-right, wrap when the row (rug padding included) would
199
+ // overflow the panel — without the padding allowance the outermost rug
200
+ // borders clip at the panel edges on an exactly-full row.
201
+ const panelZ = Math.floor(panelWidth / Z) - 2 * RUG_PAD_X;
202
+ const podRows = [[]];
203
+ let rowW = 0;
204
+ for (const pod of pods) {
205
+ const need = rowW === 0 ? pod.w : rowW + POD_GAP_X + pod.w;
206
+ const afterBillion = podRows[podRows.length - 1].some(p => p.own);
207
+ if (rowW > 0 && (need > panelZ || afterBillion)) { podRows.push([pod]); rowW = pod.w; }
208
+ else { podRows[podRows.length - 1].push(pod); rowW = need; }
209
+ }
210
+
211
+ // Anchor the arrangement near the top of the floor: centering is capped so a
212
+ // sparse office leaves its empty wood at the bottom (decor lives there)
213
+ // instead of as a dead band under the job boards. A nearly-full floor still
214
+ // centers; never start above the floor.
215
+ const rowHs = podRows.map(r => Math.max(0, ...r.map(p => p.h)));
216
+ const totalH = (rowHs.reduce((a, b) => a + b, 0) + (podRows.length - 1) * POD_GAP_Y) * Z;
217
+ const centerOff = Math.floor((panelHeight - FLOOR_TOP - totalH) / 2);
218
+ let y = Math.max(FLOOR_TOP, FLOOR_TOP + Math.min((POD_TOP_MARGIN + RUG_PAD_TOP) * Z, centerOff));
219
+
220
+ const outPods = [];
221
+ const positions = new Map();
222
+ for (let ri = 0; ri < podRows.length; ri++) {
223
+ const rowWpx = (podRows[ri].reduce((a, p) => a + p.w, 0) + (podRows[ri].length - 1) * POD_GAP_X) * Z;
224
+ let x = Math.floor((panelWidth - rowWpx) / 2);
225
+ for (const pod of podRows[ri]) {
226
+ pod.ids.forEach((id, i) => {
227
+ positions.set(id, {
228
+ x: x + (i % pod.cols) * (WS_W + WS_GAP_X) * Z,
229
+ y: y + Math.floor(i / pod.cols) * (WS_H + WS_GAP_Y) * Z,
230
+ });
231
+ });
232
+ outPods.push({ repoPath: pod.repoPath, label: pod.label, x, y, w: pod.w * Z, h: pod.h * Z });
233
+ x += pod.w * Z + POD_GAP_X * Z;
234
+ }
235
+ y += (rowHs[ri] + POD_GAP_Y) * Z;
236
+ }
237
+ return { pods: outPods, positions };
238
+ }
239
+
240
+ // The rug drawn under a pod: desk block plus padding for the repo label above
241
+ // and the agent name labels below. Also the keep-out zone for ambient decor.
242
+ export function podRugRect(pod) {
243
+ return {
244
+ x: pod.x - RUG_PAD_X * Z,
245
+ y: pod.y - RUG_PAD_TOP * Z,
246
+ w: pod.w + 2 * RUG_PAD_X * Z,
247
+ h: pod.h + (RUG_PAD_TOP + RUG_PAD_BOTTOM) * Z,
248
+ };
249
+ }
250
+
251
+ // --- Ambient decor along the bottom of the floor: a chat area in each bottom
252
+ // corner with the two plants stacked between them as a divider. A spot is kept
253
+ // only if it clears every rug by a margin wide enough to also clear the
254
+ // monitor-glow halos (shadowBlur 15px < 6 Z), so the decor simply disappears
255
+ // as pod rows grow downward. Pure function of the rug rects + panel size, so
256
+ // placement is deterministic for a given agent set. ---
257
+ const DECOR_MARGIN = 6 * Z;
258
+ // True when rect c clears rect r by DECOR_MARGIN on some side.
259
+ const apart = (c, r) =>
260
+ c.x >= r.x + r.w + DECOR_MARGIN || c.x + c.w <= r.x - DECOR_MARGIN ||
261
+ c.y >= r.y + r.h + DECOR_MARGIN || c.y + c.h <= r.y - DECOR_MARGIN;
262
+ // All decor sizes below are in px: 16px-tile pixel-agents art drawn at
263
+ // DECOR_SCALE like the characters.
264
+ const DECOR_SCALE = CHAR_SCALE;
265
+ const SOFA_W = 16 * DECOR_SCALE, SOFA_H = 32 * DECOR_SCALE; // side view, as tall as the table
266
+ const TABLE_W = 32 * DECOR_SCALE, TABLE_H = 32 * DECOR_SCALE;
267
+ const SOFA_FRONT_H = 16 * DECOR_SCALE;
268
+ // Chat area: a pod-style rug holding a side sofa each side of the coffee
269
+ // table (the right one mirrored to face it) and a front-facing sofa above it.
270
+ const CHAT_PAD_X = 13, CHAT_PAD_TOP = 14, CHAT_PAD_BOTTOM = 12, CHAT_GAP = 8;
271
+ const CHAT_W = 2 * CHAT_PAD_X + 2 * SOFA_W + 2 * CHAT_GAP + TABLE_W; // 170
272
+ const CHAT_H = CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP + TABLE_H + CHAT_PAD_BOTTOM; // 130
273
+ const CHAT_MARGIN_X = 13 * Z; // chat areas sit in from the panel edges, leaving the entry corner clear
274
+ // How far the front sofa lifts its occupant. See chatSeats for why the two side
275
+ // sofas need none.
276
+ const CHAT_FRONT_SIT_RISE = 10;
277
+ // The divider plants between the chat areas draw smaller than the 16px decor
278
+ // grid so they read as a low hedge, not sentinels towering over the sofas.
279
+ const PLANT_MID_SCALE = 1.25;
280
+ const PLANT_MID_W = Math.round(16 * PLANT_MID_SCALE), PLANT_MID_H = Math.round(32 * PLANT_MID_SCALE);
281
+ const PLANT_MID_GAP = DECOR_MARGIN; // floor between the leafy plant's pot and the cactus
282
+ export function computeDecorPlacement(rugRects, panelWidth, panelHeight) {
283
+ const z = Z;
284
+ const midX = Math.floor((panelWidth - PLANT_MID_W) / 2);
285
+ const cactusY = panelHeight - PLANT_MID_H - 3 * z;
286
+ const candidates = [
287
+ { kind: 'chat', x: CHAT_MARGIN_X, y: panelHeight - CHAT_H - 3 * z, w: CHAT_W, h: CHAT_H },
288
+ { kind: 'chat', x: panelWidth - CHAT_W - CHAT_MARGIN_X, y: panelHeight - CHAT_H - 3 * z, w: CHAT_W, h: CHAT_H },
289
+ { kind: 'cactus', x: midX, y: cactusY, w: PLANT_MID_W, h: PLANT_MID_H, scale: PLANT_MID_SCALE },
290
+ { kind: 'plant2', x: midX, y: cactusY - PLANT_MID_GAP - PLANT_MID_H, w: PLANT_MID_W, h: PLANT_MID_H, scale: PLANT_MID_SCALE },
291
+ ];
292
+ const placed = [];
293
+ for (const c of candidates) {
294
+ if (c.x >= 0 && c.y >= FLOOR_TOP && c.x + c.w <= panelWidth && c.y + c.h <= panelHeight &&
295
+ rugRects.every(r => apart(c, r)) && placed.every(p => apart(c, p))) {
296
+ placed.push(c);
297
+ }
298
+ }
299
+ return placed;
300
+ }
301
+
302
+ // Sofa seats in a chat area, derived from the same math drawDecor lays the
303
+ // furniture out with: the front sofa above the coffee table (its sitter faces
304
+ // down over it, like the conference head seat) and a side sofa each side
305
+ // facing in. Each seat carries its own approach, because unlike the
306
+ // conference set these sit at the panel's bottom edge: the lane is the seat's
307
+ // own column and the crossing row is the one just above the rug, so wanderRoute
308
+ // needs nothing from the conference set to route here. Both are preferences —
309
+ // wanderRoute vets them through approachX and corridorY like any other seat's.
310
+ export function chatSeats(spot) {
311
+ const tx = spot.x + CHAT_PAD_X + SOFA_W + CHAT_GAP;
312
+ const ty = spot.y + CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP;
313
+ const sy = ty + TABLE_H - SOFA_H - DECOR_SCALE; // drawDecor's side-sofa floor line
314
+ // A whole character above the rug, not the conference set's 24px: a walker
315
+ // standing 24px up still overlaps by 40 of its 64, so the crossing leg would
316
+ // sweep the sofas it is heading for -- and the other chat area on the way.
317
+ // Just above the rug, the same 24px idiom the conference set uses; corridorY
318
+ // is what turns the preference into a row a walker actually fits in.
319
+ const yPref = spot.y - 24;
320
+ const seat = (x, y, row, mirror) =>
321
+ ({ x, y, row, mirror, kind: 'chat', lane: x, yPref, col: CHAR_COL_SIT });
322
+ return [
323
+ // The front sofa lifts its occupant; the two side sofas do not, and that is
324
+ // deliberate: they are drawn at the same 2x as the character, so the seated
325
+ // art already lands on the cushion. The front sofa is only 16px of art tall,
326
+ // and the seated frame starts 4px lower in its box than the standing frame
327
+ // (bbox y7 vs y3), so without the lift the body fell past the sofa's front
328
+ // edge onto the coffee table and read as sitting on the floor.
329
+ seat(tx + Math.floor((TABLE_W - CHAR_W) / 2), spot.y + CHAT_PAD_TOP - 18 - CHAT_FRONT_SIT_RISE, CHAR_ROW_DOWN, false),
330
+ seat(spot.x + CHAT_PAD_X, sy - 6, CHAR_ROW_RIGHT, false),
331
+ seat(tx + TABLE_W + CHAT_GAP, sy - 6, CHAR_ROW_RIGHT, true),
332
+ ];
333
+ }
334
+
335
+ // Every seat an idle agent can wander to, conference set and sofas pooled into
336
+ // one list. The set is null below ~972px of canvas height (the band between the
337
+ // desks and the chat areas is not tall enough), which is most laptop windows --
338
+ // the sofas are always there, so the wander works at any size.
339
+ export function wanderSeats(conf, decorSpots) {
340
+ return [
341
+ ...(conf ? conf.seats.map((s, i) => ({ ...s, kind: 'conf', confIndex: i })) : []),
342
+ ...decorSpots.filter(d => d.kind === 'chat').flatMap(chatSeats),
343
+ ];
344
+ }
345
+
346
+ // --- Conference table: a vertical boardroom table centred in the open band
347
+ // between the desks (rugs + spare desks) and the chat areas, with cushioned
348
+ // chairs down both sides and one at each end. Idle agents wander over and sit
349
+ // (see the wander section below). Pure function of the obstructions + panel
350
+ // size; null when the band is too small. ---
351
+ const CONF_TABLE_W = 48 * Z; // sprite width at the desk scale
352
+ const CONF_TABLE_H = 240; // the 48×64 sprite stretched taller so it reads as a boardroom table
353
+ const CONF_CHAIR = 16 * Z;
354
+ // The table sprite's top 11 of 64 rows are empty, so the tabletop's painted edge
355
+ // is this far below the set's y. The head chair sits against that edge, not
356
+ // against the sprite box, or it floats in the open floor above it.
357
+ const CONF_TABLE_ART_TOP = Math.round(11 / 64 * CONF_TABLE_H);
358
+ // Set bbox: side chairs overhang the table 42px each side; the head sitter's
359
+ // own 18px is the tallest thing above the set's y — the head chair below them
360
+ // clears the tabletop without reaching it — and the foot chair hangs 32px
361
+ // below.
362
+ const CONF_HEAD_RISE = 18;
363
+ // A side sitter draws the seated frame, which has no legs, and sits 14px above
364
+ // its chair's anchor. The lift goes into the SEAT, not into the draw call, on
365
+ // purpose: seat.y is where the character is, and drawConference, the
366
+ // setupOfficeClick hit box and wanderRoute's endpoint all read it, so moving it
367
+ // keeps them aligned. Offsetting only the draw would leave a clickable box
368
+ // where nobody is standing. (Forcing the rise to 0 changes no other test, so
369
+ // nothing behavioural rides on the exact value.) Why 14 and not the sofa's 10: the chairs draw at Z (3x) while characters draw at
370
+ // CHAR_SCALE (2x), so the chair's seat line sits high relative to the body.
371
+ // The head and foot seats keep the standing frame — the tabletop and the
372
+ // chair-back are already drawn over their legs.
373
+ const CONF_SIDE_SIT_RISE = 14;
374
+ const CONF_SET_W = CONF_TABLE_W + 2 * 42;
375
+ const CONF_SET_H = CONF_TABLE_H + CONF_HEAD_RISE + 32;
376
+ export function computeConference(rugRects, spareDesks, decorSpots, panelWidth, panelHeight) {
377
+ const bandTop = Math.max(FLOOR_TOP,
378
+ ...rugRects.map(r => r.y + r.h),
379
+ ...spareDesks.map(d => d.y + WS_H * Z));
380
+ // Every decor spot bounds the band, not just the chats: today the divider
381
+ // plants sit below the chat tops so this changes nothing, but if the chats
382
+ // ever yield while the plants place, the set must not land on the plants.
383
+ const bandBottom = Math.min(panelHeight - 3 * Z, ...decorSpots.map(s => s.y));
384
+ // The chair overhang must also clear the entry strip on the left.
385
+ if (panelWidth < CONF_SET_W + 2 * ENTRY_STRIP_W || bandBottom - bandTop < CONF_SET_H + 2 * DECOR_MARGIN) return null;
386
+ const tx = Math.floor((panelWidth - CONF_TABLE_W) / 2);
387
+ const ty = bandTop + Math.floor((bandBottom - bandTop - CONF_SET_H) / 2) + CONF_HEAD_RISE;
388
+ const chairs = [
389
+ { x: tx - 42, y: ty + 70, kind: 'confchair' },
390
+ { x: tx - 42, y: ty + 145, kind: 'confchair' },
391
+ { x: tx + CONF_TABLE_W - 6, y: ty + 70, kind: 'confchair', mirror: true },
392
+ { x: tx + CONF_TABLE_W - 6, y: ty + 145, kind: 'confchair', mirror: true },
393
+ { x: tx + Math.floor((CONF_TABLE_W - CONF_CHAIR) / 2), y: ty + CONF_TABLE_H - 16, kind: 'confchairback' },
394
+ // Head chair, last so the indices above stay put. Its sitter faces us, so
395
+ // this is the chair's front, and it stands clear of the tabletop rather
396
+ // than tucking under it: the foot chair draws over the table and keeps its
397
+ // whole body, while this one draws behind it, so any overlap is swallowed
398
+ // and what is left reads as a stub instead of a chair. Bottom flush with
399
+ // the painted edge shows all of it and still sits at the table.
400
+ { x: tx + Math.floor((CONF_TABLE_W - CONF_CHAIR) / 2), y: ty + CONF_TABLE_ART_TOP - CONF_CHAIR, kind: 'confchairfront' },
401
+ ];
402
+ // Seats in fill order: head, then alternating sides top-down, foot last.
403
+ // Head and foot sit on the table's centreline; side sitters centre on their
404
+ // chairs. drawConference picks its draw passes by seat index (0 = head,
405
+ // 5 = foot), so this order is load-bearing.
406
+ const cx = tx + Math.floor((CONF_TABLE_W - CHAR_W) / 2);
407
+ const seats = [
408
+ { x: cx, y: ty - 18, row: CHAR_ROW_DOWN },
409
+ { x: chairs[0].x + 8, y: chairs[0].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, col: CHAR_COL_SIT },
410
+ { x: chairs[2].x + 8, y: chairs[2].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, mirror: true, col: CHAR_COL_SIT },
411
+ { x: chairs[1].x + 8, y: chairs[1].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, col: CHAR_COL_SIT },
412
+ { x: chairs[3].x + 8, y: chairs[3].y - 12 - CONF_SIDE_SIT_RISE, row: CHAR_ROW_RIGHT, mirror: true, col: CHAR_COL_SIT },
413
+ { x: cx, y: ty + CONF_TABLE_H - 38, row: CHAR_ROW_UP },
414
+ ];
415
+ return { table: { x: tx, y: ty, w: CONF_TABLE_W, h: CONF_TABLE_H }, chairs, seats };
416
+ }
417
+
418
+ // --- Spare desks: when the office has a single row of real pods, one row of
419
+ // empty workstations sits on the pod grid one row-pitch below it, so a sparse
420
+ // office reads as unassigned seats rather than bare floor. Like the decor they
421
+ // vanish as the office fills: only while every pod is on one row and the spare
422
+ // row clears the placed decor and the panel bottom by DECOR_MARGIN. Pure;
423
+ // returns [{ x, y, variant }] top-left px per desk. ---
424
+ const SPARE_DESKS = 3;
425
+ export function computeSpareDesks(pods, decorSpots, panelWidth, panelHeight) {
426
+ if (!pods.length || pods.some(p => p.y !== pods[0].y || p.h !== WS_H * Z)) return [];
427
+ const pitch = (WS_W + WS_GAP_X) * Z;
428
+ const cols = Math.round((pods[0].w + WS_GAP_X * Z) / pitch);
429
+ // On the real row's column grid when it is one pod (centred on it, to the
430
+ // nearest column); a multi-pod row has no single grid, so centre on the panel.
431
+ // Fall back to centring when the aligned row would run off the panel. A row
432
+ // that would reach into the entry strip drops a desk until it clears.
433
+ let n = Math.min(SPARE_DESKS, maxColsFor(panelWidth)), x, rowW;
434
+ for (; n > 0; n--) {
435
+ rowW = (n * WS_W + (n - 1) * WS_GAP_X) * Z;
436
+ x = pods.length === 1 ? pods[0].x + Math.round((cols - n) / 2) * pitch : Math.floor((panelWidth - rowW) / 2);
437
+ if (x < 0 || x + rowW > panelWidth) x = Math.floor((panelWidth - rowW) / 2);
438
+ if (x >= ENTRY_STRIP_W) break;
439
+ }
440
+ if (n === 0) return [];
441
+ const row = { x, y: pods[0].y + (WS_H + WS_GAP_Y) * Z, w: rowW, h: WS_H * Z };
442
+ if (row.y + row.h + DECOR_MARGIN > panelHeight || !decorSpots.every(s => apart(row, s))) return [];
443
+ return Array.from({ length: n }, (_, i) => ({ x: row.x + i * pitch, y: row.y, variant: i % 2 }));
444
+ }
445
+
446
+ // The live agents map (spawn/tab order) → pod layout for this panel size.
447
+ function computeOfficeLayout(panelWidth, panelHeight) {
448
+ const infos = [...agents].map(([id, a]) => ({ id, repoPath: a.repoPath, slug: a.repoSlug, isBillion: !!a.isBillion }));
449
+ return computePodLayout(infos, panelWidth, panelHeight);
450
+ }
451
+
452
+ // --- Wall layout: 2 windows divide wall into 3 equal bookshelf sections ---
453
+ function computeWallLayout(panelWidth) {
454
+ const totalW = Math.floor(panelWidth / Z);
455
+ const windowW = 28, windowH = 18, gap = 1;
456
+ const shelfW = Math.max(4, Math.floor((totalW - 2 * windowW - 4 * gap) / 3));
457
+
458
+ const shelf1X = 0;
459
+ const window1X = shelfW + gap;
460
+ const shelf2X = window1X + windowW + gap;
461
+ const window2X = shelf2X + shelfW + gap;
462
+ const shelf3X = window2X + windowW + gap;
463
+
464
+ return { totalW, shelfW, windowW, windowH, gap, window1X, window2X, shelf1X, shelf2X, shelf3X };
465
+ }
466
+
467
+ // --- Floor (warm wood planks — fixed palette, independent of UI theme) ---
468
+ function drawFloor(ctx, w, h) {
469
+ ctx.fillStyle = '#2a1e14';
470
+ ctx.fillRect(0, 0, w, h);
471
+ const plankH = 4 * Z;
472
+ // Fixed warm wood base: #4a3525 = rgb(74, 53, 37)
473
+ const br = 74, bg = 53, bb = 37;
474
+ const offsets = [32, 24, 30, 22, 28];
475
+ for (let py = WALL_BOTTOM; py < h; py += plankH) {
476
+ const ci = Math.floor((py - WALL_BOTTOM) / plankH) % offsets.length;
477
+ const o = offsets[ci];
478
+ ctx.fillStyle = `rgb(${Math.min(255, br + Math.round(o * 1.5))}, ${Math.min(255, bg + Math.round(o * 1.15))}, ${Math.min(255, bb + Math.round(o * 0.55))})`;
479
+ ctx.fillRect(0, py, w, plankH);
480
+ ctx.fillStyle = 'rgba(0,0,0,0.05)';
481
+ ctx.fillRect(0, py, w, 1);
482
+ const stagger = (ci % 2) * 18 * Z;
483
+ ctx.fillStyle = 'rgba(0,0,0,0.04)';
484
+ for (let px = stagger; px < w; px += 36 * Z) {
485
+ ctx.fillRect(px, py + 1, 1, plankH - 1);
486
+ }
487
+ }
488
+ }
489
+
490
+ // --- Wall texture (warm plaster — fixed palette, independent of UI theme) ---
491
+ function drawWalls(ctx, w) {
492
+ const wallH = WALL_H;
493
+ const topBand = Math.floor(wallH * 0.35);
494
+ const midBand = Math.floor(wallH * 0.35);
495
+ ctx.fillStyle = '#c8bfb0'; // warm cream plaster — top
496
+ ctx.fillRect(0, 0, w, topBand);
497
+ ctx.fillStyle = '#beb5a5'; // mid
498
+ ctx.fillRect(0, topBand, w, midBand);
499
+ ctx.fillStyle = '#b0a898'; // bottom
500
+ ctx.fillRect(0, topBand + midBand, w, wallH - topBand - midBand);
501
+
502
+ ctx.fillStyle = '#a89888'; // ceiling edge
503
+ ctx.fillRect(0, 0, w, 2 * Z);
504
+
505
+ const railY = Math.floor(wallH * 0.6);
506
+ ctx.fillStyle = '#d4c8b8'; // wainscoting rail
507
+ ctx.fillRect(0, railY, w, 2);
508
+ ctx.fillStyle = 'rgba(0,0,0,0.04)';
509
+ ctx.fillRect(0, railY + 2, w, wallH - railY - 2);
510
+
511
+ ctx.fillStyle = '#c0b090';
512
+ ctx.fillRect(0, wallH, w, Z);
513
+ ctx.fillStyle = '#a09070';
514
+ ctx.fillRect(0, wallH + Z, w, Z);
515
+ ctx.fillStyle = 'rgba(0,0,0,0.15)';
516
+ ctx.fillRect(0, wallH + 2 * Z, w, 1);
517
+ }
518
+
519
+ // --- Two windows (B) ---
520
+ function drawWindows(ctx, w, theme) {
521
+ const z = Z;
522
+ const layout = computeWallLayout(w);
523
+ const tod = getTimeOfDay();
524
+ const wc = getWindowColors(tod);
525
+ const ww = layout.windowW * z, wh = layout.windowH * z;
526
+ const wy = 3 * z; // 3Z from top
527
+
528
+ const windowXs = [layout.window1X * z, layout.window2X * z];
529
+
530
+ for (const wx of windowXs) {
531
+ // Sill
532
+ ctx.fillStyle = '#d4c8b0';
533
+ ctx.fillRect(wx - z, wy + wh, ww + 2 * z, z);
534
+
535
+ // Frame
536
+ ctx.fillStyle = '#d4c8b0';
537
+ ctx.fillRect(wx, wy, ww, wh);
538
+
539
+ // Glass
540
+ const glassX = wx + z, glassY = wy + z;
541
+ const glassW = ww - 2 * z, glassH = wh - 2 * z;
542
+ const grad = ctx.createLinearGradient(glassX, glassY, glassX, glassY + glassH);
543
+ grad.addColorStop(0, wc.glass1);
544
+ grad.addColorStop(1, wc.glass2);
545
+ ctx.fillStyle = grad;
546
+ ctx.fillRect(glassX, glassY, glassW, glassH);
547
+
548
+ // Stars (night)
549
+ if (wc.hasStars) {
550
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
551
+ ctx.fillRect(glassX + 2 * z, glassY + z, z, z);
552
+ ctx.fillRect(glassX + 6 * z, glassY + 2 * z, z, z);
553
+ ctx.fillRect(glassX + 10 * z, glassY + z, z, z);
554
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
555
+ ctx.fillRect(glassX + 4 * z, glassY + 3 * z, z, z);
556
+ }
557
+
558
+ // Clouds (day)
559
+ if (wc.hasClouds) {
560
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
561
+ ctx.fillRect(glassX + 2 * z, glassY + 3 * z, 3 * z, z);
562
+ ctx.fillRect(glassX + 3 * z, glassY + 2 * z, 2 * z, z);
563
+ ctx.fillRect(glassX + 8 * z, glassY + 4 * z, 3 * z, z);
564
+ }
565
+
566
+ // Cross-bars (3w × 2h = 6 pane)
567
+ ctx.fillStyle = '#d4c8b0';
568
+ const colW = Math.floor(glassW / 3);
569
+ ctx.fillRect(glassX + colW, glassY, 1, glassH); // vertical 1/3
570
+ ctx.fillRect(glassX + 2 * colW, glassY, 1, glassH); // vertical 2/3
571
+ ctx.fillRect(glassX, wy + Math.floor(wh / 2) - 1, glassW, 1); // horizontal center
572
+
573
+ }
574
+ }
575
+
576
+ // --- Job boards (3 freestanding whiteboards on stands, replacing the old
577
+ // seating nooks). Drawn with primitives, not sprites: the boards stand on the
578
+ // floor in front of the wall, never hang on it. ---
579
+ const BOARD_W = 26; // Z units — outer frame width (capped to the wall section)
580
+ const BOARD_H = 19; // Z units — outer frame height
581
+ const BOARD_LEG_H = 9; // Z units — frame bottom down to the feet
582
+ const BOARD_FRAME = 1; // Z units — frame thickness around the writable surface
583
+ // Z units below the baseboard where the feet land: almost against the wall,
584
+ // level with the fronts of the wall plants' pots.
585
+ const BOARD_FOOT_OFFSET = 6;
586
+ const BOARD_MIN_W = 8; // Z units — below this the section is too narrow to draw into
587
+ const BOARD_BRACE_H = 3; // Z units — brace (and centre-post foot) above the feet
588
+
589
+ // The real board's columns: COLUMNS in jobs.js, upper-cased, which a test
590
+ // keeps in step with lib/jobs.js JOB_STATES. Not imported from jobs.js, which
591
+ // would drag the socket and auth modules into the canvas. 'IN PROGRESS' only
592
+ // fits a full-width board (panel >= 432px); narrower than that fillText's
593
+ // maxWidth condenses it, harder the narrower the panel gets.
594
+ export const BOARD_TITLES = ['TO DO', 'IN PROGRESS', 'REVIEW'];
595
+ // The job states behind each board, in the same order as BOARD_TITLES.
596
+ export const BOARD_STATES = ['todo', 'in-progress', 'review'];
597
+ const BOARD_TITLE_INK = ['#2f6fb0', '#b0392b', '#2f7a4a'];
598
+ const BOARD_TITLE_FONT = `bold ${3 * Z}px monospace`;
599
+
600
+ // Pin slots for job posts, filled left-to-right top-to-bottom, one per real
601
+ // job on that board's column. Positions are fractions of the writable surface
602
+ // so the layout survives the board being narrowed on a small panel. Slightly
603
+ // jittered so a full board reads as pinned paper, not a spreadsheet.
604
+ const POST_SLOTS = [
605
+ { x: 0.05, y: 0.33, w: 0.28, h: 0.28, lines: 3 },
606
+ { x: 0.37, y: 0.31, w: 0.27, h: 0.31, lines: 4 },
607
+ { x: 0.68, y: 0.34, w: 0.27, h: 0.27, lines: 3 },
608
+ { x: 0.06, y: 0.66, w: 0.27, h: 0.27, lines: 2 },
609
+ { x: 0.36, y: 0.68, w: 0.28, h: 0.25, lines: 3 },
610
+ { x: 0.67, y: 0.66, w: 0.27, h: 0.28, lines: 2 },
611
+ ];
612
+
613
+ // How many posts each board shows for a given job count: one per job, capped
614
+ // at the slots that fit. The overflow beyond the cap is drawn as a "+N" note.
615
+ // Exported for the board-sync test.
616
+ export function boardPostPlan(count) {
617
+ const shown = Math.min(count, POST_SLOTS.length);
618
+ return { shown, overflow: count - shown };
619
+ }
620
+
621
+ // Jobs per board column, in BOARD_STATES order. `done` jobs have left the
622
+ // board and are not counted. Exported for the board-sync test.
623
+ export function countBoardJobs(jobList) {
624
+ const counts = BOARD_STATES.map(() => 0);
625
+ for (const job of jobList) {
626
+ const i = BOARD_STATES.indexOf(job && job.state);
627
+ if (i >= 0) counts[i]++;
628
+ }
629
+ return counts;
630
+ }
631
+ const PAPER_TINTS = ['#f6f3ea', '#eef1f7', '#fbf6e4', '#f2f4ef'];
632
+ const PIN_COLORS = ['#c0392b', '#2980b9', '#27ae60', '#d4a847'];
633
+
634
+ // Geometry for the three boards, in screen pixels. Exported so the placement
635
+ // rules (inside its own wall section, almost against the wall, clear of the
636
+ // desk grid) can be asserted in tests without a canvas.
637
+ export function computeBoardLayout(panelWidth) {
638
+ const z = Z;
639
+ const layout = computeWallLayout(panelWidth);
640
+
641
+ // Cap the board to its wall section so it can never spill under a window. On
642
+ // a panel too narrow for even a stub board, draw nothing rather than overlap.
643
+ // Even Z-width keeps the frame's centre exactly on the stand's centre line.
644
+ const boardZ = Math.min(BOARD_W, layout.shelfW - 2) & ~1;
645
+ const boardW = boardZ * z;
646
+ const boardH = BOARD_H * z;
647
+ const footY = WALL_BOTTOM + BOARD_FOOT_OFFSET * z; // feet on the floor, just off the baseboard
648
+ const frameBottom = footY - BOARD_LEG_H * z;
649
+ const boardTop = frameBottom - boardH;
650
+
651
+ const sections = [layout.shelf1X, layout.shelf2X, layout.shelf3X].map((secX) => {
652
+ const centerX = Math.floor((secX + layout.shelfW / 2) * z);
653
+ return { centerX, left: secX * z, right: (secX + layout.shelfW) * z };
654
+ });
655
+
656
+ return {
657
+ visible: boardZ >= BOARD_MIN_W,
658
+ boardW, boardH, boardTop, frameBottom, footY, sections,
659
+ wallBottom: WALL_BOTTOM,
660
+ floorTop: FLOOR_TOP,
661
+ };
662
+ }
663
+
664
+ function drawJobBoardEasels(ctx, w) {
665
+ const board = computeBoardLayout(w);
666
+ if (!board.visible) return;
667
+
668
+ const counts = countBoardJobs(jobs.values());
669
+ board.sections.forEach(({ centerX }, i) => {
670
+ drawBoardStand(ctx, centerX, board.frameBottom, board.footY, board.boardW);
671
+ drawWhiteboard(ctx, centerX - Math.floor(board.boardW / 2), board.boardTop, board.boardW, board.boardH, i, counts[i]);
672
+ });
673
+ }
674
+
675
+ // A-frame metal stand: two splayed legs, a brace, and a contact shadow.
676
+ function drawBoardStand(ctx, centerX, topY, footY, boardW) {
677
+ const z = Z;
678
+ const legTop = Math.round(boardW * 0.18);
679
+ const legFoot = Math.round(boardW * 0.42);
680
+ const lw = Math.max(2, Math.round(z * 0.8));
681
+
682
+ // Contact shadow on the floor — stacked rects, not an ellipse: everything
683
+ // else in the room is hard-edged and a path fill would be antialiased.
684
+ ctx.fillStyle = 'rgba(0,0,0,0.13)';
685
+ ctx.fillRect(centerX - Math.round(boardW * 0.36), footY, Math.round(boardW * 0.72), z);
686
+ ctx.fillStyle = 'rgba(0,0,0,0.09)';
687
+ ctx.fillRect(centerX - Math.round(boardW * 0.22), footY + z, Math.round(boardW * 0.44), z);
688
+
689
+ const leg = (dir) => {
690
+ ctx.beginPath();
691
+ ctx.moveTo(centerX + dir * legTop - lw / 2, topY);
692
+ ctx.lineTo(centerX + dir * legTop + lw / 2, topY);
693
+ ctx.lineTo(centerX + dir * legFoot + lw / 2, footY);
694
+ ctx.lineTo(centerX + dir * legFoot - lw / 2, footY);
695
+ ctx.closePath();
696
+ ctx.fill();
697
+ };
698
+
699
+ ctx.fillStyle = '#9aa1ab';
700
+ leg(-1);
701
+ leg(1);
702
+
703
+ // Centre post from the frame down to the brace
704
+ ctx.fillRect(centerX - lw / 2, topY, lw, Math.max(0, footY - BOARD_BRACE_H * z - topY));
705
+
706
+ // Shaded inner edge of each leg
707
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
708
+ ctx.fillRect(centerX - legFoot - lw / 2, footY - z, lw, z);
709
+ ctx.fillRect(centerX + legFoot - lw / 2, footY - z, lw, z);
710
+
711
+ // Cross brace
712
+ const braceY = footY - BOARD_BRACE_H * z;
713
+ ctx.fillStyle = '#8b929c';
714
+ ctx.fillRect(centerX - Math.round(boardW * 0.34), braceY, Math.round(boardW * 0.68), Math.max(1, Math.round(z * 0.6)));
715
+
716
+ // Feet caps
717
+ ctx.fillStyle = '#5a6068';
718
+ ctx.fillRect(centerX - legFoot - lw, footY - 1, lw * 2, Math.max(1, Math.round(z * 0.7)));
719
+ ctx.fillRect(centerX + legFoot - lw, footY - 1, lw * 2, Math.max(1, Math.round(z * 0.7)));
720
+ }
721
+
722
+ function drawWhiteboard(ctx, x, y, w, h, idx, jobCount) {
723
+ const z = Z;
724
+ const fb = BOARD_FRAME * z;
725
+
726
+ // Drop shadow against the wall
727
+ ctx.fillStyle = 'rgba(0,0,0,0.16)';
728
+ ctx.fillRect(x + z, y + z, w, h);
729
+
730
+ // Aluminium frame
731
+ ctx.fillStyle = '#b9c0c9';
732
+ ctx.fillRect(x, y, w, h);
733
+ ctx.fillStyle = '#d5dae1';
734
+ ctx.fillRect(x, y, w, Math.max(1, Math.round(fb / 2)));
735
+ ctx.fillStyle = '#8d949d';
736
+ ctx.fillRect(x, y + h - Math.max(1, Math.round(fb / 2)), w, Math.max(1, Math.round(fb / 2)));
737
+
738
+ // Writable surface
739
+ const sx = x + fb, sy = y + fb;
740
+ const sw = w - 2 * fb, sh = h - 2 * fb;
741
+ ctx.fillStyle = '#f7f8f5';
742
+ ctx.fillRect(sx, sy, sw, sh);
743
+ ctx.fillStyle = 'rgba(0,0,0,0.05)';
744
+ ctx.fillRect(sx, sy + sh - z, sw, z); // soft shading along the bottom
745
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
746
+ ctx.fillRect(sx, sy, sw, Math.max(1, Math.round(z / 2)));
747
+
748
+ // Marker-written heading
749
+ const title = BOARD_TITLES[idx % BOARD_TITLES.length];
750
+ ctx.save();
751
+ ctx.fillStyle = BOARD_TITLE_INK[idx % BOARD_TITLE_INK.length];
752
+ ctx.font = BOARD_TITLE_FONT;
753
+ ctx.textAlign = 'center';
754
+ const maxW = sw - 2 * z;
755
+ ctx.fillText(title, sx + sw / 2, sy + 3 * z, maxW);
756
+ // Underline as wide as the heading actually came out (condensed or not).
757
+ const titleW = Math.min(maxW, Math.round(ctx.measureText(title).width));
758
+ ctx.fillRect(Math.round(sx + (sw - titleW) / 2), sy + 4 * z, titleW, 1);
759
+ ctx.restore();
760
+
761
+ // Pinned job posts — one per real job in this board's column
762
+ const { shown, overflow } = boardPostPlan(jobCount);
763
+ for (let pi = 0; pi < shown; pi++) {
764
+ const post = POST_SLOTS[pi];
765
+ const px = Math.round(sx + post.x * sw);
766
+ const py = Math.round(sy + post.y * sh);
767
+ const pw = Math.max(4, Math.round(post.w * sw));
768
+ const ph = Math.max(4, Math.round(post.h * sh));
769
+ // Offset by board so the three boards don't repeat the same colour run
770
+ // (stride 3 stays coprime-ish with the 4-colour palettes; a stride of
771
+ // POST_SLOTS.length would give boards 0 and 2 identical runs).
772
+ drawJobPost(ctx, px, py, pw, ph, post.lines, idx * 3 + pi);
773
+ }
774
+ if (overflow > 0) {
775
+ // Marker note in the bottom-right corner for the jobs that don't fit,
776
+ // on a paper chip so it stays legible over the post pinned beneath it.
777
+ ctx.save();
778
+ ctx.font = BOARD_TITLE_FONT;
779
+ ctx.textAlign = 'right';
780
+ const label = `+${overflow}`;
781
+ const maxW = Math.round(sw / 2);
782
+ const labelW = Math.min(maxW, Math.ceil(ctx.measureText(label).width));
783
+ ctx.fillStyle = '#f7f8f5';
784
+ ctx.fillRect(sx + sw - labelW - 2 * z, sy + sh - 4 * z, labelW + 2 * z, 4 * z);
785
+ ctx.fillStyle = BOARD_TITLE_INK[idx % BOARD_TITLE_INK.length];
786
+ ctx.fillText(label, sx + sw - z, sy + sh - z, maxW);
787
+ ctx.restore();
788
+ }
789
+
790
+ // Marker tray with two markers
791
+ const trayW = Math.round(w * 0.5);
792
+ const trayX = x + Math.round((w - trayW) / 2);
793
+ ctx.fillStyle = '#a7aeb8';
794
+ ctx.fillRect(trayX, y + h, trayW, z);
795
+ ctx.fillStyle = 'rgba(0,0,0,0.2)';
796
+ ctx.fillRect(trayX, y + h + z, trayW, 1);
797
+ ctx.fillStyle = '#c0392b';
798
+ ctx.fillRect(trayX + z, y + h, Math.round(trayW * 0.28), Math.max(1, Math.round(z * 0.7)));
799
+ ctx.fillStyle = '#2980b9';
800
+ ctx.fillRect(trayX + Math.round(trayW * 0.5), y + h, Math.round(trayW * 0.28), Math.max(1, Math.round(z * 0.7)));
801
+ }
802
+
803
+ // One sheet of paper: drop shadow, tinted stock, ruled "text", and a pin.
804
+ function drawJobPost(ctx, x, y, w, h, lines, idx) {
805
+ const z = Z;
806
+ ctx.fillStyle = 'rgba(0,0,0,0.14)';
807
+ ctx.fillRect(x + 1, y + 1, w, h);
808
+ ctx.fillStyle = PAPER_TINTS[idx % PAPER_TINTS.length];
809
+ ctx.fillRect(x, y, w, h);
810
+ ctx.fillStyle = 'rgba(0,0,0,0.10)';
811
+ ctx.fillRect(x, y + h - 1, w, 1);
812
+
813
+ // Headline bar, then thinner body lines
814
+ const pad = Math.max(2, Math.round(w * 0.12));
815
+ let ly = y + Math.max(2, Math.round(h * 0.22));
816
+ ctx.fillStyle = 'rgba(45, 50, 58, 0.75)';
817
+ ctx.fillRect(x + pad, ly, Math.max(2, Math.round((w - 2 * pad) * 0.7)), 2);
818
+
819
+ ctx.fillStyle = 'rgba(70, 76, 86, 0.45)';
820
+ const gap = Math.max(2, Math.floor((h - (ly - y) - 4) / Math.max(1, lines - 1)));
821
+ for (let i = 1; i < lines; i++) {
822
+ ly += gap;
823
+ if (ly + 1 > y + h - 2) break;
824
+ const lw = Math.round((w - 2 * pad) * (i === lines - 1 ? 0.55 : 0.9));
825
+ ctx.fillRect(x + pad, ly, Math.max(2, lw), 1);
826
+ }
827
+
828
+ // Push pin
829
+ ctx.fillStyle = PIN_COLORS[idx % PIN_COLORS.length];
830
+ const pinS = Math.max(2, Math.round(z * 0.7));
831
+ ctx.fillRect(x + Math.round(w / 2) - Math.round(pinS / 2), y - Math.round(pinS / 2), pinS, pinS);
832
+ }
833
+
834
+ // Draw a sprite at an integer scale; no-op if its PNG failed to load.
835
+ // mirror flips it horizontally in place (same bounding box).
836
+ function drawSprite(ctx, key, x, y, scale = DECOR_SCALE, mirror = false) {
837
+ const img = SPRITES[key];
838
+ if (!img) return;
839
+ const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
840
+ if (!mirror) { ctx.drawImage(img, x, y, w, h); return; }
841
+ ctx.save();
842
+ ctx.translate(x + w, y);
843
+ ctx.scale(-1, 1);
844
+ ctx.drawImage(img, 0, 0, w, h);
845
+ ctx.restore();
846
+ }
847
+
848
+ // --- A low bookshelf run under each window: three short bookcase units
849
+ // tiled edge-to-edge, centred on the window and narrower than it, backs tight
850
+ // against the wall. The 16×16 frame's art spans x 2–14, so units step by the
851
+ // art width and the first is pulled left by the transparent margin. No
852
+ // boards (panel too narrow), no shelves. Pure; exported for the placement test. ---
853
+ const SHELF_SCALE = DECOR_SCALE;
854
+ const SHELF_UNITS = 3, SHELF_ART_X = 2, SHELF_ART_W = 12, SHELF_FRAME = 16;
855
+ const SHELF_UNIT_W = SHELF_ART_W * SHELF_SCALE, SHELF_H = SHELF_FRAME * SHELF_SCALE;
856
+ export function computeBookshelfRuns(panelWidth) {
857
+ if (!computeBoardLayout(panelWidth).visible) return [];
858
+ const layout = computeWallLayout(panelWidth);
859
+ const w = SHELF_UNITS * SHELF_UNIT_W;
860
+ return [layout.window1X, layout.window2X].map((wx) => ({
861
+ x: Math.floor((wx + layout.windowW / 2) * Z - w / 2),
862
+ y: WALL_BOTTOM + Z - SHELF_H,
863
+ w, h: SHELF_H,
864
+ }));
865
+ }
866
+
867
+ function drawBookshelves(ctx, w) {
868
+ if (!SPRITES.bookshelf) return; // no orphan shadows under a missing sprite
869
+ for (const run of computeBookshelfRuns(w)) {
870
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
871
+ ctx.fillRect(run.x, run.y + run.h, run.w, Z); // contact shadow under the feet
872
+ for (let i = 0; i < SHELF_UNITS; i++) {
873
+ drawSprite(ctx, 'bookshelf', run.x + i * SHELF_UNIT_W - SHELF_ART_X * SHELF_SCALE, run.y, SHELF_SCALE);
874
+ }
875
+ }
876
+ }
877
+
878
+ // Brand gold (#d4a847) as an rgb triple for rgba() tints on the canvas.
879
+ const GOLD_RGB = '212, 168, 71';
880
+
881
+ // --- Per-pod rug + repo label ---
882
+ // The rug is a fixed gold, independent of the UI theme like the wood floor,
883
+ // desks and sofa it sits among: the light-theme accent is nearly the same
884
+ // colour as the floor, so a theme-tinted rug vanished in light mode. The
885
+ // repo label keeps the theme text colour.
886
+ function drawPodRug(ctx, pod, theme) {
887
+ drawRug(ctx, podRugRect(pod), pod.label, theme);
888
+ }
889
+
890
+ function drawRug(ctx, r, label, theme) {
891
+ const z = Z;
892
+
893
+ ctx.fillStyle = `rgba(${GOLD_RGB}, 0.10)`;
894
+ roundRect(ctx, r.x, r.y, r.w, r.h, 3 * z);
895
+ ctx.fill();
896
+ ctx.strokeStyle = `rgba(${GOLD_RGB}, 0.32)`;
897
+ ctx.lineWidth = 1;
898
+ roundRect(ctx, r.x, r.y, r.w, r.h, 3 * z);
899
+ ctx.stroke();
900
+ // Inner border (double-border rug pattern)
901
+ ctx.strokeStyle = `rgba(${GOLD_RGB}, 0.16)`;
902
+ roundRect(ctx, r.x + 2 * z, r.y + 2 * z, r.w - 4 * z, r.h - 4 * z, 2 * z);
903
+ ctx.stroke();
904
+
905
+ if (label) {
906
+ // Styled like the agent name labels below the desks.
907
+ ctx.font = LABEL_FONT;
908
+ ctx.textAlign = 'center';
909
+ ctx.fillStyle = `rgba(${theme.tr}, ${theme.tg}, ${theme.tb}, 0.7)`;
910
+ ctx.fillText(label, r.x + r.w / 2, r.y + 5 * z, r.w - 4 * z);
911
+ ctx.textAlign = 'start';
912
+ }
913
+ }
914
+
915
+ function drawDecor(ctx, spots, theme) {
916
+ for (const spot of spots) {
917
+ if (spot.kind === 'chat') {
918
+ // Skip the whole group rather than draw a rug and pot around missing furniture.
919
+ if (!SPRITES.sofa || !SPRITES.sofafront || !SPRITES.table) continue;
920
+ drawRug(ctx, spot, null, theme);
921
+ const tx = spot.x + CHAT_PAD_X + SOFA_W + CHAT_GAP;
922
+ const ty = spot.y + CHAT_PAD_TOP + SOFA_FRONT_H + CHAT_GAP;
923
+ drawSprite(ctx, 'sofafront', tx, spot.y + CHAT_PAD_TOP);
924
+ // The side sofas' feet fill their last art row while the table's stop one
925
+ // row short, so lift them one art pixel to share the table's floor line.
926
+ const sy = ty + TABLE_H - SOFA_H - DECOR_SCALE;
927
+ drawSprite(ctx, 'sofa', spot.x + CHAT_PAD_X, sy);
928
+ drawSprite(ctx, 'table', tx, ty);
929
+ drawSprite(ctx, 'coffee', tx + 8 * DECOR_SCALE, ty + 7 * DECOR_SCALE);
930
+ drawSprite(ctx, 'sofa', tx + TABLE_W + CHAT_GAP, sy, DECOR_SCALE, true);
931
+ } else {
932
+ drawSprite(ctx, spot.kind, spot.x, spot.y, spot.scale || DECOR_SCALE); // 'plant2' or 'cactus'
933
+ }
934
+ }
935
+ }
936
+
937
+ // One character in a given sprite-sheet frame, facing a given row; mirror turns
938
+ // the right-facing row into a left-facing one (the sheet has no left row).
939
+ // Defaults to the standing column, which is what a seat without its own `col`
940
+ // (the conference head and foot) wants.
941
+ function drawCharFrame(ctx, variant, x, y, row, mirror, col = CHAR_COL_STAND) {
942
+ const sheet = SPRITES['char' + variant] || SPRITES.char0;
943
+ if (!sheet) return;
944
+ ctx.save();
945
+ if (mirror) { ctx.translate(x + CHAR_W, y); ctx.scale(-1, 1); x = 0; y = 0; }
946
+ ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, x, y, CHAR_W, CHAR_H);
947
+ ctx.restore();
948
+ }
949
+
950
+ // The conference set plus whoever is currently sitting at it. Sitters draw
951
+ // interleaved with the furniture so the head seat's feet tuck under the
952
+ // table's top rim, the head chair sits behind its sitter, and the foot chair's
953
+ // back covers its sitter's legs.
954
+ function drawConference(ctx, conf) {
955
+ // Furniture is skipped when its sprites failed to load (like drawDecor),
956
+ // but sitters ALWAYS draw: seats are claimed in updateWander regardless,
957
+ // and a missing chair PNG must never make a character invisible.
958
+ const furniture = SPRITES.conftable && SPRITES.confchair && SPRITES.confchairback && SPRITES.confchairfront;
959
+ // Keyed by CONFERENCE seat index, not the pooled one: a claim indexes
960
+ // currentSeats, and a sofa claim would otherwise alias onto a chair here.
961
+ const sitters = new Map(); // conf seat index -> variant, arrived sitters only
962
+ for (const [sid, i] of seatClaims) {
963
+ const seat = currentSeats[i];
964
+ if (seat?.kind === 'conf' && !wanderAnims.has(sid)) sitters.set(seat.confIndex, charVariant(sid));
965
+ }
966
+ const sitter = (i) => {
967
+ if (!sitters.has(i)) return;
968
+ const s = conf.seats[i];
969
+ // The seat carries its own frame: a side seat is lifted AND drawn seated,
970
+ // two halves of one decision, so they live together in computeConference.
971
+ // Head and foot carry none and fall through to standing — the tabletop
972
+ // covers one and the chair-back the other, and both compositions rely on it.
973
+ drawCharFrame(ctx, sitters.get(i), s.x, s.y, s.row, s.mirror, s.col);
974
+ };
975
+ if (furniture) for (const c of conf.chairs) if (c.kind === 'confchair') drawSprite(ctx, c.kind, c.x, c.y, Z, c.mirror);
976
+ // The head chair goes down before its sitter — they face us, so the chair is
977
+ // behind them — and the table then covers its lower half.
978
+ if (furniture) drawSprite(ctx, conf.chairs[5].kind, conf.chairs[5].x, conf.chairs[5].y, Z);
979
+ sitter(0); // head, before the table so their feet sit behind the top rim
980
+ if (furniture) {
981
+ const t = conf.table;
982
+ ctx.drawImage(SPRITES.conftable, t.x, t.y, t.w, t.h);
983
+ drawSprite(ctx, 'coffee', t.x + Math.floor(t.w / 2) - 12 * DECOR_SCALE, t.y + 50);
984
+ }
985
+ for (let i = 1; i <= 4; i++) sitter(i); // side sitters over their chairs
986
+ sitter(5); // foot, before the chair-back so it covers their legs
987
+ if (furniture) drawSprite(ctx, conf.chairs[4].kind, conf.chairs[4].x, conf.chairs[4].y, Z);
988
+ }
989
+
990
+ // Whoever has arrived at a sofa. Unlike drawConference this needs no
991
+ // interleaving with the furniture: the sofas went down in drawDecor and a
992
+ // sitter simply covers the one they are on. Sitters draw even when the sofa
993
+ // sprites failed to load, for the same reason the conference ones do.
994
+ function drawChatSitters(ctx) {
995
+ for (const [sid, i] of seatClaims) {
996
+ const seat = currentSeats[i];
997
+ if (!seat || seat.kind !== 'chat' || wanderAnims.has(sid)) continue;
998
+ drawCharFrame(ctx, charVariant(sid), seat.x, seat.y, seat.row, seat.mirror, seat.col);
999
+ }
1000
+ }
1001
+
1002
+ // --- Ambient dust motes (E) ---
1003
+ function drawParticles(ctx, w, h) {
1004
+ if (prefersReducedMotion) return;
1005
+ const t = Date.now();
1006
+ for (const mote of DUST_MOTES) {
1007
+ const x = mote.baseX * w + Math.sin(t * 0.0003 + mote.phaseX) * 15;
1008
+ const y = WALL_BOTTOM * 0.5 + mote.baseY * (WALL_BOTTOM * 1.5) + Math.cos(t * 0.0005 + mote.phaseY) * 8;
1009
+ if (y > WALL_BOTTOM + 20 * Z) continue;
1010
+ const alpha = 0.06 + Math.sin(t * 0.001 + mote.phaseX) * 0.04;
1011
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
1012
+ ctx.fillRect(Math.floor(x), Math.floor(y), Z, Z);
1013
+ }
1014
+ }
1015
+
1016
+ // --- Desk items (mug, papers, lamp) ---
1017
+ function drawDeskItems(ctx, sx, sy) {
1018
+ const z = Z;
1019
+ const deskY = sy + 12 * z;
1020
+
1021
+ // Coffee mug
1022
+ const mugX = sx + 4 * z, mugY = deskY + z;
1023
+ ctx.fillStyle = '#c0a080';
1024
+ ctx.fillRect(mugX, mugY - z, 2 * z, 2 * z);
1025
+ ctx.fillStyle = '#d4b890';
1026
+ ctx.fillRect(mugX, mugY - z, 2 * z, z);
1027
+ ctx.fillStyle = '#3a2a1a';
1028
+ ctx.fillRect(mugX, mugY - z, z, z);
1029
+
1030
+ // Paper stack
1031
+ const papX = sx + 23 * z, papY = deskY;
1032
+ ctx.fillStyle = '#e8e4dc';
1033
+ ctx.fillRect(papX, papY, 4 * z, z);
1034
+ ctx.fillStyle = '#ddd8d0';
1035
+ ctx.fillRect(papX, papY + z, 4 * z, z);
1036
+ ctx.fillStyle = '#c44040';
1037
+ ctx.fillRect(papX + z, papY, 2 * z, z);
1038
+
1039
+ // Desk lamp
1040
+ const lampX = sx + z, lampY = deskY - 3 * z;
1041
+ ctx.fillStyle = '#808890';
1042
+ ctx.fillRect(lampX, deskY, 2 * z, z);
1043
+ ctx.fillStyle = '#909090';
1044
+ ctx.fillRect(lampX + z, lampY + z, z, deskY - lampY - z);
1045
+ ctx.fillStyle = '#d4c8b0';
1046
+ ctx.fillRect(lampX, lampY, 3 * z, z);
1047
+ ctx.fillStyle = 'rgba(255, 220, 180, 0.06)';
1048
+ ctx.fillRect(lampX - z, deskY - z, 5 * z, 2 * z);
1049
+ }
1050
+
1051
+ // --- Sprite-based workstation (edgeless monitor + desk sprite bottom half) ---
1052
+ function drawWorkstation(ctx, sx, sy, state, theme, variant, agent) {
1053
+ const z = Z;
1054
+ const deskSprite = variant === 0 ? SPRITES.desk : SPRITES.desk2;
1055
+
1056
+ if (!deskSprite) {
1057
+ drawMonitor(ctx, sx + 9 * z, sy, state, theme);
1058
+ drawDesk(ctx, sx + z, sy + 12 * z);
1059
+ drawDeskItems(ctx, sx, sy);
1060
+ return;
1061
+ }
1062
+
1063
+ // --- Desk sprite (bottom half only — skip baked-in monitor) ---
1064
+ const cropH = 32 - DESK_CROP_Y; // sprite rows to draw
1065
+ ctx.drawImage(
1066
+ deskSprite,
1067
+ 0, DESK_CROP_Y, 32, cropH, // source: skip top 12 rows
1068
+ sx, sy + DESK_CROP_Y * z, 32 * z, cropH * z // dest: position below monitor
1069
+ );
1070
+
1071
+ const monX = variant === 0 ? DESK_MON_X : DESK2_MON_X;
1072
+
1073
+ // --- Modern edgeless monitor (procedural, positioned where sprite monitor was) ---
1074
+ const mw = 14, mh = 9; // monitor outer size in Z-units
1075
+ const mx = sx + monX * z; // align with sprite's original monitor position
1076
+ const my = sy + 3 * z; // above desk surface
1077
+
1078
+ // Thin dark frame (1px bezel)
1079
+ ctx.fillStyle = '#1a1a1a';
1080
+ ctx.fillRect(mx, my, mw * z, mh * z);
1081
+
1082
+ // Screen (fills almost entire monitor — 1Z bezel all around)
1083
+ const scrX = mx + z, scrY = my + z;
1084
+ const scrW = (mw - 2) * z, scrH = (mh - 2) * z;
1085
+
1086
+ if (state === 'DISCONNECTED') {
1087
+ ctx.fillStyle = '#111';
1088
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1089
+ } else if (state === 'WORKING') {
1090
+ ctx.fillStyle = '#0d1117';
1091
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1092
+ const t = Math.floor(Date.now() / 400);
1093
+ const lc = [theme.accent, '#f0c040', '#4a90d9', '#e06c75', '#98c379'];
1094
+ for (let line = 0; line < Math.min(5, mh - 2); line++) {
1095
+ ctx.fillStyle = lc[(line + t) % lc.length] + '90';
1096
+ const lw = (2 + ((line + t) % 4)) * z;
1097
+ ctx.fillRect(scrX + z, scrY + line * z, Math.min(lw, scrW - z), z - 1);
1098
+ }
1099
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.35)`;
1100
+ ctx.shadowBlur = 10;
1101
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
1102
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1103
+ ctx.shadowBlur = 0;
1104
+ } else if (state === 'MESSAGE') {
1105
+ ctx.fillStyle = '#0d1117';
1106
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1107
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.5)`;
1108
+ for (let line = 0; line < 3; line++) {
1109
+ const lw = (3 + (line % 3)) * z;
1110
+ ctx.fillRect(scrX + z, scrY + line * z, Math.min(lw, scrW - z), z - 1);
1111
+ }
1112
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
1113
+ ctx.shadowBlur = 6;
1114
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
1115
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1116
+ ctx.shadowBlur = 0;
1117
+ } else if (state === 'WAITING') {
1118
+ ctx.fillStyle = '#0d1117';
1119
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1120
+ const adds = agent && agent.additions || 0;
1121
+ const dels = agent && agent.removals || 0;
1122
+ if (adds > 0 || dels > 0) {
1123
+ // Show +additions / -removals
1124
+ ctx.font = `bold ${z * 2.5}px monospace`;
1125
+ ctx.textAlign = 'center';
1126
+ const centerX = scrX + scrW / 2;
1127
+ if (adds > 0) {
1128
+ ctx.fillStyle = '#3fb950';
1129
+ ctx.fillText(`+${adds}`, centerX, scrY + 3 * z);
1130
+ }
1131
+ if (dels > 0) {
1132
+ ctx.fillStyle = '#f85149';
1133
+ ctx.fillText(`-${dels}`, centerX, scrY + 5.5 * z);
1134
+ }
1135
+ ctx.textAlign = 'start';
1136
+ } else {
1137
+ // No changes: show prompt chevron with blinking cursor
1138
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.7)`;
1139
+ ctx.fillRect(scrX + z, scrY + 2 * z, z, z - 1);
1140
+ ctx.fillRect(scrX + 2 * z, scrY + 3 * z, z, z - 1);
1141
+ ctx.fillRect(scrX + z, scrY + 4 * z, z, z - 1);
1142
+ const blink = Math.floor(Date.now() / 600) % 2;
1143
+ if (blink) {
1144
+ ctx.fillStyle = theme.accent;
1145
+ ctx.fillRect(scrX + 4 * z, scrY + 3 * z, 2 * z, z);
1146
+ }
1147
+ }
1148
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
1149
+ ctx.shadowBlur = 8;
1150
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
1151
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1152
+ ctx.shadowBlur = 0;
1153
+ } else {
1154
+ ctx.fillStyle = '#0a0a0a';
1155
+ ctx.fillRect(scrX, scrY, scrW, scrH);
1156
+ const adds = agent && agent.additions || 0;
1157
+ const dels = agent && agent.removals || 0;
1158
+ if (adds > 0 || dels > 0) {
1159
+ // Show +additions / -removals as pixel text
1160
+ ctx.font = `bold ${z * 2.5}px monospace`;
1161
+ ctx.textAlign = 'center';
1162
+ const centerX = scrX + scrW / 2;
1163
+ if (adds > 0) {
1164
+ ctx.fillStyle = '#3fb950';
1165
+ ctx.fillText(`+${adds}`, centerX, scrY + 3 * z);
1166
+ }
1167
+ if (dels > 0) {
1168
+ ctx.fillStyle = '#f85149';
1169
+ ctx.fillText(`-${dels}`, centerX, scrY + 5.5 * z);
1170
+ }
1171
+ ctx.textAlign = 'start';
1172
+ } else {
1173
+ // No changes: dim dot
1174
+ const t = Date.now() / 2000;
1175
+ const dotX = scrX + z + Math.floor((Math.sin(t) * 0.5 + 0.5) * (scrW - 3 * z));
1176
+ const dotY = scrY + z + Math.floor((Math.cos(t * 0.7) * 0.5 + 0.5) * (scrH - 3 * z));
1177
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
1178
+ ctx.fillRect(dotX, dotY, z, z);
1179
+ }
1180
+ }
1181
+
1182
+ // Monitor stand — thin neck + slim base
1183
+ const standX = mx + Math.floor(mw / 2) * z - z;
1184
+ ctx.fillStyle = '#2a2a2a';
1185
+ ctx.fillRect(standX, my + mh * z, 2 * z, z); // neck (1Z tall)
1186
+ ctx.fillStyle = '#222';
1187
+ ctx.fillRect(standX - z, my + mh * z + z, 4 * z, z); // base
1188
+ }
1189
+
1190
+ // --- Monitor glow (A) ---
1191
+ function drawMonitorGlow(ctx, sx, sy, state, theme) {
1192
+ if (state === 'IDLE' || state === 'DISCONNECTED') return;
1193
+ const z = Z;
1194
+ const deskY = sy + 12 * z;
1195
+ const floorY = deskY + 10 * z;
1196
+ const glowX = sx + 5 * z;
1197
+ const glowW = 22 * z;
1198
+
1199
+ let glowR, glowG, glowB, deskAlpha, floorAlpha;
1200
+ if (state === 'WORKING') {
1201
+ glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
1202
+ const pulse = prefersReducedMotion ? 0 : Math.sin(Date.now() / 800) * 0.02;
1203
+ deskAlpha = 0.10 + pulse;
1204
+ floorAlpha = 0.04 + pulse * 0.5;
1205
+ } else if (state === 'MESSAGE') {
1206
+ glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
1207
+ deskAlpha = 0.08;
1208
+ floorAlpha = 0.03;
1209
+ } else {
1210
+ glowR = theme.ar; glowG = theme.ag; glowB = theme.ab;
1211
+ deskAlpha = 0.05;
1212
+ floorAlpha = 0.02;
1213
+ }
1214
+
1215
+ ctx.shadowColor = `rgba(${glowR}, ${glowG}, ${glowB}, ${deskAlpha * 2})`;
1216
+ ctx.shadowBlur = 15;
1217
+ ctx.fillStyle = `rgba(${glowR}, ${glowG}, ${glowB}, ${deskAlpha})`;
1218
+ ctx.fillRect(glowX, deskY, glowW, 3 * z);
1219
+ ctx.shadowBlur = 0;
1220
+
1221
+ ctx.fillStyle = `rgba(${glowR}, ${glowG}, ${glowB}, ${floorAlpha})`;
1222
+ ctx.beginPath();
1223
+ ctx.ellipse(sx + 16 * z, floorY + 3 * z, glowW / 2.2, 4 * z, 0, 0, Math.PI * 2);
1224
+ ctx.fill();
1225
+ }
1226
+
1227
+ function drawMonitor(ctx, x, y, state, theme) {
1228
+ const z = Z, mw = 14, mh = 10;
1229
+ ctx.fillStyle = '#c0c0c0';
1230
+ ctx.fillRect(x, y, mw * z, mh * z);
1231
+ const sx = x + z, sy = y + z, sw = (mw - 2) * z, sh = (mh - 2) * z;
1232
+
1233
+ if (state === 'DISCONNECTED') {
1234
+ ctx.fillStyle = '#111';
1235
+ ctx.fillRect(sx, sy, sw, sh);
1236
+ } else if (state === 'WORKING') {
1237
+ ctx.fillStyle = '#0d1117';
1238
+ ctx.fillRect(sx, sy, sw, sh);
1239
+ const t = Math.floor(Date.now() / 400);
1240
+ const lc = [theme.accent, '#f0c040', '#4a90d9', '#e06c75', '#98c379'];
1241
+ for (let line = 0; line < 4; line++) {
1242
+ ctx.fillStyle = lc[(line + t) % lc.length] + '90';
1243
+ const lw = (3 + ((line + t) % 5)) * z;
1244
+ ctx.fillRect(sx + z, sy + line * z, Math.min(lw, sw - z), z - 1);
1245
+ }
1246
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.35)`;
1247
+ ctx.shadowBlur = 10;
1248
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
1249
+ ctx.fillRect(sx, sy, sw, sh);
1250
+ ctx.shadowBlur = 0;
1251
+ } else if (state === 'MESSAGE') {
1252
+ ctx.fillStyle = '#0d1117';
1253
+ ctx.fillRect(sx, sy, sw, sh);
1254
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.5)`;
1255
+ for (let line = 0; line < 3; line++) {
1256
+ const lw = (4 + (line % 3)) * z;
1257
+ ctx.fillRect(sx + z, sy + line * z, Math.min(lw, sw - z), z - 1);
1258
+ }
1259
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
1260
+ ctx.shadowBlur = 6;
1261
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.03)`;
1262
+ ctx.fillRect(sx, sy, sw, sh);
1263
+ ctx.shadowBlur = 0;
1264
+ } else if (state === 'WAITING') {
1265
+ ctx.fillStyle = '#0d1117';
1266
+ ctx.fillRect(sx, sy, sw, sh);
1267
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.6)`;
1268
+ ctx.fillRect(sx + z, sy + z, 2 * z, z - 1);
1269
+ if (Math.floor(Date.now() / 500) % 2) {
1270
+ ctx.fillStyle = theme.accent;
1271
+ ctx.fillRect(sx + 4 * z, sy + z, z, z);
1272
+ }
1273
+ ctx.shadowColor = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.2)`;
1274
+ ctx.shadowBlur = 6;
1275
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.02)`;
1276
+ ctx.fillRect(sx, sy, sw, sh);
1277
+ ctx.shadowBlur = 0;
1278
+ } else {
1279
+ ctx.fillStyle = '#0a0a0a';
1280
+ ctx.fillRect(sx, sy, sw, sh);
1281
+ const t = Date.now() / 2000;
1282
+ const dotX = sx + z + Math.floor((Math.sin(t) * 0.5 + 0.5) * (sw - 3 * z));
1283
+ const dotY = sy + z + Math.floor((Math.cos(t * 0.7) * 0.5 + 0.5) * (sh - 3 * z));
1284
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, 0.3)`;
1285
+ ctx.fillRect(dotX, dotY, z, z);
1286
+ }
1287
+
1288
+ // Monitor stand
1289
+ ctx.fillStyle = '#aaa';
1290
+ ctx.fillRect(x + 5 * z, y + mh * z, 4 * z, 2 * z);
1291
+ ctx.fillRect(x + 4 * z, y + (mh + 2) * z, 6 * z, z);
1292
+ }
1293
+
1294
+ function drawDesk(ctx, x, y) {
1295
+ const z = Z, dw = 30;
1296
+ ctx.fillStyle = '#7a6550';
1297
+ ctx.fillRect(x, y, dw * z, 3 * z);
1298
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
1299
+ ctx.fillRect(x + z, y + z, (dw - 2) * z, z);
1300
+ ctx.fillStyle = '#4a3828';
1301
+ ctx.fillRect(x, y, z, 3 * z);
1302
+ ctx.fillRect(x + (dw - 1) * z, y, z, 3 * z);
1303
+ ctx.fillStyle = '#5a4838';
1304
+ ctx.fillRect(x, y + 3 * z, dw * z, 4 * z);
1305
+ ctx.fillStyle = '#4a3828';
1306
+ ctx.fillRect(x, y + 3 * z, dw * z, z);
1307
+ ctx.fillStyle = '#8a7a68';
1308
+ ctx.fillRect(x + 4 * z, y + 5 * z, 3 * z, z);
1309
+ ctx.fillRect(x + (dw - 7) * z, y + 5 * z, 3 * z, z);
1310
+ ctx.fillStyle = '#4a3828';
1311
+ ctx.fillRect(x + z, y + 7 * z, 2 * z, 3 * z);
1312
+ ctx.fillRect(x + (dw - 3) * z, y + 7 * z, 2 * z, 3 * z);
1313
+ ctx.fillStyle = '#d0d0d0';
1314
+ ctx.fillRect(x + 11 * z, y + z, 8 * z, 2 * z);
1315
+ ctx.fillStyle = '#eee';
1316
+ for (let k = 0; k < 3; k++) {
1317
+ for (let r = 0; r < 2; r++) {
1318
+ ctx.fillRect(x + (12 + k * 2) * z, y + (z + r * z), z, z - 1);
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+
1324
+ function drawMessageBubble(ctx, x, y, theme) {
1325
+ const z = Z;
1326
+ const bw = 10 * z, bh = 7 * z;
1327
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
1328
+ roundRect(ctx, x, y, bw, bh, 4);
1329
+ ctx.fill();
1330
+ // Tail dots pointing down toward the character
1331
+ ctx.beginPath();
1332
+ ctx.arc(x + 2 * z, y + bh + 2 * z, z * 1.2, 0, Math.PI * 2);
1333
+ ctx.fill();
1334
+ ctx.beginPath();
1335
+ ctx.arc(x + z, y + bh + 4 * z, z * 0.8, 0, Math.PI * 2);
1336
+ ctx.fill();
1337
+ // Three dots in accent color
1338
+ ctx.fillStyle = theme.accent;
1339
+ for (let i = 0; i < 3; i++) {
1340
+ ctx.beginPath();
1341
+ ctx.arc(x + (3 + i * 2) * z, y + bh / 2, z, 0, Math.PI * 2);
1342
+ ctx.fill();
1343
+ }
1344
+ }
1345
+
1346
+ function drawWaitingBubble(ctx, x, y, theme) {
1347
+ const z = Z;
1348
+ const bw = 10 * z, bh = 8 * z;
1349
+ const pulse = 0.85 + Math.sin(Date.now() / 400) * 0.15;
1350
+ ctx.fillStyle = `rgba(255, 255, 255, ${pulse})`;
1351
+ roundRect(ctx, x, y, bw, bh, 4);
1352
+ ctx.fill();
1353
+ ctx.beginPath();
1354
+ ctx.arc(x + 2 * z, y + bh + 2 * z, z * 1.2, 0, Math.PI * 2);
1355
+ ctx.fill();
1356
+ ctx.beginPath();
1357
+ ctx.arc(x + z, y + bh + 4 * z, z * 0.8, 0, Math.PI * 2);
1358
+ ctx.fill();
1359
+ ctx.fillStyle = `rgba(${theme.ar}, ${theme.ag}, ${theme.ab}, ${pulse})`;
1360
+ ctx.font = `bold ${z * 5}px monospace`;
1361
+ ctx.textAlign = 'center';
1362
+ ctx.fillText('?', x + bw / 2, y + bh - 2 * z);
1363
+ ctx.textAlign = 'start';
1364
+ }
1365
+
1366
+ function drawChangeBadge(ctx, x, y, count, color) {
1367
+ if (count <= 0) return;
1368
+ const z = Z;
1369
+ const text = String(count);
1370
+ const bw = Math.max(4, text.length * 3 + 2) * z;
1371
+ const bh = 4 * z;
1372
+ const bx = x + WS_W * z - bw - z;
1373
+ const by = y;
1374
+ ctx.fillStyle = color;
1375
+ roundRect(ctx, bx, by, bw, bh, 3);
1376
+ ctx.fill();
1377
+ ctx.fillStyle = '#fff';
1378
+ ctx.font = `bold ${z * 3}px monospace`;
1379
+ ctx.textAlign = 'center';
1380
+ ctx.fillText(text, bx + bw / 2, by + bh - z);
1381
+ ctx.textAlign = 'start';
1382
+ }
1383
+
1384
+ function drawConflictBadge(ctx, x, y) {
1385
+ const z = Z;
1386
+ const bx = x + WS_W * z - 5 * z;
1387
+ const by = y + 5 * z;
1388
+ ctx.fillStyle = '#f0c040';
1389
+ ctx.font = `bold ${z * 4}px monospace`;
1390
+ ctx.fillText('\u26a0', bx, by + 3 * z);
1391
+ }
1392
+
1393
+ function roundRect(ctx, x, y, w, h, r) {
1394
+ ctx.beginPath();
1395
+ ctx.moveTo(x + r, y);
1396
+ ctx.lineTo(x + w - r, y);
1397
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
1398
+ ctx.lineTo(x + w, y + h - r);
1399
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
1400
+ ctx.lineTo(x + r, y + h);
1401
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
1402
+ ctx.lineTo(x, y + r);
1403
+ ctx.quadraticCurveTo(x, y, x + r, y);
1404
+ ctx.closePath();
1405
+ }
1406
+
1407
+ // --- Transient motion: dispatch papers and walk in/out ---
1408
+ // Purely client-side, time-based state. Every animation self-expires on its
1409
+ // start timestamp, so a backgrounded tab (where rAF pauses) wakes up to find
1410
+ // them finished rather than replaying a queued burst.
1411
+ const WALK_SPEED = 130; // px/s along the walk path
1412
+ // Mirrors STALLED_AFTER_MS in lib/jobs.js, like public/modules/jobs.js does.
1413
+ const STALLED_AFTER_MS = 3 * 60 * 1000;
1414
+ const WALK_FRAME_MS = 150; // walk-cycle frame time
1415
+ const PAPER_MS = 900; // paper flight time
1416
+ const PAPER_FADE_MS = 250; // fade-out after landing
1417
+
1418
+ // null until the first jobs-list, which the server sends last in the initial
1419
+ // connect sync — session-created replays before it are pre-existing agents
1420
+ // and must render seated, not walk in.
1421
+ let prevJobStates = null; // jobId -> { state, agentSessionId }
1422
+ const paperAnims = []; // { sessionId, col, start }
1423
+ const walkAnims = new Map(); // sessionId -> { dir: 'in'|'out', start, queuedAt, fromX, fromY, variant }
1424
+
1425
+ // --- Idle wander: an agent at rest (IDLE, or WAITING — which is where every
1426
+ // TUI agent actually sits) leaves its desk, walks to a free seat and sits there
1427
+ // until its state changes, then walks back. Seats come from wanderSeats: the
1428
+ // conference set when it places, plus the chat-area sofas, which are the seats
1429
+ // that exist at every canvas size. Client-side and time-based like the walk
1430
+ // in/out anims. The claim is held from the moment the walk starts; the
1431
+ // character renders seated once its wander anim finishes. Claims clear whenever
1432
+ // the pool changes shape — characters simply render at their desks again.
1433
+ const seatClaims = new Map(); // sessionId -> index into currentSeats (NOT conf.seats)
1434
+ const wanderAnims = new Map(); // sessionId -> { dir: 'seat'|'desk', start, variant, fromX, fromY }
1435
+ let currentConf = null; // this frame's computeConference result, for drawMotion + departures
1436
+ let currentSeats = []; // this frame's wanderSeats() pool, what a claim indexes
1437
+ // The pool's shape, to drop stale claims when it changes. Length alone is not
1438
+ // the shape: a conference set with both chat areas suppressed is 6 seats, and
1439
+ // no conference set with both chat areas placed is also 6 — crossing between
1440
+ // them would leave every claim pointing at unrelated furniture. Counting the
1441
+ // conference seats separates them without depending on the set being exactly
1442
+ // six, which is not this function's invariant to rely on.
1443
+ let lastSeatCount = 0;
1444
+ let lastConfCount = -1;
1445
+ let currentObstacles = []; // this frame's furniture rects, for the walk routes
1446
+
1447
+ function updateWander(seats) {
1448
+ // Seat indices address this list, so a change to its shape (the conference
1449
+ // set placing or vanishing on resize) invalidates every claim in flight.
1450
+ let confN = 0;
1451
+ while (confN < seats.length && seats[confN].kind === 'conf') confN++;
1452
+ if (seats.length !== lastSeatCount || confN !== lastConfCount) {
1453
+ seatClaims.clear(); wanderAnims.clear();
1454
+ lastSeatCount = seats.length; lastConfCount = confN;
1455
+ }
1456
+ if (!seats.length) return;
1457
+ const taken = new Set(seatClaims.values());
1458
+ for (const [sid, agent] of agents) {
1459
+ // Read-only colleagues keep their desks: the desk pass carries their
1460
+ // dimming and owner label, which the conference pass has no room for.
1461
+ // WAITING, not just IDLE: every TUI agent (claude, aider, codex, gemini)
1462
+ // short-circuits to WAITING in detectState, so IDLE is unreachable for the
1463
+ // agents this board actually spawns and the wander never fired for them.
1464
+ // Quiet for a while, not merely quiet: detectState flips WORKING -> the
1465
+ // rest state after 3s of silence, but a walk to a seat takes 7-11s at
1466
+ // WALK_SPEED, so an agent that just paused would start a walk, get output,
1467
+ // and teleport back to its desk -- jittering on a 3s cycle and never
1468
+ // arriving. The window covers IDLE as well as WAITING: which of the two a
1469
+ // quiet agent reads as is a property of its command, not of how long it
1470
+ // has been resting. A plain shell (not a TUI, prompt not matching
1471
+ // PROMPT_PATTERNS) lands on IDLE, so guarding WAITING alone left every
1472
+ // bash terminal pacing between its desk and the sofas.
1473
+ // Same window the board already uses to call a quiet WAITING "stalled".
1474
+ const resting = (agent.state === 'IDLE' || agent.state === 'WAITING')
1475
+ && Date.now() - (agent.lastOutputAt || 0) > STALLED_AFTER_MS;
1476
+ const idle = resting && canControlAgent(agent);
1477
+ if (idle && !seatClaims.has(sid) && !wanderAnims.has(sid) && !walkAnims.has(sid)) {
1478
+ if (taken.size >= seats.length) continue; // every seat taken — stay at the desk
1479
+ const seat = seats.findIndex((_, i) => !taken.has(i));
1480
+ if (seat === -1) continue;
1481
+ seatClaims.set(sid, seat);
1482
+ taken.add(seat);
1483
+ // No walk during the connect replay (prevJobStates === null): like the
1484
+ // walk-ins, pre-existing idle agents render seated, not mid-commute.
1485
+ if (motionEnabled() && prevJobStates !== null) {
1486
+ wanderAnims.set(sid, { dir: 'seat', start: performance.now(), variant: charVariant(sid) });
1487
+ }
1488
+ } else if (!idle && seatClaims.has(sid)) {
1489
+ const seatPos = seats[seatClaims.get(sid)];
1490
+ const midWalk = wanderAnims.get(sid)?.dir === 'seat';
1491
+ seatClaims.delete(sid);
1492
+ wanderAnims.delete(sid);
1493
+ // Fully seated: walk back to the desk. Still walking over: snap back
1494
+ // rather than teleport to the seat first.
1495
+ if (!midWalk && motionEnabled()) {
1496
+ wanderAnims.set(sid, { dir: 'desk', start: performance.now(), variant: charVariant(sid), fromX: seatPos.x, fromY: seatPos.y, fromRow: seatPos.row, fromLane: seatPos.lane, fromYPref: seatPos.yPref });
1497
+ }
1498
+ }
1499
+ }
1500
+ // Deleting the current entry during Map iteration is well-defined.
1501
+ for (const sid of seatClaims.keys()) if (!agents.has(sid)) seatClaims.delete(sid);
1502
+ for (const sid of wanderAnims.keys()) if (!agents.has(sid)) wanderAnims.delete(sid);
1503
+ }
1504
+
1505
+ function motionEnabled() {
1506
+ // Live query (not the module-load const): flipping the OS reduce-motion
1507
+ // setting mid-session should affect the next animation, not the next reload.
1508
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
1509
+ return !reduced && !document.hidden;
1510
+ }
1511
+
1512
+ export function hasMotion() {
1513
+ return paperAnims.length > 0 || walkAnims.size > 0 || wanderAnims.size > 0;
1514
+ }
1515
+
1516
+ // Entrance: the left edge of the floor. There is no door — the walker appears
1517
+ // at whatever height its corridor row runs at (entryRoute), so the entrance
1518
+ // moves with the route instead of the route bending to reach a fixed point.
1519
+ const ENTRY_X = Z;
1520
+
1521
+ // Multi-leg route between a desk and a conference seat that stays off the
1522
+ // furniture: down the desk column to the corridor row above the set, across,
1523
+ // then down the seat's own clear column (side and head columns are beside or
1524
+ // above the table; the foot seat detours via the left lane, its centre column
1525
+ // being the table itself, and only brushes the table's leg row on the last
1526
+ // step). Both verticals run down approachX's clear column and the crossing
1527
+ // runs along corridorY's clear row, like the walk in/out legs. Reversed for
1528
+ // the walk back.
1529
+ // The vertical lanes just outside the chair columns — descending a seat's
1530
+ // own column would clip through the chair above it, so side and foot seats
1531
+ // are approached down the nearest outer lane instead. Clamped for the
1532
+ // narrowest panel the set can place on.
1533
+ function confLanes(conf) {
1534
+ return { left: Math.max(2, conf.table.x - 78), right: conf.table.x + conf.table.w + 46 };
1535
+ }
1536
+
1537
+ export function wanderRoute(desk, seat, conf, toSeat, obstacles = [], panelWidth = Infinity, panelHeight = Infinity) {
1538
+ // Where the crossing wants to run and which column it comes down: a sofa
1539
+ // seat carries both (it sits at the panel's bottom edge, nowhere near the
1540
+ // conference set), a conference seat derives them from the table. Only the
1541
+ // head seat (the sole down-facing one) prefers its own column — it sits
1542
+ // above the table. Both are preferences; the vetting below is what decides.
1543
+ // conf-guarded: drawMotion no longer bails on a null currentConf, and a
1544
+ // walk-back anim from a conference seat carries no yPref of its own.
1545
+ const yPref = seat.yPref ?? (conf ? conf.table.y - 24 : 0);
1546
+ const lane = seat.lane ?? (!conf || seat.row === CHAR_ROW_DOWN ? seat.x
1547
+ : seat.x < conf.table.x + conf.table.w / 2 ? confLanes(conf).left : confLanes(conf).right);
1548
+ // The row just above the furniture, but a row a walker can stand in: the
1549
+ // crossing leg runs its whole width, so it takes the same scan the walk
1550
+ // in/out corridor does rather than a fixed offset off the tabletop.
1551
+ const yMid = corridorY(obstacles, panelHeight, yPref);
1552
+ // The lane knows only the furniture's geometry, so it can point straight at
1553
+ // whatever else stands between it and the corridor. approachX vets it from
1554
+ // the lane rather than from the seat — measured from the seat the furniture
1555
+ // is the walker's own floor and every column reads clear — and steps wider
1556
+ // when the lane's column is not.
1557
+ const laneX = approachX(obstacles, { x: lane, y: seat.y }, yMid, panelWidth, desk.x);
1558
+ const deskX = approachX(obstacles, desk, yMid, panelWidth, laneX);
1559
+ const pts = [desk, { x: deskX, y: desk.y }, { x: deskX, y: yMid }, { x: laneX, y: yMid }];
1560
+ if (laneX !== seat.x) pts.push({ x: laneX, y: seat.y });
1561
+ pts.push(seat);
1562
+ if (!toSeat) pts.reverse();
1563
+ return pathThrough(pts);
1564
+ }
1565
+
1566
+ // Every rect a walk route must not cross, in one list: pod rugs, spare desks,
1567
+ // decor (chat areas + plants) and the conference set with its chair overhang.
1568
+ // Occupied desks go in too, though they sit inside a rug that already rules
1569
+ // their rows out: the rug is the walker's OWN floor on the leg along its desk
1570
+ // row, and without the desks in the list that leg would sweep the whole pod,
1571
+ // straight over the colleagues seated in it.
1572
+ export function walkObstacles(rugs, decor, spares, conf, desks = []) {
1573
+ const cell = d => ({ x: d.x, y: d.y, w: WS_W * Z, h: WS_H * Z });
1574
+ const rects = [...rugs, ...decor, ...spares.map(cell), ...desks.map(cell)];
1575
+ if (conf) {
1576
+ const t = conf.table;
1577
+ // The set bbox, not the tabletop: the chair overhang on every side is what
1578
+ // a walker has to clear, so this reads CONF_HEAD_RISE rather than repeating
1579
+ // it — a taller head chair must push the route out with it.
1580
+ rects.push({ x: t.x - 42, y: t.y - CONF_HEAD_RISE, w: t.w + 84, h: t.h + CONF_HEAD_RISE + 32 });
1581
+ }
1582
+ return rects;
1583
+ }
1584
+
1585
+ // The corridor row nearest y: the obstacles' vertical spans, inverted into the
1586
+ // gaps between them, ranked by how close to y a walker can stand in them. Spans
1587
+ // count full width — a rug half-way across still rules its rows out, which is
1588
+ // what keeps this a scan rather than a search.
1589
+ //
1590
+ // This office is dense: between the pod rugs, the spare row and the chat areas
1591
+ // the gaps are usually 18-54px against a 64px character, so a gap wide enough
1592
+ // to stand in clear of everything often does not exist. Rather than give up
1593
+ // (the old null sent the route to the bottom edge, straight through the sofas)
1594
+ // the widest gap wins when none fits, and the walker's feet ride its bottom —
1595
+ // overlapping the furniture above it, drawn in front of it, never the chats.
1596
+ export function corridorY(obstacles, panelHeight, y) {
1597
+ const spans = obstacles.map(r => [r.y, r.y + r.h]).sort((a, b) => a[0] - b[0]);
1598
+ spans.push([panelHeight, panelHeight]);
1599
+ const gaps = [];
1600
+ let top = FLOOR_TOP;
1601
+ for (const [s, e] of spans) {
1602
+ if (s > top) gaps.push([top, s]);
1603
+ top = Math.max(top, e);
1604
+ }
1605
+ // Where a walker stands in a gap: as near y as it reaches, feet on the floor
1606
+ // the gap ends at when the gap is shorter than the character.
1607
+ const row = ([a, b]) => Math.round(Math.min(Math.max(y, a), b - CHAR_H));
1608
+ const fitting = gaps.filter(([a, b]) => b - a >= CHAR_H);
1609
+ if (fitting.length) return fitting.map(row).reduce((p, c) => Math.abs(c - y) < Math.abs(p - y) ? c : p);
1610
+ if (gaps.length) return row(gaps.reduce((p, c) => (c[1] - c[0] > p[1] - p[0] ? c : p)));
1611
+ // Nothing but furniture from FLOOR_TOP down: stand on the desk's own row.
1612
+ return Math.round(Math.min(Math.max(y, FLOOR_TOP), panelHeight - CHAR_H));
1613
+ }
1614
+
1615
+ // The column a vertical approach runs down. The point's own is the default;
1616
+ // when the band between it and the corridor is blocked, the walker drops down
1617
+ // the nearest column clear of everything but the rects it is standing in (its
1618
+ // own pod rug and its own desk), then comes in along its own row. Candidates
1619
+ // are the blocking rects' edges, so this stays a scan rather than a search --
1620
+ // no candidate clear on both legs means falling back to the old
1621
+ // straight-over-everything column.
1622
+ //
1623
+ // `toward` is where the crossing leg goes on from here (the entrance, or the
1624
+ // conference lane): of two columns the same distance away, the one on that
1625
+ // side is the one the walker does not have to double back over.
1626
+ export function approachX(obstacles, point, yMid, panelWidth = Infinity, toward = point.x) {
1627
+ const cx = point.x + CHAR_W / 2, cy = point.y + CHAR_H / 2;
1628
+ // Every rect the walker is standing in, not just the first: it sits on its
1629
+ // pod rug AND at its own desk, and a route cannot be clear of the floor it
1630
+ // starts on. Excluding only one of them makes the other unroutable.
1631
+ const mine = obstacles.filter(o => cx >= o.x && cx <= o.x + o.w && cy >= o.y && cy <= o.y + o.h);
1632
+ const top = Math.min(yMid, point.y), bot = Math.max(yMid, point.y) + CHAR_H;
1633
+ const blockers = obstacles.filter(o => !mine.includes(o) && o.y < bot && o.y + o.h > top);
1634
+ const colClear = x => blockers.every(o => o.x >= x + CHAR_W || o.x + o.w <= x);
1635
+ if (colClear(point.x)) return point.x;
1636
+ // The sidestep along the point's own row must be as clear as the descent.
1637
+ const rowClear = x => blockers.every(o => o.y >= point.y + CHAR_H || o.y + o.h <= point.y ||
1638
+ o.x >= Math.max(x, point.x) + CHAR_W || o.x + o.w <= Math.min(x, point.x));
1639
+ // Detour cost: out to the column and on to wherever the crossing is headed,
1640
+ // so a column between the two is free and one past the point is not.
1641
+ const cost = x => Math.abs(x - point.x) + Math.abs(x - toward);
1642
+ return [...blockers, ...mine]
1643
+ .flatMap(o => [o.x - CHAR_W, o.x + o.w])
1644
+ .filter(x => x >= ENTRY_X && x + CHAR_W <= panelWidth)
1645
+ .sort((a, b) => cost(a) - cost(b) || Math.abs(a - point.x) - Math.abs(b - point.x))
1646
+ .find(x => colClear(x) && rowClear(x)) ?? point.x;
1647
+ }
1648
+
1649
+ // Walk in/out routes: in at the left edge on the empty floor row nearest the
1650
+ // desk, straight across that row, then up or down the desk column — and the
1651
+ // reverse walking out. The entrance rides on the corridor, so there is no
1652
+ // vertical leg along the left strip and no fixed door height. A walk-out
1653
+ // starting inside the conference set (a foot-seat sitter) steps out via the
1654
+ // nearest outer lane first. The vertical leg runs down approachX's column:
1655
+ // the desk's own when that descent is clear, else the nearest aisle beside
1656
+ // whatever blocks it, with a sidestep along the desk's own row at the end.
1657
+ export function entryRoute(point, conf, inbound, panelHeight, obstacles = [], panelWidth = Infinity) {
1658
+ const yMid = corridorY(obstacles, panelHeight, point.y);
1659
+ // A start inside the set's footprint — chair overhang included — steps out
1660
+ // via the nearest outer lane; anywhere else approachX picks the column.
1661
+ let climb = null;
1662
+ if (conf) {
1663
+ const t = conf.table;
1664
+ if (point.x + CHAR_W > t.x - 42 && point.x < t.x + t.w + 42 &&
1665
+ point.y + CHAR_H > t.y - 18 && point.y < t.y + t.h + 32) {
1666
+ const lane = point.x < t.x + t.w / 2 ? confLanes(conf).left : confLanes(conf).right;
1667
+ // The lane only knows the table's geometry, so it can point straight at
1668
+ // whatever else stands between the set and the corridor. approachX vets
1669
+ // it from the lane rather than from the seat — measured from the seat
1670
+ // the set is the walker's own floor and every column reads clear — and
1671
+ // steps wider when the lane's column is not.
1672
+ const laneX = approachX(obstacles, { x: lane, y: point.y }, yMid, panelWidth, ENTRY_X);
1673
+ climb = [{ x: laneX, y: point.y }, { x: laneX, y: yMid }];
1674
+ }
1675
+ }
1676
+ if (climb === null) {
1677
+ const laneX = approachX(obstacles, point, yMid, panelWidth, ENTRY_X);
1678
+ climb = laneX === point.x ? [{ x: point.x, y: yMid }]
1679
+ : [{ x: laneX, y: point.y }, { x: laneX, y: yMid }];
1680
+ }
1681
+ const pts = [{ x: point.x, y: point.y }, ...climb, { x: ENTRY_X, y: yMid }];
1682
+ if (inbound) pts.reverse();
1683
+ return pathThrough(pts);
1684
+ }
1685
+
1686
+ function pathThrough(pts) {
1687
+ const legs = [];
1688
+ for (let i = 1; i < pts.length; i++) {
1689
+ const a = pts[i - 1], b = pts[i];
1690
+ if (a.x !== b.x || a.y !== b.y) legs.push({ x0: a.x, y0: a.y, x1: b.x, y1: b.y });
1691
+ }
1692
+ const total = legs.reduce((s, l) => s + Math.abs(l.x1 - l.x0) + Math.abs(l.y1 - l.y0), 0);
1693
+ return { from: { x: pts[0].x, y: pts[0].y }, legs, total };
1694
+ }
1695
+
1696
+ // Point and heading a given distance along the path. Clamps to the endpoints.
1697
+ export function pointAlongPath(path, dist) {
1698
+ let d = Math.max(0, Math.min(dist, path.total));
1699
+ for (let i = 0; i < path.legs.length; i++) {
1700
+ const l = path.legs[i];
1701
+ const len = Math.abs(l.x1 - l.x0) + Math.abs(l.y1 - l.y0);
1702
+ if (d <= len || i === path.legs.length - 1) {
1703
+ const t = len ? Math.min(1, d / len) : 1;
1704
+ return {
1705
+ x: Math.round(l.x0 + (l.x1 - l.x0) * t),
1706
+ y: Math.round(l.y0 + (l.y1 - l.y0) * t),
1707
+ dx: Math.sign(l.x1 - l.x0),
1708
+ dy: Math.sign(l.y1 - l.y0),
1709
+ };
1710
+ }
1711
+ d -= len;
1712
+ }
1713
+ return { x: path.from.x, y: path.from.y, dx: 0, dy: 0 };
1714
+ }
1715
+
1716
+ // Jobs that just moved into in-progress under a fresh agent — the dispatches.
1717
+ // A manual review -> in-progress move keeps its agentSessionId, so it does not
1718
+ // register. fromState names the column the paper launches from.
1719
+ export function detectDispatches(prev, jobList) {
1720
+ const out = [];
1721
+ for (const job of jobList) {
1722
+ if (!job || job.state !== 'in-progress' || !job.agentSessionId) continue;
1723
+ const p = prev.get(job.id);
1724
+ if (p && p.agentSessionId === job.agentSessionId) continue;
1725
+ out.push({ jobId: job.id, sessionId: job.agentSessionId, fromState: p ? p.state : 'todo' });
1726
+ }
1727
+ return out;
1728
+ }
1729
+
1730
+ // Called after every jobs-list broadcast. The first call is the baseline (and
1731
+ // marks the connect sync complete); later calls diff against it for dispatches.
1732
+ export function noteJobsUpdate() {
1733
+ const list = [...jobs.values()];
1734
+ if (prevJobStates !== null && motionEnabled()) {
1735
+ for (const d of detectDispatches(prevJobStates, list)) {
1736
+ const col = Math.max(0, BOARD_STATES.indexOf(d.fromState));
1737
+ paperAnims.push({ sessionId: d.sessionId, col, start: performance.now() });
1738
+ }
1739
+ }
1740
+ prevJobStates = new Map(list.map(j => [j.id, { state: j.state, agentSessionId: j.agentSessionId || null }]));
1741
+ }
1742
+
1743
+ // A session-created for a session not yet in the agent map. Ignored during the
1744
+ // initial connect sync: those agents already existed and render seated.
1745
+ export function noteAgentArrival(sessionId) {
1746
+ if (prevJobStates === null || !motionEnabled()) return;
1747
+ walkAnims.set(sessionId, {
1748
+ dir: 'in', start: null, queuedAt: performance.now(), variant: charVariant(sessionId),
1749
+ });
1750
+ }
1751
+
1752
+ // An agent is about to leave (session-ended, or the user closing the tab).
1753
+ // Captures the desk position now — the tile may be gone next frame.
1754
+ export function noteAgentDeparture(sessionId) {
1755
+ if (!motionEnabled()) return;
1756
+ const agent = agents.get(sessionId);
1757
+ if (!agent || agent.state === 'DISCONNECTED') return;
1758
+ const canvas = document.getElementById('office-canvas');
1759
+ if (!canvas) return;
1760
+ const { w, h } = canvasSize(canvas);
1761
+ if (!w || !h) return;
1762
+ const layout = computeOfficeLayout(w, h);
1763
+ // A sitter leaves from wherever it is seated — conference table or sofa —
1764
+ // and a mid-wander walker from its current point along the walk, never a
1765
+ // snap to a spot it isn't at.
1766
+ const desk = deskCharPos(sessionId, layout);
1767
+ const seat = seatClaims.has(sessionId) ? currentSeats[seatClaims.get(sessionId)] ?? null : null;
1768
+ const anim = wanderAnims.get(sessionId);
1769
+ let pos = seat || desk;
1770
+ // The frozen route the wander is actually walking, so the walk-out starts
1771
+ // where the character is drawn rather than on a freshly recomputed path.
1772
+ if (anim && anim.path) pos = pointAlongPath(anim.path, ((performance.now() - anim.start) / 1000) * WALK_SPEED);
1773
+ seatClaims.delete(sessionId);
1774
+ wanderAnims.delete(sessionId);
1775
+ if (!pos) return;
1776
+ walkAnims.set(sessionId, {
1777
+ dir: 'out', start: performance.now(),
1778
+ fromX: pos.x, fromY: pos.y,
1779
+ variant: charVariant(sessionId),
1780
+ });
1781
+ }
1782
+
1783
+ function deskCharPos(sessionId, layout) {
1784
+ const pos = layout.positions.get(sessionId);
1785
+ if (!pos) return null;
1786
+ // Same variant rule as the seated pass: desk style keys on the agent id.
1787
+ const deskVariant = charVariant(sessionId) % 2;
1788
+ return {
1789
+ x: pos.x + (deskVariant === 0 ? DESK_CHAR_X : DESK2_CHAR_X) * Z - 1,
1790
+ y: pos.y + DESK_CHAR_Y * Z,
1791
+ };
1792
+ }
1793
+
1794
+ function drawWalker(ctx, variant, pt, now) {
1795
+ const sheet = SPRITES['char' + variant] || SPRITES.char0;
1796
+ if (!sheet) return;
1797
+ const col = Math.floor(now / WALK_FRAME_MS) % 3;
1798
+ let row = CHAR_ROW_RIGHT, mirror = false;
1799
+ if (pt.dy < 0) row = CHAR_ROW_UP;
1800
+ else if (pt.dy > 0) row = CHAR_ROW_DOWN;
1801
+ else if (pt.dx < 0) mirror = true; // sheet has no left row — mirror the right one
1802
+ ctx.save();
1803
+ if (mirror) {
1804
+ ctx.translate(pt.x + CHAR_W, pt.y);
1805
+ ctx.scale(-1, 1);
1806
+ ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, 0, 0, CHAR_W, CHAR_H);
1807
+ } else {
1808
+ ctx.drawImage(sheet, col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H, pt.x, pt.y, CHAR_W, CHAR_H);
1809
+ }
1810
+ ctx.restore();
1811
+ }
1812
+
1813
+ // A small flying job sheet, drawn like the pinned posts on the boards.
1814
+ function drawFlyingPaper(ctx, x, y, alpha) {
1815
+ const z = Z;
1816
+ const pw = 4 * z, ph = 5 * z;
1817
+ ctx.save();
1818
+ ctx.globalAlpha = alpha;
1819
+ ctx.fillStyle = 'rgba(0,0,0,0.14)';
1820
+ ctx.fillRect(x + 1, y + 1, pw, ph);
1821
+ ctx.fillStyle = '#f6f3ea';
1822
+ ctx.fillRect(x, y, pw, ph);
1823
+ ctx.fillStyle = 'rgba(45, 50, 58, 0.75)';
1824
+ ctx.fillRect(x + z, y + z, 2 * z, 2);
1825
+ ctx.fillStyle = 'rgba(70, 76, 86, 0.45)';
1826
+ ctx.fillRect(x + z, y + Math.round(2.5 * z), Math.round(2.5 * z), 1);
1827
+ ctx.fillRect(x + z, y + Math.round(3.5 * z), 2 * z, 1);
1828
+ ctx.restore();
1829
+ }
1830
+
1831
+ // Drawn on top of everything each frame. Paper targets resolve fresh from the
1832
+ // current layout, so a grid reflow retargets a flying sheet mid-flight instead
1833
+ // of leaving it aimed at a stale desk. Walk routes do not — see routeOf.
1834
+ function drawMotion(ctx, w, h, layout) {
1835
+ if (!hasMotion()) return;
1836
+ const now = performance.now();
1837
+
1838
+ // A walker's route is built once and kept on the anim. Rebuilding it from
1839
+ // live obstacles every frame re-projected the walker along a different path
1840
+ // whenever an unrelated spawn or exit changed the layout mid-stride — the
1841
+ // distance advances on wall-clock time, so a longer `total` moves it a desk
1842
+ // over. Only a canvas resize rebuilds: the room genuinely changes then.
1843
+ const routeOf = (anim, build) => {
1844
+ if (!anim.path || anim.pathW !== w || anim.pathH !== h) {
1845
+ anim.path = build();
1846
+ anim.pathW = w;
1847
+ anim.pathH = h;
1848
+ }
1849
+ return anim.path;
1850
+ };
1851
+
1852
+ const board = paperAnims.length ? computeBoardLayout(w) : null;
1853
+ for (let i = paperAnims.length - 1; i >= 0; i--) {
1854
+ const p = paperAnims[i];
1855
+ const elapsed = now - p.start;
1856
+ if (elapsed > PAPER_MS + PAPER_FADE_MS || !board.visible) { paperAnims.splice(i, 1); continue; }
1857
+ const sec = board.sections[p.col] || board.sections[0];
1858
+ const from = { x: sec.centerX, y: board.boardTop + Math.round(board.boardH / 2) };
1859
+ const to = deskCharPos(p.sessionId, layout)
1860
+ || { x: Math.round(w / 2), y: Math.round((FLOOR_TOP + h) / 2) };
1861
+ const t = Math.min(1, elapsed / PAPER_MS);
1862
+ const e = t * t * (3 - 2 * t); // smoothstep ease
1863
+ // Quadratic arc with the control point lifted above the launch
1864
+ const cx = (from.x + to.x) / 2, cy = Math.min(from.y, to.y) - 12 * Z;
1865
+ const u = 1 - e;
1866
+ const x = u * u * from.x + 2 * u * e * cx + e * e * to.x;
1867
+ const y = u * u * from.y + 2 * u * e * cy + e * e * to.y;
1868
+ const alpha = elapsed <= PAPER_MS ? 1 : 1 - (elapsed - PAPER_MS) / PAPER_FADE_MS;
1869
+ drawFlyingPaper(ctx, Math.round(x), Math.round(y), alpha);
1870
+ }
1871
+
1872
+ for (const [sid, anim] of walkAnims) {
1873
+ let path;
1874
+ if (anim.dir === 'in') {
1875
+ const to = deskCharPos(sid, layout);
1876
+ // session-created seen, but the async terminal handler hasn't put the
1877
+ // agent in the map yet — wait briefly, then give up. The same cutoff
1878
+ // drops a walk-in queued while the canvas was hidden (a phone on another
1879
+ // panel): the loop draws no frames then, so the walk would otherwise
1880
+ // start from the door whenever the office is next shown.
1881
+ if (anim.start === null && now - anim.queuedAt > 4000) { walkAnims.delete(sid); continue; }
1882
+ if (!to) { if (anim.start !== null) walkAnims.delete(sid); continue; }
1883
+ if (anim.start === null) anim.start = now;
1884
+ // Built on the first frame the desk resolves, not the first frame of
1885
+ // the anim — the walk-in waits for the async terminal handler.
1886
+ path = routeOf(anim, () => entryRoute(to, currentConf, true, h, currentObstacles, w));
1887
+ } else {
1888
+ path = routeOf(anim, () => entryRoute({ x: anim.fromX, y: anim.fromY }, currentConf, false, h, currentObstacles, w));
1889
+ }
1890
+ const dist = ((now - anim.start) / 1000) * WALK_SPEED;
1891
+ if (dist >= path.total) {
1892
+ // The seated pass already skipped this frame, so hold the walker on its
1893
+ // endpoint once more or the character blinks out for a frame on arrival.
1894
+ if (anim.dir === 'in') drawWalker(ctx, anim.variant, pointAlongPath(path, path.total), now);
1895
+ walkAnims.delete(sid);
1896
+ continue;
1897
+ }
1898
+ drawWalker(ctx, anim.variant, pointAlongPath(path, dist), now);
1899
+ }
1900
+
1901
+ // Idle wanderers between desk and conference seat. Routes freeze like the
1902
+ // walk anims; wanderRoute keeps the crossing legs off the conference
1903
+ // furniture.
1904
+ for (const [sid, anim] of wanderAnims) {
1905
+ let path;
1906
+ const desk = deskCharPos(sid, layout);
1907
+ if (!desk) { wanderAnims.delete(sid); continue; }
1908
+ if (anim.dir === 'seat') {
1909
+ const seat = currentSeats[seatClaims.get(sid)];
1910
+ if (!seat) { wanderAnims.delete(sid); continue; }
1911
+ path = routeOf(anim, () => wanderRoute(desk, seat, currentConf, true, currentObstacles, w, h));
1912
+ } else {
1913
+ const from = { x: anim.fromX, y: anim.fromY, row: anim.fromRow, lane: anim.fromLane, yPref: anim.fromYPref };
1914
+ path = routeOf(anim, () => wanderRoute(desk, from, currentConf, false, currentObstacles, w, h));
1915
+ }
1916
+ const dist = ((now - anim.start) / 1000) * WALK_SPEED;
1917
+ if (dist >= path.total) {
1918
+ // Hold the endpoint one frame: the seated/desk pass skipped this agent
1919
+ // before this anim was seen finishing, so it would blink out otherwise.
1920
+ drawWalker(ctx, anim.variant, pointAlongPath(path, path.total), now);
1921
+ wanderAnims.delete(sid);
1922
+ continue;
1923
+ }
1924
+ drawWalker(ctx, anim.variant, pointAlongPath(path, dist), now);
1925
+ }
1926
+ }
1927
+
1928
+ // Size from the canvas's own CSS box (flex:1 below .office-header), not the
1929
+ // panel's — the panel height includes the header, so a panel-sized canvas
1930
+ // overflowed the bottom and clipped the sofa and corner plants. The diff
1931
+ // viewer hides the canvas (display:none → 0x0 client box), so remember the
1932
+ // last real size: a walk-out captured while hidden still lays out against
1933
+ // the room the user will see when it comes back.
1934
+ let lastCanvasSize = { w: 0, h: 0 };
1935
+ function canvasSize(canvas) {
1936
+ const w = canvas.clientWidth, h = canvas.clientHeight;
1937
+ if (w && h) lastCanvasSize = { w, h };
1938
+ return lastCanvasSize;
1939
+ }
1940
+
1941
+ export function renderOffice() {
1942
+ const canvas = document.getElementById('office-canvas');
1943
+ const { w, h } = canvasSize(canvas);
1944
+ if (!w || !h) return; // no layout yet — nothing to draw into
1945
+ const dpr = window.devicePixelRatio || 1;
1946
+ canvas.width = w * dpr;
1947
+ canvas.height = h * dpr;
1948
+ const ctx = canvas.getContext('2d');
1949
+ ctx.scale(dpr, dpr);
1950
+ ctx.imageSmoothingEnabled = false;
1951
+ const theme = getThemeColors();
1952
+
1953
+ // Layer 1: Floor (warm wood planks)
1954
+ drawFloor(ctx, w, h);
1955
+ // Layer 2: Walls (warm plaster)
1956
+ drawWalls(ctx, w);
1957
+ // Layer 3: Windows (2 windows with day/night + light beams)
1958
+ drawWindows(ctx, w, theme);
1959
+ // Layer 4: Bookshelf runs under the windows (before the boards so easels overlap shelves)
1960
+ drawBookshelves(ctx, w);
1961
+ // Layer 5: Job boards
1962
+ drawJobBoardEasels(ctx, w);
1963
+ // Layer 6: Ambient particles
1964
+ drawParticles(ctx, w, h);
1965
+
1966
+ // Per-repo pod layout, then ambient decor in whatever floor is left over
1967
+ const layout = computeOfficeLayout(w, h);
1968
+ const rugs = layout.pods.map(podRugRect);
1969
+ const decor = computeDecorPlacement(rugs, w, h);
1970
+ drawDecor(ctx, decor, theme);
1971
+ for (const pod of layout.pods) drawPodRug(ctx, pod, theme);
1972
+ // Empty workstations on the row below a sparse office: dark screens, no one seated
1973
+ const spares = computeSpareDesks(layout.pods, decor, w, h);
1974
+ for (const d of spares) drawWorkstation(ctx, d.x, d.y, 'DISCONNECTED', theme, d.variant, null);
1975
+ // Conference table in the open band below them; idle agents wander over
1976
+ currentConf = computeConference(rugs, spares, decor, w, h);
1977
+ currentObstacles = walkObstacles(rugs, decor, spares, currentConf, [...layout.positions.values()]);
1978
+ currentSeats = wanderSeats(currentConf, decor);
1979
+ updateWander(currentSeats);
1980
+ if (currentConf) drawConference(ctx, currentConf);
1981
+ // Sofa sitters go down after drawDecor laid the sofas out above.
1982
+ drawChatSitters(ctx);
1983
+
1984
+ for (const [sessionId, agent] of agents) {
1985
+ const pos = layout.positions.get(sessionId);
1986
+ if (!pos) continue; // never let one miss blank the office every frame
1987
+ const { x: sx, y: sy } = pos;
1988
+ const isActive = sessionId === activeSessionId;
1989
+ const state = agent.state;
1990
+ const alive = state !== 'DISCONNECTED';
1991
+ // Colleague agents (you don't own them) render dimmed with an owner label (phase 3).
1992
+ const readOnly = !canControlAgent(agent);
1993
+ if (readOnly) ctx.globalAlpha = 0.5;
1994
+
1995
+ // Desk variant keys on the agent id (like the character sprite), not the
1996
+ // iteration index, so an unrelated exit never restyles later desks.
1997
+ const deskVariant = charVariant(sessionId) % 2;
1998
+ const charX = sx + (deskVariant === 0 ? DESK_CHAR_X : DESK2_CHAR_X) * Z;
1999
+ // Fall back to sheet 0 if this variant's PNG failed to load
2000
+ const sheet = SPRITES['char' + charVariant(sessionId)] || SPRITES.char0;
2001
+ // While a walk/wander anim exists the overlay pass draws the character,
2002
+ // and a conference sitter renders at the table — the desk stays, empty.
2003
+ const away = walkAnims.has(sessionId) || wanderAnims.has(sessionId) || seatClaims.has(sessionId);
2004
+ drawWorkstation(ctx, sx, sy, state, theme, deskVariant, agent);
2005
+ drawMonitorGlow(ctx, sx, sy, state, theme);
2006
+ if (alive && sheet && !away) {
2007
+ let row, col;
2008
+ if (state === 'WORKING') {
2009
+ // Seated at the keyboard, typing (2-frame cycle), back to the viewer.
2010
+ row = CHAR_ROW_UP;
2011
+ col = CHAR_COL_SIT + (prefersReducedMotion ? 0 : Math.floor(Date.now() / 300) % 2);
2012
+ } else if (state === 'IDLE') {
2013
+ row = CHAR_ROW_DOWN;
2014
+ col = CHAR_COL_STAND;
2015
+ } else {
2016
+ // WAITING / MESSAGE: chair turned around, facing the viewer
2017
+ row = CHAR_ROW_DOWN;
2018
+ col = CHAR_COL_SIT;
2019
+ }
2020
+ ctx.drawImage(
2021
+ sheet,
2022
+ col * CHAR_FRAME_W, row * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H,
2023
+ charX - 1, sy + DESK_CHAR_Y * Z,
2024
+ CHAR_FRAME_W * CHAR_SCALE, CHAR_FRAME_H * CHAR_SCALE
2025
+ );
2026
+ }
2027
+
2028
+ if (agent.name) {
2029
+ const nameY = sy + 37 * Z;
2030
+ const nameX = sx + (WS_W / 2) * Z;
2031
+ ctx.textAlign = 'center';
2032
+
2033
+ if (!isActive) {
2034
+ ctx.font = LABEL_FONT;
2035
+ ctx.fillStyle = `rgba(${theme.tr}, ${theme.tg}, ${theme.tb}, 0.7)`;
2036
+ // maxWidth: a long (renamed) label squeezes into the tile instead of overflowing onto neighbors.
2037
+ ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
2038
+ } else {
2039
+ // Pulsing glow
2040
+ const pulse = Math.sin(Date.now() / 600) * 0.5 + 0.5;
2041
+ ctx.font = LABEL_FONT;
2042
+ ctx.shadowColor = `rgba(${GOLD_RGB}, ${0.4 + pulse * 0.5})`;
2043
+ ctx.shadowBlur = 6 + pulse * 8;
2044
+ ctx.fillStyle = '#d4a847';
2045
+ ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
2046
+ ctx.fillText(agent.name, nameX, nameY, WS_W * Z);
2047
+ ctx.shadowBlur = 0;
2048
+ }
2049
+ ctx.textAlign = 'start';
2050
+ }
2051
+
2052
+ if (state === 'MESSAGE' && !away) {
2053
+ // Beside the character's head — only while the character is actually
2054
+ // at the desk; a walker's bubble would float over an empty chair.
2055
+ drawMessageBubble(ctx, charX + 5 * Z, sy + 5 * Z, theme);
2056
+ }
2057
+ if (state === 'DISCONNECTED') {
2058
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
2059
+ ctx.fillRect(sx, sy, WS_W * Z, WS_H * Z);
2060
+ const cx = sx + Math.floor(WS_W / 2) * Z;
2061
+ const cy = sy + Math.floor(WS_H / 2) * Z - 2 * Z;
2062
+ ctx.fillStyle = '#ff4444';
2063
+ for (let i = -3; i <= 3; i++) {
2064
+ ctx.fillRect(cx + i * Z, cy + i * Z, Z, Z);
2065
+ ctx.fillRect(cx + i * Z, cy - i * Z, Z, Z);
2066
+ }
2067
+ }
2068
+
2069
+ if (agent.conflicts && agent.conflicts.length > 0) {
2070
+ drawConflictBadge(ctx, sx, sy);
2071
+ }
2072
+
2073
+ // Owner label (full opacity) under the dimmed colleague tile.
2074
+ if (readOnly && agent.ownerName) {
2075
+ ctx.globalAlpha = 1;
2076
+ ctx.textAlign = 'center';
2077
+ ctx.font = '9px monospace';
2078
+ // Concrete fallback (canvas fillStyle ignores invalid colors like CSS vars).
2079
+ ctx.fillStyle = /^#[0-9a-fA-F]{3,8}$/.test(String(agent.ownerColor)) ? agent.ownerColor : '#9ca3af';
2080
+ // maxWidth clamps a long owner name to the tile so it can't overflow onto neighbors.
2081
+ ctx.fillText(agent.ownerName, sx + (WS_W / 2) * Z, sy + 46 * Z, WS_W * Z);
2082
+ ctx.textAlign = 'start';
2083
+ }
2084
+ ctx.globalAlpha = 1;
2085
+ }
2086
+
2087
+ // Transient overlays: flying dispatch papers, characters walking in/out
2088
+ drawMotion(ctx, w, h, layout);
2089
+ }
2090
+
2091
+ // True when (x, y) is on one of the painted job boards — the frame or its
2092
+ // stand. Exported so the hit box can be checked against computeBoardLayout
2093
+ // without a canvas.
2094
+ export function hitJobBoard(x, y, panelWidth) {
2095
+ const board = computeBoardLayout(panelWidth);
2096
+ if (!board.visible) return false;
2097
+ if (y < board.boardTop || y > board.footY) return false;
2098
+ const half = Math.floor(board.boardW / 2);
2099
+ return board.sections.some(({ centerX }) => x >= centerX - half && x <= centerX + half);
2100
+ }
2101
+
2102
+ export function setupOfficeClick() {
2103
+ const canvas = document.getElementById('office-canvas');
2104
+ canvas.addEventListener('click', (e) => {
2105
+ const rect = canvas.getBoundingClientRect();
2106
+ const x = e.clientX - rect.left;
2107
+ const y = e.clientY - rect.top;
2108
+ const layout = computeOfficeLayout(rect.width, rect.height);
2109
+ // The painted boards are the office's view of the Jobs tab, so clicking one
2110
+ // opens it (showJobBoard repaints the tab strip itself). Checked first: the
2111
+ // boards sit against the wall, above every desk and seat.
2112
+ // Only a tap here moves a phone to the terminal panel: switchToSession
2113
+ // and showJobBoard are also reached by spawns, exits and the reconnect
2114
+ // replay, none of which should yank a phone off the view it is on.
2115
+ if (hitJobBoard(x, y, rect.width)) { showJobBoard(); setView('terminal'); return; }
2116
+ // A seated agent's visible character is at the conference table or on a
2117
+ // sofa, so click-to-focus follows it there (the empty desk still works).
2118
+ // The claim indexes the pooled seat list, NOT conf.seats — a sofa claim is
2119
+ // past the end of conf.seats, which read undefined and threw here.
2120
+ for (const [sid, seatIdx] of seatClaims) {
2121
+ const s = currentSeats[seatIdx];
2122
+ if (!s) continue;
2123
+ if (x >= s.x - Z && x <= s.x + CHAR_W + Z && y >= s.y - Z && y <= s.y + CHAR_H + Z) {
2124
+ switchToSession(sid);
2125
+ setView('terminal');
2126
+ return;
2127
+ }
2128
+ }
2129
+ for (const [sessionId] of agents) {
2130
+ const pos = layout.positions.get(sessionId);
2131
+ if (!pos) continue;
2132
+ const sw = WS_W * Z, sh = (WS_H + 6) * Z;
2133
+ if (x >= pos.x - Z && x <= pos.x + sw + Z &&
2134
+ y >= pos.y - Z && y <= pos.y + sh + Z) {
2135
+ switchToSession(sessionId);
2136
+ setView('terminal');
2137
+ return;
2138
+ }
2139
+ }
2140
+ });
2141
+ }
2142
+
2143
+ export function startAnimationLoop() {
2144
+ function loop() {
2145
+ const hasLiving = [...agents.values()].some(a => a.state !== 'DISCONNECTED');
2146
+ // hasMotion keeps the walk-out of the last departing agent rendering.
2147
+ // A hidden canvas (phone on another view, or the diff viewer open) has no
2148
+ // client box: skip the redraw rather than paint 60fps into nothing. Every
2149
+ // animation runs on wall-clock time, so frames skipped here are not owed.
2150
+ if ((hasLiving || hasMotion()) && document.getElementById('office-canvas').clientWidth) renderOffice();
2151
+ requestAnimationFrame(loop);
2152
+ }
2153
+ loop();
2154
+ }