clawhouse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3335 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
+ <title>Pixel Office</title>
7
+ <link rel="manifest" href="/manifest.webmanifest" />
8
+ <meta name="theme-color" content="#212744" />
9
+ <meta name="apple-mobile-web-app-capable" content="yes" />
10
+ <meta name="apple-mobile-web-app-title" content="Office" />
11
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
12
+ <link rel="apple-touch-icon" href="/icon-180.png" />
13
+ <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
14
+ <style>
15
+ :root {
16
+ --bg: #151525;
17
+ --panel2: #2e3560;
18
+ --line: #0d1020;
19
+ --text: #ecf2ff;
20
+ --muted: #a8b0cc;
21
+ --green: #42d392;
22
+ --amber: #ffcd57;
23
+ --red: #ff7b7b;
24
+ --shadow: rgba(0,0,0,.35);
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body {
28
+ margin: 0;
29
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
30
+ color: var(--text);
31
+ background: linear-gradient(180deg, #121220, #1a1d33 38%, #212744 38%, #212744 100%);
32
+ min-height: 100vh;
33
+ }
34
+ .topbar {
35
+ display:flex; gap:12px; align-items:center; justify-content:space-between;
36
+ padding:16px 20px; border-bottom:4px solid var(--line); background:rgba(17,19,34,.9);
37
+ }
38
+ .title { font-size:20px; font-weight:900; letter-spacing:1px; }
39
+ .subtitle { color: var(--muted); font-size: 12px; margin-top: 4px; }
40
+ .actions { display:flex; gap:8px; flex-wrap:wrap; }
41
+ button, a.button {
42
+ appearance:none; border:0; cursor:pointer; text-decoration:none;
43
+ color:var(--text); background:var(--panel2); padding:10px 12px; border-radius:0;
44
+ box-shadow:4px 4px 0 var(--line); font:inherit; font-size:12px;
45
+ }
46
+ .button.secondary { background:#39416f; }
47
+ #chime-btn.chime-off { opacity:.5; }
48
+ #chime-btn.chime-on { opacity:1; box-shadow:4px 4px 0 #59f7c7; }
49
+ .wrap { display:grid; grid-template-columns:minmax(0, 1fr) 320px; gap:18px; padding:18px; }
50
+ .office {
51
+ /* Each room is locked to exactly the scene's width + room padding +
52
+ border (365 + 24 + 20 = 409px) so the brick frame hugs the scene
53
+ on every device — no shape-shifting, no extra wall on the sides
54
+ when the viewport is wide. justify-content:center keeps the grid
55
+ row centered in the page when there are fewer rooms per row. */
56
+ display:grid; grid-template-columns:repeat(auto-fit, 409px);
57
+ justify-content:center;
58
+ gap:0; align-content:start;
59
+ box-shadow: 8px 8px 0 var(--shadow);
60
+ position:relative; /* anchor for the roaming office dog */
61
+ }
62
+ .room {
63
+ --wall1:#313b66; --wall2:#252d50; --floor1:#8d6949; --floor2:#6d4f36; --accent:#67c6ff; --accent2:#d6f6ff; --shirt:#62d0ff; --hair:#11172f; --skin:#ffd79a; --prop:#53bf7d; --metal:#4d588d; --glow:#59f7c7;
64
+ min-height: 312px;
65
+ /* Shared brick walls between rooms — identical pattern across every
66
+ room so adjacent borders meet seamlessly, reading as one continuous
67
+ brick wall. Two layered backgrounds: the brick tile fills the
68
+ border-box (visible in the border area), and the themed interior
69
+ gradient covers padding-box (room interior). */
70
+ border: 10px solid transparent;
71
+ background:
72
+ linear-gradient(180deg, var(--wall1) 0%, var(--wall2) 100%),
73
+ url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 16' preserveAspectRatio='none' shape-rendering='crispEdges'><rect width='24' height='16' fill='%232a1810'/><rect x='0' y='0' width='11' height='7' fill='%238a4830'/><rect x='12' y='0' width='11' height='7' fill='%237a3a24'/><rect x='0' y='8' width='5' height='7' fill='%239a5840'/><rect x='6' y='8' width='11' height='7' fill='%238a4830'/><rect x='18' y='8' width='5' height='7' fill='%237a3a24'/></svg>") 0 0 / 24px 16px;
74
+ background-clip: padding-box, border-box;
75
+ background-origin: padding-box, border-box;
76
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,0.4);
77
+ position:relative; overflow:hidden; padding:12px; text-align:left;
78
+ /* Flex column with space-between pins the header to the top and the
79
+ footer to the bottom, leaving the scene centered in the remaining
80
+ space — prevents the middle from feeling squeezed when the room is
81
+ taller than its content (after the aspect-ratio change). */
82
+ display:flex; flex-direction:column; justify-content:space-between;
83
+ }
84
+ .room::before {
85
+ content:""; position:absolute; inset:0; background-image:linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px);
86
+ background-size:16px 16px; pointer-events:none;
87
+ }
88
+ .room-header,.room-footer,.scene { position:relative; z-index:1; }
89
+ .room-header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
90
+ .agent-name { font-size:15px; font-weight:900; }
91
+ .agent-role { color:var(--muted); font-size:11px; }
92
+ .badge { font-size:10px; padding:6px 8px; background:rgba(0,0,0,.25); border:2px solid var(--line); }
93
+ /* Claude-Code-style context bar in the header — sits between the agent
94
+ name (left) and the status badge (right). Color shifts with usage:
95
+ <50% green, 50-80% amber, ≥80% red. Larger and bolder than the
96
+ surrounding header text so it reads as the room's headline metric. */
97
+ .ctxbar {
98
+ font-size:11px; font-weight:700; white-space:nowrap;
99
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
100
+ letter-spacing:.2px; text-align:center; flex:1;
101
+ text-shadow:0 1px 0 rgba(0,0,0,.45);
102
+ display:inline-flex; align-items:center; justify-content:center; gap:6px;
103
+ }
104
+ .ctxbar.ok { color:#59f7c7; }
105
+ .ctxbar.warn { color:#ffd070; }
106
+ .ctxbar.hot { color:#ff7878; }
107
+ /* Cron-jobs chip — shows N scheduled jobs in OpenClaw cron for this
108
+ agent. Sits to the LEFT of the ctxbar value. Hidden when 0 jobs. */
109
+ .cron-chip {
110
+ display:inline-flex; align-items:center; gap:2px;
111
+ padding:1px 5px;
112
+ font-size:9px; font-weight:700;
113
+ background:rgba(255,208,112,.15); color:#ffd070;
114
+ border:1px solid rgba(255,208,112,.45);
115
+ border-radius:3px;
116
+ }
117
+ /* Products chip — N apps shipped/in-flight by this agent. Same shape as
118
+ cron chip; uses a green tint so it reads as "shipped" not "scheduled". */
119
+ .product-chip {
120
+ display:inline-flex; align-items:center; gap:2px;
121
+ padding:1px 5px;
122
+ font-size:9px; font-weight:700;
123
+ background:rgba(89,247,199,.12); color:#59f7c7;
124
+ border:1px solid rgba(89,247,199,.45);
125
+ border-radius:3px;
126
+ }
127
+ /* Side-panel product cards — link buttons for Local + LAN launch URLs. */
128
+ .product-list { display:grid; gap:8px; margin-top:4px; }
129
+ .product-card {
130
+ border:2px solid var(--line); background:#2b3156; padding:8px 10px;
131
+ display:grid; gap:4px;
132
+ }
133
+ .product-card-head {
134
+ display:flex; align-items:center; justify-content:space-between; gap:6px;
135
+ font-size:12px; font-weight:800; color:var(--text);
136
+ }
137
+ .product-status {
138
+ font-size:9px; padding:1px 5px; border:1px solid currentColor;
139
+ text-transform:uppercase; letter-spacing:.5px;
140
+ }
141
+ .product-status.live { color:#59f7c7; }
142
+ .product-status.offline { color:#ff7a7a; }
143
+ .product-status.checking { color:#9aa0c2; }
144
+ .product-status.wip { color:#ffd070; }
145
+ .product-status.planning { color:#79b4ff; }
146
+ .product-status.paused { color:#9aa0c2; }
147
+ .product-desc { font-size:11px; color:var(--muted); line-height:1.35; }
148
+ .product-links { display:flex; flex-wrap:wrap; gap:4px; }
149
+ .product-link {
150
+ display:inline-flex; flex-direction:column; gap:1px;
151
+ padding:4px 8px;
152
+ background:#1a1f38; color:#cfe0ff;
153
+ border:1px solid var(--line); text-decoration:none;
154
+ line-height:1.2;
155
+ }
156
+ .product-link:hover { background:#222850; }
157
+ .product-link .lbl {
158
+ font-size:11px; font-weight:700; letter-spacing:.3px;
159
+ }
160
+ .product-link .sub {
161
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
162
+ font-size:9px; color:var(--muted);
163
+ }
164
+ .product-link.start-btn {
165
+ background:#1f4a3a; color:#d6ffe9; cursor:pointer;
166
+ font-family:inherit;
167
+ }
168
+ .product-link.start-btn:hover { background:#286b53; }
169
+ .product-link.start-btn:disabled { opacity:.6; cursor:wait; }
170
+ .product-link.unavailable {
171
+ color:var(--muted); font-style:italic; cursor:default;
172
+ border-style:dashed;
173
+ }
174
+ .product-link.docs-only {
175
+ color:#cdb7ff; cursor:default; border-style:dashed;
176
+ }
177
+ .product-link.docs-only .sub { color:#9d8fc8; }
178
+ /* Scene is locked to a fixed 320x240 box (4:3) on every device. All
179
+ furniture/sprite/window/lamp positions inside the scene are absolute
180
+ pixel offsets, so freezing the scene size guarantees the same layout
181
+ on phone, tablet, and desktop — nothing reflows or shifts. The room
182
+ around it can still flex; the scene just sits centered inside it. */
183
+ .scene { width:365px; height:274px; max-width:100%; margin:0 auto; }
184
+ .decor {
185
+ position:absolute;
186
+ background-image: url('furniture.png');
187
+ background-repeat: no-repeat;
188
+ image-rendering: pixelated; image-rendering: crisp-edges;
189
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.35));
190
+ }
191
+ /* Windows — upper-right of wall, 3× scale (32x32 src → 96x96 display) */
192
+ /* All agent windows — CSS-painted, no sprite. Same wood frame + cross
193
+ mullions on every room; unique scene per agent matching their job. */
194
+ .decor.window-eagle,
195
+ .decor.window-market,
196
+ .decor.window-sage,
197
+ .decor.window-senku,
198
+ .decor.window-shikamaru,
199
+ .decor.window-tyrion,
200
+ .decor.window-harvey,
201
+ .decor.window-l,
202
+ .decor.window-d,
203
+ .decor.window-ephraim,
204
+ .decor.window-house {
205
+ top: 28px; right: 72px;
206
+ width: 96px; height: 96px;
207
+ background-image: none;
208
+ border: 4px solid #2a1810;
209
+ box-shadow: inset 0 0 0 2px #4a3018;
210
+ }
211
+ /* horizontal mullion */
212
+ .decor.window-eagle::before,
213
+ .decor.window-market::before,
214
+ .decor.window-sage::before,
215
+ .decor.window-senku::before,
216
+ .decor.window-shikamaru::before,
217
+ .decor.window-tyrion::before,
218
+ .decor.window-harvey::before,
219
+ .decor.window-l::before,
220
+ .decor.window-d::before,
221
+ .decor.window-ephraim::before,
222
+ .decor.window-house::before {
223
+ content:""; position:absolute; left:4px; right:4px;
224
+ top: calc(50% - 1px); height: 2px;
225
+ background: #2a1810; z-index: 2;
226
+ }
227
+ /* vertical mullion */
228
+ .decor.window-eagle::after,
229
+ .decor.window-market::after,
230
+ .decor.window-sage::after,
231
+ .decor.window-senku::after,
232
+ .decor.window-shikamaru::after,
233
+ .decor.window-tyrion::after,
234
+ .decor.window-harvey::after,
235
+ .decor.window-l::after,
236
+ .decor.window-d::after,
237
+ .decor.window-ephraim::after,
238
+ .decor.window-house::after {
239
+ content:""; position:absolute; top:4px; bottom:4px;
240
+ left: calc(50% - 1px); width: 2px;
241
+ background: #2a1810; z-index: 2;
242
+ }
243
+
244
+ /* Commander — relaxing vista: pastel sky, soft clouds, misty mountains,
245
+ mossy grass with wildflowers. Atmospheric perspective fades distant
246
+ mountains toward sky color for a calm, restful feel. */
247
+ .decor.window-eagle {
248
+ background:
249
+ /* wildflowers scattered in the grass */
250
+ linear-gradient(#fff8d8, #fff8d8) no-repeat 14px 90% / 1px 1px,
251
+ linear-gradient(#ffe080, #ffe080) no-repeat 28px 93% / 1px 1px,
252
+ linear-gradient(#fff8d8, #fff8d8) no-repeat 42px 88% / 1px 1px,
253
+ linear-gradient(#f8b0c8, #f8b0c8) no-repeat 56px 92% / 1px 1px,
254
+ linear-gradient(#fff8d8, #fff8d8) no-repeat 70px 90% / 1px 1px,
255
+ linear-gradient(#ffe080, #ffe080) no-repeat 82px 94% / 1px 1px,
256
+ /* grass — soft mossy meadow with depth */
257
+ linear-gradient(180deg, #88b868 0%, #6a9a48 50%, #5a8a3a 100%) no-repeat 0 100% / 100% 20%,
258
+ /* foreground mountain ridge — soft muted green-blue */
259
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><polygon points="0,76 16,60 28,70 42,55 56,64 72,58 86,66 96,62 96,96 0,96" fill="%23708898"/></svg>') no-repeat 0 0 / 100% 100%,
260
+ /* soft clouds drifting above the distant range */
261
+ radial-gradient(ellipse 18px 5px at 50% 22%, rgba(255,255,255,.82) 0%, rgba(255,255,255,.5) 55%, transparent 100%),
262
+ radial-gradient(ellipse 14px 4px at 22% 32%, rgba(255,255,255,.78) 0%, rgba(255,255,255,.45) 60%, transparent 100%),
263
+ radial-gradient(ellipse 10px 3px at 30% 16%, rgba(255,255,255,.85) 0%, transparent 100%),
264
+ /* background mountain range — distant misty blue-purple */
265
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><polygon points="0,62 12,46 24,55 38,32 52,50 64,40 78,52 96,44 96,96 0,96" fill="%239aacc4"/></svg>') no-repeat 0 0 / 100% 100%,
266
+ /* sun disc — soft pale yellow */
267
+ radial-gradient(circle at 72% 28%, #fff8d8 0%, #ffe098 8%, transparent 9%),
268
+ /* gentle sun halo */
269
+ radial-gradient(circle at 72% 28%, rgba(255,240,180,.42) 0%, rgba(255,240,180,.16) 18%, transparent 32%),
270
+ /* gentle pastel sky */
271
+ linear-gradient(180deg, #a8d0ea 0%, #c0dcee 35%, #d8eaee 65%, #e8f0e6 90%, #dde8d2 100%);
272
+ }
273
+ /* MarketHunting — financial district by day: glass towers, bright sky. */
274
+ .decor.window-market {
275
+ background:
276
+ linear-gradient(rgba(255,255,255,.35), rgba(255,255,255,.35)) no-repeat 10px 22% / 2px 12%,
277
+ linear-gradient(rgba(255,255,255,.25), rgba(255,255,255,.25)) no-repeat 28px 30% / 2px 8%,
278
+ linear-gradient(rgba(255,255,255,.3), rgba(255,255,255,.3)) no-repeat 42px 18% / 3px 18%,
279
+ linear-gradient(rgba(255,255,255,.2), rgba(255,255,255,.2)) no-repeat 60px 26% / 2px 14%,
280
+ linear-gradient(#3a5070, #3a5070) no-repeat 6px 100% / 14px 65%,
281
+ linear-gradient(#4a6080, #4a6080) no-repeat 24px 100% / 12px 52%,
282
+ linear-gradient(#3a5070, #3a5070) no-repeat 38px 100% / 18px 74%,
283
+ linear-gradient(#4a6080, #4a6080) no-repeat 58px 100% / 12px 58%,
284
+ linear-gradient(#3a5070, #3a5070) no-repeat 72px 100% / 16px 46%,
285
+ linear-gradient(#181820, #181820) no-repeat 0 100% / 100% 10%,
286
+ linear-gradient(180deg, #4a80c8 0%, #6898d8 35%, #8ab0e0 65%, #c8d8ee 90%, #e0ecf8 100%);
287
+ }
288
+ /* Sage — quiet neighborhood: 3 houses, tree, hedges, two kids playing
289
+ with a ball on the front lawn. Built as one inline SVG so we can use
290
+ triangular roofs, circles for kid heads, and an oval ball. */
291
+ .decor.window-sage {
292
+ background:
293
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><ellipse cx="20" cy="14" rx="8" ry="2.5" fill="white" opacity="0.85"/><ellipse cx="62" cy="22" rx="10" ry="3" fill="white" opacity="0.8"/><rect x="0" y="72" width="96" height="14" fill="%237aac58"/><rect x="0" y="86" width="96" height="10" fill="%23a8a8a0"/><rect x="3" y="64" width="2" height="10" fill="%235a3a20"/><ellipse cx="4" cy="56" rx="6" ry="10" fill="%233a8038"/><rect x="14" y="58" width="18" height="14" fill="%23f0e0c0"/><polygon points="12,58 23,46 34,58" fill="%23a04030"/><rect x="20" y="64" width="5" height="8" fill="%235a3a20"/><rect x="16" y="62" width="3" height="3" fill="%23fff8c0"/><rect x="28" y="62" width="3" height="3" fill="%23fff8c0"/><rect x="40" y="52" width="22" height="20" fill="%23cad8e8"/><polygon points="38,52 51,38 64,52" fill="%235070a0"/><rect x="48" y="62" width="6" height="10" fill="%23704028"/><rect x="42" y="56" width="4" height="3" fill="%23fff8c0"/><rect x="56" y="56" width="4" height="3" fill="%23fff8c0"/><rect x="68" y="60" width="20" height="12" fill="%23f8d8c0"/><polygon points="66,60 78,50 90,60" fill="%23a06040"/><rect x="76" y="64" width="5" height="8" fill="%235a3a20"/><rect x="70" y="62" width="3" height="3" fill="%23fff8c0"/><ellipse cx="36" cy="74" rx="4" ry="2" fill="%233a8038"/><ellipse cx="65" cy="74" rx="3" ry="2" fill="%233a8038"/><circle cx="24" cy="78" r="1.5" fill="%23f0d0a0"/><rect x="22.5" y="79.5" width="3" height="4" fill="%23d04848"/><circle cx="58" cy="80" r="1.5" fill="%23f0d0a0"/><rect x="56.5" y="81.5" width="3" height="3" fill="%234878c8"/><circle cx="42" cy="84" r="1.5" fill="%23ffd070"/></svg>') no-repeat 0 0 / 100% 100%,
294
+ /* gentle daytime sky */
295
+ linear-gradient(180deg, #a8d0e8 0%, #c0dff0 45%, #dceef8 75%, #eef8e0 100%);
296
+ }
297
+ /* Senku — aurora night over science facility: green glow, lab tower. */
298
+ .decor.window-senku {
299
+ background:
300
+ linear-gradient(180deg, transparent 28%, rgba(60,255,180,.22) 28% 52%, rgba(60,200,255,.14) 52% 66%, transparent 66%) no-repeat 0 0 / 100% 72%,
301
+ linear-gradient(#78dbff, #78dbff) no-repeat 40px 64% / 2px 1px,
302
+ linear-gradient(#78dbff, #78dbff) no-repeat 44px 70% / 2px 1px,
303
+ linear-gradient(#78dbff, #78dbff) no-repeat 48px 64% / 2px 1px,
304
+ linear-gradient(#050c0a, #050c0a) no-repeat 0 100% / 24px 26%,
305
+ linear-gradient(#050c0a, #050c0a) no-repeat 20px 100% / 18px 20%,
306
+ linear-gradient(#050c0a, #050c0a) no-repeat 36px 100% / 28px 38%,
307
+ linear-gradient(#050c0a, #050c0a) no-repeat 62px 100% / 16px 24%,
308
+ linear-gradient(#050c0a, #050c0a) no-repeat 76px 100% / 20px 18%,
309
+ linear-gradient(#050c0a, #050c0a) no-repeat 0 100% / 100% 14%,
310
+ linear-gradient(180deg, #020a0c 0%, #041418 35%, #083828 65%, #041820 100%);
311
+ }
312
+ /* Shikamaru — Capitol building under blue sky: domed rotunda with spire,
313
+ columned wings, marble steps, front lawn. Drawn as one SVG so the dome
314
+ (ellipse) and pediment shapes render cleanly. */
315
+ .decor.window-shikamaru {
316
+ background:
317
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><ellipse cx="14" cy="14" rx="6" ry="2" fill="white" opacity="0.8"/><ellipse cx="80" cy="22" rx="8" ry="2.5" fill="white" opacity="0.75"/><rect x="6" y="64" width="84" height="18" fill="%23eae4d2"/><rect x="36" y="52" width="24" height="14" fill="%23eae4d2"/><ellipse cx="48" cy="52" rx="12" ry="10" fill="%23dcd4be"/><ellipse cx="44" cy="49" rx="4" ry="5" fill="%23f0e8d4" opacity="0.6"/><rect x="47" y="34" width="2" height="8" fill="%23b8b09a"/><circle cx="48" cy="34" r="1.5" fill="%23d4a04a"/><rect x="14" y="68" width="2" height="12" fill="%23d4ccb6"/><rect x="20" y="68" width="2" height="12" fill="%23d4ccb6"/><rect x="26" y="68" width="2" height="12" fill="%23d4ccb6"/><rect x="40" y="56" width="2" height="10" fill="%23d4ccb6"/><rect x="46" y="56" width="2" height="10" fill="%23d4ccb6"/><rect x="50" y="56" width="2" height="10" fill="%23d4ccb6"/><rect x="56" y="56" width="2" height="10" fill="%23d4ccb6"/><rect x="68" y="68" width="2" height="12" fill="%23d4ccb6"/><rect x="74" y="68" width="2" height="12" fill="%23d4ccb6"/><rect x="80" y="68" width="2" height="12" fill="%23d4ccb6"/><polygon points="30,82 66,82 72,86 24,86" fill="%23d8d2c4"/><rect x="0" y="86" width="96" height="10" fill="%23789868"/></svg>') no-repeat 0 0 / 100% 100%,
318
+ /* clear blue daytime sky */
319
+ linear-gradient(180deg, #3858a0 0%, #5070b8 25%, #8098cc 55%, #b0c0dc 80%, #d8e4f0 100%);
320
+ }
321
+ /* Tyrion — Westeros sunset: castle wall + towers + spires. */
322
+ .decor.window-tyrion {
323
+ background:
324
+ linear-gradient(#1a0810, #1a0810) no-repeat 10px 100% / 8px 30%,
325
+ linear-gradient(#1a0810, #1a0810) no-repeat 24px 100% / 6px 24%,
326
+ linear-gradient(#1a0810, #1a0810) no-repeat 38px 100% / 12px 48%,
327
+ linear-gradient(#1a0810, #1a0810) no-repeat 58px 100% / 8px 34%,
328
+ linear-gradient(#1a0810, #1a0810) no-repeat 72px 100% / 10px 26%,
329
+ linear-gradient(#1a0810, #1a0810) no-repeat 0 100% / 100% 16%,
330
+ linear-gradient(180deg, #ffd07a 0%, #ff8a48 32%, #d04a3a 60%, #6a2848 84%, #2a1428 100%);
331
+ }
332
+ /* Harvey — Manhattan dusk: skyscrapers + scattered lit office windows. */
333
+ .decor.window-harvey {
334
+ background:
335
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 11px 70% / 1px 1px,
336
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 14px 78% / 1px 1px,
337
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 25px 55% / 1px 1px,
338
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 28px 65% / 1px 1px,
339
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 41px 45% / 1px 1px,
340
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 44px 55% / 1px 1px,
341
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 47px 65% / 1px 1px,
342
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 59px 60% / 1px 1px,
343
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 62px 70% / 1px 1px,
344
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 73px 75% / 1px 1px,
345
+ linear-gradient(#ffd07a, #ffd07a) no-repeat 78px 82% / 1px 1px,
346
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 8px 100% / 10px 42%,
347
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 22px 100% / 12px 58%,
348
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 38px 100% / 14px 70%,
349
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 56px 100% / 10px 50%,
350
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 70px 100% / 14px 36%,
351
+ linear-gradient(#0a0a18, #0a0a18) no-repeat 0 100% / 100% 12%,
352
+ linear-gradient(180deg, #2a3868 0%, #4858a0 25%, #ad6260 55%, #6a3848 82%, #1a1428 100%);
353
+ }
354
+ /* L — rainy surveillance night: wet city, cold blue lit windows, rain streaks. */
355
+ .decor.window-l {
356
+ background:
357
+ linear-gradient(rgba(150,180,220,.4), rgba(150,180,220,.4)) no-repeat 18px 10% / 1px 18%,
358
+ linear-gradient(rgba(150,180,220,.3), rgba(150,180,220,.3)) no-repeat 34px 5% / 1px 22%,
359
+ linear-gradient(rgba(150,180,220,.4), rgba(150,180,220,.4)) no-repeat 52px 12% / 1px 15%,
360
+ linear-gradient(rgba(150,180,220,.3), rgba(150,180,220,.3)) no-repeat 70px 7% / 1px 20%,
361
+ linear-gradient(rgba(150,180,220,.35), rgba(150,180,220,.35)) no-repeat 82px 5% / 1px 24%,
362
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 10px 64% / 2px 1px,
363
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 16px 72% / 1px 2px,
364
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 36px 60% / 2px 1px,
365
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 54px 68% / 1px 2px,
366
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 66px 76% / 2px 1px,
367
+ linear-gradient(#c8d8f0, #c8d8f0) no-repeat 78px 65% / 1px 1px,
368
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 6px 100% / 12px 38%,
369
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 22px 100% / 10px 28%,
370
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 34px 100% / 14px 44%,
371
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 52px 100% / 12px 32%,
372
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 66px 100% / 16px 36%,
373
+ linear-gradient(#0a0c12, #0a0c12) no-repeat 0 100% / 100% 16%,
374
+ linear-gradient(180deg, #0a0c12 0%, #141820 40%, #1e2430 70%, #283040 100%);
375
+ }
376
+ /* D — neon night city / concert district: purple sky, pink+violet lit windows. */
377
+ .decor.window-d {
378
+ background:
379
+ radial-gradient(circle at 25% 78%, rgba(255,80,180,.32) 0%, transparent 30%),
380
+ radial-gradient(circle at 74% 68%, rgba(160,80,255,.28) 0%, transparent 26%),
381
+ linear-gradient(#ff80c8, #ff80c8) no-repeat 10px 60% / 2px 2px,
382
+ linear-gradient(#c080ff, #c080ff) no-repeat 16px 70% / 1px 1px,
383
+ linear-gradient(#ff80c8, #ff80c8) no-repeat 28px 54% / 2px 1px,
384
+ linear-gradient(#c080ff, #c080ff) no-repeat 42px 64% / 1px 2px,
385
+ linear-gradient(#ff80c8, #ff80c8) no-repeat 56px 72% / 2px 1px,
386
+ linear-gradient(#c080ff, #c080ff) no-repeat 68px 62% / 1px 2px,
387
+ linear-gradient(#ff80c8, #ff80c8) no-repeat 78px 68% / 2px 1px,
388
+ linear-gradient(#0a0410, #0a0410) no-repeat 4px 100% / 10px 38%,
389
+ linear-gradient(#0a0410, #0a0410) no-repeat 18px 100% / 14px 52%,
390
+ linear-gradient(#0a0410, #0a0410) no-repeat 34px 100% / 10px 44%,
391
+ linear-gradient(#0a0410, #0a0410) no-repeat 48px 100% / 16px 58%,
392
+ linear-gradient(#0a0410, #0a0410) no-repeat 66px 100% / 12px 42%,
393
+ linear-gradient(#0a0410, #0a0410) no-repeat 80px 100% / 16px 36%,
394
+ linear-gradient(#0a0410, #0a0410) no-repeat 0 100% / 100% 12%,
395
+ linear-gradient(180deg, #0a0418 0%, #180830 40%, #2a1050 70%, #3a1860 100%);
396
+ }
397
+ /* Ephraim — Rocky-dawn ridgeline run: lone runner silhouette cresting a
398
+ foreground ridge against layered mountain silhouettes and a fiery
399
+ sunrise. Three depth layers (distant haze ridge, mid-range, foreground
400
+ hill) plus a glowing sun, a few drifting cloud streaks, and two birds.
401
+ Pulls from his red accent so the room reads as one piece. */
402
+ .decor.window-ephraim {
403
+ background:
404
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><path d="M 14 18 L 22 18" stroke="%23fff4d8" stroke-width="0.5" stroke-linecap="round" opacity="0.7"/><path d="M 28 14 L 38 14" stroke="%23fff4d8" stroke-width="0.5" stroke-linecap="round" opacity="0.55"/><path d="M 64 22 L 78 22" stroke="%23fff4d8" stroke-width="0.5" stroke-linecap="round" opacity="0.6"/><path d="M 6 12 L 14 12" stroke="%23fff4d8" stroke-width="0.5" stroke-linecap="round" opacity="0.45"/><circle cx="62" cy="46" r="9" fill="%23fff4c8"/><circle cx="62" cy="46" r="13" fill="%23ffd078" opacity="0.5"/><circle cx="62" cy="46" r="20" fill="%23ff9258" opacity="0.22"/><path d="M 10 8 L 13 9 L 16 8 M 13 9 L 13 11" stroke="%23502028" stroke-width="0.6" fill="none" stroke-linecap="round" opacity="0.85"/><path d="M 30 6 L 32 7 L 34 6 M 32 7 L 32 8" stroke="%23502028" stroke-width="0.5" fill="none" stroke-linecap="round" opacity="0.75"/><polygon points="0,58 8,52 14,55 22,46 30,52 40,48 50,54 58,50 68,56 78,52 86,58 96,54 96,62 0,62" fill="%237c5478" opacity="0.7"/><polygon points="0,64 6,58 12,62 20,52 28,58 36,52 46,60 54,56 64,62 72,58 82,64 92,60 96,64 96,68 0,68" fill="%23582448"/><polygon points="0,70 8,66 14,68 22,62 30,66 38,62 48,68 56,64 66,68 74,64 84,68 92,66 96,70 96,74 0,74" fill="%2334121f"/><polygon points="0,96 0,76 14,78 28,82 44,78 60,80 76,76 88,78 96,80 96,96" fill="%2318080c"/><path d="M 36 78 Q 48 70 60 78" stroke="%231a0a14" stroke-width="0.4" fill="none" opacity="0.5"/><g transform="translate(50.6 75.2)"><rect x="-0.25" y="-3.0" width="0.5" height="2.6" fill="%23000000"/><rect x="-0.25" y="-3.0" width="0.4" height="0.6" fill="%23c64030" opacity="0.95"/><circle cx="0" cy="-3.7" r="0.7" fill="%23000000"/><path d="M 0.5 -2.3 L 1.6 -1.5 L 1.0 -0.4" stroke="%23000000" stroke-width="0.6" fill="none" stroke-linecap="round"/><path d="M -0.5 -2.3 L -1.4 -1.4 L -1.0 -0.3" stroke="%23000000" stroke-width="0.6" fill="none" stroke-linecap="round"/><path d="M 0.0 -0.4 L 1.4 0.5 L 0.8 1.6" stroke="%23000000" stroke-width="0.65" fill="none" stroke-linecap="round"/><path d="M 0.0 -0.4 L -1.0 0.6 L -1.6 1.7" stroke="%23000000" stroke-width="0.65" fill="none" stroke-linecap="round"/></g></svg>') no-repeat 0 0 / 100% 100%,
405
+ /* fiery dawn sky — deep indigo top, magenta band, fiery orange,
406
+ pale gold near horizon */
407
+ linear-gradient(180deg, #1a1438 0%, #321a4a 14%, #6a2a4a 28%, #c63a3e 44%, #ff7a40 58%, #ffa860 70%, #ffd884 82%, #ffe8a0 92%, #f6c884 100%);
408
+ }
409
+ /* House — hospital exterior at dusk: tall medical center silhouette with
410
+ red cross, scattered lit windows, overcast slate-blue sky. Cool, clinical,
411
+ a little dreary — fits "everybody lies." */
412
+ .decor.window-house {
413
+ background:
414
+ url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" preserveAspectRatio="none"><ellipse cx="22" cy="20" rx="14" ry="3" fill="%23dde4ec" opacity="0.55"/><ellipse cx="68" cy="14" rx="16" ry="2.5" fill="%23dde4ec" opacity="0.45"/><rect x="0" y="86" width="96" height="10" fill="%237a8088"/><rect x="22" y="40" width="22" height="46" fill="%23586878"/><rect x="44" y="48" width="18" height="38" fill="%23485668"/><rect x="62" y="55" width="14" height="31" fill="%235a6a7c"/><rect x="32" y="34" width="2" height="6" fill="%23c92020"/><rect x="29" y="36" width="8" height="2" fill="%23c92020"/><rect x="6" y="68" width="14" height="18" fill="%234a5868"/><rect x="76" y="62" width="14" height="24" fill="%234a5868"/><rect x="25" y="44" width="2" height="2" fill="%23ffd470"/><rect x="29" y="44" width="2" height="2" fill="%23394452"/><rect x="33" y="44" width="2" height="2" fill="%23ffd470"/><rect x="37" y="44" width="2" height="2" fill="%23394452"/><rect x="41" y="44" width="2" height="2" fill="%23ffd470"/><rect x="25" y="50" width="2" height="2" fill="%23394452"/><rect x="29" y="50" width="2" height="2" fill="%23ffd470"/><rect x="33" y="50" width="2" height="2" fill="%23394452"/><rect x="37" y="50" width="2" height="2" fill="%23ffd470"/><rect x="41" y="50" width="2" height="2" fill="%23394452"/><rect x="25" y="56" width="2" height="2" fill="%23ffd470"/><rect x="29" y="56" width="2" height="2" fill="%23394452"/><rect x="33" y="56" width="2" height="2" fill="%23ffd470"/><rect x="37" y="56" width="2" height="2" fill="%23ffd470"/><rect x="41" y="56" width="2" height="2" fill="%23394452"/><rect x="25" y="62" width="2" height="2" fill="%23394452"/><rect x="29" y="62" width="2" height="2" fill="%23ffd470"/><rect x="33" y="62" width="2" height="2" fill="%23394452"/><rect x="37" y="62" width="2" height="2" fill="%23ffd470"/><rect x="41" y="62" width="2" height="2" fill="%23394452"/><rect x="25" y="68" width="2" height="2" fill="%23ffd470"/><rect x="29" y="68" width="2" height="2" fill="%23394452"/><rect x="33" y="68" width="2" height="2" fill="%23ffd470"/><rect x="37" y="68" width="2" height="2" fill="%23394452"/><rect x="41" y="68" width="2" height="2" fill="%23ffd470"/><rect x="25" y="74" width="2" height="2" fill="%23394452"/><rect x="29" y="74" width="2" height="2" fill="%23ffd470"/><rect x="33" y="74" width="2" height="2" fill="%23394452"/><rect x="37" y="74" width="2" height="2" fill="%23ffd470"/><rect x="41" y="74" width="2" height="2" fill="%23ffd470"/><rect x="47" y="52" width="2" height="2" fill="%23394452"/><rect x="51" y="52" width="2" height="2" fill="%23ffd470"/><rect x="55" y="52" width="2" height="2" fill="%23394452"/><rect x="47" y="58" width="2" height="2" fill="%23ffd470"/><rect x="51" y="58" width="2" height="2" fill="%23394452"/><rect x="55" y="58" width="2" height="2" fill="%23ffd470"/><rect x="47" y="64" width="2" height="2" fill="%23394452"/><rect x="51" y="64" width="2" height="2" fill="%23ffd470"/><rect x="55" y="64" width="2" height="2" fill="%23394452"/><rect x="47" y="70" width="2" height="2" fill="%23ffd470"/><rect x="51" y="70" width="2" height="2" fill="%23394452"/><rect x="55" y="70" width="2" height="2" fill="%23ffd470"/><rect x="47" y="76" width="2" height="2" fill="%23394452"/><rect x="51" y="76" width="2" height="2" fill="%23394452"/><rect x="55" y="76" width="2" height="2" fill="%23ffd470"/><rect x="65" y="60" width="2" height="2" fill="%23ffd470"/><rect x="69" y="60" width="2" height="2" fill="%23394452"/><rect x="73" y="60" width="2" height="2" fill="%23ffd470"/><rect x="65" y="66" width="2" height="2" fill="%23394452"/><rect x="69" y="66" width="2" height="2" fill="%23ffd470"/><rect x="73" y="66" width="2" height="2" fill="%23394452"/><rect x="65" y="72" width="2" height="2" fill="%23ffd470"/><rect x="69" y="72" width="2" height="2" fill="%23394452"/><rect x="73" y="72" width="2" height="2" fill="%23ffd470"/><rect x="65" y="78" width="2" height="2" fill="%23394452"/><rect x="69" y="78" width="2" height="2" fill="%23ffd470"/><rect x="73" y="78" width="2" height="2" fill="%23394452"/></svg>') no-repeat 0 0 / 100% 100%,
415
+ /* overcast dusk sky — slate blue with subtle warm under-glow at horizon */
416
+ linear-gradient(180deg, #3a4858 0%, #4a5a6c 30%, #6a7888 60%, #8a8c92 82%, #a89488 100%);
417
+ }
418
+ /* Connecting doors — rotated side-wall doors. Each door is the door sprite
419
+ tilted via CSS perspective+rotateY so it reads as the side face of a 3D
420
+ box receding into the wall. Adjacent rooms in the office grid line up
421
+ so room A's right door visually meets room B's left door across the
422
+ shared wall, reading as a corridor between offices. */
423
+ .door-left, .door-right {
424
+ position:absolute; bottom: 0;
425
+ width: 86px; height: 192px;
426
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.4));
427
+ z-index: 1; pointer-events: none;
428
+ }
429
+ /* Sprite lives on a pseudo-element so we can mirror just the source art on
430
+ one side without disturbing the parent's perspective rotation. */
431
+ .door-left::before, .door-right::before {
432
+ content: ''; position:absolute; inset:0;
433
+ background-image: url('furniture.png');
434
+ background-size: 192px 3072px; /* sheet 32x512 displayed at 6x */
435
+ background-position: -53px -624px; /* center crop: 192px sprite, take middle 86; y=104*6 */
436
+ background-repeat: no-repeat;
437
+ image-rendering: pixelated; image-rendering: crisp-edges;
438
+ }
439
+ /* Only the left door mirrors its source art — leaves the right door at the
440
+ sprite's natural orientation so the two knobs end up on opposite sides. */
441
+ .door-left::before { transform: scaleX(-1); }
442
+ .door-right {
443
+ right: -12px;
444
+ transform-origin: 100% 50%;
445
+ transform: perspective(420px) rotateY(-78deg);
446
+ }
447
+ .door-left {
448
+ left: -12px;
449
+ transform-origin: 0 50%;
450
+ transform: perspective(420px) rotateY(78deg);
451
+ }
452
+ .desk {
453
+ position:absolute; left:104px; right:80px; bottom:44px; height:16px;
454
+ background-image: url('furniture.png');
455
+ background-size: 64px 1024px; /* sheet 32x512 displayed at 2x */
456
+ background-position: 0 var(--desk-y, 0);
457
+ background-repeat: repeat-x;
458
+ image-rendering: pixelated; image-rendering: crisp-edges;
459
+ z-index: 1;
460
+ }
461
+ .desk.oak { --desk-y: 0; --leg: #5b3c1f; --leg-h: #7a532e; --leg-i: #6a4524; }
462
+ .desk.steel { --desk-y: -928px; --leg: #0d0f17; --leg-h: #2a3050; --leg-i: #1a1d2a; }
463
+ .desk.walnut { --desk-y: -960px; --leg: #1a0d06; --leg-h: #3e2114; --leg-i: #26140a; }
464
+ .desk.white { --desk-y: -992px; --leg: #7d8395; --leg-h: #cdd2dc; --leg-i: #a0a7b8; }
465
+
466
+ /* 4-leg CSS layout — ::before = left pair, ::after = right pair.
467
+ Each pair: outer leg (tall, at edge) + inner leg (shorter, shifted ~9px toward center). */
468
+ .desk::before, .desk::after {
469
+ content: ""; position: absolute; top: 100%; height: 28px;
470
+ width: 22px;
471
+ pointer-events: none; z-index: 1;
472
+ }
473
+ /* Both legs hang from y=0 (the desk apron bottom) so their tops connect to
474
+ the table; inner leg ends earlier so it reads as shorter. */
475
+ .desk::before {
476
+ left: 2px;
477
+ background:
478
+ /* outer-left: full 28px, anchored at top */
479
+ linear-gradient(var(--leg, #5b3c1f) 0 86%, var(--leg-h, #7a532e) 86% 100%) 0 0 / 5px 28px no-repeat,
480
+ /* inner-left: also anchored at top, only 22px tall */
481
+ linear-gradient(var(--leg-i, #6a4524) 0 91%, var(--leg-h, #7a532e) 91% 100%) 11px 0 / 4px 22px no-repeat;
482
+ }
483
+ .desk::after {
484
+ right: 2px;
485
+ background:
486
+ /* outer-right: full 28px, anchored at top */
487
+ linear-gradient(var(--leg, #5b3c1f) 0 86%, var(--leg-h, #7a532e) 86% 100%) 17px 0 / 5px 28px no-repeat,
488
+ /* inner-right: also anchored at top, only 22px tall */
489
+ linear-gradient(var(--leg-i, #6a4524) 0 91%, var(--leg-h, #7a532e) 91% 100%) 7px 0 / 4px 22px no-repeat;
490
+ }
491
+ /* Floor band — plank tile drawn twice (top + bottom row) so the wood fills
492
+ the bottom 32px of the scene. Per-agent variants set --floor-y. */
493
+ .floor {
494
+ position:absolute; left:-12px; right:-12px; bottom:0; height:32px;
495
+ background-image: url('furniture.png');
496
+ background-size: 64px 1024px; /* sheet 32x512 at 2x */
497
+ background-repeat: repeat-x;
498
+ background-position: 0 var(--floor-y, -800px);
499
+ image-rendering: pixelated; image-rendering: crisp-edges;
500
+ z-index: 0;
501
+ }
502
+ .floor::after {
503
+ content:""; position:absolute; left:0; right:0; bottom:0; height:16px;
504
+ background-image: url('furniture.png');
505
+ background-size: 64px 1024px;
506
+ background-repeat: repeat-x;
507
+ background-position: 0 var(--floor-y, -800px);
508
+ image-rendering: pixelated; image-rendering: crisp-edges;
509
+ }
510
+ /* Skirting board — thin trim where wall meets floor (sits 3px above floor top). */
511
+ .floor::before {
512
+ content:""; position:absolute; left:0; right:0; top:-3px; height:3px;
513
+ background:
514
+ linear-gradient(180deg,
515
+ rgba(0,0,0,0.55) 0 33%,
516
+ var(--skirt, #2a1810) 33% 100%);
517
+ pointer-events: none;
518
+ z-index: 0;
519
+ }
520
+ .floor.oak { --floor-y: -800px; --skirt: #2a1810; } /* y=400 * 2 */
521
+ .floor.ash { --floor-y: -832px; --skirt: #4a3522; } /* y=416 * 2 */
522
+ .floor.cherry { --floor-y: -864px; --skirt: #2a1008; } /* y=432 * 2 */
523
+ .floor.graphite { --floor-y: -896px; --skirt: #14141a; } /* y=448 * 2 */
524
+ /* Ceiling lamp — compact fixture pinned to the very top of the scene */
525
+ .lamp {
526
+ position:absolute; top:0px; left:50%; width:3px; height:3px;
527
+ background:#1a1d33;
528
+ transform: translateX(-50%);
529
+ z-index: 0;
530
+ }
531
+ .lamp::before {
532
+ content:""; position:absolute; left:-9px; top:2px;
533
+ width:0; height:0;
534
+ border-left:10px solid transparent; border-right:10px solid transparent;
535
+ border-top:8px solid #2a3050;
536
+ }
537
+ .lamp::after {
538
+ content:""; position:absolute; left:-7px; top:8px;
539
+ width:16px; height:2px; background:#fff3b8;
540
+ box-shadow: 0 0 6px #ffe98a;
541
+ }
542
+ /* Soft light pool falling from the lamp onto the desk and floor */
543
+ .lamp-pool {
544
+ position:absolute; left:50%; top:10px; width:240px; height:200px;
545
+ transform: translateX(-50%);
546
+ background: radial-gradient(ellipse at 50% 0%,
547
+ rgba(255,236,170,.32) 0%,
548
+ rgba(255,236,170,.16) 28%,
549
+ rgba(255,236,170,.06) 55%,
550
+ rgba(255,236,170,0) 80%);
551
+ pointer-events:none; z-index: 3;
552
+ mix-blend-mode: screen;
553
+ }
554
+ .room.away .lamp::after { background:#5a5a4a; box-shadow:none; }
555
+ .room.away .lamp-pool { opacity: .25; }
556
+ /* Keyboard + mouse — universal desk-surface kit. Sits on the desk top in
557
+ front of the monitor, slightly occluding the monitor stand.
558
+ Desk top is at scene-y=60 from bottom; keyboard occupies y=55..60. */
559
+ .keyboard {
560
+ position:absolute; bottom:55px; left:116px;
561
+ width:60px; height:5px;
562
+ background: linear-gradient(180deg,
563
+ #0d1020 0 1px,
564
+ #2c3250 1px 4px,
565
+ #0d1020 4px 5px);
566
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.45));
567
+ z-index: 2;
568
+ pointer-events: none;
569
+ }
570
+ .keyboard::before {
571
+ content:""; position:absolute; left:2px; right:2px; top:1px; height:2px;
572
+ background: repeating-linear-gradient(90deg,
573
+ #6b7090 0 2px, #2c3250 2px 4px);
574
+ }
575
+ .mouse {
576
+ position:absolute; bottom:55px; left:180px;
577
+ width:6px; height:5px;
578
+ background: linear-gradient(180deg,
579
+ #0d1020 0 1px,
580
+ #cdd2dc 1px 4px,
581
+ #0d1020 4px 5px);
582
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.4));
583
+ z-index: 2;
584
+ pointer-events: none;
585
+ }
586
+ /* Small per-agent desk accent — sits on the desk top to the left of the
587
+ monitor (scene-y=60, x≈106..114). Pixel-art mug/cup style, color per agent. */
588
+ .accent-cup {
589
+ position:absolute; bottom:60px; left:192px;
590
+ width:7px; height:10px;
591
+ background:
592
+ linear-gradient(180deg,
593
+ #0d1020 0 1px,
594
+ var(--cup, #c34a4a) 1px 7px,
595
+ #0d1020 7px 8px,
596
+ rgba(0,0,0,0) 8px 10px);
597
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.45));
598
+ z-index: 2;
599
+ pointer-events: none;
600
+ }
601
+ .accent-cup::before {
602
+ content:""; position:absolute; right:-2px; top:2px;
603
+ width:2px; height:4px;
604
+ background:
605
+ linear-gradient(90deg, #0d1020 0 1px, transparent 1px 2px),
606
+ var(--cup, #c34a4a);
607
+ }
608
+ .accent-cup.crimson { --cup: #c34a4a; }
609
+ .accent-cup.emerald { --cup: #3aa86b; }
610
+ .accent-cup.tea { --cup: #d4b87a; }
611
+ .accent-cup.cyan { --cup: #5acce0; }
612
+ .accent-cup.lavender { --cup: #8b8edc; }
613
+ .accent-cup.gold { --cup: #d4a04a; }
614
+ .accent-cup.plum { --cup: #8a5ea8; }
615
+ .accent-cup.white { --cup: #e0e4ec; }
616
+ .accent-cup.pink { --cup: #e07ab4; }
617
+ /* Harvey law books — messy cluster on the right side of the desk.
618
+ 9 standing spines + 2 leaning + 3 stacked flat = 14 books total. */
619
+ .law-books {
620
+ position:absolute; bottom:60px; right:88px;
621
+ width:64px; height:38px;
622
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.5));
623
+ z-index: 2;
624
+ pointer-events: none;
625
+ }
626
+ .law-books .standing {
627
+ position:absolute; left:8px; bottom:0;
628
+ width:50px; height:26px;
629
+ background:
630
+ linear-gradient(90deg,
631
+ #0d1020 0 1px,
632
+ #3a2418 1px 5px, #5a3424 5px 6px,
633
+ #0d1020 6px 7px,
634
+ #5a2222 7px 11px, #7a3030 11px 12px,
635
+ #0d1020 12px 13px,
636
+ #2a4032 13px 17px, #3e5848 17px 18px,
637
+ #0d1020 18px 19px,
638
+ #4a2818 19px 23px, #6a3a24 23px 24px,
639
+ #0d1020 24px 25px,
640
+ #2a3050 25px 29px, #3e466a 29px 30px,
641
+ #0d1020 30px 31px,
642
+ #4a2828 31px 35px, #6a3838 35px 36px,
643
+ #0d1020 36px 37px,
644
+ #1f2616 37px 41px, #354228 41px 42px,
645
+ #0d1020 42px 43px,
646
+ #5a3a14 43px 47px, #7a5224 47px 48px,
647
+ #0d1020 48px 49px,
648
+ #3a2418 49px 50px);
649
+ border-top: 1px solid #0d1020;
650
+ border-bottom: 1px solid #0d1020;
651
+ }
652
+ .law-books .lean {
653
+ position:absolute; left:58px; bottom:0;
654
+ width:6px; height:22px;
655
+ background:
656
+ linear-gradient(90deg,
657
+ #0d1020 0 1px,
658
+ #2a4032 1px 3px, #3e5848 3px 4px,
659
+ #5a2222 4px 5px,
660
+ #0d1020 5px 6px);
661
+ border-top: 1px solid #0d1020;
662
+ border-bottom: 1px solid #0d1020;
663
+ transform: rotate(16deg);
664
+ transform-origin: 50% 100%;
665
+ }
666
+ .law-books .lean2 {
667
+ position:absolute; left:0; bottom:0;
668
+ width:5px; height:18px;
669
+ background:
670
+ linear-gradient(90deg,
671
+ #0d1020 0 1px,
672
+ #4a2422 1px 3px, #6a3838 3px 4px,
673
+ #0d1020 4px 5px);
674
+ border-top: 1px solid #0d1020;
675
+ border-bottom: 1px solid #0d1020;
676
+ transform: rotate(-12deg);
677
+ transform-origin: 50% 100%;
678
+ }
679
+ .law-books .stack {
680
+ position:absolute; left:14px; bottom:26px;
681
+ width:30px; height:5px;
682
+ background:
683
+ linear-gradient(180deg,
684
+ #0d1020 0 1px,
685
+ #5a2222 1px 4px,
686
+ #0d1020 4px 5px);
687
+ }
688
+ .law-books .stack2 {
689
+ position:absolute; left:10px; bottom:31px;
690
+ width:26px; height:4px;
691
+ background:
692
+ linear-gradient(180deg,
693
+ #0d1020 0 1px,
694
+ #4a2818 1px 3px,
695
+ #0d1020 3px 4px);
696
+ transform: rotate(-4deg);
697
+ transform-origin: 50% 100%;
698
+ }
699
+ .law-books .stack3 {
700
+ position:absolute; left:20px; bottom:35px;
701
+ width:18px; height:3px;
702
+ background:
703
+ linear-gradient(180deg,
704
+ #0d1020 0 1px,
705
+ #2a3050 1px 2px,
706
+ #0d1020 2px 3px);
707
+ transform: rotate(3deg);
708
+ transform-origin: 50% 100%;
709
+ }
710
+ /* House medical journals — librarian-tidy stack of textbooks: 8 standing
711
+ spines in clean medical colors (white, teals, navy, deep red), one
712
+ crowned with a small red cross, a flat stack on top, and a stethoscope
713
+ draped over the stack (thin black curve ending in a chest piece). */
714
+ .med-books {
715
+ position:absolute; bottom:60px; right:88px;
716
+ width:64px; height:38px;
717
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.5));
718
+ z-index: 2;
719
+ pointer-events: none;
720
+ }
721
+ /* 8 upright spines — width 6 each, separator stripe between them.
722
+ Palette: clinical white, pale teal, deep teal, ocean blue, navy,
723
+ sage green, NEJM-red, cream. Each spine has a thin gold band detail. */
724
+ .med-books .standing {
725
+ position:absolute; left:6px; bottom:0;
726
+ width:50px; height:26px;
727
+ background:
728
+ linear-gradient(90deg,
729
+ #0d1020 0 1px,
730
+ #eef2f4 1px 5px, #c4cbd0 5px 6px,
731
+ #0d1020 6px 7px,
732
+ #6cbcb8 7px 11px, #4f8e8a 11px 12px,
733
+ #0d1020 12px 13px,
734
+ #1f4e58 13px 17px, #173842 17px 18px,
735
+ #0d1020 18px 19px,
736
+ #2c5e8e 19px 23px, #1c4068 23px 24px,
737
+ #0d1020 24px 25px,
738
+ #1a2540 25px 29px, #2a3656 29px 30px,
739
+ #0d1020 30px 31px,
740
+ #4f7a4c 31px 35px, #36563b 35px 36px,
741
+ #0d1020 36px 37px,
742
+ #b22b2b 37px 41px, #7c1a1a 41px 42px,
743
+ #0d1020 42px 43px,
744
+ #f2e9c8 43px 47px, #c9b98a 47px 48px,
745
+ #0d1020 48px 49px,
746
+ #1f4e58 49px 50px);
747
+ border-top: 1px solid #0d1020;
748
+ border-bottom: 1px solid #0d1020;
749
+ }
750
+ /* Flat stack on top — closed textbooks lying horizontally, two layers.
751
+ Top layer is the cleanest white volume with a red cross detail. */
752
+ .med-books .stack {
753
+ position:absolute; left:10px; bottom:26px;
754
+ width:30px; height:5px;
755
+ background:
756
+ linear-gradient(180deg,
757
+ #0d1020 0 1px,
758
+ #1f4e58 1px 4px,
759
+ #0d1020 4px 5px);
760
+ }
761
+ .med-books .stack2 {
762
+ position:absolute; left:6px; bottom:31px;
763
+ width:32px; height:5px;
764
+ background:
765
+ linear-gradient(180deg,
766
+ #0d1020 0 1px,
767
+ #eef2f4 1px 4px,
768
+ #0d1020 4px 5px);
769
+ transform: rotate(-3deg);
770
+ transform-origin: 50% 100%;
771
+ }
772
+ /* Red cross sitting flush on the white top book. Two crossed bars. */
773
+ .med-books .cross {
774
+ position:absolute; left:25px; bottom:32px;
775
+ width:6px; height:3px;
776
+ background:
777
+ linear-gradient(90deg,
778
+ transparent 0 2px,
779
+ #c92020 2px 4px,
780
+ transparent 4px 6px),
781
+ linear-gradient(180deg, #c92020 0 100%);
782
+ background-size: 100% 1px, 100% 100%;
783
+ background-repeat: no-repeat;
784
+ background-position: 0 1px, 0 0;
785
+ }
786
+ /* Stethoscope draped over the stack — a soft S-curve of dark tubing
787
+ ending in a small chrome chest-piece. Simulated with a curved border. */
788
+ .med-books .scope {
789
+ position:absolute; left:38px; bottom:24px;
790
+ width:18px; height:14px;
791
+ border: 1.5px solid #0d1020;
792
+ border-color: transparent #0d1020 #0d1020 transparent;
793
+ border-radius: 0 50% 50% 0;
794
+ background: transparent;
795
+ }
796
+ .med-books .scope-end {
797
+ position:absolute; left:50px; bottom:22px;
798
+ width:5px; height:5px;
799
+ background: #b8c0c8;
800
+ border: 1px solid #0d1020;
801
+ border-radius: 50%;
802
+ }
803
+ .trophy {
804
+ position:absolute; bottom:60px; right:104px;
805
+ width:16px; height:22px;
806
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
807
+ z-index: 2;
808
+ pointer-events: none;
809
+ }
810
+ .trophy .base {
811
+ position:absolute; left:2px; bottom:0;
812
+ width:12px; height:4px;
813
+ background:
814
+ linear-gradient(180deg,
815
+ #0d1020 0 1px,
816
+ #6a4418 1px 3px,
817
+ #3a2410 3px 4px);
818
+ }
819
+ .trophy .stem {
820
+ position:absolute; left:7px; bottom:4px;
821
+ width:2px; height:3px;
822
+ background: #b58330;
823
+ box-shadow: -1px 0 0 #0d1020, 1px 0 0 #0d1020;
824
+ }
825
+ .trophy .cup {
826
+ position:absolute; left:3px; bottom:7px;
827
+ width:10px; height:12px;
828
+ background:
829
+ linear-gradient(180deg,
830
+ #fff3b8 0 2px,
831
+ #d4a04a 2px 7px,
832
+ #a0742a 7px 11px,
833
+ #6a4a14 11px 12px);
834
+ box-shadow: inset 0 0 0 1px #0d1020;
835
+ }
836
+ .trophy .handle-l {
837
+ position:absolute; left:0; bottom:10px;
838
+ width:3px; height:6px;
839
+ background: #b58330;
840
+ box-shadow: inset 0 0 0 1px #0d1020;
841
+ }
842
+ .trophy .handle-r {
843
+ position:absolute; right:0; bottom:10px;
844
+ width:3px; height:6px;
845
+ background: #b58330;
846
+ box-shadow: inset 0 0 0 1px #0d1020;
847
+ }
848
+ /* Silver mini-trophy — second-place chalice, sits left of the gold one. */
849
+ .mini-trophy {
850
+ position:absolute; bottom:60px; right:124px;
851
+ width:12px; height:16px;
852
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
853
+ z-index: 2;
854
+ pointer-events: none;
855
+ }
856
+ .mini-trophy .base {
857
+ position:absolute; left:1px; bottom:0;
858
+ width:10px; height:3px;
859
+ background:
860
+ linear-gradient(180deg,
861
+ #0d1020 0 1px,
862
+ #6a4418 1px 2px,
863
+ #3a2410 2px 3px);
864
+ }
865
+ .mini-trophy .stem {
866
+ position:absolute; left:5px; bottom:3px;
867
+ width:2px; height:2px;
868
+ background: #a8a8b0;
869
+ box-shadow: -1px 0 0 #0d1020, 1px 0 0 #0d1020;
870
+ }
871
+ .mini-trophy .cup {
872
+ position:absolute; left:2px; bottom:5px;
873
+ width:8px; height:9px;
874
+ background:
875
+ linear-gradient(180deg,
876
+ #ffffff 0 1px,
877
+ #d8dce4 1px 5px,
878
+ #8a8e98 5px 8px,
879
+ #5a5e68 8px 9px);
880
+ box-shadow: inset 0 0 0 1px #0d1020;
881
+ }
882
+ .mini-trophy .handle-l {
883
+ position:absolute; left:0; bottom:7px;
884
+ width:2px; height:4px;
885
+ background: #a8a8b0;
886
+ box-shadow: inset 0 0 0 1px #0d1020;
887
+ }
888
+ .mini-trophy .handle-r {
889
+ position:absolute; right:0; bottom:7px;
890
+ width:2px; height:4px;
891
+ background: #a8a8b0;
892
+ box-shadow: inset 0 0 0 1px #0d1020;
893
+ }
894
+ /* Bronze medal hanging from a red ribbon. */
895
+ .medal {
896
+ position:absolute; bottom:60px; right:90px;
897
+ width:8px; height:14px;
898
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
899
+ z-index: 2;
900
+ pointer-events: none;
901
+ }
902
+ .medal .ribbon {
903
+ position:absolute; left:3px; bottom:6px;
904
+ width:2px; height:8px;
905
+ background:
906
+ repeating-linear-gradient(180deg,
907
+ #c43838 0 2px,
908
+ #8a2222 2px 4px);
909
+ box-shadow: -1px 0 0 #0d1020, 1px 0 0 #0d1020;
910
+ }
911
+ .medal .disc {
912
+ position:absolute; left:0; bottom:0;
913
+ width:8px; height:7px;
914
+ border-radius: 50%;
915
+ background:
916
+ radial-gradient(circle at 35% 30%,
917
+ #ffd86a 0 25%,
918
+ #cc7a3a 25% 65%,
919
+ #8a4818 65% 100%);
920
+ box-shadow: 0 0 0 1px #0d1020;
921
+ }
922
+ .medal .pip {
923
+ position:absolute; left:3px; bottom:2px;
924
+ width:2px; height:2px;
925
+ background: #fff3b8;
926
+ }
927
+ /* Engraved award plaque on a small wood stand. */
928
+ .plaque {
929
+ position:absolute; bottom:60px; right:144px;
930
+ width:14px; height:10px;
931
+ filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
932
+ z-index: 2;
933
+ pointer-events: none;
934
+ }
935
+ .plaque .stand {
936
+ position:absolute; left:5px; bottom:0;
937
+ width:4px; height:2px;
938
+ background: #3a2410;
939
+ box-shadow: inset 0 0 0 1px #0d1020;
940
+ }
941
+ .plaque .plate {
942
+ position:absolute; left:0; bottom:2px;
943
+ width:14px; height:8px;
944
+ background:
945
+ linear-gradient(180deg,
946
+ #4a3018 0 1px,
947
+ #6a4418 1px 4px,
948
+ #3a2410 4px 7px,
949
+ #0d1020 7px 8px);
950
+ box-shadow: inset 0 0 0 1px #0d1020;
951
+ }
952
+ .plaque .star {
953
+ position:absolute; left:5px; bottom:4px;
954
+ width:4px; height:3px;
955
+ background:
956
+ linear-gradient(180deg,
957
+ #fff3b8 0 1px,
958
+ #d4a04a 1px 3px);
959
+ }
960
+ .monitor {
961
+ position:absolute; left:108px; bottom:48px; width:96px; height:96px;
962
+ background-image: url('monitors.png');
963
+ background-size: 96px 1152px; /* sheet 32x384 displayed at 3x (12 rows: 9 agents + ephraim + house + markethunting2 secondary) */
964
+ background-position: 0 calc(var(--sprite-row, 0) * -96px);
965
+ background-repeat: no-repeat;
966
+ image-rendering: pixelated; image-rendering: crisp-edges;
967
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.3));
968
+ z-index: 1; /* on the back layer with the desk */
969
+ }
970
+ .monitor.secondary { left: 188px; right: auto; } /* slight overlap onto main monitor */
971
+ .monitor.active { animation: monitor-glow 2.4s ease-in-out infinite; }
972
+ /* Signal-icon strip lives in the header center between agent name (left)
973
+ and status badge (right). Each icon renders only when its server-side
974
+ flag is true. No animation yet — we're observing real data first to
975
+ decide which ones deserve flashing later. */
976
+ .signals { display:flex; gap:6px; flex:1; justify-content:center; align-items:center; }
977
+ .signal-icon {
978
+ display:inline-flex; align-items:center; justify-content:center;
979
+ width:22px; height:22px; border-radius:4px;
980
+ font-size:13px; line-height:1;
981
+ background:rgba(0,0,0,.25); border:1px solid var(--line);
982
+ cursor:help; user-select:none;
983
+ }
984
+ .signal-icon.sev-red { background:rgba(255,60,60,.18); border-color:#ff5050; color:#ffb0b0; }
985
+ .signal-icon.sev-yellow { background:rgba(255,200,80,.15); border-color:#ffd070; color:#ffd070; }
986
+ .signal-icon.sev-blue { background:rgba(120,180,255,.12);border-color:#79b4ff; color:#a8c8ff; }
987
+ .chair {
988
+ position:absolute; left:40px; bottom:0; width:64px; height:144px;
989
+ background-image: url('furniture.png');
990
+ background-size: 64px 1024px; /* sheet 32x512 at 2x */
991
+ background-position: 0 -656px; /* y=328 * 2 */
992
+ background-repeat: no-repeat;
993
+ image-rendering: pixelated; image-rendering: crisp-edges;
994
+ z-index: 1;
995
+ transition: left 0.5s ease-in-out;
996
+ }
997
+ /* Working pose: chair tucked under the desk leading edge AND brought to
998
+ the FRONT layer so its backrest sits in front of the seated character —
999
+ view becomes "looking over the chair at the back of someone working".
1000
+ The chair sprite is already a back-view (transparent top 8 rows) so the
1001
+ character's head pokes naturally above the backrest. */
1002
+ .chair.working { left: 108px; z-index: 3; }
1003
+ .sprite {
1004
+ /* Standing baseline. Walking shares this same bottom so feet stay grounded
1005
+ consistently as the sprite moves. .sitting overrides upward only when the
1006
+ walker has parked the sprite at the desk x. */
1007
+ position:absolute; left:56px; bottom:7px; width:32px; height:48px;
1008
+ background-image:url('sprites.png'); background-repeat:no-repeat;
1009
+ background-position: 0 calc(var(--sprite-row, 0) * -48px);
1010
+ image-rendering: pixelated; image-rendering: crisp-edges;
1011
+ --facing: 2.5; /* Phase 2: flipped to -2.5 by .facing-left to mirror sprite */
1012
+ transform: scale(var(--facing), 2.5); transform-origin: 50% 100%;
1013
+ filter: drop-shadow(3px 3px 0 rgba(0,0,0,.35));
1014
+ z-index: 2;
1015
+ }
1016
+ .sprite.facing-left { --facing: -2.5; }
1017
+ /* Sub-agent minions — mini versions of the parent sprite that appear when
1018
+ the agent has spawned background workers (sessionKey contains :subagent:).
1019
+ Capped at 3 visible. Positioned along the floor to the right of the desk
1020
+ so they cluster around the parent without obscuring the work seat. */
1021
+ .minion {
1022
+ position:absolute; bottom:6px; width:32px; height:48px;
1023
+ background-image:url('sprites.png'); background-repeat:no-repeat;
1024
+ background-position: 0 calc(var(--sprite-row, 0) * -48px);
1025
+ image-rendering: pixelated; image-rendering: crisp-edges;
1026
+ transform: scale(1.4, 1.4); transform-origin: 50% 100%;
1027
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.35)) brightness(0.92);
1028
+ animation: minion-bob 2.4s ease-in-out infinite, sprite-active 0.72s steps(4) infinite;
1029
+ opacity: 0.92;
1030
+ z-index: 2;
1031
+ }
1032
+ .minion.m0 { left: 240px; animation-delay: 0s, 0s; }
1033
+ .minion.m1 { left: 278px; animation-delay: .4s, .12s; }
1034
+ .minion.m2 { left: 316px; animation-delay: .8s, .24s; }
1035
+ @keyframes minion-bob {
1036
+ 0%,100% { transform: scale(1.4, 1.4) translateY(0); }
1037
+ 50% { transform: scale(1.4, 1.4) translateY(-2px); }
1038
+ }
1039
+ .minion-count {
1040
+ position:absolute; top:-6px; right:-8px;
1041
+ background:#1a1a2a; color:#7df9d0; font-size:9px; font-weight:700;
1042
+ border:2px solid #0a0a1a; padding:1px 4px; border-radius:8px;
1043
+ pointer-events:none;
1044
+ }
1045
+ .sprite.active { animation: sprite-active 0.72s steps(4) infinite, sprite-breathe 3.2s ease-in-out infinite; }
1046
+ .sprite.idle { animation: sprite-idle 2.4s steps(4) infinite, sprite-breathe 3.6s ease-in-out infinite; }
1047
+ .sprite.sip { animation: sprite-sip 2.0s steps(4) infinite, sprite-breathe 3.6s ease-in-out infinite; }
1048
+ .sprite.away { animation: sprite-away 4.8s steps(4) infinite, sprite-breathe 5.2s ease-in-out infinite; }
1049
+ .sprite.walking { animation: sprite-walk 0.6s steps(4) infinite, sprite-breathe 1.2s ease-in-out infinite; }
1050
+ /* Position-driven posture: walker adds .sitting only when the sprite is
1051
+ parked at the desk x AND the agent status is active. Lift compensates for
1052
+ chair seat height so the body reads as seated, not floating mid-floor. */
1053
+ .sprite.sitting { bottom: 32px; }
1054
+ /* Seen-from-behind composition: at the work seat the character sits BETWEEN
1055
+ the monitor (back layer) and the chair backrest (front layer). */
1056
+ .sprite.sitting.active { z-index: 2; }
1057
+ @keyframes sprite-active { from { background-position-x: 0; } to { background-position-x: -128px; } }
1058
+ @keyframes sprite-idle { from { background-position-x: -128px; } to { background-position-x: -256px; } }
1059
+ @keyframes sprite-sip { from { background-position-x: -256px; } to { background-position-x: -384px; } }
1060
+ @keyframes sprite-away { from { background-position-x: -384px; } to { background-position-x: -512px; } }
1061
+ @keyframes sprite-walk { from { background-position-x: -512px; } to { background-position-x: -640px; } }
1062
+ /* Phase 1 — idle breathing: subtle vertical bob on top of the frame cycle.
1063
+ translateY is listed before scale so it stays in screen pixels (not scaled).
1064
+ Per-state durations are deliberately offset from the frame-cycle period so
1065
+ the bob and frame shift never lock into the same beat (breaks robotic feel).
1066
+ Phase 2 — scaleX uses --facing so .facing-left can mirror the sprite without
1067
+ fighting the breathe transform; walking uses a faster bob to mimic stride lift. */
1068
+ @keyframes sprite-breathe {
1069
+ 0%, 100% { transform: translateY(0) scale(var(--facing, 2.5), 2.5); }
1070
+ 50% { transform: translateY(-2px) scale(var(--facing, 2.5), 2.5); }
1071
+ }
1072
+ .status-dot { position:absolute; left:88px; bottom:118px; width:12px; height:12px; border:3px solid var(--line); background:var(--green); }
1073
+ .status-dot.active { animation: blink 1.2s infinite steps(2, end); }
1074
+ .status-dot.idle { background:var(--amber); }
1075
+ .status-dot.away { background:var(--red); }
1076
+ .monitor.active::after { animation: flicker 0.8s infinite steps(3, end); }
1077
+ .zzz { position:absolute; left:80px; bottom:140px; font-size:10px; color:rgba(236,242,255,.65); letter-spacing:2px; animation: zfloat 3.2s infinite ease-in-out; pointer-events:none; }
1078
+ .room.away::before { background-image:linear-gradient(rgba(0,0,0,.35), rgba(0,0,0,.35)), linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px); }
1079
+ /* Rectangular speech bubble with a triangular tail pointing down at the
1080
+ sprite head. Single-line: bubble width grows with content (up to 200px
1081
+ max), then ellipsis truncates anything longer so it can't push into the
1082
+ wall board or off-scene. */
1083
+ .speech { position:absolute; left:96px; top:96px; max-width:200px; background:#fff5d9; color:#2b2440; border:4px solid var(--line); padding:5px 9px; font-size:10px; line-height:1.35;
1084
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1085
+ /* Wall-mounted metrics board — sits between the wall art (left) and the
1086
+ window (right), above where the speech bubble used to live. Same wood
1087
+ frame as the window for visual consistency. Top inset accent shifts
1088
+ color with status so the board feels alive (green/amber/grey). */
1089
+ .statsboard {
1090
+ position:absolute; top:24px; left:14px; width:170px; height:66px;
1091
+ background:#1f2236;
1092
+ border:3px solid #2a1810;
1093
+ box-shadow: inset 0 0 0 2px #4a3018;
1094
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
1095
+ font-size:9px; line-height:14px; letter-spacing:.5px; text-transform:uppercase;
1096
+ color:#d6dff0; padding:4px 6px; z-index:1;
1097
+ }
1098
+ .statsboard .row { display:flex; justify-content:space-between; gap:4px; }
1099
+ .statsboard .label { color:#8a93b0; }
1100
+ .statsboard .value { color:#fff; font-weight:700; }
1101
+ .room.active .statsboard { box-shadow: inset 0 0 0 2px #4a3018, inset 0 2px 0 0 #59f7c7; }
1102
+ .room.idle .statsboard { box-shadow: inset 0 0 0 2px #4a3018, inset 0 2px 0 0 #ffd070; }
1103
+ .room.away .statsboard { box-shadow: inset 0 0 0 2px #4a3018, inset 0 2px 0 0 #707090; }
1104
+ .speech::after { content:""; position:absolute; left:10px; bottom:-10px; border-width:10px 10px 0 0; border-style:solid; border-color:var(--line) transparent transparent transparent; }
1105
+ .speech::before { content:""; position:absolute; left:13px; bottom:-5px; border-width:7px 7px 0 0; border-style:solid; border-color:#fff5d9 transparent transparent transparent; z-index:1; }
1106
+ /* Cost-aware room glow: overrides --glow when session burn rate is high.
1107
+ .burn-hot = session consumed ≥40% of the agent's 24h budget (amber)
1108
+ .burn-critical = session consumed ≥75% of the 24h budget (red pulse) */
1109
+ .room.burn-hot { --glow: #ff9830 !important; }
1110
+ .room.burn-critical { --glow: #ff3d30 !important; animation: burn-pulse 1.6s ease-in-out infinite; }
1111
+ @keyframes burn-pulse {
1112
+ 0%,100% { box-shadow: none; }
1113
+ 50% { box-shadow: 0 0 0 3px #ff3d3044 inset; }
1114
+ }
1115
+
1116
+ /* Contextual speech-bubble variants — fire when the server emits a
1117
+ non-null agent.bubble. Each variant repaints the fill + border and the
1118
+ tail triangles so the colour story stays coherent end-to-end.
1119
+ alert red — agent crashed or has a 2× failure streak
1120
+ warn amber — recent failures or context near full
1121
+ busy blue — agent is actively running */
1122
+ .speech.alert { background:#ffd9d4; color:#3b1410; border-color:#7a1a12; animation: bubble-pulse 1.4s ease-in-out infinite; }
1123
+ .speech.alert::after { border-top-color:#7a1a12; }
1124
+ .speech.alert::before { border-top-color:#ffd9d4; }
1125
+ .speech.warn { background:#fff0c8; color:#3a2a08; border-color:#7a560a; }
1126
+ .speech.warn::after { border-top-color:#7a560a; }
1127
+ .speech.warn::before { border-top-color:#fff0c8; }
1128
+ .speech.busy { background:#d6efff; color:#0a2a4a; border-color:#1a4a7a; }
1129
+ .speech.busy::after { border-top-color:#1a4a7a; }
1130
+ .speech.busy::before { border-top-color:#d6efff; }
1131
+ @keyframes bubble-pulse {
1132
+ 0%,100% { transform:translateY(0); }
1133
+ 50% { transform:translateY(-2px); }
1134
+ }
1135
+ .room-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:8px; font-size:11px; color:var(--muted); }
1136
+ .prop {
1137
+ position:absolute; width:32px; height:32px;
1138
+ background-image:url('props.png'); background-repeat:no-repeat;
1139
+ background-position: 0 calc(var(--sprite-row, 0) * -32px);
1140
+ image-rendering: pixelated; image-rendering: crisp-edges;
1141
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.3));
1142
+ z-index: 1;
1143
+ }
1144
+ .prop.pos-wall { top:130px; left:213px; transform: scale(2); transform-origin: 0 0; }
1145
+ .prop.pos-floor { bottom:6px; left:6px; transform: scale(2); transform-origin: 0 100%; }
1146
+ .prop.pos-desk { bottom:52px; right:88px; transform: scale(2); transform-origin: 100% 100%; }
1147
+ .prop.pos-shelf { top:78px; left:8px; transform: scale(1.75); transform-origin: 0 0; }
1148
+
1149
+ /* Ambient particles — children of .prop, drawn in the prop's 32x32 local space */
1150
+ .prop .fx { position:absolute; pointer-events:none; }
1151
+ .puff { width:2px; height:2px; background:rgba(255,255,255,.9); }
1152
+ .bubble { width:1px; height:1px; background:rgba(255,255,255,.95); }
1153
+ .scanline { left:4px; right:4px; height:1px; background:linear-gradient(90deg, transparent, rgba(110,255,180,.8), transparent); }
1154
+ .led { width:2px; height:2px; background:#ff5a5a; box-shadow:0 0 1px #ff8a8a; }
1155
+ .pin-pulse { width:2px; height:2px; background:#ff5a5a; }
1156
+ .sparkle { width:1px; height:1px; background:#fff; }
1157
+
1158
+ /* Sage kettle — three staggered steam puffs */
1159
+ .prop[data-fx="steam"] .puff { animation: puff-rise 2.4s ease-out infinite; }
1160
+ .prop[data-fx="steam"] .puff:nth-child(1) { left:13px; top:4px; animation-delay:0s; }
1161
+ .prop[data-fx="steam"] .puff:nth-child(2) { left:18px; top:4px; animation-delay:.8s; }
1162
+ .prop[data-fx="steam"] .puff:nth-child(3) { left:22px; top:4px; animation-delay:1.6s; }
1163
+ @keyframes puff-rise {
1164
+ 0% { transform:translateY(2px); opacity:0; }
1165
+ 15% { opacity:.9; }
1166
+ 100% { transform:translateY(-6px); opacity:0; }
1167
+ }
1168
+
1169
+ /* Senku flasks — bubbles rising in each */
1170
+ .prop[data-fx="bubbles"] .bubble { animation: bubble-rise 1.8s linear infinite; }
1171
+ .prop[data-fx="bubbles"] .bubble:nth-child(1) { left:8px; top:21px; animation-delay:0s; }
1172
+ .prop[data-fx="bubbles"] .bubble:nth-child(2) { left:16px; top:21px; animation-delay:.6s; }
1173
+ .prop[data-fx="bubbles"] .bubble:nth-child(3) { left:24px; top:21px; animation-delay:1.2s; }
1174
+ .prop[data-fx="bubbles"] .bubble:nth-child(4) { left:9px; top:21px; animation-delay:.9s; }
1175
+ .prop[data-fx="bubbles"] .bubble:nth-child(5) { left:23px; top:21px; animation-delay:.3s; }
1176
+ @keyframes bubble-rise {
1177
+ 0% { transform:translateY(0); opacity:0; }
1178
+ 20% { opacity:.9; }
1179
+ 100% { transform:translateY(-4px); opacity:0; }
1180
+ }
1181
+
1182
+ /* MarketHunting monitor — vertical scanline sweep */
1183
+ .prop[data-fx="scan"] .scanline { animation: scan-sweep 2.6s linear infinite; }
1184
+ @keyframes scan-sweep {
1185
+ 0% { top:6px; opacity:0; }
1186
+ 10% { opacity:.7; }
1187
+ 90% { opacity:.7; }
1188
+ 100% { top:21px; opacity:0; }
1189
+ }
1190
+
1191
+ /* D mic — pulsing red record LED */
1192
+ .prop[data-fx="led"] .led { left:21px; top:14px; animation: led-pulse 1.6s ease-in-out infinite; }
1193
+ @keyframes led-pulse {
1194
+ 0%,100% { opacity:.4; }
1195
+ 50% { opacity:1; }
1196
+ }
1197
+
1198
+ /* Commander war map — pulsing red pin */
1199
+ .prop[data-fx="pin"] .pin-pulse { animation: pin-blink 1.4s ease-in-out infinite; }
1200
+ .prop[data-fx="pin"] .pin-pulse:nth-child(1) { left:11px; top:10px; animation-delay:0s; }
1201
+ .prop[data-fx="pin"] .pin-pulse:nth-child(2) { left:19px; top:18px; animation-delay:.5s; }
1202
+ .prop[data-fx="pin"] .pin-pulse:nth-child(3) { left:24px; top:12px; animation-delay:1s; }
1203
+ @keyframes pin-blink {
1204
+ 0%,80%,100% { opacity:0; }
1205
+ 10%,40% { opacity:1; }
1206
+ }
1207
+
1208
+ /* L candy pile — sparkle on lollipop */
1209
+ .prop[data-fx="sparkle"] .sparkle { animation: sparkle-twinkle 2.6s ease-in-out infinite; }
1210
+ .prop[data-fx="sparkle"] .sparkle:nth-child(1) { left:13px; top:14px; animation-delay:0s; }
1211
+ .prop[data-fx="sparkle"] .sparkle:nth-child(2) { left:21px; top:15px; animation-delay:1.3s; }
1212
+ @keyframes sparkle-twinkle {
1213
+ 0%,90%,100% { opacity:0; transform:scale(.5); }
1214
+ 40%,55% { opacity:1; transform:scale(1); }
1215
+ }
1216
+
1217
+ .room-footer .mood { color:var(--text); opacity:.85; }
1218
+ .sidepanel { background:#1a1f38; border:4px solid var(--line); box-shadow:8px 8px 0 var(--shadow); padding:16px; align-self:start; position:sticky; top:12px; }
1219
+ .panel-title { font-size:14px; font-weight:900; margin-bottom:12px; }
1220
+ .meta { display:grid; gap:10px; font-size:12px; color:var(--muted); }
1221
+ .meta strong { color:var(--text); display:block; margin-bottom:4px; }
1222
+ .model-picker { display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap; }
1223
+ .model-select {
1224
+ font:inherit; font-size:12px; color:var(--text);
1225
+ background:#2b3156; border:2px solid var(--line); padding:4px 6px;
1226
+ box-shadow:2px 2px 0 var(--shadow);
1227
+ }
1228
+ .model-select:disabled { opacity:.6; cursor:wait; }
1229
+ .model-status { font-size:10px; padding:2px 6px; border:2px solid var(--line); }
1230
+ .model-status.pending { color:#3b2c00; background:var(--amber); }
1231
+ .model-status.ok { color:#10291f; background:var(--green); }
1232
+ .model-status.err { color:#3b1111; background:var(--red); }
1233
+ .legend { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
1234
+ .pill { font-size:10px; padding:6px 8px; border:2px solid var(--line); background:#2b3156; }
1235
+ .pill.active { color:#10291f; background:var(--green); }
1236
+ .pill.idle { color:#3b2c00; background:var(--amber); }
1237
+ .pill.away { color:#3b1111; background:var(--red); }
1238
+ .empty { color:var(--muted); font-size:12px; }
1239
+
1240
+ /* Cost block — used both in the side panel (per agent) and in the topbar
1241
+ fleet strip. Window buttons share state across both via window.__costWindow
1242
+ so the operator only has to pick once per page-load. */
1243
+ .cost-block { display:flex; flex-direction:column; gap:6px; }
1244
+ .cost-windows { display:flex; gap:4px; flex-wrap:wrap; }
1245
+ .cost-win-btn {
1246
+ font-size:10px; padding:4px 8px; border:2px solid var(--line);
1247
+ background:#2b3156; color:var(--text); cursor:pointer; box-shadow:none;
1248
+ letter-spacing:.4px; text-transform:uppercase;
1249
+ }
1250
+ .cost-win-btn.on { background:var(--accent2, #d6f6ff); color:#10131f; }
1251
+ .cost-win-btn:disabled { cursor:default; }
1252
+ .cost-total { font-size:18px; font-weight:900; color:var(--text); line-height:1.1; }
1253
+ .cost-provs { display:flex; gap:6px; flex-wrap:wrap; }
1254
+ .cost-prov {
1255
+ font-size:10px; padding:3px 6px; border:2px solid var(--line);
1256
+ background:#202644; color:var(--text);
1257
+ }
1258
+ .cost-prov.anthropic { background:#4a2a55; }
1259
+ .cost-prov.openai { background:#1f4a3a; }
1260
+ .cost-meta { font-size:10px; color:var(--muted); }
1261
+ .cost-warn { font-size:10px; color:#3b2c00; background:var(--amber); padding:2px 6px; border:2px solid var(--line); }
1262
+ .cost-models { display:flex; flex-direction:column; gap:2px; margin-top:2px; }
1263
+ .cost-model-row {
1264
+ display:flex; justify-content:space-between; gap:8px;
1265
+ font-size:11px; padding:2px 0; border-top:1px dashed rgba(255,255,255,.08);
1266
+ }
1267
+ .cost-model-row .name {
1268
+ color:var(--text); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
1269
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:180px;
1270
+ }
1271
+ .cost-model-row .amt { color:var(--text); font-variant-numeric:tabular-nums; }
1272
+ .fleet-cost .cost-model-row .name { max-width:140px; }
1273
+
1274
+ /* Fleet cost strip lives in the topbar, between the title block and the
1275
+ action buttons. Compact horizontal layout — windows on the left, total
1276
+ in the middle, per-provider chips on the right. */
1277
+ .fleet-cost {
1278
+ display:flex; gap:10px; align-items:center; flex-wrap:wrap;
1279
+ padding:6px 10px; border:2px solid var(--line); background:rgba(17,19,34,.6);
1280
+ }
1281
+ .fleet-cost .cost-windows { gap:2px; }
1282
+ .fleet-cost .cost-total { font-size:14px; }
1283
+ .fleet-cost-label { font-size:10px; color:var(--muted); letter-spacing:.5px; text-transform:uppercase; }
1284
+ @media (max-width: 720px) { .fleet-cost { width:100%; } }
1285
+ @keyframes blink { 0%,49%{ opacity:1 } 50%,100%{ opacity:.35 } }
1286
+ @keyframes monitor-glow {
1287
+ 0%,100% { filter: drop-shadow(2px 2px 0 rgba(0,0,0,.3)) brightness(1); }
1288
+ 50% { filter: drop-shadow(2px 2px 0 rgba(0,0,0,.3)) brightness(1.18); }
1289
+ }
1290
+ @keyframes zfloat { 0%{ transform:translateY(0); opacity:0 } 30%{ opacity:.9 } 80%{ transform:translateY(-10px); opacity:.4 } 100%{ transform:translateY(-14px); opacity:0 } }
1291
+ @media (max-width: 980px) { .wrap { grid-template-columns:1fr; } .sidepanel { position:static; } }
1292
+
1293
+ /* ---- Office dog ----
1294
+ A roaming pixel mascot that walks across the bottom of the office grid,
1295
+ randomly stops to sleep / pee / stare at the screen. Built from positioned
1296
+ divs (no sprite sheet) so it fits the same chunky-pixel aesthetic as the
1297
+ props. JS just toggles state classes; all motion / pose comes from CSS. */
1298
+ /* ---- Office dog (fluffy white spitz/Samoyed, ~58×40) ----
1299
+ Profile view facing right; .facing-left flips horizontally. Stocky
1300
+ body with a fluffy lower edge, big round head, triangle pointed ears
1301
+ up, short black snout, curled tail OVER the back (the spitz tell). */
1302
+ .dog {
1303
+ position:absolute; left:40px; bottom:8px;
1304
+ width:58px; height:40px; pointer-events:none; z-index:4;
1305
+ transition: left 0s linear; /* JS sets transition durations live */
1306
+ filter: drop-shadow(2px 2px 0 rgba(0,0,0,.5));
1307
+ image-rendering: pixelated;
1308
+ }
1309
+ .dog .dpart { position:absolute; }
1310
+ /* Body — chunky oval. Belly = darker stripe along bottom for shading. */
1311
+ .dog .body { left:8px; bottom:9px; width:34px; height:14px; background:#f7f1e3; }
1312
+ .dog .belly { left:9px; bottom:9px; width:32px; height:3px; background:#d8cfbe; }
1313
+ .dog .chest { left:36px; bottom:11px; width:6px; height:8px; background:#f7f1e3; }
1314
+ .dog .fluff-l { left:6px; bottom:11px; width:2px; height:5px; background:#f7f1e3; }
1315
+ .dog .fluff-r { left:42px; bottom:13px; width:2px; height:6px; background:#f7f1e3; }
1316
+ .dog .spot { background:#d8d2c2; }
1317
+ .dog .spot.s1 { left:16px; bottom:16px; width:6px; height:4px; }
1318
+ .dog .spot.s2 { left:26px; bottom:13px; width:5px; height:3px; }
1319
+ /* Head — big and round, sits above-front of body. */
1320
+ .dog .head { left:34px; bottom:18px; width:16px; height:13px; background:#f7f1e3; }
1321
+ .dog .cheek { left:34px; bottom:18px; width:4px; height:5px; background:#e6dccb; }
1322
+ /* Short muzzle — small bump, NOT pointed like a mouse. */
1323
+ .dog .muzzle { left:48px; bottom:19px; width:5px; height:5px; background:#f7f1e3; }
1324
+ .dog .nose { left:51px; bottom:22px; width:3px; height:3px; background:#1a1a1a; }
1325
+ .dog .mouth { left:48px; bottom:18px; width:4px; height:1px; background:#1a1a1a; }
1326
+ .dog .eye { left:44px; bottom:25px; width:2px; height:3px; background:#0c0c0c; }
1327
+ .dog .eye-shine { left:44px; bottom:27px; width:1px; height:1px; background:#fff; }
1328
+ /* Ears — triangle, pointing UP. Two layers per ear for inner color. */
1329
+ .dog .ear { left:34px; bottom:30px; width:5px; height:6px; background:#f7f1e3;
1330
+ clip-path: polygon(0 100%, 100% 100%, 50% 0); }
1331
+ .dog .ear.r { left:44px; background:#e2dac5; }
1332
+ .dog .ear-inner { left:35px; bottom:30px; width:3px; height:3px; background:#cf3a3a;
1333
+ clip-path: polygon(0 100%, 100% 100%, 50% 0); opacity:.45; }
1334
+ .dog .ear-inner.r { left:45px; }
1335
+ /* Collar + tag — sits at the neck. */
1336
+ .dog .collar { left:36px; bottom:15px; width:6px; height:3px; background:#cf3a3a; }
1337
+ .dog .tag { left:39px; bottom:13px; width:2px; height:2px; background:#ffd866; box-shadow:0 -1px 0 #cf3a3a; }
1338
+ /* Curled tail — three blocks form a coil that sits OVER the back. */
1339
+ .dog .tail-base { left:4px; bottom:18px; width:4px; height:5px; background:#f7f1e3; }
1340
+ .dog .tail-mid { left:4px; bottom:22px; width:6px; height:4px; background:#f7f1e3;
1341
+ transform-origin:left bottom; }
1342
+ .dog .tail-curl { left:8px; bottom:24px; width:6px; height:4px; background:#f7f1e3;
1343
+ transform-origin:left bottom; }
1344
+ .dog .tail-tip { left:12px; bottom:21px; width:3px; height:4px; background:#e2dac5; }
1345
+ /* Legs — short and stocky, darker paws. */
1346
+ .dog .leg { width:5px; height:8px; bottom:1px; background:#f7f1e3; }
1347
+ .dog .leg.fl { left:34px; }
1348
+ .dog .leg.fr { left:40px; }
1349
+ .dog .leg.bl { left:11px; }
1350
+ .dog .leg.br { left:18px; }
1351
+ .dog .paw { width:5px; height:2px; bottom:0; background:#f7f1e3; }
1352
+ .dog .paw.fl { left:34px; }
1353
+ .dog .paw.fr { left:40px; }
1354
+ .dog .paw.bl { left:11px; }
1355
+ .dog .paw.br { left:18px; }
1356
+ .dog.facing-left { transform: scaleX(-1); }
1357
+
1358
+ /* Walking — alternate leg pairs, wiggle the tail-curl. */
1359
+ .dog.walking .leg.fl, .dog.walking .paw.fl,
1360
+ .dog.walking .leg.br, .dog.walking .paw.br { animation: dog-leg-a 0.34s steps(2) infinite; }
1361
+ .dog.walking .leg.fr, .dog.walking .paw.fr,
1362
+ .dog.walking .leg.bl, .dog.walking .paw.bl { animation: dog-leg-b 0.34s steps(2) infinite; }
1363
+ .dog.walking .tail-curl, .dog.walking .tail-tip {
1364
+ animation: dog-curl-wiggle 0.4s ease-in-out infinite alternate;
1365
+ }
1366
+ /* Running — same leg / tail rig but on a faster cycle. */
1367
+ .dog.running .leg.fl, .dog.running .paw.fl,
1368
+ .dog.running .leg.br, .dog.running .paw.br { animation: dog-leg-a 0.16s steps(2) infinite; }
1369
+ .dog.running .leg.fr, .dog.running .paw.fr,
1370
+ .dog.running .leg.bl, .dog.running .paw.bl { animation: dog-leg-b 0.16s steps(2) infinite; }
1371
+ .dog.running .tail-curl, .dog.running .tail-tip {
1372
+ animation: dog-curl-wiggle 0.18s ease-in-out infinite alternate;
1373
+ }
1374
+ /* Tiny "speed lines" that only show while running. */
1375
+ .dog.running .speed-1,
1376
+ .dog.running .speed-2 { opacity:.85; animation: dog-speed-flick 0.18s steps(2) infinite; }
1377
+ .dog .speed-1, .dog .speed-2 { background:#ffffff; opacity:0; }
1378
+ .dog .speed-1 { left:0; bottom:14px; width:5px; height:1px; }
1379
+ .dog .speed-2 { left:0; bottom:9px; width:4px; height:1px; }
1380
+ @keyframes dog-speed-flick {
1381
+ 0% { transform:translateX(0); opacity:.85; }
1382
+ 100% { transform:translateX(-3px); opacity:.35; }
1383
+ }
1384
+ @keyframes dog-leg-a { 0% { height:8px; bottom:1px; } 100% { height:5px; bottom:4px; } }
1385
+ @keyframes dog-leg-b { 0% { height:5px; bottom:4px; } 100% { height:8px; bottom:1px; } }
1386
+ @keyframes dog-curl-wiggle { 0% { transform:rotate(-8deg); } 100% { transform:rotate(8deg); } }
1387
+
1388
+ /* Sleeping — body curled on floor, head tucked, ears flatten, Z's. */
1389
+ .dog.sleeping .body { bottom:2px; height:9px; }
1390
+ .dog.sleeping .belly { bottom:2px; }
1391
+ .dog.sleeping .chest { bottom:5px; height:5px; }
1392
+ .dog.sleeping .fluff-l { bottom:4px; height:4px; }
1393
+ .dog.sleeping .fluff-r { bottom:6px; height:5px; }
1394
+ .dog.sleeping .spot.s1, .dog.sleeping .spot.s2 { bottom:7px; }
1395
+ .dog.sleeping .head { bottom:6px; left:38px; transform:rotate(15deg); }
1396
+ .dog.sleeping .cheek { bottom:6px; left:38px; }
1397
+ .dog.sleeping .muzzle { bottom:5px; left:50px; }
1398
+ .dog.sleeping .nose { bottom:7px; left:53px; }
1399
+ .dog.sleeping .mouth { bottom:5px; left:50px; }
1400
+ .dog.sleeping .eye, .dog.sleeping .eye-shine { display:none; }
1401
+ .dog.sleeping .ear { bottom:16px; transform:rotate(-25deg); }
1402
+ .dog.sleeping .ear.r { bottom:14px; transform:rotate(15deg); }
1403
+ .dog.sleeping .ear-inner,
1404
+ .dog.sleeping .ear-inner.r { display:none; }
1405
+ .dog.sleeping .collar, .dog.sleeping .tag { display:none; }
1406
+ .dog.sleeping .leg, .dog.sleeping .paw { display:none; }
1407
+ .dog.sleeping .tail-base { bottom:7px; }
1408
+ .dog.sleeping .tail-mid { bottom:10px; transform:rotate(-15deg); }
1409
+ .dog.sleeping .tail-curl { bottom:8px; transform:rotate(-30deg); }
1410
+ .dog.sleeping .tail-tip { bottom:5px; }
1411
+ .dog .zz {
1412
+ position:absolute; right:-14px; bottom:30px;
1413
+ font-size:14px; font-weight:900; color:rgba(236,242,255,.85);
1414
+ letter-spacing:1px; opacity:0;
1415
+ }
1416
+ .dog.sleeping .zz { animation: dog-zz 2.4s ease-in-out infinite; }
1417
+ @keyframes dog-zz {
1418
+ 0% { opacity:0; transform:translate(0, 0); }
1419
+ 30% { opacity:.95; }
1420
+ 80% { opacity:.4; transform:translate(4px, -12px); }
1421
+ 100% { opacity:0; transform:translate(6px, -16px); }
1422
+ }
1423
+
1424
+ /* Peeing — back-left leg lifted, yellow puddle blooms underneath. */
1425
+ .dog.peeing .leg.bl, .dog.peeing .paw.bl {
1426
+ transform-origin:top right;
1427
+ transform:rotate(-55deg);
1428
+ }
1429
+ .dog .puddle {
1430
+ position:absolute; left:6px; bottom:-1px;
1431
+ width:16px; height:4px; background:#ffd866;
1432
+ border-radius:50%; opacity:0;
1433
+ box-shadow:0 1px 0 rgba(0,0,0,.35);
1434
+ }
1435
+ .dog.peeing .puddle { animation: dog-puddle 1.6s ease-out forwards; }
1436
+ @keyframes dog-puddle {
1437
+ 0% { opacity:0; width:3px; }
1438
+ 40% { opacity:.9; width:16px; }
1439
+ 100% { opacity:.6; width:20px; }
1440
+ }
1441
+
1442
+ /* Staring at the screen — sit-back pose, head turns front-on (two eyes,
1443
+ two pointed ears), pink tongue, tail-curl peeks behind. */
1444
+ .dog.staring .body { bottom:4px; height:13px; left:16px; width:24px; }
1445
+ .dog.staring .belly { bottom:4px; left:17px; width:22px; height:4px; }
1446
+ .dog.staring .chest { bottom:6px; left:22px; width:12px; height:8px; background:#f7f1e3; }
1447
+ .dog.staring .fluff-l { left:14px; bottom:6px; height:4px; }
1448
+ .dog.staring .fluff-r { left:40px; bottom:6px; height:4px; }
1449
+ .dog.staring .spot.s1 { left:20px; bottom:9px; }
1450
+ .dog.staring .spot.s2 { left:30px; bottom:7px; }
1451
+ .dog.staring .head { left:18px; bottom:17px; width:20px; height:13px; }
1452
+ .dog.staring .cheek { display:none; }
1453
+ .dog.staring .muzzle { left:25px; bottom:17px; width:6px; height:5px; }
1454
+ .dog.staring .nose { left:27px; bottom:20px; width:2px; height:2px; }
1455
+ .dog.staring .mouth { left:25px; bottom:16px; width:6px; height:1px; }
1456
+ .dog.staring .tongue {
1457
+ position:absolute; left:26px; bottom:14px; width:4px; height:2px; background:#ff7a8a;
1458
+ animation: dog-pant 0.6s ease-in-out infinite alternate;
1459
+ }
1460
+ .dog.staring .eye { left:22px; bottom:25px; width:2px; height:3px; }
1461
+ .dog.staring .eye.r { left:33px; }
1462
+ .dog.staring .eye-shine { left:22px; bottom:27px; }
1463
+ .dog.staring .eye-shine.r{ left:33px; }
1464
+ .dog.staring .ear { left:14px; bottom:29px; transform:rotate(-12deg); }
1465
+ .dog.staring .ear.r { left:38px; transform:rotate(12deg); }
1466
+ .dog.staring .ear-inner { left:15px; bottom:29px; transform:rotate(-12deg); }
1467
+ .dog.staring .ear-inner.r{ left:39px; transform:rotate(12deg); }
1468
+ .dog.staring .collar { left:20px; bottom:15px; width:18px; }
1469
+ .dog.staring .tag { left:29px; bottom:13px; }
1470
+ .dog.staring .leg.fl, .dog.staring .paw.fl { left:20px; height:4px; }
1471
+ .dog.staring .leg.fr, .dog.staring .paw.fr { left:34px; height:4px; }
1472
+ .dog.staring .leg.bl, .dog.staring .leg.br,
1473
+ .dog.staring .paw.bl, .dog.staring .paw.br { display:none; }
1474
+ /* Tail curl peeks behind the seated body and thumps. */
1475
+ .dog.staring .tail-base { left:42px; bottom:10px; }
1476
+ .dog.staring .tail-mid { left:42px; bottom:14px; transform-origin:left bottom;
1477
+ animation: dog-tail-thump 0.4s ease-in-out infinite alternate; }
1478
+ .dog.staring .tail-curl { left:46px; bottom:16px; }
1479
+ .dog.staring .tail-tip { left:50px; bottom:13px; }
1480
+ @keyframes dog-tail-thump { 0% { transform:rotate(-15deg);} 100% { transform:rotate(20deg);} }
1481
+ @keyframes dog-pant { 0% { height:2px; } 100% { height:3px; } }
1482
+ /* Front-facing extras only show in 'staring' mode. */
1483
+ .dog .tongue, .dog .eye-shine.r, .dog .eye.r { display:none; }
1484
+ .dog.staring .tongue, .dog.staring .eye-shine.r, .dog.staring .eye.r { display:block; }
1485
+
1486
+ /* Products rail — full-width strip above the office grid. Mirrors
1487
+ Control Center's "Products & apps" section but pixel-styled (chunky
1488
+ borders, hard shadow, no rounded corners) to match the office look. */
1489
+ .products-rail {
1490
+ max-width:1200px; margin:0 auto 18px;
1491
+ background:#1a1f38; border:4px solid var(--line);
1492
+ box-shadow:8px 8px 0 var(--shadow); padding:18px 20px;
1493
+ }
1494
+ .rail-head { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom:14px; }
1495
+ .rail-eyebrow { font-size:10px; letter-spacing:.3em; text-transform:uppercase; color:var(--muted); }
1496
+ .rail-title { font-size:18px; font-weight:900; color:var(--text); margin:4px 0 0; }
1497
+ .rail-lan { font-size:11px; color:var(--muted); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
1498
+ .rail-grid { display:grid; gap:12px; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); }
1499
+ .rail-card {
1500
+ background:#2b3156; border:3px solid var(--line);
1501
+ padding:12px 14px; display:flex; flex-direction:column; gap:8px;
1502
+ box-shadow:4px 4px 0 var(--shadow);
1503
+ }
1504
+ .rail-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
1505
+ .rail-owner { font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); }
1506
+ .rail-name { font-size:15px; font-weight:900; color:var(--text); margin-top:2px; }
1507
+ .rail-status { font-size:9px; padding:2px 6px; border:1px solid currentColor; text-transform:uppercase; letter-spacing:.5px; white-space:nowrap; }
1508
+ .rail-status.live { color:#59f7c7; }
1509
+ .rail-status.offline { color:#ff7a7a; }
1510
+ .rail-status.checking { color:#9aa0c2; }
1511
+ .rail-status.wip { color:#ffd070; }
1512
+ .rail-status.planning { color:#79b4ff; }
1513
+ .rail-status.paused { color:#9aa0c2; }
1514
+ .rail-desc { font-size:12px; color:var(--muted); line-height:1.4; }
1515
+ .rail-tags { display:flex; flex-wrap:wrap; gap:4px; }
1516
+ .rail-tag { font-size:9px; padding:1px 5px; background:#1a1f38; border:1px solid var(--line); color:var(--muted); }
1517
+ .rail-links { display:flex; flex-wrap:wrap; gap:6px; margin-top:auto; }
1518
+ @media (max-width: 980px) {
1519
+ .products-rail { margin:0 auto 14px; padding:14px 16px; }
1520
+ .rail-head { flex-direction:column; align-items:flex-start; gap:4px; }
1521
+ }
1522
+ </style>
1523
+ </head>
1524
+ <body>
1525
+ <div class="topbar">
1526
+ <div>
1527
+ <div class="title">PIXEL OFFICE</div>
1528
+ <div class="subtitle">cute shell on top, real control room underneath</div>
1529
+ </div>
1530
+ <div id="fleet-cost" class="fleet-cost" hidden></div>
1531
+ <div class="actions">
1532
+ <a id="openclaw-link" class="button secondary" target="_blank" rel="noreferrer">Open Control UI</a>
1533
+ <button id="chime-btn" class="secondary" title="Toggle chime when an agent finishes a task">🔕 Chime</button>
1534
+ <button id="chime-btn" class="secondary chime-off" title="Enable sound chime when an agent finishes a task">🔔</button>
1535
+ <button id="refresh-btn" title="Re-fetch data (long-press for full reload)">Refresh</button>
1536
+ <button id="reload-btn" class="secondary" title="Reload page (clears cached HTML/JS)">Reload</button>
1537
+ </div>
1538
+ </div>
1539
+
1540
+ <section id="products-rail" class="products-rail" hidden>
1541
+ <div class="rail-head">
1542
+ <div>
1543
+ <div class="rail-eyebrow">Built by the fleet</div>
1544
+ <h2 class="rail-title">Products &amp; apps</h2>
1545
+ </div>
1546
+ <div class="rail-lan" id="rail-lan"></div>
1547
+ </div>
1548
+ <div id="rail-grid" class="rail-grid"></div>
1549
+ </section>
1550
+
1551
+ <div class="wrap">
1552
+ <div><div id="office" class="office"></div></div>
1553
+ <aside class="sidepanel">
1554
+ <div class="panel-title">Office panel</div>
1555
+ <div class="meta" id="panel-meta"><div class="empty">Pick an agent room.</div></div>
1556
+ <div class="legend">
1557
+ <div class="pill active">active &lt; 1m</div>
1558
+ <div class="pill idle">idle &lt; 10m</div>
1559
+ <div class="pill away">away ≥ 10m</div>
1560
+ </div>
1561
+ </aside>
1562
+ </div>
1563
+
1564
+ <script>
1565
+ const officeEl = document.getElementById('office');
1566
+ const panelMeta = document.getElementById('panel-meta');
1567
+ const openclawLink = document.getElementById('openclaw-link');
1568
+ const refreshBtn = document.getElementById('refresh-btn');
1569
+ const railEl = document.getElementById('products-rail');
1570
+ const railGrid = document.getElementById('rail-grid');
1571
+ const railLanEl = document.getElementById('rail-lan');
1572
+ const fleetCostEl = document.getElementById('fleet-cost');
1573
+
1574
+ // Shared selected window for the topbar fleet strip and the side-panel
1575
+ // cost block. Operator picks once, both views update together. Day is the
1576
+ // most useful default — short enough to spot a runaway turn, long enough
1577
+ // to actually carry numbers worth reading.
1578
+ let costWindow = 'day';
1579
+ let lastState = null;
1580
+ // Rolling windows (server uses 24h / 30d / 365d relative to now) — labels
1581
+ // say so explicitly so "30d" can't be confused with "May so far".
1582
+ const COST_WINDOWS = [
1583
+ { key: 'session', label: 'Session' },
1584
+ { key: 'day', label: '24h' },
1585
+ { key: 'month', label: '30d' },
1586
+ { key: 'year', label: '365d' },
1587
+ ];
1588
+ const PROVIDER_LABELS = {
1589
+ 'claude-cli': 'Anthropic',
1590
+ 'openai-codex': 'OpenAI',
1591
+ };
1592
+ function providerClass(key) {
1593
+ if (key === 'claude-cli') return 'anthropic';
1594
+ if (key === 'openai-codex') return 'openai';
1595
+ return '';
1596
+ }
1597
+
1598
+ // ─────────────────────────────────────────────────────────────────────
1599
+ // AGENT_CONFIG — single source of truth for everything an agent's room
1600
+ // looks/feels like. Adding a new agent? Append ONE entry here and (if you
1601
+ // want a unique CSS-painted window) one .decor.window-<id> CSS block.
1602
+ // Every field is optional; missing fields fall back to AGENT_DEFAULTS so a
1603
+ // brand-new agent renders cleanly without any config at all.
1604
+ //
1605
+ // theme wall/floor/accent palette (CSS vars on the room)
1606
+ // spriteRow row index into sprites.png (0..N matching scripts/build-sprites.js)
1607
+ // ambient { kind, particle, count } — emits prop ambient particles
1608
+ // propPos 'wall' | 'floor' | 'desk' | 'shelf'
1609
+ // propEnabled false to suppress the prop entirely (markethunting uses
1610
+ // the slot for a 2nd monitor)
1611
+ // secondaryMonitor optional { spriteRow }; renders a second monitor
1612
+ // desk 'oak' | 'steel' | 'walnut' | 'white'
1613
+ // cup cup-color variant token (see CSS .cup.<variant>)
1614
+ // floor 'oak' | 'graphite' | 'ash' | 'cherry'
1615
+ // deskPieces array of DESK_KIT names ('trophy','medal',…)
1616
+ // decor 'window-<id>' (matches a .decor.window-<id> CSS block);
1617
+ // defaults to 'window-wide' when not provided
1618
+ // personalityLines short in-character quips for the speech bubble
1619
+ // ─────────────────────────────────────────────────────────────────────
1620
+ const AGENT_DEFAULTS = {
1621
+ theme: { wall1:'#3a3f5e', wall2:'#262a44', floor1:'#806447', floor2:'#5a4330', accent:'#bcd0ff', accent2:'#eef3ff', shirt:'#9aa6d6', hair:'#191b30', prop:'plant', mood:'workspace', glow:'#bcd0ff' },
1622
+ spriteRow: 0,
1623
+ ambient: null,
1624
+ propPos: 'wall',
1625
+ propEnabled: true,
1626
+ secondaryMonitor: null,
1627
+ desk: 'oak',
1628
+ cup: 'crimson',
1629
+ floor: 'oak',
1630
+ deskPieces: [],
1631
+ decor: 'window-wide',
1632
+ personalityLines: [],
1633
+ };
1634
+
1635
+ const AGENT_CONFIG = {
1636
+ main: {
1637
+ theme: { wall1:'#344075', wall2:'#252f5d', floor1:'#8f6d4b', floor2:'#65462f', accent:'#7ed1ff', accent2:'#e4f8ff', shirt:'#7f93ff', hair:'#10182f', prop:'plant', mood:'commander nest', glow:'#77f1cc' },
1638
+ spriteRow: 0,
1639
+ ambient: { kind: 'pin', particle: 'pin-pulse', count: 3 },
1640
+ propPos: 'wall', // war map mounted on cork board
1641
+ desk: 'walnut', // commander nest — dark brown executive desk
1642
+ cup: 'crimson',
1643
+ floor: 'oak',
1644
+ deskPieces: ['plaque', 'mini-trophy', 'trophy', 'medal'],
1645
+ decor: 'window-eagle',
1646
+ personalityLines: ['noted.', 'all in place.', 'fleet looks good.', 'let me pin that.', 'watching the board.', 'another pattern.'],
1647
+ },
1648
+ markethunting: {
1649
+ theme: { wall1:'#24483e', wall2:'#18332d', floor1:'#806447', floor2:'#5a4330', accent:'#81f0bf', accent2:'#dfffee', shirt:'#4bd38d', hair:'#17251f', prop:'chart', mood:'market pit', glow:'#6dffb4' },
1650
+ spriteRow: 1,
1651
+ ambient: { kind: 'scan', particle: 'scanline', count: 1 },
1652
+ propPos: 'desk',
1653
+ propEnabled: false, // chart monitor slot used by secondary monitor instead
1654
+ secondaryMonitor: { spriteRow: 11 }, // row 11 = paintOrderFlow (after house was inserted at 10)
1655
+ desk: 'steel', // trader pit — glass + hairpin steel
1656
+ cup: 'emerald',
1657
+ floor: 'graphite', // market pit — cool dark trading floor
1658
+ decor: 'window-market',
1659
+ personalityLines: ['chart never lies.', 'candles talking.', 'buy the dip?', 'follow the volume.', 'setup forming.', 'EOD looks clean.'],
1660
+ },
1661
+ sage: {
1662
+ theme: { wall1:'#39514a', wall2:'#263831', floor1:'#8c7157', floor2:'#685240', accent:'#9fe8d8', accent2:'#ecfff9', shirt:'#8fd3b0', hair:'#274237', prop:'book', mood:'tea corner', glow:'#b6f7de' },
1663
+ spriteRow: 2,
1664
+ ambient: { kind: 'steam', particle: 'puff', count: 3 },
1665
+ propPos: 'desk', // kettle on counter
1666
+ desk: 'oak', // tea corner — homey wood
1667
+ cup: 'tea',
1668
+ floor: 'ash', // tea corner — soft pale
1669
+ decor: 'window-sage',
1670
+ personalityLines: ['calm and steady.', 'another loop done.', 'tea is hot.', 'all good here.', 'just observing.'],
1671
+ },
1672
+ senku: {
1673
+ theme: { wall1:'#37505d', wall2:'#223740', floor1:'#7e6550', floor2:'#5a4839', accent:'#78dbff', accent2:'#e3fbff', shirt:'#77f6d8', hair:'#d7f268', prop:'flask', mood:'lab bench', glow:'#7cffef' },
1674
+ spriteRow: 3,
1675
+ ambient: { kind: 'bubbles', particle: 'bubble', count: 5 },
1676
+ propPos: 'desk', // flask rack on the lab bench
1677
+ desk: 'steel', // lab bench — metal + glass
1678
+ cup: 'cyan',
1679
+ floor: 'graphite', // lab bench — clean dark composite
1680
+ decor: 'window-senku',
1681
+ personalityLines: ['10 billion percent.', 'to the lab!', 'experiment running.', 'data is beautiful.', 'science is fun.', 'mixing reagents.'],
1682
+ },
1683
+ shikamaru: {
1684
+ theme: { wall1:'#3f3d67', wall2:'#292745', floor1:'#85705a', floor2:'#5f4d3f', accent:'#d5d7ff', accent2:'#f5f6ff', shirt:'#a8adf6', hair:'#191b30', prop:'columns', mood:'policy cave', glow:'#b8c0ff' },
1685
+ spriteRow: 4,
1686
+ propPos: 'desk', // shogi board on desk
1687
+ desk: 'walnut', // policy cave — heavy executive
1688
+ cup: 'lavender',
1689
+ floor: 'oak',
1690
+ decor: 'window-shikamaru',
1691
+ personalityLines: ['what a drag.', 'thinking it through.', '200 moves ahead.', 'patience.', 'let me think.'],
1692
+ },
1693
+ tyrion: {
1694
+ theme: { wall1:'#58473b', wall2:'#3c3028', floor1:'#8d6b4a', floor2:'#5e452f', accent:'#ffd27a', accent2:'#fff1ca', shirt:'#c79b59', hair:'#2b1d16', prop:'briefcase', mood:'capital desk', glow:'#ffd77d' },
1695
+ spriteRow: 5,
1696
+ propPos: 'desk', // ledgers + wine on desk
1697
+ desk: 'walnut', // capital desk — big walnut
1698
+ cup: 'gold',
1699
+ floor: 'cherry', // capital desk — rich red
1700
+ decor: 'window-tyrion',
1701
+ personalityLines: ['knowledge is power.', 'pour me a cup.', 'small council.', 'wits and wine.', 'I drink, I know.'],
1702
+ },
1703
+ harvey: {
1704
+ theme: { wall1:'#4c3945', wall2:'#32242c', floor1:'#8a6b55', floor2:'#654b3d', accent:'#d7d0ff', accent2:'#f5f2ff', shirt:'#b59fe9', hair:'#21141c', prop:'scales', mood:'case files', glow:'#d8c5ff' },
1705
+ spriteRow: 6,
1706
+ propPos: 'floor', // tall filing cabinet
1707
+ desk: 'walnut', // case files — formal walnut
1708
+ cup: 'plum',
1709
+ floor: 'cherry', // case files — formal red
1710
+ deskPieces: ['law-books'],
1711
+ decor: 'window-harvey',
1712
+ personalityLines: ["I don't lose.", 'got it covered.', 'best closer.', 'objection.', 'case closed.'],
1713
+ },
1714
+ l: {
1715
+ theme: { wall1:'#2f334d', wall2:'#202334', floor1:'#75685b', floor2:'#564a41', accent:'#dce4ff', accent2:'#fbfdff', shirt:'#9db4d8', hair:'#0d111f', prop:'magnifier', mood:'audit bunker', glow:'#cbd7ff' },
1716
+ spriteRow: 7,
1717
+ ambient: { kind: 'sparkle', particle: 'sparkle', count: 2 },
1718
+ propPos: 'desk', // candy pile on desk
1719
+ desk: 'white', // audit bunker — sterile white
1720
+ cup: 'white',
1721
+ floor: 'graphite', // audit bunker — cool dark
1722
+ decor: 'window-l',
1723
+ personalityLines: ['watching everyone.', 'logged.', 'interesting...', 'patterns emerging.', 'I see you.', 'noted in the log.'],
1724
+ },
1725
+ d: {
1726
+ theme: { wall1:'#4a364f', wall2:'#2f2234', floor1:'#7e5d49', floor2:'#593f32', accent:'#ff8ccd', accent2:'#ffe0f1', shirt:'#d07ac4', hair:'#151226', prop:'mic', mood:'studio desk', glow:'#ff9ed8' },
1727
+ spriteRow: 8,
1728
+ ambient: { kind: 'led', particle: 'led', count: 1 },
1729
+ propPos: 'desk',
1730
+ desk: 'white', // studio desk — clean white
1731
+ cup: 'pink',
1732
+ floor: 'cherry', // studio desk — rich warm
1733
+ decor: 'window-d',
1734
+ personalityLines: ['feel the field.', 'vibrating right.', 'good energy.', 'in the flow.', 'frequency dialed.'],
1735
+ },
1736
+ ephraim: {
1737
+ theme: { wall1:'#3a4452', wall2:'#252c38', floor1:'#5a5e62', floor2:'#3e4146', accent:'#e84a3a', accent2:'#ffd0c8', shirt:'#1d2230', hair:'#15100a', prop:'gym-rack', mood:'training room', glow:'#ff6a5a' },
1738
+ spriteRow: 9,
1739
+ ambient: { kind: 'pin', particle: 'pin-pulse', count: 2 }, // pulse — heartbeat / set-rep cadence
1740
+ propPos: 'wall', // pull-up bar + dumbbell rack on wall
1741
+ desk: 'steel', // training room — utilitarian metal
1742
+ cup: 'cyan', // hydration / electrolyte
1743
+ floor: 'graphite', // gym rubber feel
1744
+ deskPieces: ['medal'], // a single medal still sits on the desk (career hardware)
1745
+ decor: 'window-ephraim', // sunrise track view — dawn run vibe
1746
+ personalityLines: ['form first.', 'RPE 7.', 'log it.', 'rest is work.', 'trust the plan.', 'one more rep.', 'sustainable.'],
1747
+ },
1748
+ house: {
1749
+ theme: { wall1:'#3e4f5a', wall2:'#293841', floor1:'#7a8088', floor2:'#52575e', accent:'#dff3ff', accent2:'#ffffff', shirt:'#5a8aa3', hair:'#7c7c7c', prop:'xray', mood:'diagnostics room', glow:'#bfe6ff' },
1750
+ spriteRow: 10,
1751
+ ambient: { kind: 'sparkle', particle: 'sparkle', count: 2 }, // glow flicker from the x-ray light box
1752
+ propPos: 'wall', // x-ray viewer + clipboard mounted on wall
1753
+ desk: 'steel', // exam room — clinical metal
1754
+ cup: 'white', // coffee / clinic ceramic
1755
+ floor: 'ash', // clinical linoleum feel
1756
+ deskPieces: ['med-books'], // medical journals — clinical palette + red cross + stethoscope drape
1757
+ decor: 'window-house', // hospital exterior at dusk with lit windows
1758
+ personalityLines: ['everybody lies.', 'differential.', 'it is never lupus.', 'rule it out.', 'pain is data.', 'idiot move.', 'next test.'],
1759
+ },
1760
+ };
1761
+
1762
+ // Returns merged config for an agent id, falling back to AGENT_DEFAULTS for
1763
+ // any field the entry omits. Unknown agent ids get a clean default room.
1764
+ function agentConfig(id) {
1765
+ const entry = AGENT_CONFIG[id] || {};
1766
+ return { ...AGENT_DEFAULTS, ...entry };
1767
+ }
1768
+
1769
+ function addAmbient(prop, agentId) {
1770
+ const cfg = agentConfig(agentId).ambient;
1771
+ if (!cfg) return;
1772
+ prop.dataset.fx = cfg.kind;
1773
+ for (let i = 0; i < cfg.count; i++) {
1774
+ prop.appendChild(el('span', `fx ${cfg.particle}`));
1775
+ }
1776
+ }
1777
+
1778
+ // Desk trinkets that sit on the desk top. Each entry maps the wrapper class
1779
+ // to the list of child span classes the CSS expects. Add a new piece by
1780
+ // writing a CSS block (.piece + .piece .part {...}) and listing its parts
1781
+ // here, then list the piece names in AGENT_CONFIG.<id>.deskPieces.
1782
+ const DESK_KIT = {
1783
+ trophy: ['base', 'stem', 'cup', 'handle-l', 'handle-r'],
1784
+ 'mini-trophy': ['base', 'stem', 'cup', 'handle-l', 'handle-r'],
1785
+ medal: ['ribbon', 'disc', 'pip'],
1786
+ plaque: ['stand', 'plate', 'star'],
1787
+ 'law-books': ['standing', 'lean', 'lean2', 'stack', 'stack2', 'stack3'],
1788
+ 'med-books': ['standing', 'stack', 'stack2', 'cross', 'scope', 'scope-end'],
1789
+ };
1790
+
1791
+ function buildDeskPiece(name) {
1792
+ const parts = DESK_KIT[name];
1793
+ if (!parts) return null;
1794
+ const piece = el('div', name);
1795
+ parts.forEach(p => piece.appendChild(el('span', p)));
1796
+ return piece;
1797
+ }
1798
+
1799
+ // Quip bank rotates slowly (~1 per minute) so the speech bubble feels
1800
+ // alive without jittering on every 5s poll. Each agent's id is hashed
1801
+ // into the minute slot so different rooms land on different lines
1802
+ // simultaneously. Source list lives in AGENT_CONFIG.<id>.personalityLines.
1803
+ function pickQuip(agent) {
1804
+ const lines = agentConfig(agent.id).personalityLines;
1805
+ if (!lines || !lines.length) return agent.vibe || '';
1806
+ const slot = Math.floor(Date.now() / 60000);
1807
+ let h = 0;
1808
+ for (let i = 0; i < agent.id.length; i++) h = (h * 31 + agent.id.charCodeAt(i)) | 0;
1809
+ const idx = (((slot + Math.abs(h)) % lines.length) + lines.length) % lines.length;
1810
+ return lines[idx];
1811
+ }
1812
+
1813
+ // Resolves what the speech bubble should say + how it should look. When
1814
+ // the server has surfaced a contextual signal (crashed, failing, working,
1815
+ // context-hot) we honour it and apply a severity-coloured variant. Otherwise
1816
+ // we fall back to the in-character personality quip rotation. Returns
1817
+ // { kind, text } where kind is one of: alert | warn | busy | quip.
1818
+ function pickBubble(agent) {
1819
+ const b = agent.bubble;
1820
+ if (b && b.text) return { kind: b.kind || 'busy', text: b.text };
1821
+ return { kind: 'quip', text: pickQuip(agent) };
1822
+ }
1823
+
1824
+ // Speech bubble is single-line, max-width 200px (~32 chars at 10px font).
1825
+ // Clamp text at 32 chars so the bubble grows with content but stays inside
1826
+ // its max-width without falling back to CSS ellipsis truncation.
1827
+ function clampSpeech(s) {
1828
+ const t = String(s ?? '').trim();
1829
+ if (t.length <= 32) return t;
1830
+ return t.slice(0, 30).trimEnd() + '…';
1831
+ }
1832
+
1833
+ // Compact "1.2k" / "124k" / "1.05M" formatter for token counts.
1834
+ function formatK(n) {
1835
+ const v = Number(n) || 0;
1836
+ if (v >= 1_000_000) return (v / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M';
1837
+ if (v >= 100_000) return Math.round(v / 1_000) + 'k';
1838
+ if (v >= 1_000) return (v / 1_000).toFixed(1).replace(/\.0$/, '') + 'k';
1839
+ return String(v);
1840
+ }
1841
+
1842
+ // "2917k/1049k (278%) · 🗄 99%" — mirrors the gateway's
1843
+ // `openclaw sessions` Tokens column (totalTokens / contextTokens) plus
1844
+ // a cache-hit rate. Empty string when the latest session has no token
1845
+ // data so the header falls back to name+badge cleanly.
1846
+ function formatContextBar(agent) {
1847
+ if (!agent.contextWindow || !agent.totalTokens) return '';
1848
+ return `${formatK(agent.totalTokens)}/${formatK(agent.contextWindow)} (${agent.contextPct}%) · 🗄 ${agent.cachePct}%`;
1849
+ }
1850
+
1851
+ function ctxBand(pct) {
1852
+ const p = Number(pct) || 0;
1853
+ if (p >= 80) return 'hot';
1854
+ if (p >= 50) return 'warn';
1855
+ return 'ok';
1856
+ }
1857
+
1858
+ // Strip the trailing " ago" from "3m ago" / "1h ago" so the wall board
1859
+ // can fit the value in its narrow right column. Falls back to '—'.
1860
+ function shortLastSeen(s) {
1861
+ if (!s) return '—';
1862
+ return String(s).replace(/\s*ago\s*$/i, '').trim() || '—';
1863
+ }
1864
+
1865
+ // Wall-mounted metrics board — Phase 1 stats surfacing. Reads only fields
1866
+ // already in /api/pixel-office/state (no server changes). 4 rows: model,
1867
+ // sessions/notes combined, session-channel breakdown, freshness.
1868
+ // Header signal strip — shows one icon per truthy signal in agent.signals.
1869
+ // Severity colors are independent from "is this real" — we'll trim the
1870
+ // list once we see what fires in practice.
1871
+ // 🚨 ABORTED — last session ended mid-run (red)
1872
+ // 🔁 STREAK — recurring task failed 2x in a row (red)
1873
+ // ⚠ RECENT — task failure in the last 6 hours (red)
1874
+ // 📈 CTX — context window ≥95% full (yellow)
1875
+ // ↩ FALL — running on fallback model, not primary (yellow)
1876
+ const SIGNAL_DEFS = [
1877
+ { key: 'aborted', icon: '🚨', sev: 'red', label: 'ABORTED — last session ended mid-run' },
1878
+ { key: 'streak', icon: '🔁', sev: 'red', label: (d) => `STREAK — ${d.streak} recurring task(s) failed 2× in a row` },
1879
+ { key: 'recentFail', icon: '⚠', sev: 'red', label: (d) => `RECENT — ${d.recentFail} task issue(s) in last 6h` },
1880
+ { key: 'ctxHot', icon: '📈', sev: 'yellow', label: (d) => `CONTEXT — ${d.ctxPct}% of window used` },
1881
+ { key: 'fallback', icon: '↩', sev: 'yellow', label: (d) => `FALLBACK — running on ${d.modelDisplay} (primary: ${d.modelPrimary})` },
1882
+ ];
1883
+ function buildSignalStrip(agent) {
1884
+ const strip = el('div', 'signals');
1885
+ const sig = agent.signals || {};
1886
+ const det = agent.signalDetail || {};
1887
+ for (const def of SIGNAL_DEFS) {
1888
+ if (!sig[def.key]) continue;
1889
+ const node = el('span', `signal-icon sev-${def.sev}`, def.icon);
1890
+ node.title = typeof def.label === 'function' ? def.label(det) : def.label;
1891
+ strip.appendChild(node);
1892
+ }
1893
+ return strip;
1894
+ }
1895
+
1896
+ function buildStatsBoard(agent) {
1897
+ const board = el('div', 'statsboard');
1898
+ const sn = `${agent.sessionCount ?? 0}/${agent.memoryNotes ?? 0}`;
1899
+ const dgs = `${agent.directCount ?? 0}/${agent.groupCount ?? 0}/${agent.cronCount ?? 0}/${agent.subagentCount ?? 0}`;
1900
+ const rows = [
1901
+ ['MODEL', agent.modelDisplay || agent.modelPrimary || '—'],
1902
+ ['SES/NOTES', sn],
1903
+ ['DIR/GRP/CRN/SUB', dgs],
1904
+ ['TOKENS', formatK(agent.totalTokens)],
1905
+ ];
1906
+ rows.forEach(([label, value]) => {
1907
+ const row = el('div', 'row');
1908
+ row.appendChild(el('span', 'label', label));
1909
+ row.appendChild(el('span', 'value', String(value)));
1910
+ board.appendChild(row);
1911
+ });
1912
+ return board;
1913
+ }
1914
+
1915
+ // Map runtime status -> sprite animation class.
1916
+ function spriteClass(agent) {
1917
+ if (agent.status === 'active') return 'active';
1918
+ if (agent.status === 'away') return 'away';
1919
+ // For idle: deterministically vary between idle / sip per agent so the
1920
+ // office doesn't look like 8 agents all swaying in lockstep.
1921
+ const seed = (agent.id || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0);
1922
+ const minute = Math.floor(Date.now() / 60000);
1923
+ return ((seed + minute) % 3 === 0) ? 'sip' : 'idle';
1924
+ }
1925
+
1926
+ function el(tag, className, text) {
1927
+ const node = document.createElement(tag);
1928
+ if (className) node.className = className;
1929
+ if (text != null) node.textContent = text;
1930
+ return node;
1931
+ }
1932
+
1933
+ function roomRole(agent) {
1934
+ if (agent.channel === 'telegram') return 'chat desk';
1935
+ if (agent.sessionKey && agent.sessionKey.includes(':cron:')) return 'scheduled run';
1936
+ return 'specialist desk';
1937
+ }
1938
+
1939
+ function getTheme(agent) { return agentConfig(agent.id).theme; }
1940
+
1941
+ function applyTheme(room, theme) {
1942
+ room.style.setProperty('--wall1', theme.wall1);
1943
+ room.style.setProperty('--wall2', theme.wall2);
1944
+ room.style.setProperty('--floor1', theme.floor1);
1945
+ room.style.setProperty('--floor2', theme.floor2);
1946
+ room.style.setProperty('--accent', theme.accent);
1947
+ room.style.setProperty('--accent2', theme.accent2);
1948
+ room.style.setProperty('--shirt', theme.shirt);
1949
+ room.style.setProperty('--hair', theme.hair);
1950
+ room.style.setProperty('--glow', theme.glow);
1951
+ }
1952
+
1953
+ // Per-million-token USD rates by model short name. Numbers are
1954
+ // approximate published list prices — meant for an "is this getting
1955
+ // expensive?" gut check, not invoicing. Add a model here when its
1956
+ // pricing diverges; everything else falls through to MODEL_COST_FALLBACK.
1957
+ const MODEL_COSTS = {
1958
+ 'claude-opus-4-7': { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
1959
+ 'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
1960
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
1961
+ 'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
1962
+ 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
1963
+ 'claude-haiku-4-5-20251001': { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
1964
+ 'gpt-5.4': { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
1965
+ 'gpt-5.4-mini': { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 },
1966
+ };
1967
+ const MODEL_COST_FALLBACK = { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 };
1968
+
1969
+ function estimateCostUSD(agent) {
1970
+ const model = agent.modelDisplay || agent.modelPrimary;
1971
+ const rates = MODEL_COSTS[model] || MODEL_COST_FALLBACK;
1972
+ const ct = (n, perM) => (Number(n) || 0) * perM / 1_000_000;
1973
+ return ct(agent.inputTokens, rates.input)
1974
+ + ct(agent.outputTokens, rates.output)
1975
+ + ct(agent.cacheRead, rates.cacheRead)
1976
+ + ct(agent.cacheWrite, rates.cacheWrite);
1977
+ }
1978
+ function formatUSD(n) {
1979
+ if (n >= 100) return '$' + n.toFixed(0);
1980
+ if (n >= 10) return '$' + n.toFixed(1);
1981
+ if (n >= 1) return '$' + n.toFixed(2);
1982
+ if (n >= 0.01)return '$' + n.toFixed(3);
1983
+ return '<$0.01';
1984
+ }
1985
+
1986
+ // Build a window-picker + total + per-provider chip block for agent or
1987
+ // fleet usage. `usageMap` is {session, day, month, year} from the API.
1988
+ // Buttons flip `costWindow` and re-render both the side panel (if an agent
1989
+ // is pinned) and the topbar fleet strip so the operator only picks once.
1990
+ function buildCostBlock(usageMap) {
1991
+ if (!usageMap) {
1992
+ const wrap = el('div', 'cost-block');
1993
+ wrap.appendChild(el('span', 'cost-meta', 'No cost data yet — scanning…'));
1994
+ return wrap;
1995
+ }
1996
+ const wrap = el('div', 'cost-block');
1997
+
1998
+ // Window selector buttons
1999
+ const winRow = el('div', 'cost-windows');
2000
+ COST_WINDOWS.forEach(({ key, label }) => {
2001
+ const btn = el('button', 'cost-win-btn' + (key === costWindow ? ' on' : ''));
2002
+ btn.type = 'button';
2003
+ btn.textContent = label;
2004
+ btn.addEventListener('click', () => {
2005
+ costWindow = key;
2006
+ // Re-render panel (if an agent is pinned) and fleet strip
2007
+ if (lastState) {
2008
+ const pinned = selectedAgentId
2009
+ ? lastState.agents.find(a => a.id === selectedAgentId)
2010
+ : lastState.agents[0];
2011
+ if (pinned) renderPanel(pinned);
2012
+ renderFleetCost(lastState);
2013
+ }
2014
+ });
2015
+ winRow.appendChild(btn);
2016
+ });
2017
+ wrap.appendChild(winRow);
2018
+
2019
+ const bucket = usageMap[costWindow] || { billed: 0, byProvider: {}, count: 0 };
2020
+ const billed = bucket.billed || 0;
2021
+
2022
+ // All providers are flat-rate subscriptions — show prorated flat fees only,
2023
+ // no per-token estimates or per-model breakdown.
2024
+ wrap.appendChild(el('div', 'cost-total', formatUSD(billed)));
2025
+
2026
+ const billingModes = window.__billingModes || {};
2027
+ const provRow = el('div', 'cost-provs');
2028
+ Object.entries(bucket.byProvider).forEach(([key, val]) => {
2029
+ const label = PROVIDER_LABELS[key] || key;
2030
+ const mode = billingModes[key];
2031
+ const tag = mode === 'subscription' ? ' (sub)' : mode === 'api' ? ' (api)' : '';
2032
+ const chip = el('span', `cost-prov ${providerClass(key)}`, `${label}: ${formatUSD(val)}${tag}`);
2033
+ provRow.appendChild(chip);
2034
+ });
2035
+ if (provRow.childNodes.length > 0) wrap.appendChild(provRow);
2036
+
2037
+ if (bucket.count > 0) {
2038
+ wrap.appendChild(el('span', 'cost-meta', `${bucket.count} turn${bucket.count === 1 ? '' : 's'}`));
2039
+ }
2040
+
2041
+ return wrap;
2042
+ }
2043
+
2044
+ // Renders the topbar fleet-wide cost strip using the fleet.usage rollup.
2045
+ function renderFleetCost(state) {
2046
+ const fu = state.fleet && state.fleet.usage;
2047
+ if (!fu) { fleetCostEl.hidden = true; return; }
2048
+ fleetCostEl.hidden = false;
2049
+ fleetCostEl.innerHTML = '';
2050
+ fleetCostEl.appendChild(el('span', 'fleet-cost-label', 'Fleet'));
2051
+ fleetCostEl.appendChild(buildCostBlock(fu));
2052
+ }
2053
+
2054
+ // "in 2h" / "in 18m" / "in 3d" — relative future time, mirroring
2055
+ // shortLastSeen for consistency. Negative = overdue, returned as
2056
+ // "overdue 5m" so a stuck cron is obvious in the panel.
2057
+ function formatRelativeFuture(ms) {
2058
+ if (!ms) return '—';
2059
+ const delta = ms - Date.now();
2060
+ const sign = delta < 0 ? 'overdue ' : 'in ';
2061
+ const sec = Math.floor(Math.abs(delta) / 1000);
2062
+ if (sec < 60) return sign + sec + 's';
2063
+ const min = Math.floor(sec / 60);
2064
+ if (min < 60) return sign + min + 'm';
2065
+ const hr = Math.floor(min / 60);
2066
+ if (hr < 48) return sign + hr + 'h';
2067
+ const day = Math.floor(hr / 24);
2068
+ return sign + day + 'd';
2069
+ }
2070
+
2071
+ // Side-panel cron block — shows count plus per-job "name · in 18h · ok"
2072
+ // lines so the panel reads as a real schedule, not just a count. Returns
2073
+ // a multi-line string so it slots into the existing key/value row layout.
2074
+ function formatCronSchedule(agent) {
2075
+ const n = agent.cronJobs || 0;
2076
+ if (n === 0) return '0';
2077
+ const lines = (agent.cronSchedule || []).slice(0, 6).map(j => {
2078
+ const next = formatRelativeFuture(j.nextRunAtMs);
2079
+ const status = j.lastStatus ? ` · ${j.lastStatus}` : '';
2080
+ const expr = j.expr ? ` (${j.expr})` : '';
2081
+ return ` ${j.name}${expr}\n next ${next}${status}`;
2082
+ });
2083
+ return `${n} job${n===1?'':'s'}\n${lines.join('\n')}`;
2084
+ }
2085
+
2086
+ // Builds Local + LAN launch buttons per product. Local always uses
2087
+ // 127.0.0.1; LAN uses the product's pinned lanHosts override or falls
2088
+ // back to the page host + server-detected IPs.
2089
+ // - "Local" → http://localhost:PORT/PATH
2090
+ // - "LAN" → product.lanHosts (if pinned) else page-host + server-detected IPs
2091
+ // (numbered "LAN 1" / "LAN 2" when more than one)
2092
+ // - "LAN unavailable" placeholder when no LAN host can be derived.
2093
+ function buildProductLink(label, host, product) {
2094
+ const path = product.path || '';
2095
+ const url = `http://${host}:${product.port}${path}`;
2096
+ const a = el('a', 'product-link');
2097
+ a.href = url; a.target = '_blank'; a.rel = 'noreferrer';
2098
+ a.appendChild(el('span', 'lbl', label));
2099
+ a.appendChild(el('span', 'sub', `${host}:${product.port}${path}`));
2100
+ return a;
2101
+ }
2102
+ // Documents-only products have no port — they're served by pixel-office
2103
+ // itself under /docs/<id>/. Build Local + LAN links pointing at this
2104
+ // server's host:port so the card has real, tappable URLs (matches the
2105
+ // pattern of port-bound products).
2106
+ function buildDocsLink(label, host, product) {
2107
+ const port = window.location.port || '80';
2108
+ const path = product.docsPath || `/docs/${product.id}/`;
2109
+ const url = `http://${host}:${port}${path}`;
2110
+ const a = el('a', 'product-link docs-only');
2111
+ a.href = url; a.target = '_blank'; a.rel = 'noreferrer';
2112
+ a.appendChild(el('span', 'lbl', label));
2113
+ a.appendChild(el('span', 'sub', `${host}:${port}${path}`));
2114
+ return a;
2115
+ }
2116
+ function buildDocsLinks(product) {
2117
+ const out = [];
2118
+ const pageHost = window.location.hostname;
2119
+ const isPageLan = pageHost && pageHost !== 'localhost' && pageHost !== '127.0.0.1';
2120
+ const serverLan = (window.__lanHosts || []).filter(Boolean);
2121
+ out.push(buildDocsLink('Local', 'localhost', product));
2122
+ const lanHosts = isPageLan
2123
+ ? [pageHost, ...serverLan.filter(h => h !== pageHost)]
2124
+ : serverLan;
2125
+ lanHosts.forEach((host, idx) => {
2126
+ const label = lanHosts.length > 1 ? `LAN ${idx + 1}` : 'LAN';
2127
+ out.push(buildDocsLink(label, host, product));
2128
+ });
2129
+ return out;
2130
+ }
2131
+ // Reachability is probed CLIENT-SIDE (not server-side) because dev servers
2132
+ // commonly run on the Windows host and WSL2 can't reach Windows-bound
2133
+ // ports through mirrored networking — server probes give false negatives.
2134
+ // The browser's perspective is the one that matters anyway: "is this URL
2135
+ // openable from the device I'm looking at right now?"
2136
+ //
2137
+ // probeStatus[productId] holds 'pending' | 'live' | 'offline'.
2138
+ // probesInFlight[productId] dedupes concurrent probes triggered by polls.
2139
+ const probeStatus = new Map();
2140
+ const probesInFlight = new Map();
2141
+ const PROBE_TIMEOUT_MS = 2500;
2142
+
2143
+ async function probeOneUrl(url) {
2144
+ const ctrl = new AbortController();
2145
+ const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
2146
+ try {
2147
+ // no-cors lets the request go even when the target lacks CORS headers.
2148
+ // A reachable server returns an opaque response; an unreachable one
2149
+ // throws TypeError. The fetch result body is unreadable but we only
2150
+ // care about reachability.
2151
+ await fetch(url, { mode: 'no-cors', cache: 'no-store', signal: ctrl.signal });
2152
+ return true;
2153
+ } catch {
2154
+ return false;
2155
+ } finally {
2156
+ clearTimeout(timer);
2157
+ }
2158
+ }
2159
+
2160
+ function candidateProbeUrls(product) {
2161
+ const path = product.path || '';
2162
+ const hosts = new Set();
2163
+ // The page's own origin first — works for the user's current network
2164
+ // context (phone on LAN, laptop on WSL, etc.).
2165
+ const pageHost = window.location.hostname;
2166
+ if (pageHost) hosts.add(pageHost);
2167
+ // localhost only useful when the page is also on localhost.
2168
+ if (pageHost === 'localhost' || pageHost === '127.0.0.1') hosts.add('127.0.0.1');
2169
+ // Product-pinned LAN hosts.
2170
+ for (const h of (product.lanHosts || [])) hosts.add(h);
2171
+ return [...hosts].map(h => `http://${h}:${product.port}${path}`);
2172
+ }
2173
+
2174
+ function probeProduct(product) {
2175
+ if (!product.port) return Promise.resolve(true);
2176
+ if (probesInFlight.has(product.id)) return probesInFlight.get(product.id);
2177
+ const urls = candidateProbeUrls(product);
2178
+ const promise = Promise.all(urls.map(probeOneUrl)).then(results => {
2179
+ const reachable = results.some(Boolean);
2180
+ probeStatus.set(product.id, reachable ? 'live' : 'offline');
2181
+ probesInFlight.delete(product.id);
2182
+ // Re-render so the badge updates without waiting for the next poll.
2183
+ if (lastState) render(lastState);
2184
+ return reachable;
2185
+ });
2186
+ probesInFlight.set(product.id, promise);
2187
+ return promise;
2188
+ }
2189
+
2190
+ function ensureProbed(product) {
2191
+ if (!product.port) return;
2192
+ if (probeStatus.has(product.id)) return;
2193
+ probeStatus.set(product.id, 'pending');
2194
+ probeProduct(product);
2195
+ }
2196
+
2197
+ // Re-probe every 60s so cards turn green again once a dev server comes
2198
+ // back, and turn red once one falls over.
2199
+ setInterval(() => {
2200
+ if (!lastState) return;
2201
+ for (const a of lastState.agents) {
2202
+ for (const p of (a.products || [])) probeProduct(p);
2203
+ }
2204
+ }, 60_000);
2205
+
2206
+ // "live" downgrades to "offline" when the client-side probe can't reach
2207
+ // any candidate URL. Other declared statuses (wip/planning/paused) pass
2208
+ // through unchanged — those are intentional, not auto-detected.
2209
+ function effectiveProductStatus(p) {
2210
+ if (p.status !== 'live') return p.status;
2211
+ ensureProbed(p);
2212
+ const ps = probeStatus.get(p.id);
2213
+ if (ps === 'offline') return 'offline';
2214
+ if (ps === 'pending') return 'checking';
2215
+ return 'live';
2216
+ }
2217
+ function renderProducts(agent) {
2218
+ const products = agent.products || [];
2219
+ if (products.length === 0) return document.createTextNode('—');
2220
+ const pageHost = window.location.hostname;
2221
+ const isPageLan = pageHost && pageHost !== 'localhost' && pageHost !== '127.0.0.1';
2222
+ const serverLan = (window.__lanHosts || []).filter(Boolean);
2223
+ const list = el('div', 'product-list');
2224
+ products.forEach(p => {
2225
+ const card = el('div', 'product-card');
2226
+ const head = el('div', 'product-card-head');
2227
+ head.appendChild(el('span', null, p.name));
2228
+ const status = effectiveProductStatus(p);
2229
+ head.appendChild(el('span', `product-status ${status}`, status));
2230
+ card.appendChild(head);
2231
+ if (p.description) card.appendChild(el('div', 'product-desc', p.description));
2232
+ const links = el('div', 'product-links');
2233
+ if (!p.port) {
2234
+ buildDocsLinks(p).forEach(n => links.appendChild(n));
2235
+ } else {
2236
+ links.appendChild(buildProductLink('Local', 'localhost', p));
2237
+ const lanHosts = (p.lanHosts && p.lanHosts.length > 0)
2238
+ ? p.lanHosts
2239
+ : (isPageLan
2240
+ ? [pageHost, ...serverLan.filter(h => h !== pageHost)]
2241
+ : serverLan);
2242
+ if (lanHosts.length === 0) {
2243
+ const placeholder = el('span', 'product-link unavailable');
2244
+ placeholder.appendChild(el('span', 'lbl', 'LAN unavailable'));
2245
+ links.appendChild(placeholder);
2246
+ } else {
2247
+ lanHosts.forEach((host, idx) => {
2248
+ const label = lanHosts.length > 1 ? `LAN ${idx + 1}` : 'LAN';
2249
+ links.appendChild(buildProductLink(label, host, p));
2250
+ });
2251
+ }
2252
+ if (status === 'offline' && p.start) links.appendChild(buildStartButton(p));
2253
+ }
2254
+ card.appendChild(links);
2255
+ list.appendChild(card);
2256
+ });
2257
+ return list;
2258
+ }
2259
+
2260
+ // POSTs /api/pixel-office/product/:id/start. The button shows "Starting…"
2261
+ // for ~6s (long enough for quarto/python to bind a port), then re-probes
2262
+ // so the badge flips to live without waiting for the 60s auto-cycle.
2263
+ function buildStartButton(product) {
2264
+ const btn = el('button', 'product-link start-btn');
2265
+ btn.type = 'button';
2266
+ btn.appendChild(el('span', 'lbl', 'Start'));
2267
+ btn.appendChild(el('span', 'sub', product.start.cmd));
2268
+ btn.addEventListener('click', async (ev) => {
2269
+ ev.preventDefault();
2270
+ btn.disabled = true;
2271
+ btn.querySelector('.lbl').textContent = 'Starting…';
2272
+ try {
2273
+ const r = await fetch(`/api/pixel-office/product/${encodeURIComponent(product.id)}/start`, { method: 'POST' });
2274
+ const body = await r.json().catch(() => ({}));
2275
+ if (!r.ok || !body.ok) {
2276
+ btn.querySelector('.lbl').textContent = 'Start failed';
2277
+ btn.querySelector('.sub').textContent = body.error || `HTTP ${r.status}`;
2278
+ setTimeout(() => { btn.disabled = false; btn.querySelector('.lbl').textContent = 'Start'; btn.querySelector('.sub').textContent = product.start.cmd; }, 4000);
2279
+ return;
2280
+ }
2281
+ // Give it a moment to bind the port, then probe a few times
2282
+ // until reachable (or give up after ~12s).
2283
+ let attempts = 0;
2284
+ const tick = async () => {
2285
+ attempts += 1;
2286
+ // Force a fresh probe rather than reading cached pending state.
2287
+ probeStatus.delete(product.id);
2288
+ const reachable = await probeProduct(product);
2289
+ if (reachable || attempts >= 6) {
2290
+ btn.disabled = false;
2291
+ btn.querySelector('.lbl').textContent = reachable ? 'Started' : 'Still down';
2292
+ btn.querySelector('.sub').textContent = reachable ? body.logPath : `pid ${body.pid}; check ${body.logPath}`;
2293
+ return;
2294
+ }
2295
+ setTimeout(tick, 2000);
2296
+ };
2297
+ setTimeout(tick, 2000);
2298
+ } catch (e) {
2299
+ btn.querySelector('.lbl').textContent = 'Start error';
2300
+ btn.querySelector('.sub').textContent = e.message;
2301
+ setTimeout(() => { btn.disabled = false; btn.querySelector('.lbl').textContent = 'Start'; btn.querySelector('.sub').textContent = product.start.cmd; }, 4000);
2302
+ }
2303
+ });
2304
+ return btn;
2305
+ }
2306
+
2307
+ // Lets the operator change an agent's primary model live. The dropdown
2308
+ // POSTs to /api/pixel-office/agent/:id/model, which shells out to
2309
+ // `openclaw config set agents.list.<idx>.model.primary <id>`. We block the
2310
+ // refresh poll while a save is in flight so its 5-second tick can't
2311
+ // overwrite the dropdown selection mid-save.
2312
+ function buildModelPicker(agent) {
2313
+ const wrap = el('span', 'model-picker');
2314
+ const select = document.createElement('select');
2315
+ select.className = 'model-select';
2316
+ const opts = window.__availableModels || [];
2317
+ const current = agent.modelPrimary || agent.modelDisplay || '';
2318
+ const tail = current.split('/').pop();
2319
+ let matched = false;
2320
+ opts.forEach(m => {
2321
+ const o = document.createElement('option');
2322
+ o.value = m.id;
2323
+ o.textContent = m.label;
2324
+ if (m.id === current || m.id.split('/').pop() === tail) {
2325
+ o.selected = true;
2326
+ matched = true;
2327
+ }
2328
+ select.appendChild(o);
2329
+ });
2330
+ if (!matched && current) {
2331
+ // Show whatever the agent is currently configured with even if it's not
2332
+ // in the allowlist, so the operator isn't surprised by a default.
2333
+ const o = document.createElement('option');
2334
+ o.value = current;
2335
+ o.textContent = `${current} (current)`;
2336
+ o.disabled = true;
2337
+ o.selected = true;
2338
+ select.insertBefore(o, select.firstChild);
2339
+ }
2340
+ const status = el('span', 'model-status', '');
2341
+ wrap.appendChild(select);
2342
+ wrap.appendChild(status);
2343
+ select.addEventListener('change', async () => {
2344
+ const target = select.value;
2345
+ select.disabled = true;
2346
+ status.textContent = 'saving…';
2347
+ status.className = 'model-status pending';
2348
+ modelSaving = true;
2349
+ try {
2350
+ const res = await fetch(`/api/pixel-office/agent/${encodeURIComponent(agent.id)}/model`, {
2351
+ method: 'POST',
2352
+ headers: { 'Content-Type': 'application/json' },
2353
+ body: JSON.stringify({ model: target }),
2354
+ });
2355
+ if (!res.ok) {
2356
+ const body = await res.json().catch(() => ({}));
2357
+ throw new Error(body.error || `HTTP ${res.status}`);
2358
+ }
2359
+ status.textContent = 'saved';
2360
+ status.className = 'model-status ok';
2361
+ await load();
2362
+ } catch (err) {
2363
+ status.textContent = `failed: ${err.message}`;
2364
+ status.className = 'model-status err';
2365
+ } finally {
2366
+ modelSaving = false;
2367
+ select.disabled = false;
2368
+ }
2369
+ });
2370
+ return wrap;
2371
+ }
2372
+
2373
+ function renderPanel(agent) {
2374
+ const theme = getTheme(agent);
2375
+ panelMeta.innerHTML = '';
2376
+ const tokLine = agent.totalTokens
2377
+ ? `${(agent.totalTokens).toLocaleString()} tok · ${agent.contextPct}% of ${(agent.contextWindow||0).toLocaleString()} · 🗄 ${agent.cachePct}%`
2378
+ : '—';
2379
+ const t = agent.tasks || {};
2380
+ const tasksLine = (t.tracked || 0) === 0
2381
+ ? '—'
2382
+ : `${t.active||0} active · ${t.issues||0} issue${t.issues===1?'':'s'} · ${t.succeeded||0} ok · ${t.tracked||0} tracked`;
2383
+ const memoryLine = agent.latestLog
2384
+ ? `${agent.latestLog}${agent.latestLogAt ? ' · ' + new Date(agent.latestLogAt).toISOString().slice(0,10) : ''}`
2385
+ : `${agent.memoryNotes || 0} note${agent.memoryNotes===1?'':'s'}`;
2386
+ const sig = agent.signals || {};
2387
+ const det = agent.signalDetail || {};
2388
+ const activeSignals = SIGNAL_DEFS
2389
+ .filter(d => sig[d.key])
2390
+ .map(d => `${d.icon} ${(typeof d.label === 'function' ? d.label(det) : d.label).split(' — ')[0]}`)
2391
+ .join(', ');
2392
+ const fields = [
2393
+ ['Agent', `${agent.emoji} ${agent.displayName}`],
2394
+ ['Status', agent.statusLabel],
2395
+ ['Signals', activeSignals || 'none'],
2396
+ ['Model (primary)', buildModelPicker(agent)],
2397
+ ['Model (active)', agent.modelDisplay || agent.modelPrimary || '—'],
2398
+ ['Last seen', agent.lastSeen],
2399
+ ['Latest session', agent.sessionKey || 'none'],
2400
+ ['Tokens (this session)', tokLine],
2401
+ ['Cost', buildCostBlock(agent.usage)],
2402
+ ['Cron jobs', formatCronSchedule(agent)],
2403
+ ['Products', renderProducts(agent)],
2404
+ ['Tasks', tasksLine],
2405
+ ['Latest memory', memoryLine],
2406
+ ['Sessions tracked', String(agent.sessionCount)],
2407
+ ['Room vibe', theme.mood],
2408
+ ['Hint', agent.currentTask || 'No safe task snippet exposed yet.'],
2409
+ ];
2410
+ fields.forEach(([label, value]) => {
2411
+ const row = el('div');
2412
+ const strong = el('strong', null, label);
2413
+ row.appendChild(strong);
2414
+ // Multi-line values (e.g. cron schedule) need whitespace preserved so
2415
+ // each " name\n next in 18h" line stays readable instead of
2416
+ // collapsing into one paragraph.
2417
+ if (value instanceof Node) {
2418
+ row.appendChild(value);
2419
+ } else if (typeof value === 'string' && value.includes('\n')) {
2420
+ const block = el('span');
2421
+ block.style.whiteSpace = 'pre-wrap';
2422
+ block.style.fontFamily = 'ui-monospace,"SF Mono",Menlo,Consolas,monospace';
2423
+ block.style.fontSize = '11px';
2424
+ block.textContent = value;
2425
+ row.appendChild(block);
2426
+ } else {
2427
+ row.appendChild(document.createTextNode(value));
2428
+ }
2429
+ panelMeta.appendChild(row);
2430
+ });
2431
+ const link = el('a', 'button');
2432
+ link.href = agent.openclawUrl;
2433
+ link.target = '_blank';
2434
+ link.rel = 'noreferrer';
2435
+ link.textContent = 'Open real control UI';
2436
+ panelMeta.appendChild(link);
2437
+ }
2438
+
2439
+ function burnClass(agent) {
2440
+ if (agent.burnSignal === 'critical') return ' burn-critical';
2441
+ if (agent.burnSignal === 'hot') return ' burn-hot';
2442
+ return '';
2443
+ }
2444
+
2445
+ function createRoom(agent) {
2446
+ const theme = getTheme(agent);
2447
+ const room = el('button', `room ${agent.status}${burnClass(agent)}`);
2448
+ room.type = 'button';
2449
+ room.dataset.agent = agent.id;
2450
+ applyTheme(room, theme);
2451
+ room.addEventListener('click', () => { selectedAgentId = agent.id; renderPanel(agent); });
2452
+
2453
+ const header = el('div', 'room-header');
2454
+ const left = el('div');
2455
+ left.appendChild(el('div', 'agent-name', `${agent.emoji} ${agent.displayName}`));
2456
+ left.appendChild(el('div', 'agent-role', roomRole(agent)));
2457
+ header.appendChild(left);
2458
+ header.appendChild(buildSignalStrip(agent));
2459
+ header.appendChild(el('div', `badge ${agent.status}`, agent.statusLabel.toUpperCase()));
2460
+ room.appendChild(header);
2461
+
2462
+ const cfg = agentConfig(agent.id);
2463
+ const scene = el('div', 'scene');
2464
+ scene.appendChild(el('div', `floor ${cfg.floor}`));
2465
+ scene.appendChild(el('div', 'lamp'));
2466
+ scene.appendChild(el('div', 'lamp-pool'));
2467
+ if (cfg.propEnabled) {
2468
+ const prop = el('div', `prop pos-${cfg.propPos}`);
2469
+ prop.style.setProperty('--sprite-row', cfg.spriteRow);
2470
+ addAmbient(prop, agent.id);
2471
+ scene.appendChild(prop);
2472
+ }
2473
+ scene.appendChild(el('div', `decor ${cfg.decor}`));
2474
+ scene.appendChild(el('div', 'door-left'));
2475
+ scene.appendChild(el('div', 'door-right'));
2476
+ scene.appendChild(el('div', `desk ${cfg.desk}`));
2477
+ const monitor = el('div', `monitor ${agent.status}`);
2478
+ monitor.style.setProperty('--sprite-row', cfg.spriteRow);
2479
+ scene.appendChild(monitor);
2480
+ if (cfg.secondaryMonitor) {
2481
+ const monitor2 = el('div', `monitor secondary ${agent.status}`);
2482
+ monitor2.style.setProperty('--sprite-row', cfg.secondaryMonitor.spriteRow);
2483
+ scene.appendChild(monitor2);
2484
+ }
2485
+ scene.appendChild(el('div', 'chair'));
2486
+ scene.appendChild(el('div', 'keyboard'));
2487
+ scene.appendChild(el('div', 'mouse'));
2488
+ scene.appendChild(el('div', `accent-cup ${cfg.cup}`));
2489
+ cfg.deskPieces.forEach(name => {
2490
+ const piece = buildDeskPiece(name);
2491
+ if (piece) scene.appendChild(piece);
2492
+ });
2493
+ const sprite = el('div', `sprite ${spriteClass(agent)}`);
2494
+ sprite.style.setProperty('--sprite-row', cfg.spriteRow);
2495
+ sprite.dataset.agent = agent.id;
2496
+ scene.appendChild(sprite);
2497
+ // Render up to 3 sub-agent minions next to the parent. If there are
2498
+ // more than 3, the third one carries a +N badge so the count stays
2499
+ // legible without packing the floor with sprites.
2500
+ const subN = Number(agent.subagentCount) || 0;
2501
+ if (subN > 0) {
2502
+ const visible = Math.min(subN, 3);
2503
+ for (let i = 0; i < visible; i++) {
2504
+ const m = el('div', `minion m${i}`);
2505
+ m.style.setProperty('--sprite-row', cfg.spriteRow);
2506
+ if (i === visible - 1 && subN > 3) {
2507
+ m.appendChild(el('span', 'minion-count', `+${subN - 3}`));
2508
+ }
2509
+ scene.appendChild(m);
2510
+ }
2511
+ }
2512
+ scene.appendChild(el('div', `status-dot ${agent.status}`));
2513
+ if (agent.status === 'away') scene.appendChild(el('div', 'zzz', 'z z z'));
2514
+ scene.appendChild(buildStatsBoard(agent));
2515
+ const bubble = pickBubble(agent);
2516
+ const bubbleClass = bubble.kind === 'quip' ? 'speech' : `speech ${bubble.kind}`;
2517
+ scene.appendChild(el('div', bubbleClass, clampSpeech(bubble.text)));
2518
+ room.appendChild(scene);
2519
+
2520
+ const footer = el('div', 'room-footer');
2521
+ footer.appendChild(el('span', null, `seen ${agent.lastSeen}`));
2522
+ const ctxText = formatContextBar(agent);
2523
+ if (ctxText) {
2524
+ const bar = el('span', `ctxbar ${ctxBand(agent.contextPct)}`);
2525
+ if (agent.cronJobs > 0) {
2526
+ const chip = el('span', 'cron-chip', `⏰ ${agent.cronJobs}`);
2527
+ chip.title = `${agent.cronJobs} scheduled cron job${agent.cronJobs === 1 ? '' : 's'}`;
2528
+ bar.appendChild(chip);
2529
+ }
2530
+ const products = agent.products || [];
2531
+ if (products.length > 0) {
2532
+ const chip = el('span', 'product-chip', `🚀 ${products.length}`);
2533
+ chip.title = `${products.length} product${products.length === 1 ? '' : 's'}: ${products.map(p => p.name).join(', ')}`;
2534
+ bar.appendChild(chip);
2535
+ }
2536
+ bar.appendChild(el('span', null, ctxText));
2537
+ footer.appendChild(bar);
2538
+ }
2539
+ footer.appendChild(el('span', 'mood', theme.mood));
2540
+ room.appendChild(footer);
2541
+ return room;
2542
+ }
2543
+
2544
+ // Phase 3 — in-room pacing.
2545
+ // Each non-away agent gets a Walker that schedules random short walks within
2546
+ // the room. Walkers persist across status polls; render() now diffs by agent
2547
+ // id set rather than wiping the DOM, so positions and motion stay continuous.
2548
+ const SPRITE_DEFAULT_LEFT = 56; // matches .sprite { left:56px } — IDLE seat (chair pulled back)
2549
+ const WORK_SEAT_X = 124; // sprite-center over keyboard center; chair tucks under desk
2550
+ const SPRITE_VISUAL_HALF = 40; // 32px sprite × 2.5 / 2
2551
+ const WALK_SPEED_PX_S = 60; // CSS pixels per second along left axis
2552
+
2553
+ const STATE_CLASSES = ['active', 'idle', 'sip', 'away', 'walking'];
2554
+ const POSTURE_CLASSES = ['sitting'];
2555
+ const SEAT_X_TOLERANCE = 14; // sprite is "at seat" within ±14px of either seat position
2556
+ const EXIT_CHANCE = 0.20; // per-walk probability of door transition (when neighbor exists)
2557
+ const EXIT_FADE_MS = 450; // duration of exit fade-out
2558
+ const ENTER_FADE_MS = 400; // duration of entry fade-in
2559
+ // Design-phase lock: pin every character at the work seat in the working
2560
+ // pose so we can iterate on the seated composition without movement noise.
2561
+ // Flip back to false to restore status-driven behavior.
2562
+ const DESIGN_FREEZE_AT_DESK = false;
2563
+
2564
+ // Phase 4 — adjacency. Computed at render time and on resize. Maps each
2565
+ // agent.id -> { left: agentId|null, right: agentId|null } based on the
2566
+ // visible row layout (rooms with similar getBoundingClientRect().top).
2567
+ let adjacencyMap = new Map();
2568
+ let adjacencyTimer = null;
2569
+
2570
+ function computeAdjacency() {
2571
+ adjacencyMap = new Map();
2572
+ const rooms = Array.from(officeEl.querySelectorAll('.room'));
2573
+ if (!rooms.length) return;
2574
+ const positioned = rooms.map(r => ({
2575
+ id: r.dataset.agent,
2576
+ rect: r.getBoundingClientRect(),
2577
+ })).filter(p => p.id);
2578
+ positioned.sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
2579
+ const rows = [];
2580
+ positioned.forEach(p => {
2581
+ const row = rows.find(r => Math.abs(r[0].rect.top - p.rect.top) < 40);
2582
+ if (row) row.push(p); else rows.push([p]);
2583
+ });
2584
+ rows.forEach(row => {
2585
+ row.sort((a, b) => a.rect.left - b.rect.left);
2586
+ row.forEach((p, i) => {
2587
+ adjacencyMap.set(p.id, {
2588
+ left: i > 0 ? row[i - 1].id : null,
2589
+ right: i < row.length - 1 ? row[i + 1].id : null,
2590
+ });
2591
+ });
2592
+ });
2593
+ }
2594
+
2595
+ window.addEventListener('resize', () => {
2596
+ clearTimeout(adjacencyTimer);
2597
+ adjacencyTimer = setTimeout(computeAdjacency, 200);
2598
+ });
2599
+
2600
+ function findRoom(agentId) {
2601
+ return officeEl.querySelector(`.room[data-agent="${agentId}"]`);
2602
+ }
2603
+
2604
+ function pickNeighbor(currentRoom) {
2605
+ if (!currentRoom) return null;
2606
+ const id = currentRoom.dataset.agent;
2607
+ const adj = adjacencyMap.get(id);
2608
+ if (!adj) return null;
2609
+ const dirs = [];
2610
+ if (adj.left) dirs.push({ dir: 'left', id: adj.left });
2611
+ if (adj.right) dirs.push({ dir: 'right', id: adj.right });
2612
+ if (!dirs.length) return null;
2613
+ const choice = dirs[Math.floor(Math.random() * dirs.length)];
2614
+ const room = findRoom(choice.id);
2615
+ return room ? { dir: choice.dir, room } : null;
2616
+ }
2617
+
2618
+ class Walker {
2619
+ constructor(sprite, scene, agent, room) {
2620
+ this.sprite = sprite;
2621
+ this.scene = scene;
2622
+ this.room = room; // Phase 4: current room (changes after migration)
2623
+ this.agent = agent;
2624
+ this.busy = false;
2625
+ this.timer = null;
2626
+ this.raf = null;
2627
+ this.alive = true;
2628
+ // Phase 5: an active agent starts at the WORK seat so the chair tucks
2629
+ // under the desk on first paint. Idle/sip/away start at the IDLE seat.
2630
+ // DESIGN_FREEZE_AT_DESK overrides this: every character locks to WORK_SEAT.
2631
+ if (DESIGN_FREEZE_AT_DESK || agent.status === 'active') {
2632
+ sprite.style.left = WORK_SEAT_X + 'px';
2633
+ }
2634
+ this.applyPose();
2635
+ if (!DESIGN_FREEZE_AT_DESK) this.scheduleNext(true);
2636
+ }
2637
+ isHome() {
2638
+ return this.room && this.room.dataset.agent === this.agent.id;
2639
+ }
2640
+ setStatus(status) {
2641
+ const prev = this.agent.status;
2642
+ this.agent = { ...this.agent, status };
2643
+ if (DESIGN_FREEZE_AT_DESK) {
2644
+ // Stay frozen at the work seat regardless of incoming status.
2645
+ this.applyPose();
2646
+ return;
2647
+ }
2648
+ if (status === prev) return;
2649
+ // Only the home walker may toggle this room's chair — otherwise a
2650
+ // visitor's status flip would tuck the host's chair under the desk.
2651
+ const chair = this.scene && this.scene.querySelector('.chair');
2652
+ if (chair && this.isHome()) chair.classList.toggle('working', status === 'active');
2653
+ if (this.busy) return; // an in-flight walk will resolve in finish()
2654
+ if (status === 'away') { this.applyPose(); return; }
2655
+ // Visitors must never aim for the host's seats. pickAndWalk will route
2656
+ // them home on the next tick.
2657
+ if (!this.isHome()) { this.applyPose(); return; }
2658
+ const target = status === 'active' ? WORK_SEAT_X : SPRITE_DEFAULT_LEFT;
2659
+ if (Math.abs(this.currentX() - target) > SEAT_X_TOLERANCE) {
2660
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
2661
+ this.walkTo(target);
2662
+ } else {
2663
+ this.applyPose();
2664
+ }
2665
+ }
2666
+ // Pose = state class + posture class for the CURRENT settled position.
2667
+ // Sitting is position-driven: any seat (idle OR work) gives .sitting.
2668
+ // Chair "working" tuck is status-driven: .chair.working iff status=active.
2669
+ applyPose() {
2670
+ STATE_CLASSES.forEach(c => this.sprite.classList.remove(c));
2671
+ POSTURE_CLASSES.forEach(c => this.sprite.classList.remove(c));
2672
+ const chair = this.scene && this.scene.querySelector('.chair');
2673
+ if (DESIGN_FREEZE_AT_DESK) {
2674
+ this.sprite.classList.add('active', 'sitting');
2675
+ if (chair) chair.classList.add('working');
2676
+ this.sprite.classList.remove('facing-left');
2677
+ return;
2678
+ }
2679
+ const x = this.currentX();
2680
+ const atSeat = Math.abs(x - SPRITE_DEFAULT_LEFT) < SEAT_X_TOLERANCE
2681
+ || Math.abs(x - WORK_SEAT_X) < SEAT_X_TOLERANCE;
2682
+ let stateClass;
2683
+ if (this.agent.status === 'active') {
2684
+ stateClass = 'active';
2685
+ } else if (this.agent.status === 'away') {
2686
+ stateClass = 'away';
2687
+ } else {
2688
+ const seed = (this.agent.id || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0);
2689
+ const minute = Math.floor(Date.now() / 60000);
2690
+ stateClass = ((seed + minute) % 3 === 0) ? 'sip' : 'idle';
2691
+ }
2692
+ this.sprite.classList.add(stateClass);
2693
+ // Visitors are not allowed to sit on or use the host's seats — only
2694
+ // the home walker may pose .sitting and toggle the chair tuck.
2695
+ if (atSeat && this.isHome()) this.sprite.classList.add('sitting');
2696
+ if (chair && this.isHome()) chair.classList.toggle('working', this.agent.status === 'active');
2697
+ if (!this.busy) this.sprite.classList.remove('facing-left');
2698
+ }
2699
+ bounds() {
2700
+ const w = this.scene.clientWidth || 280;
2701
+ return { minX: SPRITE_VISUAL_HALF - 16, maxX: Math.max(SPRITE_DEFAULT_LEFT, w - SPRITE_VISUAL_HALF - 16) };
2702
+ }
2703
+ scheduleNext(initial = false) {
2704
+ if (!this.alive) return;
2705
+ const dwell = initial
2706
+ ? 2000 + Math.random() * 5000
2707
+ : 8000 + Math.random() * 16000;
2708
+ this.timer = setTimeout(() => this.pickAndWalk(), dwell);
2709
+ }
2710
+ // Pick the door direction that leads back to the walker's own room,
2711
+ // chosen by shortest path along the wrap-around document order. Returns
2712
+ // null if the walker is already home or if no neighbor exists.
2713
+ findHomeDir() {
2714
+ if (this.isHome()) return null;
2715
+ const rooms = Array.from(officeEl.querySelectorAll('.room'));
2716
+ const homeIdx = rooms.findIndex(r => r.dataset.agent === this.agent.id);
2717
+ const curIdx = rooms.findIndex(r => r === this.room);
2718
+ if (homeIdx < 0 || curIdx < 0) return null;
2719
+ const adj = adjacencyMap.get(this.room.dataset.agent) || {};
2720
+ const N = rooms.length;
2721
+ const cyclicDist = (a, b) => Math.min((a - b + N) % N, (b - a + N) % N);
2722
+ const candidates = [];
2723
+ if (adj.left) {
2724
+ const idx = rooms.findIndex(r => r.dataset.agent === adj.left);
2725
+ if (idx >= 0) candidates.push({ dir: 'left', id: adj.left, d: cyclicDist(idx, homeIdx) });
2726
+ }
2727
+ if (adj.right) {
2728
+ const idx = rooms.findIndex(r => r.dataset.agent === adj.right);
2729
+ if (idx >= 0) candidates.push({ dir: 'right', id: adj.right, d: cyclicDist(idx, homeIdx) });
2730
+ }
2731
+ if (!candidates.length) return null;
2732
+ candidates.sort((a, b) => a.d - b.d);
2733
+ const room = findRoom(candidates[0].id);
2734
+ return room ? { dir: candidates[0].dir, room } : null;
2735
+ }
2736
+ pickAndWalk() {
2737
+ if (!this.alive || this.agent.status === 'away') {
2738
+ this.scheduleNext();
2739
+ return;
2740
+ }
2741
+ // Visiting another agent's room — head home immediately on the next
2742
+ // action. Visitors are never allowed to settle, sit, or use the host's
2743
+ // chair/PC, so we don't pick a wander target here.
2744
+ if (!this.isHome()) {
2745
+ const home = this.findHomeDir();
2746
+ if (home) return this.walkToDoorAndExit(home.dir, home.room);
2747
+ // No path home (e.g. lost adjacency mid-resize). Stay put and retry.
2748
+ this.scheduleNext();
2749
+ return;
2750
+ }
2751
+ // Phase 5: active = sit and work. Don't wander, don't visit. If we
2752
+ // somehow drifted off the work seat, walk back to it.
2753
+ if (this.agent.status === 'active') {
2754
+ if (Math.abs(this.currentX() - WORK_SEAT_X) > SEAT_X_TOLERANCE) {
2755
+ this.walkTo(WORK_SEAT_X);
2756
+ return;
2757
+ }
2758
+ this.scheduleNext();
2759
+ return;
2760
+ }
2761
+ // Idle/sip: chance to visit a neighbor, otherwise wander or settle
2762
+ // back at the idle (relaxed) seat.
2763
+ if (Math.random() < EXIT_CHANCE) {
2764
+ const neighbor = pickNeighbor(this.room);
2765
+ if (neighbor) return this.walkToDoorAndExit(neighbor.dir, neighbor.room);
2766
+ }
2767
+ const { minX, maxX } = this.bounds();
2768
+ const target = Math.random() < 0.35
2769
+ ? SPRITE_DEFAULT_LEFT
2770
+ : minX + Math.random() * (maxX - minX);
2771
+ this.walkTo(target);
2772
+ }
2773
+ currentX() {
2774
+ const inline = this.sprite.style.left;
2775
+ if (inline) return parseFloat(inline);
2776
+ return SPRITE_DEFAULT_LEFT;
2777
+ }
2778
+ walkTo(targetX, onComplete = null) {
2779
+ const startX = this.currentX();
2780
+ const dx = targetX - startX;
2781
+ if (Math.abs(dx) < 6) {
2782
+ if (onComplete) onComplete();
2783
+ else { this.applyPose(); this.scheduleNext(); }
2784
+ return;
2785
+ }
2786
+ this.busy = true;
2787
+ STATE_CLASSES.forEach(c => this.sprite.classList.remove(c));
2788
+ POSTURE_CLASSES.forEach(c => this.sprite.classList.remove(c));
2789
+ this.sprite.classList.add('walking');
2790
+ this.sprite.classList.toggle('facing-left', dx < 0);
2791
+ const duration = Math.abs(dx) / WALK_SPEED_PX_S * 1000;
2792
+ const startT = performance.now();
2793
+ const step = (now) => {
2794
+ if (!this.alive) return;
2795
+ const t = Math.min(1, (now - startT) / duration);
2796
+ this.sprite.style.left = (startX + dx * t) + 'px';
2797
+ if (t < 1) this.raf = requestAnimationFrame(step);
2798
+ else this.finish(onComplete);
2799
+ };
2800
+ this.raf = requestAnimationFrame(step);
2801
+ }
2802
+ finish(onComplete) {
2803
+ this.busy = false;
2804
+ if (onComplete) {
2805
+ onComplete();
2806
+ return;
2807
+ }
2808
+ // If status flipped to active mid-walk and we ended somewhere other
2809
+ // than the work seat, re-route to it — but only when home. A visiting
2810
+ // walker must never be steered onto another agent's chair.
2811
+ if (this.agent.status === 'active'
2812
+ && this.isHome()
2813
+ && Math.abs(this.currentX() - WORK_SEAT_X) > SEAT_X_TOLERANCE) {
2814
+ this.walkTo(WORK_SEAT_X);
2815
+ return;
2816
+ }
2817
+ this.applyPose();
2818
+ this.scheduleNext();
2819
+ }
2820
+ // Phase 4: walk to the side door, then trigger the cross-room transition.
2821
+ walkToDoorAndExit(dir, neighborRoom) {
2822
+ const { minX, maxX } = this.bounds();
2823
+ const targetX = dir === 'right' ? maxX : minX;
2824
+ this.walkTo(targetX, () => this.beginExitTransition(dir, neighborRoom));
2825
+ }
2826
+ beginExitTransition(dir, neighborRoom) {
2827
+ if (!this.alive) return;
2828
+ this.busy = true;
2829
+ // Sink behind the door visually + slide a touch further into it.
2830
+ this.sprite.style.zIndex = '0';
2831
+ this.sprite.classList.add('walking');
2832
+ this.sprite.classList.toggle('facing-left', dir === 'left');
2833
+ const slide = (dir === 'right' ? 24 : -24);
2834
+ this.sprite.style.transition = `opacity ${EXIT_FADE_MS}ms ease-in, left ${EXIT_FADE_MS}ms ease-in`;
2835
+ this.sprite.style.left = (this.currentX() + slide) + 'px';
2836
+ this.sprite.style.opacity = '0';
2837
+ this.timer = setTimeout(() => this.completeMigration(dir, neighborRoom), EXIT_FADE_MS + 30);
2838
+ }
2839
+ completeMigration(dir, neighborRoom) {
2840
+ if (!this.alive) return;
2841
+ const newScene = neighborRoom.querySelector('.scene');
2842
+ if (!newScene) {
2843
+ // Neighbor vanished mid-transition (rare). Bail back to current room.
2844
+ this.sprite.style.transition = '';
2845
+ this.sprite.style.opacity = '1';
2846
+ this.sprite.style.zIndex = '';
2847
+ this.busy = false;
2848
+ this.applyPose();
2849
+ this.scheduleNext();
2850
+ return;
2851
+ }
2852
+ newScene.appendChild(this.sprite);
2853
+ this.scene = newScene;
2854
+ this.room = neighborRoom;
2855
+ // Sprite enters from the OPPOSITE door (came out of right → enters left).
2856
+ const enterDir = dir === 'right' ? 'left' : 'right';
2857
+ const { minX, maxX } = this.bounds();
2858
+ const startX = enterDir === 'left' ? minX : maxX;
2859
+ this.sprite.style.transition = '';
2860
+ this.sprite.style.left = startX + 'px';
2861
+ this.sprite.classList.toggle('facing-left', enterDir === 'right');
2862
+ // Fade in next frame.
2863
+ requestAnimationFrame(() => {
2864
+ this.sprite.style.transition = `opacity ${ENTER_FADE_MS}ms ease-out`;
2865
+ this.sprite.style.opacity = '1';
2866
+ });
2867
+ this.timer = setTimeout(() => {
2868
+ if (!this.alive) return;
2869
+ this.sprite.style.transition = '';
2870
+ this.sprite.style.zIndex = '';
2871
+ this.busy = false;
2872
+ // Walk a bit further into the new room before settling.
2873
+ const offset = enterDir === 'left' ? 80 : -80;
2874
+ this.walkTo(this.currentX() + offset);
2875
+ }, ENTER_FADE_MS + 30);
2876
+ }
2877
+ destroy() {
2878
+ this.alive = false;
2879
+ if (this.timer) clearTimeout(this.timer);
2880
+ if (this.raf) cancelAnimationFrame(this.raf);
2881
+ this.sprite.classList.remove('walking');
2882
+ this.sprite.style.opacity = '';
2883
+ this.sprite.style.transition = '';
2884
+ this.sprite.style.zIndex = '';
2885
+ }
2886
+ }
2887
+
2888
+ const walkers = new Map(); // agentId -> Walker
2889
+ let lastAgentIds = '';
2890
+ let selectedAgentId = null;
2891
+ // True while a model dropdown POST is in flight — the 5s poll is skipped so
2892
+ // the user's pending selection isn't blown away by an in-place re-render.
2893
+ let modelSaving = false;
2894
+
2895
+ function attachWalker(room, agent) {
2896
+ const sprite = room.querySelector('.sprite');
2897
+ const scene = room.querySelector('.scene');
2898
+ if (!sprite || !scene) return;
2899
+ // Away agents also get a walker — it just keeps them parked at the desk
2900
+ // in the slumped pose. This way the walker, not updateRoomInPlace, owns
2901
+ // every state class on the sprite, avoiding fights between the two.
2902
+ if (agent.status === 'away' && sprite.style.left) sprite.style.left = '';
2903
+ walkers.set(agent.id, new Walker(sprite, scene, agent, room));
2904
+ }
2905
+
2906
+ function updateRoomInPlace(agent) {
2907
+ const room = officeEl.querySelector(`.room[data-agent="${agent.id}"]`);
2908
+ if (!room) return;
2909
+ room.classList.remove('active', 'idle', 'away', 'burn-hot', 'burn-critical');
2910
+ room.classList.add(agent.status);
2911
+ const bc = burnClass(agent).trim();
2912
+ if (bc) room.classList.add(bc);
2913
+ const badge = room.querySelector('.badge');
2914
+ if (badge) {
2915
+ badge.className = `badge ${agent.status}`;
2916
+ badge.textContent = agent.statusLabel.toUpperCase();
2917
+ }
2918
+ const dot = room.querySelector('.status-dot');
2919
+ if (dot) dot.className = `status-dot ${agent.status}`;
2920
+ const monitor = room.querySelector('.monitor');
2921
+ if (monitor) {
2922
+ monitor.classList.remove('active', 'idle', 'away');
2923
+ monitor.classList.add(agent.status);
2924
+ }
2925
+ let zzz = room.querySelector('.zzz');
2926
+ if (agent.status === 'away' && !zzz) {
2927
+ const sceneEl = room.querySelector('.scene');
2928
+ if (sceneEl) sceneEl.appendChild(el('div', 'zzz', 'z z z'));
2929
+ } else if (agent.status !== 'away' && zzz) {
2930
+ zzz.remove();
2931
+ }
2932
+ // Hand the new status off to the walker, which re-poses the sprite
2933
+ // (sitting at desk vs standing elsewhere vs slumped on desk for away).
2934
+ const walker = walkers.get(agent.id);
2935
+ if (walker) {
2936
+ walker.setStatus(agent.status);
2937
+ // If the agent flipped to away, snap them back to the desk in their
2938
+ // CURRENT room (which may be a neighbor's room if they were visiting).
2939
+ if (agent.status === 'away') {
2940
+ walker.sprite.style.left = '';
2941
+ walker.applyPose();
2942
+ }
2943
+ } else {
2944
+ attachWalker(room, agent);
2945
+ }
2946
+ }
2947
+
2948
+ // Top rail card — same content as the side-panel card but bigger and
2949
+ // headlining the page. One per product, owner-byline up top.
2950
+ function buildRailCard(product, owner) {
2951
+ const card = el('div', 'rail-card');
2952
+ const head = el('div', 'rail-card-head');
2953
+ const titleBox = el('div');
2954
+ const ownerLine = owner
2955
+ ? `${owner.emoji} ${owner.displayName}${owner.role ? ' · ' + owner.role : ''}`
2956
+ : product.agentId;
2957
+ titleBox.appendChild(el('div', 'rail-owner', ownerLine));
2958
+ titleBox.appendChild(el('div', 'rail-name', product.name));
2959
+ head.appendChild(titleBox);
2960
+ const railStatus = effectiveProductStatus(product);
2961
+ head.appendChild(el('span', `rail-status ${railStatus}`, railStatus));
2962
+ card.appendChild(head);
2963
+ if (product.description) card.appendChild(el('div', 'rail-desc', product.description));
2964
+ if (product.tags && product.tags.length > 0) {
2965
+ const tagBox = el('div', 'rail-tags');
2966
+ product.tags.forEach(t => tagBox.appendChild(el('span', 'rail-tag', t)));
2967
+ card.appendChild(tagBox);
2968
+ }
2969
+ // Reuse the same Local/LAN button builder as the side panel so the URL
2970
+ // logic stays in one place.
2971
+ const links = el('div', 'rail-links');
2972
+ if (!product.port) {
2973
+ buildDocsLinks(product).forEach(n => links.appendChild(n));
2974
+ } else {
2975
+ const pageHost = window.location.hostname;
2976
+ const isPageLan = pageHost && pageHost !== 'localhost' && pageHost !== '127.0.0.1';
2977
+ const serverLan = (window.__lanHosts || []).filter(Boolean);
2978
+ links.appendChild(buildProductLink('Local', 'localhost', product));
2979
+ const lanHosts = (product.lanHosts && product.lanHosts.length > 0)
2980
+ ? product.lanHosts
2981
+ : (isPageLan
2982
+ ? [pageHost, ...serverLan.filter(h => h !== pageHost)]
2983
+ : serverLan);
2984
+ if (lanHosts.length === 0) {
2985
+ const placeholder = el('span', 'product-link unavailable');
2986
+ placeholder.appendChild(el('span', 'lbl', 'LAN unavailable'));
2987
+ links.appendChild(placeholder);
2988
+ } else {
2989
+ lanHosts.forEach((host, idx) => {
2990
+ const label = lanHosts.length > 1 ? `LAN ${idx + 1}` : 'LAN';
2991
+ links.appendChild(buildProductLink(label, host, product));
2992
+ });
2993
+ }
2994
+ if (railStatus === 'offline' && product.start) links.appendChild(buildStartButton(product));
2995
+ }
2996
+ card.appendChild(links);
2997
+ return card;
2998
+ }
2999
+
3000
+ function renderProductsRail(state) {
3001
+ const owners = {};
3002
+ for (const a of state.agents) owners[a.id] = a;
3003
+ const products = [];
3004
+ for (const a of state.agents) for (const p of (a.products || [])) products.push(p);
3005
+ if (products.length === 0) { railEl.hidden = true; return; }
3006
+ railEl.hidden = false;
3007
+ railGrid.innerHTML = '';
3008
+ // Header LAN-host hint shows "LAN: x.x.x.x" so phones know where to point.
3009
+ const lans = (state.lanHosts || []).slice();
3010
+ railLanEl.textContent = lans.length > 0 ? `LAN: ${lans.join(', ')}` : 'No LAN host detected';
3011
+ products.forEach(p => railGrid.appendChild(buildRailCard(p, owners[p.agentId])));
3012
+ }
3013
+
3014
+ function render(state) {
3015
+ lastState = state;
3016
+ openclawLink.href = state.openclawUrl;
3017
+ window.__lanHosts = state.lanHosts || [];
3018
+ window.__availableModels = state.availableModels || [];
3019
+ window.__subscriptionProviders = state.subscriptionProviders || [];
3020
+ window.__billingModes = state.billingModes || {};
3021
+ renderProductsRail(state);
3022
+ renderFleetCost(state);
3023
+ const ids = state.agents.map(a => a.id).join(',');
3024
+ if (ids !== lastAgentIds) {
3025
+ // Agent set changed — full rebuild.
3026
+ walkers.forEach(w => w.destroy());
3027
+ walkers.clear();
3028
+ officeEl.innerHTML = '';
3029
+ state.agents.forEach(agent => {
3030
+ const room = createRoom(agent);
3031
+ officeEl.appendChild(room);
3032
+ attachWalker(room, agent);
3033
+ });
3034
+ lastAgentIds = ids;
3035
+ // Phase 4: recompute room adjacency so walkers know who's next door.
3036
+ computeAdjacency();
3037
+ } else {
3038
+ state.agents.forEach(updateRoomInPlace);
3039
+ }
3040
+ const pinned = selectedAgentId
3041
+ ? state.agents.find(a => a.id === selectedAgentId)
3042
+ : null;
3043
+ const target = pinned || state.agents[0];
3044
+ if (target) renderPanel(target);
3045
+ }
3046
+
3047
+ // ---- Completion chime ----
3048
+ // Opt-in sound that fires when an agent transitions from sessionStatus
3049
+ // 'running' → 'done' between polls. Uses WebAudio (no asset files).
3050
+ // A short two-tone "ding": fundamental at 880 Hz followed by 1320 Hz.
3051
+ // The user enables it by clicking 🔔; state persists in localStorage.
3052
+ const chimeBtn = document.getElementById('chime-btn');
3053
+ let chimeEnabled = localStorage.getItem('pixel-office-chime') === '1';
3054
+ let prevSessionStatus = {}; // agentId → last known sessionStatus
3055
+
3056
+ function applyChimeClass() {
3057
+ chimeBtn.classList.toggle('chime-on', chimeEnabled);
3058
+ chimeBtn.classList.toggle('chime-off', !chimeEnabled);
3059
+ chimeBtn.title = chimeEnabled
3060
+ ? 'Sound chime ON — click to disable'
3061
+ : 'Sound chime OFF — click to enable';
3062
+ }
3063
+ applyChimeClass();
3064
+
3065
+ chimeBtn.addEventListener('click', () => {
3066
+ chimeEnabled = !chimeEnabled;
3067
+ localStorage.setItem('pixel-office-chime', chimeEnabled ? '1' : '0');
3068
+ applyChimeClass();
3069
+ });
3070
+
3071
+ function playChime() {
3072
+ try {
3073
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
3074
+ const gain = ctx.createGain();
3075
+ gain.gain.setValueAtTime(0.18, ctx.currentTime);
3076
+ gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.55);
3077
+ gain.connect(ctx.destination);
3078
+ [[880, 0], [1320, 0.12]].forEach(([freq, delay]) => {
3079
+ const osc = ctx.createOscillator();
3080
+ osc.type = 'sine';
3081
+ osc.frequency.value = freq;
3082
+ osc.connect(gain);
3083
+ osc.start(ctx.currentTime + delay);
3084
+ osc.stop(ctx.currentTime + delay + 0.4);
3085
+ });
3086
+ setTimeout(() => ctx.close(), 700);
3087
+ } catch {}
3088
+ }
3089
+
3090
+ function detectChimes(state) {
3091
+ if (!chimeEnabled) { prevSessionStatus = {}; return; }
3092
+ for (const a of state.agents) {
3093
+ const prev = prevSessionStatus[a.id];
3094
+ if (prev === 'running' && a.sessionStatus === 'done') playChime();
3095
+ prevSessionStatus[a.id] = a.sessionStatus;
3096
+ }
3097
+ }
3098
+
3099
+ async function load() {
3100
+ refreshBtn.disabled = true;
3101
+ try {
3102
+ const res = await fetch('/api/pixel-office/state', { cache:'no-store' });
3103
+ const state = await res.json();
3104
+ detectChimes(state);
3105
+ render(state);
3106
+ } catch {
3107
+ officeEl.innerHTML = '<div class="empty">Failed to load office state.</div>';
3108
+ } finally {
3109
+ refreshBtn.disabled = false;
3110
+ }
3111
+ }
3112
+
3113
+ refreshBtn.addEventListener('click', load);
3114
+ // Hard reload: bypasses HTML/JS cache. Necessary in installed PWAs on iOS
3115
+ // where there's no Safari address bar to pull-to-refresh from.
3116
+ function hardReload() {
3117
+ // Append a cache-buster to defeat any intermediate cache (HTTP cache is
3118
+ // already no-store for HTML/JS, but service workers / proxies can lie).
3119
+ const u = new URL(window.location.href);
3120
+ u.searchParams.set('_r', Date.now().toString(36));
3121
+ window.location.replace(u.toString());
3122
+ }
3123
+ document.getElementById('reload-btn').addEventListener('click', hardReload);
3124
+ // Long-press anywhere on the Refresh button (>=600ms) = hard reload.
3125
+ let pressTimer = null;
3126
+ const startPress = () => { pressTimer = setTimeout(hardReload, 600); };
3127
+ const cancelPress = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } };
3128
+ refreshBtn.addEventListener('touchstart', startPress, { passive: true });
3129
+ refreshBtn.addEventListener('touchend', cancelPress);
3130
+ refreshBtn.addEventListener('touchcancel', cancelPress);
3131
+ refreshBtn.addEventListener('mousedown', startPress);
3132
+ refreshBtn.addEventListener('mouseup', cancelPress);
3133
+ refreshBtn.addEventListener('mouseleave', cancelPress);
3134
+ load();
3135
+ setInterval(() => { if (!modelSaving) load(); }, 5000);
3136
+
3137
+ // ---- Office dog ----
3138
+ // A roaming pixel mascot that lives along the bottom of the office grid.
3139
+ // It walks to a random x, then rolls a die for what to do: keep walking,
3140
+ // sleep for a while, pee (and leave a fading puddle), or sit and stare at
3141
+ // the screen. State changes are class flips; CSS does the actual posing.
3142
+ // Office dog — lives inside one room's scene at a time, walks the floor,
3143
+ // and periodically migrates to a random other room with a quick fade so it
3144
+ // feels like a real office pet making the rounds. Same state palette as
3145
+ // before (walk / sleep / pee / stare) plus a 'visit' action that triggers
3146
+ // a room hop.
3147
+ const DOG_STATES = ['walking','running','sleeping','peeing','staring'];
3148
+ const DOG_X_MIN = 14; // keep clear of left wall
3149
+ const DOG_X_MAX = 320; // 365 scene width − dog width − margin
3150
+ const DOG_FADE_MS = 380;
3151
+ class OfficeDog {
3152
+ constructor() {
3153
+ this.el = el('div', 'dog');
3154
+ const parts = [
3155
+ 'tail-base','tail-mid','tail-curl','tail-tip',
3156
+ 'body','belly','chest','fluff-l','fluff-r',
3157
+ 'spot s1','spot s2',
3158
+ 'head','cheek','muzzle','nose','mouth','tongue',
3159
+ 'eye','eye r','eye-shine','eye-shine r',
3160
+ 'ear','ear r','ear-inner','ear-inner r',
3161
+ 'collar','tag',
3162
+ 'leg fl','leg fr','leg bl','leg br',
3163
+ 'paw fl','paw fr','paw bl','paw br',
3164
+ 'speed-1','speed-2',
3165
+ 'puddle','zz',
3166
+ ];
3167
+ parts.forEach(cls => {
3168
+ const d = el('div', `dpart ${cls}`);
3169
+ if (cls === 'zz') d.textContent = 'z';
3170
+ this.el.appendChild(d);
3171
+ });
3172
+ this.scene = null;
3173
+ this.x = DOG_X_MIN + 20;
3174
+ this.facing = 'right';
3175
+ this.el.style.left = this.x + 'px';
3176
+ this.timer = null;
3177
+ this.tryAttach();
3178
+ }
3179
+ // Find a scene to live in. If our current scene is gone (full rebuild
3180
+ // wiped the office), pick a fresh random one. Returns true on success.
3181
+ tryAttach() {
3182
+ if (this.scene && this.scene.isConnected) return true;
3183
+ const scenes = document.querySelectorAll('#office .scene');
3184
+ if (scenes.length === 0) {
3185
+ this.timer = setTimeout(() => this.tryAttach(), 500);
3186
+ return false;
3187
+ }
3188
+ const target = scenes[Math.floor(Math.random() * scenes.length)];
3189
+ this.placeIn(target);
3190
+ this.scheduleNext(800);
3191
+ return true;
3192
+ }
3193
+ placeIn(scene) {
3194
+ this.scene = scene;
3195
+ scene.appendChild(this.el);
3196
+ this.el.style.opacity = '1';
3197
+ }
3198
+ setState(state) {
3199
+ DOG_STATES.forEach(s => this.el.classList.remove(s));
3200
+ this.el.classList.add(state);
3201
+ }
3202
+ walkTo(targetX, opts = {}) {
3203
+ return new Promise(resolve => {
3204
+ const tx = Math.max(DOG_X_MIN, Math.min(DOG_X_MAX, targetX));
3205
+ const dx = tx - this.x;
3206
+ if (Math.abs(dx) < 4) { resolve(); return; }
3207
+ this.facing = dx < 0 ? 'left' : 'right';
3208
+ this.el.classList.toggle('facing-left', this.facing === 'left');
3209
+ // 'walking' = stroll (35 px/s), 'running' = sprint (90 px/s).
3210
+ const running = !!opts.run;
3211
+ this.setState(running ? 'running' : 'walking');
3212
+ const speed = running ? 90 : 35;
3213
+ const duration = Math.abs(dx) / speed * 1000;
3214
+ this.el.style.transition = `left ${duration}ms linear`;
3215
+ this.el.style.left = tx + 'px';
3216
+ this.x = tx;
3217
+ setTimeout(() => {
3218
+ this.el.style.transition = 'left 0s linear';
3219
+ // Drop the walking/running class so legs and tail stop animating
3220
+ // while the dog stands still between actions.
3221
+ DOG_STATES.forEach(s => this.el.classList.remove(s));
3222
+ resolve();
3223
+ }, duration + 30);
3224
+ });
3225
+ }
3226
+ // Migrate: walk to a door, fade out, fade into the ADJACENT room on
3227
+ // that side. No teleporting across the office — the dog only ever
3228
+ // visits its current room's left or right neighbor (uses the same
3229
+ // adjacencyMap the agent walkers use). If neither neighbor exists,
3230
+ // just wander in place.
3231
+ async migrate() {
3232
+ // Document-order adjacency with wrap-around. Going "right" steps to
3233
+ // the next room in the office grid; from the LAST room it wraps back
3234
+ // to the FIRST. Going "left" steps backward; from the first room it
3235
+ // wraps to the last. This works the same on desktop multi-column and
3236
+ // phone single-column layouts.
3237
+ const rooms = Array.from(officeEl.querySelectorAll('.room'));
3238
+ if (rooms.length < 2) {
3239
+ const target = DOG_X_MIN + Math.round(Math.random() * (DOG_X_MAX - DOG_X_MIN));
3240
+ await this.walkTo(target);
3241
+ this.scheduleNext(800 + Math.random() * 1500);
3242
+ return;
3243
+ }
3244
+ const room = this.scene && this.scene.closest('.room');
3245
+ let idx = rooms.indexOf(room);
3246
+ if (idx < 0) idx = 0;
3247
+ const exitDir = Math.random() < 0.5 ? 'left' : 'right';
3248
+ const nextIdx = exitDir === 'right'
3249
+ ? (idx + 1) % rooms.length
3250
+ : (idx - 1 + rooms.length) % rooms.length;
3251
+ const next = rooms[nextIdx].querySelector('.scene');
3252
+ if (!next) { this.scheduleNext(1500); return; }
3253
+ const exitX = exitDir === 'left' ? DOG_X_MIN : DOG_X_MAX;
3254
+ await this.walkTo(exitX);
3255
+ this.el.style.transition = `opacity ${DOG_FADE_MS}ms ease-in`;
3256
+ this.el.style.opacity = '0';
3257
+ await new Promise(r => setTimeout(r, DOG_FADE_MS + 30));
3258
+ // Re-enter from the opposite door of the new room.
3259
+ const enterDir = exitDir === 'left' ? 'right' : 'left';
3260
+ const enterX = enterDir === 'left' ? DOG_X_MIN : DOG_X_MAX;
3261
+ this.placeIn(next);
3262
+ this.el.style.transition = 'left 0s linear';
3263
+ this.el.style.left = enterX + 'px';
3264
+ this.x = enterX;
3265
+ this.facing = enterDir === 'left' ? 'right' : 'left';
3266
+ this.el.classList.toggle('facing-left', this.facing === 'left');
3267
+ this.setState('walking');
3268
+ // Fade in next frame.
3269
+ requestAnimationFrame(() => {
3270
+ this.el.style.transition = `opacity ${DOG_FADE_MS}ms ease-out`;
3271
+ this.el.style.opacity = '1';
3272
+ });
3273
+ await new Promise(r => setTimeout(r, DOG_FADE_MS));
3274
+ // Stroll a bit into the new room.
3275
+ const settle = enterDir === 'left'
3276
+ ? this.x + 60 + Math.random() * 80
3277
+ : this.x - 60 - Math.random() * 80;
3278
+ await this.walkTo(Math.round(settle));
3279
+ this.scheduleNext(600 + Math.random() * 1500);
3280
+ }
3281
+ async pickAction() {
3282
+ if (!this.tryAttach()) return;
3283
+ // Action distribution for the roaming office dog:
3284
+ // wander 50% stroll to a random x in this room
3285
+ // run 12.5% sprint to a random x (faster legs + speed lines)
3286
+ // migrate 15% walk-fade-walk to the next/prev room (wraps)
3287
+ // stare 10% sit, face camera, pant, tail thumps
3288
+ // sleep 7.5% curl on floor with Z's
3289
+ // pee 5% lift back leg, leave a fading puddle
3290
+ const r = Math.random();
3291
+ if (r < 0.500) {
3292
+ const target = DOG_X_MIN + Math.round(Math.random() * (DOG_X_MAX - DOG_X_MIN));
3293
+ await this.walkTo(target);
3294
+ this.scheduleNext(400 + Math.random() * 1500);
3295
+ } else if (r < 0.625) {
3296
+ const target = DOG_X_MIN + Math.round(Math.random() * (DOG_X_MAX - DOG_X_MIN));
3297
+ await this.walkTo(target, { run: true });
3298
+ this.scheduleNext(300 + Math.random() * 1000);
3299
+ } else if (r < 0.775) {
3300
+ await this.migrate();
3301
+ } else if (r < 0.875) {
3302
+ this.setState('staring');
3303
+ this.scheduleNext(4000 + Math.random() * 4000);
3304
+ } else if (r < 0.950) {
3305
+ this.setState('sleeping');
3306
+ this.scheduleNext(6000 + Math.random() * 8000);
3307
+ } else {
3308
+ this.setState('peeing');
3309
+ this.scheduleNext(1700);
3310
+ }
3311
+ }
3312
+ scheduleNext(delay) {
3313
+ clearTimeout(this.timer);
3314
+ this.timer = setTimeout(() => this.pickAction(), delay);
3315
+ }
3316
+ }
3317
+ const officeDog = new OfficeDog();
3318
+ window.dog = officeDog; // dev handle: dog.setState('staring'), dog.migrate()
3319
+
3320
+ // Phase 2 dev helpers — manual walk-in-place toggle from devtools.
3321
+ // walk('main') — walk facing right
3322
+ // walk('main','left') — walk facing left
3323
+ // walk('main', null) — stop walking
3324
+ // walk() — walk all sprites (random direction per sprite)
3325
+ window.walk = function (agentId, dir = 'right') {
3326
+ const sel = agentId ? `.sprite[data-agent="${agentId}"]` : '.sprite';
3327
+ document.querySelectorAll(sel).forEach((s) => {
3328
+ const direction = dir === 'random' ? (Math.random() < 0.5 ? 'left' : 'right') : dir;
3329
+ s.classList.toggle('walking', direction != null);
3330
+ s.classList.toggle('facing-left', direction === 'left');
3331
+ });
3332
+ };
3333
+ </script>
3334
+ </body>
3335
+ </html>