@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,506 @@
1
+ :root {
2
+ --bg: #0a0d14;
3
+ --bg2: #0e121c;
4
+ --card: #131927;
5
+ --card2: #0f1420;
6
+ --line: #222c40;
7
+ --line2: #1a2336;
8
+ --ink: #cfd9ea;
9
+ --muted: #6b7689;
10
+ --muted2: #4a5266;
11
+ --green: #39d98a;
12
+ --cyan: #5cd0ff;
13
+ --gold: #ffd166;
14
+ --pink: #ff5cba;
15
+ --pixel: 'Press Start 2P', ui-monospace, monospace;
16
+ --term: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
17
+ --mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
18
+
19
+ /* ---- type scale ---------------------------------------------------------
20
+ One mono ramp for body/labels, a few display sizes, and a small pixel set —
21
+ replaces the old ~18 ad-hoc px values. Press Start 2P renders ~1.4x larger
22
+ per px than the mono, so its sizes are intentionally the smaller numbers.
23
+ Keep the pixel font for ONLY: the wordmark, topbar stat numbers, section
24
+ caps/titles, and tiny chips. Everything else is mono. */
25
+ --t-fine: 11px; /* captions / meta / fine print */
26
+ --t-sub: 12px; /* secondary labels, hints, notes */
27
+ --t-body: 13px; /* body, list rows (the default) */
28
+ --t-val: 15px; /* emphasized inline values / info-card name */
29
+ --t-h: 20px; /* small headline numbers */
30
+ --t-big: 28px; /* stat numbers */
31
+ --t-hero: 44px; /* hero numbers (headcount, payroll, burn) */
32
+ --p-chip: 8px; /* pixel chips / badges / tier / lead */
33
+ --p-cap: 9px; /* pixel section caps / card titles / toggles */
34
+ --p-num: 14px; /* pixel display numbers */
35
+ }
36
+
37
+ * { box-sizing: border-box; }
38
+
39
+ html, body {
40
+ margin: 0;
41
+ height: 100%;
42
+ background:
43
+ radial-gradient(1200px 600px at 70% -10%, #161d2e 0%, transparent 60%),
44
+ var(--bg);
45
+ color: var(--ink);
46
+ font-family: var(--term);
47
+ /* a calm 13px baseline so inherited text isn't the browser's silent 16px */
48
+ font-size: var(--t-body);
49
+ overflow: hidden;
50
+ }
51
+
52
+ /* ---- CRT scanlines + flicker overlay ----
53
+ The retro CRT effect belongs on the OFFICE FLOOR, not on the text panels. We
54
+ keep the z-index BELOW the side panel (z-30) and chat panel (z-200) so those
55
+ render on top of the scanlines and their text stays crisp; the floor (lower
56
+ z) still gets the full effect. Scanline contrast is also dialled back from the
57
+ old 0.16 so even where it does show, it never muddies type. */
58
+ .scanlines {
59
+ position: fixed; inset: 0; pointer-events: none; z-index: 5;
60
+ background:
61
+ repeating-linear-gradient(0deg, rgba(0,0,0,0.09) 0 1px, transparent 1px 3px),
62
+ radial-gradient(140% 120% at 50% 50%, transparent 62%, rgba(0,0,0,0.4) 100%);
63
+ mix-blend-mode: multiply;
64
+ animation: flicker 6s infinite;
65
+ }
66
+ @keyframes flicker { 0%,97%,100% { opacity: 1; } 98% { opacity: 0.9; } 99% { opacity: 0.96; } }
67
+
68
+ /* ---- top bar ---- */
69
+ .topbar {
70
+ display: flex; align-items: center; justify-content: space-between;
71
+ height: 72px; padding: 0 20px;
72
+ background: linear-gradient(180deg, #121828, #0c111b);
73
+ border-bottom: 2px solid var(--line);
74
+ }
75
+ .brand { display: flex; align-items: center; gap: 14px; }
76
+ .logo { font-size: 26px; filter: drop-shadow(0 0 8px rgba(57,217,138,0.4)); }
77
+ .brand-name {
78
+ font-family: var(--pixel); font-size: 16px; letter-spacing: 2px;
79
+ color: #fff; text-shadow: 0 0 10px rgba(92,208,255,0.5), 2px 2px 0 #1b2740;
80
+ }
81
+ .brand-sub { font-size: var(--t-sub); color: var(--muted); margin-top: 3px; letter-spacing: 1px; }
82
+
83
+ /* right side of the topbar: the live stats, then a divided controls cluster */
84
+ .topright { display: flex; align-items: center; gap: 22px; }
85
+ .topstats { display: flex; align-items: center; gap: 20px; }
86
+ .topctrls {
87
+ display: flex; align-items: center; gap: 10px;
88
+ padding-left: 22px; border-left: 1px solid var(--line);
89
+ }
90
+ .tstat { text-align: center; }
91
+ .tstat-val { font-family: var(--pixel); font-size: var(--p-num); line-height: 1.1; color: var(--green); text-shadow: 0 0 10px rgba(57,217,138,0.4); }
92
+ /* labels use a readable mono (IBM Plex Mono); big topbar numbers use Press Start 2P
93
+ (pixel reads great at display size). Pixel identity = art + headers + numbers. */
94
+ .tstat-lbl { font-family: var(--term); font-size: var(--t-fine); color: var(--muted); margin-top: 4px; letter-spacing: 0.5px; text-transform: uppercase; }
95
+ /* burn unit ("tok/min") rides smaller next to the live number so the figure leads;
96
+ mono (not the blocky pixel) so it reads as a clean unit, not a second stat. */
97
+ .tstat-unit { font-family: var(--term); font-size: var(--t-fine); color: var(--muted); text-shadow: none; letter-spacing: 0.5px; }
98
+ .live-pill {
99
+ font-family: var(--pixel); font-size: var(--p-chip); color: var(--pink); letter-spacing: 2px;
100
+ display: flex; align-items: center; gap: 7px; padding: 6px 10px;
101
+ border: 1px solid #3a2336; border-radius: 4px; background: #1a1018;
102
+ }
103
+ .rec { width: 8px; height: 8px; border-radius: 50%; background: var(--pink); box-shadow: 0 0 8px var(--pink); animation: pulse 1.4s infinite; }
104
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
105
+
106
+ /* "just finished / unread" pill: a topbar chip counting agents that wrapped a
107
+ turn the user hasn't opened yet. Cyan = "new, for you" (distinct from the gold
108
+ "needs-you" waiting pill and the pink LIVE rec). Click jumps to the first. */
109
+ .unread-pill {
110
+ font-family: var(--pixel); font-size: var(--p-chip); letter-spacing: 1px;
111
+ color: #04222e; background: var(--cyan); border: 1px solid var(--cyan);
112
+ border-radius: 999px; padding: 6px 11px; cursor: pointer; line-height: 1;
113
+ white-space: nowrap; box-shadow: 0 0 10px rgba(92,208,255,0.45);
114
+ animation: unread-pulse 1.8s ease-in-out infinite;
115
+ }
116
+ .unread-pill:hover { filter: brightness(1.1); }
117
+ .unread-pill.hidden { display: none; }
118
+ @keyframes unread-pulse { 0%,100% { box-shadow: 0 0 10px rgba(92,208,255,0.45); } 50% { box-shadow: 0 0 16px rgba(92,208,255,0.75); } }
119
+
120
+ /* sound toggle */
121
+ .sound-toggle {
122
+ font-size: 16px; line-height: 1; cursor: pointer;
123
+ width: 38px; height: 34px; padding: 0;
124
+ border: 1px solid var(--line); border-radius: 4px;
125
+ background: #0c111b; color: var(--ink);
126
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
127
+ }
128
+ .sound-toggle:hover { border-color: var(--cyan); }
129
+ .sound-toggle:active { transform: translateY(1px); }
130
+ .sound-toggle.on { border-color: var(--green); box-shadow: 0 0 10px rgba(57,217,138,0.35); }
131
+
132
+ /* ---- layout ---- */
133
+ .layout { display: flex; height: calc(100% - 72px); position: relative; }
134
+ .floor-col { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; padding: 16px; gap: 12px; }
135
+
136
+ /* The floor is a fixed viewport; the office pans/zooms inside it via .world. */
137
+ .floor-frame {
138
+ position: relative; flex: 1 1 auto; min-height: 0;
139
+ background: linear-gradient(180deg, #0b0f18, #0a0d14);
140
+ border-radius: 8px; overflow: hidden; padding: 0;
141
+ box-shadow: inset 0 0 0 2px var(--line), inset 0 0 60px rgba(0,0,0,0.6), 0 0 0 4px #0a0d14;
142
+ cursor: grab; touch-action: none;
143
+ }
144
+ .world { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }
145
+ #office {
146
+ display: block;
147
+ image-rendering: pixelated; image-rendering: crisp-edges;
148
+ }
149
+ .label-layer { position: absolute; top: 0; left: 0; pointer-events: none; transform-origin: 0 0; }
150
+
151
+ /* recenter button — only appears once the camera has been moved */
152
+ .recenter {
153
+ position: absolute; left: 12px; bottom: 12px; z-index: 20;
154
+ display: none; align-items: center; gap: 6px; cursor: pointer;
155
+ font-family: var(--term); font-size: var(--t-body); line-height: 1;
156
+ padding: 6px 11px; border: 1px solid var(--line); border-radius: 5px;
157
+ background: rgba(12,16,26,0.9); color: var(--ink);
158
+ transition: border-color .15s, color .15s, transform .05s;
159
+ }
160
+ .recenter.show { display: inline-flex; }
161
+ .recenter:hover { border-color: var(--cyan); color: #fff; }
162
+ .recenter:active { transform: translateY(1px); }
163
+
164
+ /* one-time gesture hint, fades on its own */
165
+ .floor-hint {
166
+ position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%);
167
+ z-index: 18; pointer-events: none; white-space: nowrap;
168
+ font-size: var(--t-sub); color: var(--muted); letter-spacing: 0.5px;
169
+ background: rgba(8,11,18,0.6); padding: 4px 12px; border-radius: 20px;
170
+ animation: hintfade 1.2s ease 6s forwards;
171
+ }
172
+ @keyframes hintfade { to { opacity: 0; visibility: hidden; } }
173
+
174
+ /* ---- in-world name tags (scale with the floor; can never overlap) ----
175
+ These ride inside the floor's scaled label layer, so their px values are in
176
+ BUFFER units (multiplied by the zoom) — they are deliberately NOT on the
177
+ screen-space type scale above. Leave them be. */
178
+ .tag {
179
+ position: absolute; transform: translateX(-50%);
180
+ display: inline-flex; align-items: center; gap: 4px;
181
+ max-width: 70px; padding: 1px 5px; box-sizing: border-box;
182
+ background: rgba(8,11,18,0.78); border: 1px solid var(--line2);
183
+ border-radius: 4px; white-space: nowrap; pointer-events: none;
184
+ }
185
+ .tag-name { font-size: 13px; color: var(--ink); line-height: 1.2; overflow: hidden; text-overflow: ellipsis; }
186
+ .tag-chip { width: 6px; height: 6px; border-radius: 1px; flex: 0 0 auto; }
187
+ /* gold "PM" pill on the lead's nameplate (matches the in-world crown) */
188
+ .tag-lead {
189
+ font-family: var(--pixel); font-size: 6px; line-height: 1; letter-spacing: 1px;
190
+ color: #2a1f05; background: var(--gold); border-radius: 2px;
191
+ padding: 1px 2px; flex: 0 0 auto;
192
+ }
193
+
194
+ .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: 0 0 auto; }
195
+ .dot.busy { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 1.1s infinite; }
196
+ .dot.idle { background: #4a5568; }
197
+ .dot.done { background: var(--cyan); box-shadow: 0 0 6px var(--cyan); }
198
+ .dot.shell { background: #ffb454; box-shadow: 0 0 6px #ffb454; animation: pulse 1.4s infinite; }
199
+
200
+ /* ---- hover / selection info card (screen-space, always readable) ---- */
201
+ .info-card {
202
+ position: absolute; z-index: 40; pointer-events: none;
203
+ transform: translate(-50%, -100%);
204
+ min-width: 150px; max-width: 250px; padding: 7px 10px 8px;
205
+ background: rgba(8,11,18,0.97); border: 1px solid var(--line);
206
+ border-radius: 6px; box-shadow: 0 8px 26px rgba(0,0,0,0.6);
207
+ margin-top: -8px;
208
+ }
209
+ .info-card.below { transform: translate(-50%, 0); margin-top: 8px; }
210
+ .ic-head { display: flex; align-items: center; gap: 6px; }
211
+ .ic-name { font-size: var(--t-val); color: var(--gold); line-height: 1; }
212
+ .ic-tier { font-family: var(--pixel); font-size: var(--p-chip); letter-spacing: 1px; text-transform: uppercase; margin-left: auto; }
213
+ /* role tags in the card head: PM gold, teammate cyan */
214
+ .ic-lead, .ic-teammate {
215
+ font-family: var(--pixel); font-size: var(--p-chip); line-height: 1; letter-spacing: 1px;
216
+ border-radius: 2px; padding: 2px 3px;
217
+ }
218
+ .ic-lead { color: #2a1f05; background: var(--gold); }
219
+ .ic-teammate { color: #06231f; background: var(--cyan); }
220
+ /* push the model tier to the far right even with a role tag present */
221
+ .ic-head .ic-lead, .ic-head .ic-teammate { margin-left: 4px; }
222
+ .ic-head .ic-lead + .ic-tier, .ic-head .ic-teammate + .ic-tier { margin-left: 6px; }
223
+ .ic-sub { font-size: var(--t-sub); color: var(--muted); margin-top: 3px; }
224
+ .ic-sub .ic-dept { color: var(--cyan); }
225
+ .ic-status { font-size: var(--t-sub); margin-top: 4px; color: var(--ink); }
226
+ .ic-status.working { color: var(--green); }
227
+ .ic-status.shell { color: #ffb454; }
228
+ .ic-status.done { color: var(--cyan); }
229
+ .ic-task { color: var(--gold); font-style: italic; }
230
+ .ic-foot { font-size: var(--t-fine); color: var(--muted2); margin-top: 5px; display: flex; gap: 10px; flex-wrap: wrap; }
231
+ .ic-foot .ic-sub-badge { color: #46d6cf; }
232
+ .ic-foot .ic-oc { color: #ff9f43; }
233
+ .ic-foot .ic-codex { color: #ff8a3d; }
234
+ .ic-foot .ic-slug { color: #ffb27a; }
235
+
236
+ .empty-banner {
237
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
238
+ background: rgba(8,11,18,0.95); border: 1px dashed var(--line);
239
+ padding: 14px 20px; border-radius: 6px; font-size: var(--t-val); color: var(--muted);
240
+ }
241
+ .empty-banner code { color: var(--green); background: #0c1119; padding: 1px 6px; border-radius: 3px; }
242
+ .hidden { display: none; }
243
+
244
+ /* ---- side panel (hovering overlay, collapsible) ---- */
245
+ .panel {
246
+ position: absolute; top: 0; right: 0; z-index: 30;
247
+ width: 340px; max-width: 86vw; height: 100%; overflow-y: auto;
248
+ padding: 16px 16px 40px;
249
+ /* a touch more opaque than before so panel text reads cleanly over the floor
250
+ + scanlines (legibility pass) while keeping the glassy backdrop blur. */
251
+ background: rgba(12, 16, 26, 0.93);
252
+ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
253
+ border-left: 1px solid var(--line);
254
+ box-shadow: -10px 0 40px rgba(0,0,0,0.55);
255
+ display: flex; flex-direction: column; gap: 12px;
256
+ transition: transform 0.28s ease;
257
+ }
258
+ .panel.collapsed { transform: translateX(100%); }
259
+
260
+ /* top-bar panel toggle + edge reopen handle */
261
+ .panel-toggle {
262
+ font-size: 16px; line-height: 1; cursor: pointer;
263
+ width: 38px; height: 34px; padding: 0; margin-left: 4px;
264
+ border: 1px solid var(--line); border-radius: 4px;
265
+ background: #0c111b; color: var(--ink);
266
+ transition: border-color .15s, box-shadow .15s, transform .05s;
267
+ }
268
+ .panel-toggle:hover { border-color: var(--cyan); }
269
+ .panel-toggle:active { transform: translateY(1px); }
270
+ .panel-toggle.active { border-color: var(--cyan); box-shadow: 0 0 10px rgba(92,208,255,.3); }
271
+ .panel-handle {
272
+ position: absolute; top: 50%; right: 0; transform: translateY(-50%);
273
+ z-index: 25; cursor: pointer; display: none;
274
+ align-items: center; justify-content: center;
275
+ width: 22px; height: 66px; font-size: var(--t-sub);
276
+ background: rgba(12,16,26,0.92); color: var(--cyan);
277
+ border: 1px solid var(--line); border-right: none;
278
+ border-radius: 6px 0 0 6px; box-shadow: -6px 0 18px rgba(0,0,0,0.45);
279
+ }
280
+ .panel-handle:hover { color: #fff; border-color: var(--cyan); }
281
+ .panel-handle.show { display: flex; }
282
+ .panel::-webkit-scrollbar { width: 8px; }
283
+ .panel::-webkit-scrollbar-thumb { background: #222c40; border-radius: 4px; }
284
+
285
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 14px; }
286
+ /* collapsible cards (Assumptions, All-time ledger) — these are set-once / static
287
+ panels that used to take up permanent room; folding them gives the office more
288
+ space while keeping them one click away. */
289
+ .card-fold { padding: 0; }
290
+ .card-fold > summary {
291
+ list-style: none; cursor: pointer; padding: 14px; user-select: none;
292
+ border-radius: 8px; position: relative; padding-right: 30px;
293
+ }
294
+ .card-fold > summary::-webkit-details-marker { display: none; }
295
+ .card-fold > summary::after {
296
+ content: '▸'; position: absolute; right: 14px; top: 50%;
297
+ transform: translateY(-50%); color: var(--muted); font-size: var(--t-sub);
298
+ transition: transform .15s;
299
+ }
300
+ .card-fold[open] > summary::after { transform: translateY(-50%) rotate(90deg); }
301
+ .card-fold > summary:hover { background: rgba(255,255,255,0.025); }
302
+ .card-fold > summary:hover::after { color: var(--cyan); }
303
+ /* the folded body gets its own padding (summary already has 14px top) */
304
+ .card-fold > :not(summary) { margin-left: 14px; margin-right: 14px; }
305
+ .card-fold > :last-child { margin-bottom: 14px; }
306
+ /* card titles keep Press Start 2P (the headline accent) but bright ink + spacing
307
+ so they read at a glance instead of straining the eye. */
308
+ .card-title { font-family: var(--pixel); font-size: var(--p-cap); line-height: 1.4; letter-spacing: 1px; color: var(--cyan); text-transform: uppercase; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
309
+ .card-title-hint { font-family: var(--term); font-size: var(--t-sub); color: var(--muted); letter-spacing: 0; text-transform: none; }
310
+ .card-note { font-size: var(--t-sub); color: var(--muted); margin-top: 2px; }
311
+
312
+ /* ---- sidebar Now | Analytics view toggle ---- */
313
+ .view-toggle {
314
+ display: flex; gap: 4px; padding: 4px;
315
+ background: #0c111b; border: 1px solid var(--line); border-radius: 8px;
316
+ }
317
+ .vt-btn {
318
+ flex: 1 1 0; cursor: pointer; padding: 8px 6px;
319
+ font-family: var(--pixel); font-size: var(--p-cap); letter-spacing: 1px; text-transform: uppercase;
320
+ color: var(--muted); background: transparent; border: none; border-radius: 5px;
321
+ transition: color .15s, background .15s, box-shadow .15s;
322
+ }
323
+ .vt-btn:hover { color: var(--ink); }
324
+ .vt-btn.active {
325
+ color: #04222e; background: var(--cyan);
326
+ box-shadow: 0 0 10px rgba(92,208,255,0.35);
327
+ }
328
+ .view-now, .view-analytics { display: flex; flex-direction: column; gap: 12px; }
329
+ .view-now.hidden, .view-analytics.hidden { display: none; }
330
+
331
+ /* ---- "Now" view cards ---- */
332
+ .now-card { background: linear-gradient(180deg, #102016, #0c1410); border-color: #1d5a3a; }
333
+ .now-card #nowBurn { font-size: var(--t-hero); line-height: 0.95; color: #fff; text-shadow: 0 0 18px rgba(57,217,138,0.55); }
334
+ /* live output-token rate sparkline (last ~30 min), drawn under the burn figure */
335
+ .burnspark {
336
+ width: 100%; height: 46px; display: block; margin-top: 10px;
337
+ image-rendering: pixelated;
338
+ }
339
+ .now-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; }
340
+ .now-stat { text-align: center; }
341
+ .now-k { font-size: var(--t-big); line-height: 1; color: var(--green); }
342
+ .now-stat:nth-child(2) .now-k { color: #ffb454; }
343
+ .now-stat:nth-child(3) .now-k { color: var(--muted); }
344
+ .now-l { font-size: var(--t-fine); color: var(--muted); margin-top: 3px; line-height: 1.1; }
345
+ .now-bar {
346
+ display: flex; height: 10px; margin-top: 12px; border-radius: 5px; overflow: hidden;
347
+ background: #0c1119; border: 1px solid var(--line2);
348
+ }
349
+ .now-seg { height: 100%; }
350
+ .now-seg-working { background: var(--green); }
351
+ .now-seg-shell { background: #ffb454; }
352
+ .now-seg-idle { background: #3a4358; }
353
+ .now-foot { font-size: var(--t-sub); color: var(--muted); margin-top: 8px; }
354
+
355
+ /* recent tool calls (Now view) */
356
+ .nowtoollist { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
357
+ .nowtool-head { display: flex; justify-content: space-between; font-size: var(--t-body); }
358
+ .nowtool-name { color: var(--ink); }
359
+ .nowtool-val { color: var(--gold); }
360
+ .nowtool-bar { height: 7px; background: #0c1119; border-radius: 4px; overflow: hidden; margin-top: 3px; border: 1px solid var(--line2); }
361
+ .nowtool-fill { height: 100%; background: linear-gradient(90deg, #2d8f6f, #39d98a); }
362
+
363
+ /* TODAY card — the prominent "shipped like a team of N today" headline. Green
364
+ accent (same family as the live "Now" view) so it reads as today's fresh work,
365
+ distinct from the blue lifetime/recent headcount card below it. */
366
+ .today-card { background: linear-gradient(180deg, #102016, #0c1410); border-color: #1d5a3a; }
367
+ .today-line { font-size: var(--t-body); color: var(--ink); margin-top: 8px; }
368
+ .today-card #tdTeam { font-size: var(--t-hero); line-height: 0.9; color: #fff; text-shadow: 0 0 18px rgba(57,217,138,0.55); }
369
+ .today-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 8px; margin-top: 12px; border-top: 1px solid var(--line2); padding-top: 10px; }
370
+ .td-k { font-size: var(--t-h); color: var(--green); }
371
+ .td-l { font-size: var(--t-fine); color: var(--muted); line-height: 1.1; margin-top: 1px; }
372
+
373
+ /* headcount card */
374
+ .headcount-card { background: linear-gradient(180deg, #16213a, #101627); border-color: #25406b; }
375
+ .bignum { display: flex; align-items: baseline; gap: 10px; margin-top: 8px; }
376
+ #hcNum { font-size: var(--t-hero); line-height: 0.9; color: #fff; text-shadow: 0 0 18px rgba(92,208,255,0.55); }
377
+ .bignum-unit { font-family: var(--pixel); font-size: var(--p-cap); color: var(--cyan); }
378
+ /* live payroll-equivalent meter (odometer) */
379
+ .payroll-meter {
380
+ margin-top: 12px; padding: 10px 12px;
381
+ background: linear-gradient(180deg, #1a1406, #120d04);
382
+ border: 1px solid #6e5410; border-radius: 6px;
383
+ box-shadow: inset 0 0 18px rgba(255,209,102,0.08);
384
+ }
385
+ .pm-label {
386
+ font-family: var(--pixel); font-size: var(--p-chip); line-height: 1.5; letter-spacing: 1px;
387
+ color: var(--gold); text-transform: uppercase;
388
+ display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
389
+ }
390
+ .pm-rate { font-family: var(--term); font-size: var(--t-sub); letter-spacing: 0; text-transform: none; color: var(--green); }
391
+ .pm-value {
392
+ /* 30px (not the 44px hero) so a 7–8 figure payroll fits the panel without
393
+ clipping at the card edge; still the boldest number in the card. */
394
+ margin-top: 6px; font-family: var(--term); font-size: 30px; line-height: 0.95;
395
+ color: var(--gold); letter-spacing: 0.5px; white-space: nowrap;
396
+ text-shadow: 0 0 14px rgba(255,209,102,0.5), 1px 1px 0 #5a4410;
397
+ font-variant-numeric: tabular-nums; font-feature-settings: "tnum";
398
+ }
399
+ .pm-cents { font-size: var(--t-h); color: #b9923a; }
400
+
401
+ .heads { width: 100%; height: auto; image-rendering: pixelated; margin: 12px 0 6px; }
402
+ .hc-compare { font-size: var(--t-body); color: var(--ink); }
403
+ .hc-compare b { color: var(--gold); font-size: var(--t-h); }
404
+ .hc-cap { font-size: var(--t-fine); color: var(--muted2); }
405
+ .hc-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; border-top: 1px solid var(--line2); padding-top: 10px; }
406
+ .hc-k { font-size: var(--t-h); color: var(--green); }
407
+ .hc-l { font-size: var(--t-fine); color: var(--muted); line-height: 1.1; }
408
+
409
+ /* sliders */
410
+ .slider { margin-top: 12px; }
411
+ .slider label { display: flex; justify-content: space-between; font-size: var(--t-sub); color: var(--muted); }
412
+ .slider label b { color: var(--ink); }
413
+ .slider input[type=range] { width: 100%; margin-top: 5px; accent-color: var(--green); height: 4px; }
414
+
415
+ /* model mix */
416
+ .modelbar { display: flex; height: 16px; border-radius: 4px; overflow: hidden; margin-top: 10px; background: #0c1119; border: 1px solid var(--line2); }
417
+ .modelbar .seg { height: 100%; }
418
+ .legend { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; }
419
+ .legend-item { display: flex; align-items: center; gap: 8px; font-size: var(--t-body); }
420
+ .swatch { width: 11px; height: 11px; border-radius: 2px; flex: 0 0 auto; }
421
+ .lg-name { color: var(--ink); flex: 1 1 auto; }
422
+ .lg-val { color: var(--muted); }
423
+
424
+ /* departments */
425
+ .deptlist { display: flex; flex-direction: column; gap: 11px; margin-top: 10px; }
426
+ .dept-head { display: flex; justify-content: space-between; font-size: var(--t-body); }
427
+ .dept-name { color: var(--ink); }
428
+ .dept-val { color: var(--gold); }
429
+ /* quiet "tok" unit suffix so the project value reads clearly as token output */
430
+ .dept-unit { color: var(--muted); font-size: var(--t-fine); }
431
+ .dept-bar { height: 7px; background: #0c1119; border-radius: 4px; overflow: hidden; margin: 3px 0; border: 1px solid var(--line2); }
432
+ .dept-fill { height: 100%; background: linear-gradient(90deg, #2d6fb0, #5cd0ff); }
433
+ .dept-meta { font-size: var(--t-fine); color: var(--muted2); }
434
+ /* live "an agent is here now" dot before an active department's name */
435
+ .dept-live { color: var(--green); font-size: var(--t-fine); margin-right: 5px; text-shadow: 0 0 6px var(--green); }
436
+ .dept-more { font-size: var(--t-fine); color: var(--muted2); margin-top: 2px; }
437
+ .dept-empty { font-size: var(--t-sub); color: var(--muted); }
438
+ .dept-alltime { font-size: var(--t-fine); color: var(--muted2); font-style: italic; }
439
+
440
+ /* daily */
441
+ .daily { width: 100%; display: block; margin-top: 8px; }
442
+
443
+ /* ledger */
444
+ .ledger { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
445
+ .ledger-row { display: flex; justify-content: space-between; font-size: var(--t-body); color: var(--muted); border-bottom: 1px dotted var(--line2); padding-bottom: 3px; }
446
+ .ledger-row b { color: var(--ink); }
447
+
448
+ @media (max-width: 1000px) {
449
+ .panel { width: 86vw; }
450
+ }
451
+
452
+ /* ===== Leaderboard (opt-in social layer) ===== */
453
+ .lb-overlay {
454
+ position: fixed; inset: 0; z-index: 200;
455
+ display: flex; align-items: center; justify-content: center;
456
+ background: rgba(4, 6, 12, 0.72);
457
+ backdrop-filter: blur(3px);
458
+ }
459
+ .lb-overlay.hidden { display: none; } /* compound selector beats .hidden source-order */
460
+ .lb-modal {
461
+ position: relative;
462
+ width: min(520px, 92vw); max-height: 86vh; overflow: hidden;
463
+ display: flex; flex-direction: column;
464
+ background: var(--card); border: 1px solid var(--line); border-radius: 10px;
465
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
466
+ padding: 20px;
467
+ }
468
+ .lb-close {
469
+ position: absolute; top: 10px; right: 12px;
470
+ background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer;
471
+ }
472
+ .lb-close:hover { color: var(--ink); }
473
+ .lb-head h2 { margin: 0 0 4px; font-family: var(--pixel); font-size: var(--p-cap); color: var(--gold); }
474
+ .lb-sub { margin: 0 0 14px; color: var(--muted); font: 12px var(--mono); }
475
+ .lb-body { overflow-y: auto; }
476
+ .lb-note { color: var(--ink); font: 13px/1.5 var(--mono); margin: 0 0 12px; }
477
+ .lb-note b { color: var(--gold); }
478
+ .lb-preview { color: var(--cyan); font: 12px var(--mono); margin: 0 0 12px; }
479
+ .lb-form { display: flex; gap: 8px; margin-bottom: 8px; }
480
+ .lb-form input {
481
+ flex: 1; background: var(--card2); border: 1px solid var(--line); border-radius: 6px;
482
+ color: var(--ink); font: 13px var(--mono); padding: 8px 10px;
483
+ }
484
+ .lb-form input:focus { outline: none; border-color: var(--cyan); }
485
+ .lb-body button {
486
+ background: var(--card2); border: 1px solid var(--line); border-radius: 6px;
487
+ color: var(--ink); font: 12px var(--mono); padding: 8px 14px; cursor: pointer;
488
+ }
489
+ .lb-body button:hover { border-color: var(--cyan); color: #fff; }
490
+ .lb-body button:disabled { opacity: 0.5; cursor: default; }
491
+ .lb-body button.lb-danger:hover { border-color: var(--pink); color: var(--pink); }
492
+ .lb-msg { color: var(--muted); font: 11px var(--mono); min-height: 14px; margin-top: 6px; }
493
+ .lb-you { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px; }
494
+ .lb-you-handle { color: var(--gold); font: 15px var(--mono); }
495
+ .lb-you-rank { color: var(--ink); font: 13px var(--mono); }
496
+ .lb-actions { display: flex; gap: 8px; margin-bottom: 6px; }
497
+ .lb-list { margin-top: 14px; border-top: 1px solid var(--line2); padding-top: 10px; }
498
+ .lb-row {
499
+ display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 8px;
500
+ padding: 6px 8px; border-radius: 5px; font: 13px var(--mono); color: var(--ink);
501
+ }
502
+ .lb-row:nth-child(even) { background: var(--card2); }
503
+ .lb-mine { outline: 1px solid var(--gold); background: rgba(255, 209, 102, 0.08) !important; }
504
+ .lb-rank { color: var(--muted); }
505
+ .lb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
506
+ .lb-val { color: var(--green); text-align: right; }
package/public/ui.js ADDED
@@ -0,0 +1,50 @@
1
+ // ui.js — wires the stats DRAWER to the renderer. The office camera (pan / zoom /
2
+ // recenter) is driven by gestures inside office.js.
3
+ //
4
+ // The stats panel is a DRAWER you summon, not a pinned rail: the office is always
5
+ // full-bleed (we never reserve width for it), and opening the drawer overlays it
6
+ // on the right. It starts CLOSED so the floor is the hero on load and nothing
7
+ // auto-blocks the office. Per-agent detail lives in a floating card beside the
8
+ // agent (chat-panel.js), not here.
9
+
10
+ const $ = (id) => document.getElementById(id);
11
+
12
+ export function initUI() {
13
+ const panel = document.querySelector('.panel');
14
+ const toggle = $('panelToggle');
15
+ const handle = $('panelHandle');
16
+
17
+ let open = loadOpen();
18
+
19
+ function apply() {
20
+ if (panel) panel.classList.toggle('collapsed', !open);
21
+ if (toggle) toggle.classList.toggle('active', open);
22
+ if (handle) handle.classList.toggle('show', !open);
23
+ // Office stays full-bleed: the drawer overlays it rather than shrinking it,
24
+ // so we never reserve width (no setReservedRight call).
25
+ }
26
+ function set(next) {
27
+ open = next;
28
+ try {
29
+ localStorage.setItem('agency.panelOpen', open ? '1' : '0');
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ apply();
34
+ }
35
+
36
+ if (toggle) toggle.addEventListener('click', () => set(!open));
37
+ if (handle) handle.addEventListener('click', () => set(true));
38
+
39
+ apply();
40
+ }
41
+
42
+ function loadOpen() {
43
+ try {
44
+ // Default CLOSED so the office is unobstructed on load; remembers the user's
45
+ // choice once they open it.
46
+ return localStorage.getItem('agency.panelOpen') === '1';
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,104 @@
1
+ // _pixpng.mjs — a tiny zero-dependency pixel canvas + PNG encoder, shared by the
2
+ // charsheet / animsheet generators. The character & pet renderers in
3
+ // public/sprites.js only use fillStyle + fillRect (+ save/translate/scale/restore
4
+ // for the cat's flip), so this shim is enough to run them headlessly in Node and
5
+ // dump the result to a PNG via stdlib zlib + a hand-rolled CRC32.
6
+ import zlib from 'node:zlib';
7
+
8
+ export function parseColor(s) {
9
+ if (s[0] === '#') {
10
+ let h = s.slice(1);
11
+ if (h.length === 3) h = h.replace(/./g, (c) => c + c);
12
+ const n = parseInt(h, 16);
13
+ return [(n >>> 16) & 255, (n >>> 8) & 255, n & 255, 255];
14
+ }
15
+ const m = (s.match(/[\d.]+/g) || [0, 0, 0, 0]).map(Number); // rgba(...)
16
+ return [m[0] | 0, m[1] | 0, m[2] | 0, Math.round((m[3] ?? 1) * 255)];
17
+ }
18
+
19
+ // Canvas2D-ish context backed by an RGBA buffer. Supports the subset sprites.js
20
+ // uses: fillStyle, fillRect, and a scale+translate transform stack (enough for an
21
+ // axis-aligned mirror — the cat's `scale(-1,1)` flip).
22
+ export class Ctx {
23
+ constructor(w, h, bg) {
24
+ this.W = w; this.H = h; this.buf = new Uint8Array(w * h * 4); this._c = [0, 0, 0, 255];
25
+ this._t = { sx: 1, sy: 1, tx: 0, ty: 0 }; this._stack = [];
26
+ if (bg != null) { const [r, g, b] = parseColor(bg); for (let i = 0; i < this.buf.length; i += 4) { this.buf[i] = r; this.buf[i + 1] = g; this.buf[i + 2] = b; this.buf[i + 3] = 255; } }
27
+ }
28
+ set fillStyle(s) { this._c = parseColor(s); }
29
+ get fillStyle() { return this._c; }
30
+ save() { this._stack.push({ ...this._t }); }
31
+ restore() { if (this._stack.length) this._t = this._stack.pop(); }
32
+ translate(x, y) { this._t.tx += this._t.sx * x; this._t.ty += this._t.sy * y; }
33
+ scale(x, y) { this._t.sx *= x; this._t.sy *= y; }
34
+ fillRect(x, y, w, h) {
35
+ const [r, g, b, a] = this._c;
36
+ if (a === 0) return;
37
+ const T = this._t;
38
+ const x0 = T.sx * x + T.tx, y0 = T.sy * y + T.ty, x1 = T.sx * (x + w) + T.tx, y1 = T.sy * (y + h) + T.ty;
39
+ const rx = Math.round(Math.min(x0, x1)), ry = Math.round(Math.min(y0, y1));
40
+ const rw = Math.round(Math.abs(x1 - x0)), rh = Math.round(Math.abs(y1 - y0));
41
+ const X1 = Math.max(0, rx), Y1 = Math.max(0, ry), X2 = Math.min(this.W, rx + rw), Y2 = Math.min(this.H, ry + rh);
42
+ const ia = a / 255, na = 1 - ia;
43
+ for (let yy = Y1; yy < Y2; yy++) {
44
+ let o = (yy * this.W + X1) * 4;
45
+ for (let xx = X1; xx < X2; xx++, o += 4) {
46
+ if (a === 255) { this.buf[o] = r; this.buf[o + 1] = g; this.buf[o + 2] = b; }
47
+ else { this.buf[o] = r * ia + this.buf[o] * na; this.buf[o + 1] = g * ia + this.buf[o + 1] * na; this.buf[o + 2] = b * ia + this.buf[o + 2] * na; }
48
+ this.buf[o + 3] = 255;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ export function upscale(buf, w, h, s) {
55
+ if (s === 1) return buf;
56
+ const OW = w * s, out = new Uint8Array(OW * h * s * 4);
57
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
58
+ const o = (y * w + x) * 4;
59
+ for (let dy = 0; dy < s; dy++) { let oo = (((y * s + dy) * OW) + x * s) * 4; for (let dx = 0; dx < s; dx++, oo += 4) { out[oo] = buf[o]; out[oo + 1] = buf[o + 1]; out[oo + 2] = buf[o + 2]; out[oo + 3] = 255; } }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ const CRC_TABLE = (() => {
65
+ const t = new Uint32Array(256);
66
+ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
67
+ return t;
68
+ })();
69
+ function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
70
+ function chunk(type, data) {
71
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
72
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
73
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
74
+ return Buffer.concat([len, body, crc]);
75
+ }
76
+ export function encodePNG(w, h, rgba) {
77
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
78
+ const ihdr = Buffer.alloc(13);
79
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
80
+ ihdr[8] = 8; ihdr[9] = 6; // 8-bit, RGBA
81
+ const rowLen = w * 4, raw = Buffer.alloc(h * (1 + rowLen));
82
+ const src = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.length);
83
+ for (let y = 0; y < h; y++) { raw[y * (1 + rowLen)] = 0; src.copy(raw, y * (1 + rowLen) + 1, y * rowLen, (y + 1) * rowLen); }
84
+ const idat = zlib.deflateSync(raw, { level: 9 });
85
+ return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
86
+ }
87
+
88
+ // Structural self-check: shim fill + alpha blend + a valid PNG that round-trips.
89
+ // Throws on any failure. Both generators call this before rendering.
90
+ export function selfCheckPNG() {
91
+ const t = new Ctx(2, 1, '#ffffff');
92
+ const ok = (c, m) => { if (!c) throw new Error('pixpng self-check failed: ' + m); };
93
+ t.fillStyle = '#ff0000'; t.fillRect(0, 0, 1, 1);
94
+ ok(t.buf[0] === 255 && t.buf[1] === 0 && t.buf[2] === 0, 'opaque fill');
95
+ t.fillStyle = 'rgba(0,0,255,0.5)'; t.fillRect(1, 0, 1, 1);
96
+ ok(Math.abs(t.buf[4] - 128) <= 2 && t.buf[6] === 255, 'alpha blend');
97
+ t.save(); t.translate(12, 0); t.scale(-1, 1); t.fillStyle = '#00ff00'; t.fillRect(10, 0, 2, 1); t.restore(); // mirror → x∈[0,2)
98
+ ok(t.buf[1] === 255, 'mirror transform');
99
+ const png = encodePNG(2, 1, t.buf);
100
+ ok(png.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'png signature');
101
+ ok(png.readUInt32BE(16) === 2 && png.readUInt32BE(20) === 1, 'png dims');
102
+ const idat = png.slice(png.indexOf(Buffer.from('IDAT')) + 4, png.indexOf(Buffer.from('IEND')) - 8);
103
+ ok(zlib.inflateSync(idat).length === 1 * (1 + 2 * 4), 'idat round-trips');
104
+ }