@henryz2004/agency 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
@@ -0,0 +1,983 @@
1
+ // sprites.js — ALL procedural pixel-art primitives for the office. Nothing here
2
+ // blits a sprite sheet; every glyph is drawn with px() rects into a low-res
3
+ // buffer that CSS scales up crisply (image-rendering:pixelated).
4
+ //
5
+ // ONE consistent style: chunky 1px-grid pixel art, warm palette, soft top
6
+ // highlight + bottom shade on every solid so volumes read.
7
+
8
+ // ---- tiny helpers ----------------------------------------------------------
9
+ export function px(ctx, x, y, w, h, c) {
10
+ ctx.fillStyle = c;
11
+ ctx.fillRect(x | 0, y | 0, w | 0, h | 0);
12
+ }
13
+
14
+ export function shade(hex, amt) {
15
+ const h = hex.replace('#', '');
16
+ const n = parseInt(h.length === 3 ? h.replace(/(.)/g, '$1$1') : h, 16);
17
+ let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
18
+ r = Math.max(0, Math.min(255, r + amt));
19
+ g = Math.max(0, Math.min(255, g + amt));
20
+ b = Math.max(0, Math.min(255, b + amt));
21
+ return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
22
+ }
23
+
24
+ export function rng(seed) {
25
+ let s = seed >>> 0 || 1;
26
+ return () => {
27
+ s = (Math.imul(s, 1103515245) + 12345) & 0x7fffffff;
28
+ return s / 0x7fffffff;
29
+ };
30
+ }
31
+
32
+ export function hashInt(s) {
33
+ let h = 2166136261 >>> 0;
34
+ s = String(s);
35
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
36
+ return h >>> 0;
37
+ }
38
+
39
+ // ---- palette ---------------------------------------------------------------
40
+ // Warm, cozy, cohesive. Model tiers keep the established color language:
41
+ // opus=gold, sonnet=cyan, haiku=green, codex=orange.
42
+ export const TIER = {
43
+ opus: { screen: '#ffce5e', glow: 'rgba(255,206,94,0.30)', led: '#ffd166', code: '#fff0c0' },
44
+ sonnet: { screen: '#5cd0ff', glow: 'rgba(92,208,255,0.30)', led: '#5cd0ff', code: '#d0f4ff' },
45
+ haiku: { screen: '#6cff9a', glow: 'rgba(108,255,154,0.28)', led: '#6cff9a', code: '#d6ffe2' },
46
+ codex: { screen: '#ff9a4d', glow: 'rgba(255,138,61,0.30)', led: '#ff8a3d', code: '#ffe0c9' },
47
+ };
48
+ export function tierFor(model) {
49
+ const m = (model || '').toLowerCase();
50
+ if (m.includes('codex')) return TIER.codex;
51
+ if (m.includes('haiku')) return TIER.haiku;
52
+ if (m.includes('sonnet')) return TIER.sonnet;
53
+ return TIER.opus; // opus / fable / default
54
+ }
55
+
56
+ // ---- model color palette (salvaged from the old sprites.js) ----------------
57
+ // Used by app.js's model-mix bar/legend and the headcount comparison heads.
58
+ // Kept here (the sole sprites module) so app.js's `import { drawHead, colorFor }
59
+ // from './sprites.js'` resolves after proc graduation.
60
+ export const MODEL_COLORS = {
61
+ 'opus': { screen: '#ffd166', glow: 'rgba(255,209,102,0.25)', code: '#fff0c0' },
62
+ 'sonnet': { screen: '#5cd0ff', glow: 'rgba(92,208,255,0.25)', code: '#d0f4ff' },
63
+ 'haiku': { screen: '#6cff9a', glow: 'rgba(108,255,154,0.22)', code: '#d6ffe2' },
64
+ };
65
+
66
+ // Color for a model slug (used by the model-mix panel). Falls back to a stable
67
+ // hash-derived hue for any model not in MODEL_COLORS.
68
+ export function colorFor(model) {
69
+ if (!model) return MODEL_COLORS.opus;
70
+ const m = model.toLowerCase();
71
+ if (m.includes('codex')) return { screen: '#ff8a3d', glow: 'rgba(255,138,61,0.25)', code: '#ffe0c9' };
72
+ if (m.includes('opus') || m.includes('fable')) return MODEL_COLORS.opus;
73
+ if (m.includes('sonnet')) return MODEL_COLORS.sonnet;
74
+ if (m.includes('haiku')) return MODEL_COLORS.haiku;
75
+ let h = 2166136261 >>> 0;
76
+ for (let i = 0; i < m.length; i++) {
77
+ h ^= m.charCodeAt(i);
78
+ h = Math.imul(h, 16777619) >>> 0;
79
+ }
80
+ const hue = h % 360;
81
+ const screen = hslToHex(hue, 72, 68);
82
+ const glow = `hsla(${hue} 72% 68% / 0.24)`;
83
+ const code = hslToHex(hue, 60, 85);
84
+ return { screen, glow, code };
85
+ }
86
+
87
+ function hslToHex(h, s, l) {
88
+ s /= 100; l /= 100;
89
+ const k = n => (n + h / 30) % 12;
90
+ const a = s * Math.min(l, 1 - l);
91
+ const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
92
+ const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0');
93
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
94
+ }
95
+
96
+ // A single pixel "head" used for the headcount comparison row in app.js.
97
+ export function drawHead(ctx, x, y, { skin = '#f0c8a0', hair = '#2b2233', shirt = '#5d9ce0' } = {}) {
98
+ px(ctx, x + 1, y, 6, 2, hair);
99
+ px(ctx, x, y + 2, 8, 2, hair);
100
+ px(ctx, x + 1, y + 2, 6, 4, skin);
101
+ px(ctx, x + 2, y + 4, 1, 1, '#1a1a22');
102
+ px(ctx, x + 5, y + 4, 1, 1, '#1a1a22');
103
+ px(ctx, x, y + 6, 8, 3, shirt);
104
+ }
105
+
106
+ // Skin / hair / shirt variety palettes — warm and varied but harmonious.
107
+ // Exported so scripts/charsheet.mjs can enumerate the full appearance space.
108
+ export const SKINS = ['#ffe0bd', '#f2cda4', '#e8b88a', '#d49a6a', '#b87a4e', '#a86a44', '#8a5a3c', '#6b4426'];
109
+ export const HAIRS = ['#2b2233', '#4a3326', '#6b4a2e', '#8a5a30', '#c98a3a', '#9a9aa6', '#1d1d24', '#5a2d2d',
110
+ '#b5532a', '#e6c878', '#e07db0', '#5d7de0', '#3aa6a0', '#d8d8e0']; // + ginger, blonde, pink, blue, teal, silver
111
+ export const SHIRTS = ['#d9694f', '#5d9ce0', '#5dc98a', '#e0b05d', '#a87de0', '#5dc9c9', '#e07db0', '#c0584f', '#4f8fb0',
112
+ '#7d8a3a', '#3a6f8a', '#8a8f99', '#d97f3a', '#6f5dc9']; // + olive, steel, grey, orange, indigo
113
+ export const HAIR_STYLES = 7; // distinct hairStyle values — see drawHeadFace's switch
114
+ export const TOPS = 6; // distinct clothing styles — see drawTorso
115
+
116
+ // ---- the back wall ---------------------------------------------------------
117
+ // A textured cream plaster wall (no sky — wall fills the whole band from the top
118
+ // of the canvas) with a wood rail, windows looking out on blue sky, a wall clock,
119
+ // framed pictures, and a glass whiteboard — all procedural.
120
+ // `wallH` is the full wall band height (the floor begins at y = wallH).
121
+ // ---- time-of-day mood ------------------------------------------------------
122
+ // The office mood shifts with the user's LOCAL hour: soft dawn, bright midday,
123
+ // warm dusk, dark-blue night. Each mood tints the wall plaster, the window sky,
124
+ // the ambient floor wash, and how strongly the interior pendant lights read.
125
+ // Night is kept READABLE — only the empty floor + walls darken; the team rugs
126
+ // and desks are drawn ON TOP of the wash, so they stay full color. office.js
127
+ // reads the clock (new Date() — fine client-side) and passes the mood in.
128
+ export const MOODS = {
129
+ dawn: { wall: '#e7dde0', sky: ['#c7a8d8', '#f3ccc2'], wash: ['rgba(255,206,196,0.16)', 'rgba(255,222,206,0.05)', 'rgba(48,30,40,0.16)'], glow: 0.35 },
130
+ day: { wall: '#efe6d4', sky: ['#7fc0f0', '#a9d8f7'], wash: ['rgba(255,238,200,0.16)', 'rgba(255,238,200,0.03)', 'rgba(40,22,6,0.14)'], glow: 0.0 },
131
+ dusk: { wall: '#e4cdb2', sky: ['#f3a05e', '#f8cf94'], wash: ['rgba(255,165,85,0.20)', 'rgba(255,150,95,0.06)', 'rgba(58,24,12,0.24)'], glow: 0.6 },
132
+ night: { wall: '#3a3f55', sky: ['#16223f', '#283a5e'], wash: ['rgba(28,42,82,0.34)', 'rgba(22,32,62,0.22)', 'rgba(8,12,30,0.42)'], glow: 1.0 },
133
+ };
134
+ // Continuous time-of-day mood: interpolate between anchor moods so the lighting
135
+ // glides smoothly across the day instead of snapping between four states. Integer
136
+ // hours (the live app) land on sensible values; fractional hours (a time-lapse)
137
+ // get a smooth gradient.
138
+ const _hx = (c) => { c = c.replace('#', ''); return [parseInt(c.slice(0, 2), 16), parseInt(c.slice(2, 4), 16), parseInt(c.slice(4, 6), 16)]; };
139
+ const _lerp = (a, b, t) => a + (b - a) * t;
140
+ const _toHex = (r, g, b) => '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
141
+ const _lerpHex = (a, b, t) => { const x = _hx(a), y = _hx(b); return _toHex(_lerp(x[0], y[0], t), _lerp(x[1], y[1], t), _lerp(x[2], y[2], t)); };
142
+ const _rgba = (s) => (s.match(/[\d.]+/g) || [0, 0, 0, 0]).map(Number);
143
+ const _lerpRgba = (a, b, t) => { const x = _rgba(a), y = _rgba(b); return `rgba(${Math.round(_lerp(x[0], y[0], t))},${Math.round(_lerp(x[1], y[1], t))},${Math.round(_lerp(x[2], y[2], t))},${_lerp(x[3] || 0, y[3] || 0, t).toFixed(3)})`; };
144
+ const _lerpMood = (A, B, t) => ({
145
+ wall: _lerpHex(A.wall, B.wall, t),
146
+ sky: [_lerpHex(A.sky[0], B.sky[0], t), _lerpHex(A.sky[1], B.sky[1], t)],
147
+ wash: [_lerpRgba(A.wash[0], B.wash[0], t), _lerpRgba(A.wash[1], B.wash[1], t), _lerpRgba(A.wash[2], B.wash[2], t)],
148
+ glow: _lerp(A.glow, B.glow, t),
149
+ });
150
+ const _MOOD_KEYS = [[0, MOODS.night], [6, MOODS.night], [8, MOODS.dawn], [10.5, MOODS.day], [16, MOODS.day], [18.5, MOODS.dusk], [20.5, MOODS.night], [24, MOODS.night]];
151
+ export function moodForHour(h) {
152
+ h = ((h % 24) + 24) % 24;
153
+ for (let i = 0; i < _MOOD_KEYS.length - 1; i++) {
154
+ const [h0, m0] = _MOOD_KEYS[i], [h1, m1] = _MOOD_KEYS[i + 1];
155
+ if (h >= h0 && h <= h1) return _lerpMood(m0, m1, (h - h0) / (h1 - h0));
156
+ }
157
+ return MOODS.night;
158
+ }
159
+
160
+ export function drawWall(ctx, bufW, wallH, frame, mood = MOODS.day) {
161
+ // --- plaster wall fills the band from the top ---
162
+ const wy = 0;
163
+ ctx.fillStyle = mood.wall;
164
+ ctx.fillRect(0, wy, bufW, wallH);
165
+ // soft horizontal paneling lines (very faint) for texture
166
+ ctx.fillStyle = 'rgba(150,120,80,0.05)';
167
+ for (let y = wy + 12; y < wy + wallH - 8; y += 10) ctx.fillRect(0, y, bufW, 1);
168
+ // a subtle ceiling shade along the very top so the wall reads as receding
169
+ ctx.fillStyle = 'rgba(80,60,40,0.12)';
170
+ ctx.fillRect(0, wy, bufW, 3);
171
+ ctx.fillStyle = 'rgba(80,60,40,0.05)';
172
+ ctx.fillRect(0, wy + 3, bufW, 2);
173
+
174
+ // --- wall hangings: clock, pictures, glass board, windows ---
175
+ // Reserve the FIXED features' x-ranges first, then tile windows ONLY where they
176
+ // don't collide — so a narrow wall naturally shows fewer windows (down to one on
177
+ // a 1-desk office) and the glass board never lands on top of a window.
178
+ const winY = wy + 14, winW = 26, winH = 32;
179
+ const clockX = Math.round(bufW * 0.46);
180
+ const boardX = Math.round(bufW * 0.5) + 8, boardW = 40;
181
+ const occupied = [
182
+ [8, 38], // framed picture + calendar (upper-left)
183
+ [clockX - 4, clockX + 14], // wall clock
184
+ [boardX - 3, boardX + boardW + 3], // glass board
185
+ ];
186
+ for (let x = 40; x < bufW - 36; x += 64) {
187
+ if (occupied.some(([a, b]) => x + winW > a && x < b)) continue; // would overlap a feature
188
+ drawWindow(ctx, x, winY, winW, winH, frame, mood.sky);
189
+ }
190
+ drawClock(ctx, clockX, wy + 16, frame); // wall clock
191
+ drawPicture(ctx, 14, wy + 18); // framed landscape, upper-left
192
+ drawCalendar(ctx, 15, wy + 34); // calendar tucked beneath it
193
+ drawGlassBoard(ctx, boardX, wy + 16); // small green glass whiteboard
194
+
195
+ // --- wood rail + baseboard at the bottom of the wall ---
196
+ ctx.fillStyle = '#b07c44';
197
+ ctx.fillRect(0, wy + wallH - 7, bufW, 4); // wood rail
198
+ ctx.fillStyle = 'rgba(255,255,255,0.18)';
199
+ ctx.fillRect(0, wy + wallH - 7, bufW, 1); // rail highlight
200
+ ctx.fillStyle = '#7d5630';
201
+ ctx.fillRect(0, wy + wallH - 3, bufW, 3); // baseboard
202
+ }
203
+
204
+ function drawWindow(ctx, x, y, w, h, frame, sky = ['#7fc0f0', '#a9d8f7']) {
205
+ // frame
206
+ px(ctx, x - 1, y - 1, w + 2, h + 2, '#3a4a63');
207
+ // sky glass with a soft gradient + a diagonal glare streak (tinted by time of day)
208
+ const g = ctx.createLinearGradient(x, y, x, y + h);
209
+ g.addColorStop(0, sky[0]);
210
+ g.addColorStop(1, sky[1]);
211
+ ctx.fillStyle = g;
212
+ ctx.fillRect(x, y, w, h);
213
+ // glare
214
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
215
+ ctx.fillRect(x + 3, y + 2, 2, h - 4);
216
+ ctx.fillRect(x + 6, y + 2, 1, h - 4);
217
+ // muntins (cross bars)
218
+ px(ctx, x + (w >> 1), y, 1, h, '#3a4a63');
219
+ px(ctx, x, y + (h >> 1), w, 1, '#3a4a63');
220
+ // a tiny potted plant on some sills (deterministic)
221
+ if ((hashInt('sill' + x) & 3) === 0) {
222
+ px(ctx, x + w - 7, y + h - 4, 4, 3, '#b5602e');
223
+ px(ctx, x + w - 6, y + h - 7, 2, 3, '#4cb364');
224
+ }
225
+ }
226
+
227
+ function drawClock(ctx, x, y, frame) {
228
+ px(ctx, x - 1, y - 1, 12, 12, '#cdd6e6'); // rim
229
+ px(ctx, x, y, 10, 10, '#fbf7ee'); // face
230
+ px(ctx, x, y, 10, 1, '#e3ddd0');
231
+ // ticks
232
+ ctx.fillStyle = '#9a9488';
233
+ px(ctx, x + 4, y + 1, 1, 1, '#9a9488');
234
+ px(ctx, x + 4, y + 8, 1, 1, '#9a9488');
235
+ px(ctx, x + 1, y + 4, 1, 1, '#9a9488');
236
+ px(ctx, x + 8, y + 4, 1, 1, '#9a9488');
237
+ // hands (hour fixed-ish, minute sweeps slowly)
238
+ const min = (frame * 6) % 360;
239
+ const a = (min * Math.PI) / 180;
240
+ px(ctx, x + 5, y + 5, 1, 1, '#23262e');
241
+ px(ctx, x + 5, y + 2, 1, 3, '#23262e'); // hour up
242
+ px(ctx, x + 5 + Math.round(Math.sin(a) * 3), y + 5 - Math.round(Math.cos(a) * 3), 1, 1, '#c0473a');
243
+ }
244
+
245
+ function drawPicture(ctx, x, y) {
246
+ px(ctx, x, y, 16, 12, '#6b4a2e'); // frame
247
+ px(ctx, x + 1, y + 1, 14, 10, '#bfe3f5'); // sky
248
+ px(ctx, x + 1, y + 7, 14, 4, '#e9c986'); // sandy ground
249
+ px(ctx, x + 3, y + 4, 4, 4, '#e7a23a'); // sun-ish
250
+ px(ctx, x + 1, y + 6, 14, 1, '#cda35e');
251
+ }
252
+
253
+ function drawCalendar(ctx, x, y) {
254
+ px(ctx, x, y, 13, 12, '#f4f1ea');
255
+ px(ctx, x, y, 13, 3, '#c0473a'); // red header
256
+ ctx.fillStyle = '#b9c0cc';
257
+ for (let r = 0; r < 3; r++)
258
+ for (let c = 0; c < 4; c++) px(ctx, x + 1 + c * 3, y + 4 + r * 3, 2, 2, '#b9c0cc');
259
+ px(ctx, x + 7, y + 7, 2, 2, '#e0855d'); // a marked day
260
+ }
261
+
262
+ function drawGlassBoard(ctx, x, y) {
263
+ px(ctx, x - 1, y - 1, 40, 22, '#2e5b4a'); // dark green board
264
+ px(ctx, x, y, 38, 20, '#356a56');
265
+ px(ctx, x, y, 38, 1, 'rgba(255,255,255,0.12)');
266
+ // faint scribbles + a tiny mountain logo (a la NORTHRIDGE STUDIO)
267
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
268
+ px(ctx, x + 4, y + 4, 6, 1, 'rgba(255,255,255,0.55)');
269
+ px(ctx, x + 4, y + 7, 10, 1, 'rgba(255,255,255,0.35)');
270
+ px(ctx, x + 4, y + 10, 7, 1, 'rgba(255,255,255,0.35)');
271
+ // little chart bars
272
+ ctx.fillStyle = '#8fdcb6';
273
+ px(ctx, x + 24, y + 13, 2, 4, '#8fdcb6');
274
+ px(ctx, x + 27, y + 11, 2, 6, '#8fdcb6');
275
+ px(ctx, x + 30, y + 8, 2, 9, '#8fdcb6');
276
+ }
277
+
278
+ // ---- the floor -------------------------------------------------------------
279
+ // Warm wood planks — the cozy WeWork / Stardew vibe rather than cold tile.
280
+ export function drawFloor(ctx, bufW, top, bufH) {
281
+ const plank = 14;
282
+ for (let y = top, row = 0; y < bufH; y += plank, row++) {
283
+ ctx.fillStyle = row % 2 ? '#c79a5e' : '#bb8e52'; // alternating board shade
284
+ ctx.fillRect(0, y, bufW, plank);
285
+ ctx.fillStyle = 'rgba(70,40,12,0.28)'; // seam below each board row
286
+ ctx.fillRect(0, y + plank - 1, bufW, 1);
287
+ ctx.fillStyle = 'rgba(255,240,210,0.10)'; // top sheen of each board
288
+ ctx.fillRect(0, y, bufW, 1);
289
+ ctx.fillStyle = 'rgba(70,40,12,0.16)'; // staggered board-end joints
290
+ for (let x = (row % 2 ? 0 : 44); x < bufW; x += 88) ctx.fillRect(x, y, 1, plank - 1);
291
+ }
292
+ }
293
+
294
+ // Soft warm daylight wash from the windows fading toward the front.
295
+ export function drawDaylight(ctx, bufW, top, bufH, mood = MOODS.day) {
296
+ const g = ctx.createLinearGradient(0, top, 0, bufH);
297
+ g.addColorStop(0, mood.wash[0]);
298
+ g.addColorStop(0.5, mood.wash[1]);
299
+ g.addColorStop(1, mood.wash[2]);
300
+ ctx.fillStyle = g;
301
+ ctx.fillRect(0, top, bufW, bufH - top);
302
+ }
303
+
304
+ // ---- a worker ("person at a desk") -----------------------------------------
305
+ // A little front-facing person: hair/skin/shirt vary per seed; hands type when
306
+ // working. Drawn so the head clears the cubicle divider and the body sits behind
307
+ // the desk. (cx, seatY) = centre-x, the chair seat line.
308
+
309
+ // Deterministic per-seed appearance (skin / hair / shirt / style / glasses /
310
+ // beard). Extracted so the seated worker and the standing walker derive the SAME
311
+ // look from a seed — keep the draw ORDER below so existing seeds are unchanged.
312
+ export function personLook(seed) {
313
+ const vr = rng(seed * 2654435761 + 7);
314
+ return {
315
+ skin: SKINS[Math.floor(vr() * SKINS.length)],
316
+ hair: HAIRS[Math.floor(vr() * HAIRS.length)],
317
+ shirt: SHIRTS[Math.floor(vr() * SHIRTS.length)],
318
+ hairStyle: Math.floor(vr() * HAIR_STYLES),
319
+ top: Math.floor(vr() * TOPS),
320
+ glasses: vr() < 0.3,
321
+ beard: vr() < 0.22,
322
+ };
323
+ }
324
+
325
+ // Hair + head + face + neck for one worker, from a personLook(). Shared by
326
+ // drawPerson (seated) and drawWalker (standing) so a worker looks identical either way.
327
+ function drawHeadFace(ctx, cx, hy, look, blink) {
328
+ const { skin, hair, hairStyle, glasses, beard } = look;
329
+ const hx = cx - 6;
330
+ const fy = hy + 4;
331
+ // --- hair style (7 variants). `sides`/`sideH` control the temple hair that
332
+ // frames the face; bald shaves the sides (bare crown). All styles stay flush
333
+ // to the skull — no protruding tufts that read as detached nubs at this size. ---
334
+ let sides = true, sideH = 7;
335
+ switch (hairStyle) {
336
+ case 1: // tall quiff / pompadour
337
+ px(ctx, hx, hy - 2, 12, 8, hair); px(ctx, hx + 3, hy - 4, 6, 3, hair); break;
338
+ case 2: // side part — a parting line cut INTO the cap (not a raised tab)
339
+ px(ctx, hx, hy, 12, 6, hair); px(ctx, hx + 4, hy, 1, 5, shade(hair, -34)); break;
340
+ case 3: // cropped / buzz
341
+ px(ctx, hx + 1, hy + 1, 10, 4, hair); break;
342
+ case 4: // long — frames the face down past the jaw
343
+ px(ctx, hx, hy, 12, 6, hair); sideH = 14; break;
344
+ case 5: // bald — bare crown, no side hair
345
+ px(ctx, hx + 1, hy + 1, 10, 4, skin);
346
+ px(ctx, hx + 2, hy + 1, 6, 1, shade(skin, 18)); // pate shine
347
+ sides = false; break;
348
+ case 6: // afro / curls — a big rounded cap
349
+ px(ctx, hx - 1, hy - 2, 14, 8, hair); px(ctx, hx, hy - 3, 12, 2, hair); sideH = 9; break;
350
+ default: // 0 classic
351
+ px(ctx, hx, hy, 12, 6, hair);
352
+ }
353
+ if (sides) {
354
+ px(ctx, hx - 1, hy + 4, 2, sideH, hair); // sides
355
+ px(ctx, hx + 11, hy + 4, 2, sideH, hair);
356
+ }
357
+ px(ctx, cx - 5, fy, 11, 10, skin);
358
+ px(ctx, cx - 5, fy, 11, 2, shade(skin, 16)); // forehead light
359
+ if (blink) {
360
+ px(ctx, cx - 3, fy + 5, 2, 1, shade(skin, -40));
361
+ px(ctx, cx + 2, fy + 5, 2, 1, shade(skin, -40));
362
+ } else {
363
+ px(ctx, cx - 3, fy + 4, 2, 2, '#1b1b22');
364
+ px(ctx, cx + 2, fy + 4, 2, 2, '#1b1b22');
365
+ }
366
+ if (glasses) {
367
+ px(ctx, cx - 4, fy + 3, 4, 4, '#23262e');
368
+ px(ctx, cx + 1, fy + 3, 4, 4, '#23262e');
369
+ px(ctx, cx - 3, fy + 4, 2, 2, blink ? skin : '#1b1b22');
370
+ px(ctx, cx + 2, fy + 4, 2, 2, blink ? skin : '#1b1b22');
371
+ px(ctx, cx, fy + 4, 1, 1, '#23262e'); // bridge
372
+ }
373
+ if (beard) px(ctx, cx - 5, fy + 7, 11, 3, shade(skin, -48));
374
+ px(ctx, cx - 1, fy + 8, 3, 1, shade(skin, -30)); // mouth
375
+ px(ctx, cx - 1, fy + 10, 3, 2, shade(skin, -14)); // neck
376
+ }
377
+
378
+ // Torso + clothing for one worker, from a personLook(). Shared by drawPerson
379
+ // (seated) and drawWalker (standing) so the outfit matches in both. (cx, ty) =
380
+ // centre-x and the shoulder line. Arms/legs are drawn by the caller; this is
381
+ // just the shirt block + a per-`top` clothing detail.
382
+ function drawTorso(ctx, cx, ty, look) {
383
+ const s = look.shirt;
384
+ px(ctx, cx - 5, ty, 11, 3, s); // shoulders (tapered in)
385
+ px(ctx, cx - 6, ty + 3, 13, 8, s); // torso body
386
+ px(ctx, cx + 3, ty + 3, 4, 8, shade(s, -26)); // right-side shade
387
+ switch (look.top) {
388
+ case 1: // hoodie — neck roll, kangaroo pocket, drawstrings
389
+ px(ctx, cx - 5, ty, 11, 2, shade(s, -34));
390
+ px(ctx, cx - 3, ty + 6, 7, 3, shade(s, -30));
391
+ px(ctx, cx - 3, ty + 6, 7, 1, shade(s, -14));
392
+ px(ctx, cx - 1, ty + 2, 1, 4, '#eee8dc');
393
+ px(ctx, cx + 1, ty + 2, 1, 4, '#eee8dc');
394
+ break;
395
+ case 2: // button-up / collared shirt
396
+ px(ctx, cx - 3, ty, 3, 2, shade(s, 20)); // collar
397
+ px(ctx, cx + 1, ty, 3, 2, shade(s, 20));
398
+ px(ctx, cx - 1, ty, 2, 11, shade(s, 14)); // placket
399
+ for (let i = 0; i < 4; i++) px(ctx, cx, ty + 2 + i * 2, 1, 1, shade(s, -52)); // buttons
400
+ break;
401
+ case 3: // crewneck sweater — ribbed collar + contrast chest band
402
+ px(ctx, cx - 4, ty, 9, 2, shade(s, 24));
403
+ px(ctx, cx - 6, ty + 6, 13, 2, shade(s, -22));
404
+ break;
405
+ case 4: // blazer over a tee — lapels + a lighter inner panel
406
+ px(ctx, cx - 2, ty + 1, 5, 10, shade(s, 70));
407
+ px(ctx, cx - 5, ty, 3, 6, shade(s, -22));
408
+ px(ctx, cx + 3, ty, 3, 6, shade(s, -22));
409
+ px(ctx, cx + 3, ty + 3, 4, 8, shade(s, -36));
410
+ break;
411
+ case 5: // striped tee
412
+ px(ctx, cx - 6, ty + 4, 13, 1, shade(s, -40));
413
+ px(ctx, cx - 6, ty + 7, 13, 1, shade(s, -40));
414
+ px(ctx, cx - 6, ty + 10, 13, 1, shade(s, -40));
415
+ px(ctx, cx - 1, ty, 2, 4, shade(s, 18)); // small collar
416
+ break;
417
+ default: // 0 plain tee
418
+ px(ctx, cx - 1, ty, 2, 11, shade(s, 18)); // collar / placket
419
+ }
420
+ }
421
+
422
+ // A staggered idle "fidget": every ~FIDGET_PERIOD frames an idling worker briefly
423
+ // performs one of three small actions, so a roomful never moves in lockstep.
424
+ // Returns { kind 0..2, t:0..1 progress } while one plays, else null. Deterministic
425
+ // from seed+frame (frame advances ~7.7×/s, so a fidget runs ~3s every ~15s).
426
+ const FIDGET_PERIOD = 116, FIDGET_LEN = 22;
427
+ function idleFidget(seed, frame) {
428
+ const ph = (((frame + seed * 37) % FIDGET_PERIOD) + FIDGET_PERIOD) % FIDGET_PERIOD;
429
+ if (ph >= FIDGET_LEN) return null;
430
+ const kind = Math.floor((frame + seed * 37) / FIDGET_PERIOD) % 3;
431
+ return { kind, t: ph / FIDGET_LEN };
432
+ }
433
+
434
+ export function drawPerson(ctx, cx, headTopY, opts) {
435
+ const { seed = 1, activity = 'idle', frame = 0, unread = false } = opts;
436
+ const look = opts.look || personLook(seed);
437
+ const { skin, shirt } = look;
438
+
439
+ const typing = activity === 'working';
440
+ const shellRunning = activity === 'shell';
441
+ const idle = !typing && !shellRunning;
442
+ const fp = frame + (seed % 6);
443
+ // Vertical body bob. WORKING dips DOWN on the typing cadence (leaning in);
444
+ // shell/just-finished "breathe" up on a quick cadence; settled-idle breathes
445
+ // calmly on a slow one AND throws an occasional fidget (sip / stretch / lean).
446
+ const fidget = idle && !unread ? idleFidget(seed, frame) : null;
447
+ const breath = (shellRunning || unread) && fp % 8 < 4 ? 1 : 0;
448
+ const idleBreath = idle && !unread && fp % 16 < 8 ? 1 : 0;
449
+ const typeBob = typing && frame % 4 < 2 ? 1 : 0; // dips DOWN, not up
450
+ const hy = headTopY - breath - idleBreath + typeBob;
451
+ const blink = (frame + (seed % 11)) % 13 === 0;
452
+ const fy = hy + 4;
453
+
454
+ // hair + head + face + neck (shared with the standing drawWalker)
455
+ drawHeadFace(ctx, cx, hy, look, blink);
456
+
457
+ // --- torso (shirt) — slimmed so the worker reads as a compact little person.
458
+ // (The desk hides the lower half on a seated worker.)
459
+ const ty = fy + 12; // carries the breath/typeBob shift via hy
460
+ drawTorso(ctx, cx, ty, look); // shirt + per-`top` clothing
461
+ const arm = shade(shirt, -12);
462
+ // arms. UNREAD waves; a settled-idle worker fidgets; a working one strikes the
463
+ // keys; otherwise the arms rest at the desk.
464
+ if (unread && !typing && !shellRunning) {
465
+ const wig = frame % 4 < 2 ? 0 : 2; // slow side-to-side hand wiggle (~2 Hz)
466
+ px(ctx, cx - 8, ty + 3, 2, 7, arm); // left arm down
467
+ px(ctx, cx - 8, ty + 10, 2, 2, skin); // left hand at desk
468
+ px(ctx, cx + 6, ty, 2, 5, arm); // right upper arm, raised
469
+ px(ctx, cx + 7, ty - 5, 2, 6, arm); // right forearm up
470
+ px(ctx, cx + 6 + wig, ty - 9, 3, 3, skin); // waving hand
471
+ } else if (fidget && fidget.kind === 0) { // sip a coffee — hand to the mouth
472
+ px(ctx, cx - 8, ty + 3, 2, 7, arm); px(ctx, cx - 8, ty + 10, 2, 2, skin); // left rests
473
+ px(ctx, cx + 7, ty + 2, 2, 4, arm); // right upper arm
474
+ px(ctx, cx + 3, ty - 1, 2, 4, arm); // forearm angled toward the face
475
+ px(ctx, cx + 1, fy + 7, 4, 4, '#e4ded3'); // mug at the mouth
476
+ px(ctx, cx + 1, fy + 7, 4, 1, '#f2ede4');
477
+ px(ctx, cx + 5, fy + 8, 1, 2, '#c4bdb2'); // handle
478
+ if (frame % 4 < 2) px(ctx, cx + 2, fy + 4, 1, 1, '#b8bdc8'); // steam
479
+ } else if (fidget && fidget.kind === 1) { // stretch — both arms reach up & ease back
480
+ const up = Math.round(Math.sin(fidget.t * Math.PI) * 5);
481
+ px(ctx, cx - 8, ty + 1 - up, 2, 6, arm); px(ctx, cx - 8, ty - 4 - up, 2, 2, skin);
482
+ px(ctx, cx + 7, ty + 1 - up, 2, 6, arm); px(ctx, cx + 7, ty - 4 - up, 2, 2, skin);
483
+ } else if (fidget && fidget.kind === 2) { // lean back — elbows winged out, hands behind head
484
+ px(ctx, cx - 9, ty + 1, 2, 4, arm); px(ctx, cx - 9, ty - 2, 4, 2, arm);
485
+ px(ctx, cx + 7, ty + 1, 2, 4, arm); px(ctx, cx + 6, ty - 2, 4, 2, arm);
486
+ } else {
487
+ // typing strikes alternate L/R; at rest the arms simply hang at the desk.
488
+ const lh = typing && frame % 2 ? 2 : 0;
489
+ const rh = typing && frame % 2 ? 0 : 2;
490
+ px(ctx, cx - 8, ty + 3, 2, 7 - lh, arm); // left arm (raises with its hand)
491
+ px(ctx, cx + 7, ty + 3, 2, 7 - rh, arm); // right arm (mirror about cx)
492
+ px(ctx, cx - 8, ty + 10 - lh, 2, 2, skin); // left hand
493
+ px(ctx, cx + 7, ty + 10 - rh, 2, 2, skin); // right hand
494
+ }
495
+ }
496
+
497
+ // A STANDING / walking worker — the same person as drawPerson, on its feet, for
498
+ // the idle-wander floor life. (cx, feetY) = centre-x and the floor line at the
499
+ // feet. Front-facing (no flip); `walking` strides the legs and bobs the body.
500
+ export function drawWalker(ctx, cx, feetY, opts = {}) {
501
+ const { seed = 1, frame = 0, walking = false } = opts;
502
+ const look = opts.look || personLook(seed);
503
+ const { skin, shirt } = look;
504
+ const blink = (frame + (seed % 11)) % 13 === 0;
505
+ // soft pixel ground shadow for grounding
506
+ px(ctx, cx - 6, feetY, 12, 1, 'rgba(0,0,0,0.20)');
507
+ px(ctx, cx - 4, feetY + 1, 8, 1, 'rgba(0,0,0,0.12)');
508
+ const bob = walking && frame % 4 < 2 ? 1 : 0; // gentle walk bob
509
+ const idleRise = !walking && (frame + seed * 3) % 18 < 9 ? 1 : 0; // calm standing breath
510
+ const ty = feetY - 19 - bob - idleRise; // torso top
511
+ const hy = ty - 16; // head top (neck lands back at ty)
512
+ drawHeadFace(ctx, cx, hy, look, blink);
513
+ // torso (shirt) — shared with the seated worker so the outfit matches
514
+ drawTorso(ctx, cx, ty, look);
515
+ const arm = shade(shirt, -12);
516
+ // arms — swing opposite the legs while walking, fidget while idling (stretch /
517
+ // check a phone), else hang. Idle fidgets are seed-staggered so wanderers don't sync.
518
+ const fidget = !walking ? idleFidget(seed, frame) : null;
519
+ if (fidget && fidget.kind === 1) { // stretch up
520
+ const up = Math.round(Math.sin(fidget.t * Math.PI) * 5);
521
+ px(ctx, cx - 8, ty + 1 - up, 2, 6, arm); px(ctx, cx - 8, ty - 4 - up, 2, 2, skin);
522
+ px(ctx, cx + 7, ty + 1 - up, 2, 6, arm); px(ctx, cx + 7, ty - 4 - up, 2, 2, skin);
523
+ } else if (fidget && fidget.kind === 0) { // check a phone — hands meet in front, screen glows
524
+ px(ctx, cx - 6, ty + 4, 2, 4, arm); px(ctx, cx + 5, ty + 4, 2, 4, arm); // forearms angled in
525
+ px(ctx, cx - 4, ty + 7, 8, 2, skin); // hands at the belly
526
+ px(ctx, cx - 2, ty + 6, 5, 2, '#2a2f3a'); // the phone
527
+ px(ctx, cx - 2, ty + 6, 5, 1, '#5cd0ff'); // its glow
528
+ } else { // hang; hands swing ±1 opposite while walking
529
+ const sw = walking ? (frame % 4 < 2 ? 1 : -1) : 0;
530
+ px(ctx, cx - 8, ty + 3, 2, 7, arm); px(ctx, cx - 8, ty + 10 + sw, 2, 2, skin);
531
+ px(ctx, cx + 7, ty + 3, 2, 7, arm); px(ctx, cx + 7, ty + 10 - sw, 2, 2, skin);
532
+ }
533
+ // legs (pants, darker than the shirt) + an alternating walk stride
534
+ const pants = shade(shirt, -55);
535
+ const legTop = ty + 11, legH = feetY - legTop;
536
+ const lLift = walking && frame % 4 < 2 ? 1 : 0;
537
+ const rLift = walking && frame % 4 < 2 ? 0 : 1;
538
+ px(ctx, cx - 4, legTop + lLift, 3, legH - lLift, pants);
539
+ px(ctx, cx + 1, legTop + rLift, 3, legH - rLift, pants);
540
+ }
541
+
542
+ // ---- pets: a wandering cat + a lounging dog -------------------------------
543
+ // Pure procedural sprites (office.js owns their roaming STATE and passes it in as
544
+ // opts). Both animate off `frame`; the cat additionally flips to face its heading.
545
+ // (cx of the cat art is ~x+5; the dog faces right and doesn't flip.)
546
+ export function drawCat(ctx, x, y, frame, dir = 1, opts = {}) {
547
+ x = Math.round(x); y = Math.round(y);
548
+ const { sleeping = false, petted = false, walking = false } = opts;
549
+ const body = '#23252c';
550
+ const flip = dir < 0; // mirror about the body centre so it walks the way it heads
551
+ if (flip) { ctx.save(); ctx.translate((x + 5) * 2, 0); ctx.scale(-1, 1); }
552
+ if (sleeping) {
553
+ // curled up, eyes shut, slow breathing; a "z" drifts above.
554
+ const br = frame % 16 < 8 ? 0 : 1;
555
+ px(ctx, x, y - 4 - br, 11, 4 + br, body); // curled body
556
+ px(ctx, x + 7, y - 5 - br, 5, 4, body); // tucked head
557
+ px(ctx, x + 8, y - 7 - br, 2, 2, body); // folded ear
558
+ px(ctx, x + 8, y - 3 - br, 2, 1, '#3a3d45'); // closed eye
559
+ px(ctx, x - 1, y - 1, 6, 1, body); // tail wrapped round the front
560
+ const zz = '#9aa3b5', zy = y - 11 - (frame % 8 < 4 ? 0 : 1); // "z" bobs
561
+ px(ctx, x + 12, zy, 3, 1, zz); px(ctx, x + 13, zy + 1, 1, 1, zz); px(ctx, x + 12, zy + 2, 3, 1, zz);
562
+ } else {
563
+ // sitting / walking. Idle cats periodically groom (head dips, a pink tongue)
564
+ // and flick an ear; walking cats bob and shuffle their paws.
565
+ const grooming = !petted && !walking && (frame + 3) % 70 < 12;
566
+ const earTwitch = !petted && !grooming && frame % 44 < 2;
567
+ const bob = walking && frame % 4 < 2 ? 1 : 0, by = y - bob;
568
+ const hd = grooming ? 2 : 0; // head dips to groom
569
+ px(ctx, x, by - 6, 8, 6, body); // body
570
+ px(ctx, x + 6, by - 11 + hd, 6, 6, body); // head
571
+ px(ctx, x + 6, by - 13 + hd - (earTwitch ? 1 : 0), 2, 3 + (earTwitch ? 1 : 0), body); // left ear (twitches)
572
+ px(ctx, x + 10, by - 13 + hd, 2, 3, body); // right ear
573
+ if (grooming) {
574
+ px(ctx, x + 8, by - 8, 1, 1, '#3a3d45'); // eye lowered while licking
575
+ px(ctx, x + 8, by - 5, 1, 1, '#ff9db0'); // tongue at the paw
576
+ } else {
577
+ px(ctx, x + 8, by - 9, 1, 1, '#6cff9a'); px(ctx, x + 10, by - 9, 1, 1, '#6cff9a'); // eyes
578
+ }
579
+ const tail = frame % 8 < 4 ? 0 : 1;
580
+ if (petted) px(ctx, x - 2, by - 12, 2, 7, body); // happy upright tail
581
+ else if (walking) px(ctx, x - 3, by - 7 - tail, 3, 2, body); // tail trails behind
582
+ else px(ctx, x - 2, by - 8 - tail, 2, 6, body); // lazy tail flick
583
+ const step = walking && frame % 4 < 2 ? 1 : 0; // paw shuffle
584
+ px(ctx, x + 1, by - step, 1, 1, body); px(ctx, x + 5, by - (walking ? 1 - step : 0), 1, 1, body);
585
+ }
586
+ if (flip) ctx.restore();
587
+ if (petted) { // hearts float up (drawn UNflipped so they read upright)
588
+ const hx = x + 3, hy = y - 16 - (frame % 6), pink = '#ff6b9d';
589
+ px(ctx, hx, hy, 1, 1, pink); px(ctx, hx + 2, hy, 1, 1, pink);
590
+ px(ctx, hx - 1, hy + 1, 5, 1, pink); px(ctx, hx, hy + 2, 3, 1, pink); px(ctx, hx + 1, hy + 3, 1, 1, pink);
591
+ }
592
+ }
593
+
594
+ export function drawDog(ctx, x, y, opts = {}) {
595
+ x = Math.round(x); y = Math.round(y);
596
+ const { frame = 0, petted = false } = opts;
597
+ const fur = '#d98a4a', dk = '#b5702e';
598
+ // periodic behaviours on offset cadences so they don't coincide
599
+ const yawning = !petted && (frame + 5) % 104 < 10; // a slow yawn
600
+ const panting = petted || frame % 64 < 20; // tongue out (always while petted)
601
+ const earPerk = (frame + 7) % 52 < 6; // ear pricks up, alert
602
+ const lookDn = (frame + 11) % 86 < 8 ? 1 : 0; // glances down/around
603
+ const headBob = petted && frame % 4 < 2 ? 1 : 0; // happy head bob when petted
604
+ px(ctx, x, y - 5, 12, 5, fur); // body
605
+ const hy = y - 9 - headBob;
606
+ px(ctx, x + 10, hy, 6, 6, fur); // head
607
+ px(ctx, x + 9, hy - 1 - (earPerk ? 1 : 0), 2, 4 + (earPerk ? 1 : 0), dk); // ear (perks)
608
+ px(ctx, x + 14, hy + 3 + lookDn, 1, 1, '#1b1b22'); // eye (drifts when looking around)
609
+ px(ctx, x + 16, hy + 4, 2, 1, '#1b1b22'); // snout
610
+ if (yawning) { // open mouth + a bit of tongue
611
+ px(ctx, x + 15, hy + 5, 3, 2, '#3a1f16'); px(ctx, x + 16, hy + 6, 1, 1, '#ff9db0');
612
+ } else if (panting) {
613
+ const t = frame % 8 < 4 ? 1 : 0; px(ctx, x + 16, hy + 5 + t, 2, 2 - t, '#ff7a93'); // lolling tongue
614
+ }
615
+ const wag = petted ? (frame % 4 < 2 ? 0 : 2) : (frame % 8 < 4 ? 0 : 1);
616
+ px(ctx, x - 3, y - 6 - wag, 4, 2, '#e09a5a'); // wagging tail
617
+ px(ctx, x + 8, y - 5, 4, 3, '#fff'); // white belly patch
618
+ px(ctx, x + 1, y, 1, 1, dk); px(ctx, x + 9, y, 1, 1, dk); // paws
619
+ if (petted) {
620
+ const hx = x + 12, hy2 = y - 16 - (frame % 6), pink = '#ff6b9d';
621
+ px(ctx, hx, hy2, 1, 1, pink); px(ctx, hx + 2, hy2, 1, 1, pink);
622
+ px(ctx, hx - 1, hy2 + 1, 5, 1, pink); px(ctx, hx, hy2 + 2, 3, 1, pink); px(ctx, hx + 1, hy2 + 3, 1, 1, pink);
623
+ }
624
+ }
625
+
626
+ // ---- a desk workstation ----------------------------------------------------
627
+ // A clean open desk (NO enclosing cubicle frame): a wood desk surface, a glowing
628
+ // tier-colored monitor + keyboard, a country/flag pin, a sticky-note cluster, a
629
+ // desk plant or mug, and the seated person. Everything procedural. The desk sits
630
+ // directly on its team rug (drawn by the office), reading like open team pods
631
+ // rather than boxed cubicles.
632
+ // (x, y) = top-left of the cell; CW/CH = cell size.
633
+ export const CELL_W = 58;
634
+ export const CELL_H = 78;
635
+ const DESK_TOP = 52; // desk surface y within the cell
636
+ const DIV_W = 4; // legacy inset used to position desk props/flag
637
+
638
+ export function drawCubicle(ctx, x, y, agent, frame, selected, hovered, unread = false, away = false) {
639
+ const tier = tierFor(agent.model);
640
+ const seed = (agent.pid | 0) || hashInt(agent.sessionId || 'a');
641
+ const cx = x + CELL_W / 2;
642
+ // --- the seated worker + their personal props. Idle no longer desaturates: the
643
+ // OFF (dark) monitor + grey status dot already read as idle, so the worker keeps
644
+ // full color. `away` (idle-wander): worker has left its desk → draw it empty.
645
+ if (!away) drawPerson(ctx, cx - 6, y + DESK_TOP - 27, { seed, activity: agent.activity, frame, unread });
646
+ // a pinned flag + sticky-note cluster (personalization)
647
+ drawPin(ctx, cx - 22, y + DESK_TOP - 20, seed);
648
+ drawStickies(ctx, cx + 14, y + DESK_TOP - 20, seed);
649
+
650
+ // --- wood desk surface across the cell ---
651
+ const dx = x + DIV_W, dw = CELL_W - DIV_W * 2;
652
+ px(ctx, dx, y + DESK_TOP, dw, 8, '#9c6f44'); // desk slab
653
+ px(ctx, dx, y + DESK_TOP, dw, 2, '#b3855a'); // top light
654
+ px(ctx, dx, y + DESK_TOP + 8, dw, 3, '#6f4a2a'); // front edge
655
+
656
+ // --- monitor (tier-colored screen + activity content) on the right ---
657
+ drawMonitor(ctx, x + CELL_W - 24, y + DESK_TOP - 18, tier, agent, seed, frame);
658
+ // keyboard in front of the worker
659
+ px(ctx, cx - 9, y + DESK_TOP + 1, 16, 3, '#1c2029');
660
+ px(ctx, cx - 8, y + DESK_TOP + 1, 14, 1, '#2a3340');
661
+ // a desk mug or tiny plant on the left
662
+ drawDeskProp(ctx, x + DIV_W + 3, y + DESK_TOP, seed, frame);
663
+
664
+ // --- status LED on the desk ---
665
+ const led = ledColor(agent.activity, frame);
666
+ px(ctx, dx + 2, y + DESK_TOP + 3, 3, 3, led);
667
+ // soft monitor glow over the cell while active
668
+ if (agent.activity === 'working' || agent.activity === 'shell') {
669
+ ctx.fillStyle = tier.glow;
670
+ ctx.fillRect(x + CELL_W - 30, y + DESK_TOP - 22, 28, 24);
671
+ }
672
+
673
+ // --- subagents: tiny helpers gathered in front of the desk. Kept ABOVE the
674
+ // name chip (its top sits at CELL_H-10) so the row never peeks out behind the
675
+ // nameplate as a clipped teal band. ---
676
+ const subs = agent.subagents || [];
677
+ if (subs.length) {
678
+ const n = Math.min(subs.length, 4), sp = 9;
679
+ const sx = Math.round(x + (CELL_W - n * sp) / 2);
680
+ for (let i = 0; i < n; i++) drawMinion(ctx, sx + i * sp, y + CELL_H - 13, (frame + i) % 2);
681
+ }
682
+
683
+ // --- selection / hover ring --- (selected = persistent tier-colored pulse;
684
+ // hover = cyan #5cd0ff)
685
+ if (selected || hovered) drawRing(ctx, x, y, CELL_W, CELL_H, selected ? tier.led : '#5cd0ff', frame, selected);
686
+ }
687
+
688
+ function ledColor(act, frame) {
689
+ if (act === 'working') return frame % 2 ? '#39d98a' : '#1f7d52';
690
+ if (act === 'shell') return frame % 2 ? '#ffb454' : '#9c6a1f';
691
+ return '#5a6478';
692
+ }
693
+
694
+ function drawMonitor(ctx, x, y, tier, agent, seed, frame) {
695
+ const mw = 20, mh = 15;
696
+ px(ctx, x - 1, y - 1, mw + 2, mh + 2, '#15181f'); // bezel
697
+ px(ctx, x, y, mw, mh, '#0d0f14');
698
+ const typing = agent.activity === 'working';
699
+ const shellRunning = agent.activity === 'shell';
700
+ // The lit inner glass. (sx, sy) = top-left of usable pixels, sw × sh = its size.
701
+ const sx = x + 1, sy = y + 1, sw = mw - 2, sh = mh - 2;
702
+ if (typing) {
703
+ px(ctx, sx, sy, sw, sh, shade(tier.screen, -178)); // deep tier-tinted glass
704
+ // Deterministic content style per agent (a given agent always shows the same
705
+ // app): code editor, terminal, diff, or a tiny dashboard.
706
+ const style = shellRunning ? 1 : hashInt('mon' + seed) % 4;
707
+ if (style === 0) drawScreenEditor(ctx, sx, sy, sw, sh, tier, seed, frame);
708
+ else if (style === 1) drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame);
709
+ else if (style === 2) drawScreenDiff(ctx, sx, sy, sw, sh, tier, seed, frame);
710
+ else drawScreenChart(ctx, sx, sy, sw, sh, tier, seed, frame);
711
+ } else if (shellRunning) {
712
+ px(ctx, sx, sy, sw, sh, '#0b130d'); // shell → dark green glass
713
+ drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame);
714
+ } else { // idle: screen OFF — dark glass + a faint reflection so it reads "off"
715
+ px(ctx, sx, sy, sw, sh, '#0b0e14');
716
+ px(ctx, x + 2, y + 2, 1, mh - 4, 'rgba(255,255,255,0.06)');
717
+ px(ctx, x + 4, y + 3, 1, mh - 6, 'rgba(255,255,255,0.03)');
718
+ }
719
+ // stand
720
+ px(ctx, x + (mw >> 1) - 1, y + mh, 3, 3, '#3a4150');
721
+ px(ctx, x + (mw >> 1) - 4, y + mh + 3, 9, 2, '#2b313e');
722
+ }
723
+
724
+ // --- monitor screen content -------------------------------------------------
725
+ // All four renderers fill the inner glass (sx, sy, sw, sh) and never spill past
726
+ // it. They lay out on a fixed 2px line grid so rows never overlap, vary length /
727
+ // color / indent deterministically from `seed`, and animate gently via `frame`.
728
+ // `LH` = line height (1px text + 1px gap); the syntax palette mixes tier.code
729
+ // with muted accents so a row of code reads as keywords/strings/idents.
730
+
731
+ const LH = 2; // px per text line (1px glyph + 1px gap)
732
+
733
+ // A 1px window-chrome title strip across the top of an app screen: a slightly
734
+ // lighter bar with two tiny "window dots", so editor/terminal/diff read as real
735
+ // app windows. Returns the y where content should begin (1px below the bar).
736
+ function drawScreenChrome(ctx, sx, sy, sw, tier) {
737
+ px(ctx, sx, sy, sw, 1, 'rgba(255,255,255,0.10)'); // title bar
738
+ px(ctx, sx + 1, sy, 1, 1, shade(tier.screen, 30)); // left "tab/dot"
739
+ px(ctx, sx + 3, sy, 1, 1, 'rgba(255,255,255,0.28)');
740
+ return sy + 1; // content starts on the next row
741
+ }
742
+
743
+ // A believable code editor: a title bar, a gutter of line numbers, then indented
744
+ // code lines whose token runs alternate color (keyword / ident / string), plus a
745
+ // caret on the active line that blinks and steps down a line every few frames.
746
+ function drawScreenEditor(ctx, sx, sy, sw, sh, tier, seed, frame) {
747
+ const kw = shade(tier.screen, 50); // "keyword" — bright tier hue
748
+ const ident = tier.code; // identifiers — light text
749
+ const str = '#e0a35d'; // strings — warm
750
+ const gut = 'rgba(255,255,255,0.20)'; // gutter line numbers
751
+ const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
752
+ const rows = Math.floor((sy + sh - cy0) / LH);
753
+ const gw = 3; // gutter width (room for a 2px digit)
754
+ // gutter background tick marks (one faint 1px "line number" per row)
755
+ for (let i = 0; i < rows; i++) px(ctx, sx + 1, cy0 + i * LH, 1, 1, gut);
756
+ // a slow vertical scroll so new code drifts up over time
757
+ const scroll = Math.floor(frame / 24);
758
+ const active = (frame >> 1) % rows; // the line the caret sits on
759
+ let caretX = sx + gw + 1;
760
+ for (let i = 0; i < rows; i++) {
761
+ const rr = rng(seed * 17 + (i + scroll) * 131 + 7);
762
+ const indent = (Math.floor(rr() * 3)) * 2; // 0 / 2 / 4 px indent
763
+ let cx = sx + gw + 1 + indent;
764
+ const lineRight = sx + sw - 1;
765
+ // 1–3 token runs of alternating color across the row
766
+ const tokens = 1 + Math.floor(rr() * 3);
767
+ const palette = [kw, ident, str];
768
+ for (let t = 0; t < tokens && cx < lineRight; t++) {
769
+ const tw = 2 + Math.floor(rr() * 4);
770
+ const w = Math.min(tw, lineRight - cx);
771
+ if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, palette[(t + i) % 3]);
772
+ cx += w + 1; // 1px space between tokens
773
+ }
774
+ if (i === active) caretX = Math.min(cx, lineRight); // caret follows the code
775
+ }
776
+ // blinking caret on the active line
777
+ if (frame % 2) px(ctx, caretX, cy0 + active * LH, 1, 1, '#ffffff');
778
+ }
779
+
780
+ // A terminal: a title strip, "$" prompt lines + output that scroll up as a new
781
+ // line is appended, with a blinking block caret on the active prompt.
782
+ function drawScreenTerminal(ctx, sx, sy, sw, sh, tier, seed, frame) {
783
+ const green = '#8bd450', dim = 'rgba(139,212,80,0.55)', warn = '#e0a35d';
784
+ const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
785
+ const rows = Math.floor((sy + sh - cy0) / LH);
786
+ const right = sx + sw - 1;
787
+ // which output line is the newest (it grows, mimicking a command printing)
788
+ const tick = Math.floor(frame / 16);
789
+ for (let i = 0; i < rows - 1; i++) {
790
+ const rr = rng(seed * 23 + (i + tick) * 97 + 3);
791
+ const isPrompt = rr() < 0.34;
792
+ let cx = sx + 1;
793
+ if (isPrompt) { px(ctx, cx, cy0 + i * LH, 1, 1, green); cx += 2; } // "$"
794
+ // output run: length varies; the bottom-most line "types in" with frame
795
+ let lw = 3 + Math.floor(rr() * 9);
796
+ if (i === rows - 2) lw = 2 + (Math.floor(frame / 4) % 11);
797
+ const w = Math.min(lw, right - cx);
798
+ if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, isPrompt ? green : (rr() < 0.3 ? warn : dim));
799
+ }
800
+ // active prompt + blinking block caret on the last row
801
+ const ly = cy0 + (rows - 1) * LH;
802
+ px(ctx, sx + 1, ly, 1, 1, green);
803
+ if (frame % 2) px(ctx, sx + 3, ly, 2, 1, green);
804
+ }
805
+
806
+ // A diff view: a title strip, a red/green change gutter and corresponding +/-
807
+ // lines, like a review pane. Lines hold their color (add=green, del=red, ctx=dim).
808
+ function drawScreenDiff(ctx, sx, sy, sw, sh, tier, seed, frame) {
809
+ const add = '#4caf6a', addg = '#2e6b42', del = '#c0473a', delg = '#7a2e26';
810
+ const ctxt = 'rgba(255,255,255,0.28)';
811
+ const cy0 = drawScreenChrome(ctx, sx, sy, sw, tier);
812
+ const rows = Math.floor((sy + sh - cy0) / LH);
813
+ const scroll = Math.floor(frame / 30);
814
+ for (let i = 0; i < rows; i++) {
815
+ const rr = rng(seed * 29 + (i + scroll) * 113 + 11);
816
+ const k = rr();
817
+ const kind = k < 0.4 ? 'add' : (k < 0.7 ? 'del' : 'ctx');
818
+ const gutc = kind === 'add' ? addg : kind === 'del' ? delg : 'transparent';
819
+ const txtc = kind === 'add' ? add : kind === 'del' ? del : ctxt;
820
+ if (gutc !== 'transparent') px(ctx, sx + 1, cy0 + i * LH, 1, 1, gutc); // change bar
821
+ const indent = (Math.floor(rr() * 3)) * 2;
822
+ const cx = sx + 3 + indent;
823
+ const lw = 3 + Math.floor(rr() * 8);
824
+ const w = Math.min(lw, sx + sw - 1 - cx);
825
+ if (w > 0) px(ctx, cx, cy0 + i * LH, w, 1, txtc);
826
+ }
827
+ }
828
+
829
+ // A tiny dashboard: a title strip, then a small live bar chart whose bars sway
830
+ // gently with frame — reads as a metrics/monitoring screen.
831
+ function drawScreenChart(ctx, sx, sy, sw, sh, tier, seed, frame) {
832
+ const bar = shade(tier.screen, 30), tip = tier.code;
833
+ const top = drawScreenChrome(ctx, sx, sy, sw, tier);
834
+ // baseline
835
+ const base = sy + sh - 1;
836
+ px(ctx, sx + 1, base, sw - 2, 1, 'rgba(255,255,255,0.18)');
837
+ const bw = 2, gap = 1;
838
+ const maxH = base - top - 1;
839
+ let bx = sx + 2;
840
+ let i = 0;
841
+ while (bx + bw <= sx + sw - 1) {
842
+ const phase = (frame * 0.18) + i * 1.1 + (hashInt('bar' + seed) % 7);
843
+ const norm = 0.35 + 0.55 * (0.5 - 0.5 * Math.cos(phase)); // 0.35..0.9, swaying
844
+ const h = Math.max(1, Math.round(maxH * norm));
845
+ const by = base - h;
846
+ px(ctx, bx, by, bw, h, bar);
847
+ px(ctx, bx, by, bw, 1, tip); // bright cap
848
+ bx += bw + gap;
849
+ i++;
850
+ }
851
+ }
852
+
853
+ function drawDeskProp(ctx, x, baseY, seed, frame) {
854
+ const kind = hashInt('prop' + seed) % 4;
855
+ if (kind === 0) { // coffee mug + steam
856
+ px(ctx, x, baseY - 5, 5, 5, '#e4ded3');
857
+ px(ctx, x, baseY - 5, 5, 1, '#f2ede4');
858
+ px(ctx, x + 1, baseY - 4, 3, 1, '#6f5240');
859
+ px(ctx, x + 5, baseY - 4, 1, 2, '#c4bdb2');
860
+ if (frame % 4 < 2) px(ctx, x + 2, baseY - 7, 1, 1, '#b8bdc8');
861
+ } else if (kind === 1) { // tiny plant
862
+ px(ctx, x + 1, baseY - 3, 4, 3, '#9c5a2e');
863
+ px(ctx, x, baseY - 6, 2, 3, '#3f9a55');
864
+ px(ctx, x + 2, baseY - 7, 2, 4, '#4cb364');
865
+ px(ctx, x + 4, baseY - 5, 2, 2, '#3f9a55');
866
+ } else if (kind === 2) { // a small stack of books
867
+ px(ctx, x, baseY - 2, 7, 2, '#c0473a');
868
+ px(ctx, x + 1, baseY - 4, 6, 2, '#5d9ce0');
869
+ px(ctx, x, baseY - 6, 7, 2, '#5dc98a');
870
+ } else { // framed photo
871
+ px(ctx, x, baseY - 6, 6, 6, '#6b4a2e');
872
+ px(ctx, x + 1, baseY - 5, 4, 4, '#bfe3f5');
873
+ px(ctx, x + 2, baseY - 3, 2, 2, '#e0855d');
874
+ }
875
+ }
876
+
877
+ function drawPin(ctx, x, y, seed) {
878
+ const flags = [
879
+ ['#c0473a', '#ffffff', '#3a5bbf'], // tricolor-ish
880
+ ['#3a5bbf', '#ffffff', '#3a5bbf'],
881
+ ['#2e7d4f', '#ffffff', '#e0855d'],
882
+ ['#e0b05d', '#c0473a', '#2e5b8a'],
883
+ ];
884
+ const f = flags[hashInt('f' + seed) % flags.length];
885
+ px(ctx, x, y, 9, 6, '#ffffff');
886
+ px(ctx, x, y, 3, 6, f[0]);
887
+ px(ctx, x + 3, y, 3, 6, f[1]);
888
+ px(ctx, x + 6, y, 3, 6, f[2]);
889
+ px(ctx, x, y, 9, 1, 'rgba(255,255,255,0.3)');
890
+ px(ctx, x + 4, y - 1, 1, 1, '#888'); // pin
891
+ }
892
+
893
+ function drawStickies(ctx, x, y, seed) {
894
+ const cols = ['#f4d35e', '#9ad5e0', '#e8a0c0', '#a8e0a0'];
895
+ const n = 1 + (hashInt('st' + seed) % 3);
896
+ for (let i = 0; i < n; i++) {
897
+ const c = cols[(hashInt('sc' + seed + i)) % cols.length];
898
+ px(ctx, x + (i % 2) * 6, y + (i >> 1) * 6, 5, 5, c);
899
+ px(ctx, x + (i % 2) * 6, y + (i >> 1) * 6, 5, 1, shade(c, 20));
900
+ }
901
+ }
902
+
903
+ function drawMinion(ctx, x, feetY, bob) {
904
+ const y = feetY - bob;
905
+ px(ctx, x + 1, y - 9, 4, 2, '#1f2a33');
906
+ px(ctx, x + 1, y - 8, 4, 3, '#f0c8a0');
907
+ px(ctx, x + 2, y - 7, 1, 1, '#10141a');
908
+ px(ctx, x, y - 5, 6, 4, '#2bb0aa');
909
+ px(ctx, x + 4, y - 5, 2, 4, '#1f8a85');
910
+ px(ctx, x + 1, y - 1, 1, 1, '#171b22');
911
+ px(ctx, x + 4, y - 1, 1, 1, '#171b22');
912
+ }
913
+
914
+ function drawRing(ctx, x, y, w, h, color, frame, strong) {
915
+ const L = 7;
916
+ ctx.globalAlpha = strong ? 1 : 0.6;
917
+ ctx.fillStyle = color;
918
+ const x1 = x + w - 1, y1 = y + h - 1;
919
+ ctx.fillRect(x, y, L, 1); ctx.fillRect(x, y, 1, L);
920
+ ctx.fillRect(x1 - L + 1, y, L, 1); ctx.fillRect(x1, y, 1, L);
921
+ ctx.fillRect(x, y1, L, 1); ctx.fillRect(x, y1 - L + 1, 1, L);
922
+ ctx.fillRect(x1 - L + 1, y1, L, 1); ctx.fillRect(x1, y1 - L + 1, 1, L);
923
+ if (strong && frame % 4 < 2) {
924
+ ctx.globalAlpha = 0.14;
925
+ ctx.fillRect(x, y, w, 1); ctx.fillRect(x, y1, w, 1);
926
+ ctx.fillRect(x, y, 1, h); ctx.fillRect(x1, y, 1, h);
927
+ }
928
+ ctx.globalAlpha = 1;
929
+ }
930
+
931
+ // "Waiting on you" — the floor's HIGHEST-signal state (a background agent blocked
932
+ // on the user). Treats the WHOLE DESK as the alert, not just a small floating !:
933
+ // a soft pulsing amber glow pooled over the cell + a bold pulsing amber frame
934
+ // around it + a bobbing "!" bubble over the head. Reads come-help-me, and is
935
+ // deliberately unlike the green working LED, the gray idle desk, and the pink
936
+ // just-finished pip. (x, y) = the cell's TOP-LEFT (CELL_W × CELL_H).
937
+ export function drawNeedsYou(ctx, x, y, frame) {
938
+ const ph = (frame % 16) / 16;
939
+ const pulse = 0.5 - 0.5 * Math.cos(ph * 2 * Math.PI); // smooth 0→1→0 breathing
940
+ const amber = '#ffb454';
941
+ // soft amber glow pooling over the desk
942
+ const gx = x + CELL_W / 2, gy = y + CELL_H / 2;
943
+ const grad = ctx.createRadialGradient(gx, gy, 4, gx, gy, CELL_W * 0.72);
944
+ grad.addColorStop(0, `rgba(255,180,84,${(0.12 + pulse * 0.16).toFixed(3)})`);
945
+ grad.addColorStop(1, 'rgba(255,180,84,0)');
946
+ ctx.fillStyle = grad;
947
+ ctx.fillRect(x - 3, y, CELL_W + 6, CELL_H);
948
+ // bold pulsing amber frame around the whole cell (the come-help-me outline)
949
+ ctx.globalAlpha = 0.55 + pulse * 0.45;
950
+ const x1 = x + CELL_W;
951
+ px(ctx, x, y + 3, CELL_W, 2, amber); // top
952
+ px(ctx, x, y + CELL_H - 5, CELL_W, 2, amber); // bottom
953
+ px(ctx, x, y + 3, 2, CELL_H - 8, amber); // left
954
+ px(ctx, x1 - 2, y + 3, 2, CELL_H - 8, amber); // right
955
+ ctx.globalAlpha = 1;
956
+ // --- bobbing amber "!" bubble over the head ---
957
+ const bob = frame % 6 < 3 ? 0 : 1;
958
+ const bx = gx + 6, by = y + 16 - bob;
959
+ ctx.globalAlpha = 0.30 + pulse * 0.28; // halo breathes with the frame
960
+ px(ctx, bx - 2, by - 1, 16, 13, amber);
961
+ ctx.globalAlpha = 1;
962
+ px(ctx, bx - 1, by, 12, 9, amber); // bubble body
963
+ px(ctx, bx - 1, by, 12, 1, '#ffd793'); // top highlight
964
+ px(ctx, bx - 1, by + 8, 12, 1, '#e08a2a'); // bottom shade
965
+ px(ctx, bx, by + 9, 3, 2, amber); // tail toward the head
966
+ px(ctx, bx + 4, by + 2, 2, 4, '#3a2606'); // "!"
967
+ px(ctx, bx + 4, by + 7, 2, 1, '#3a2606');
968
+ }
969
+
970
+ // PM crown — a small gold crown bobbing above the lead's head.
971
+ export function drawCrown(ctx, cx, headTopY, frame) {
972
+ const bob = frame % 8 < 4 ? 0 : 1;
973
+ const y = headTopY - 7 - bob;
974
+ const g = '#ffd166', hl = '#fff0c0', dk = '#c79a2e';
975
+ px(ctx, cx - 4, y + 4, 9, 2, g);
976
+ px(ctx, cx - 4, y + 5, 9, 1, dk);
977
+ px(ctx, cx - 4, y + 1, 2, 3, g);
978
+ px(ctx, cx, y, 2, 4, g);
979
+ px(ctx, cx + 4, y + 1, 2, 3, g);
980
+ px(ctx, cx - 4, y + 1, 1, 1, hl);
981
+ px(ctx, cx, y, 1, 1, hl);
982
+ px(ctx, cx, y + 2, 1, 1, '#ff5cba');
983
+ }