infinicode 2.5.1 → 2.6.1

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.
@@ -7,6 +7,18 @@
7
7
  * POST /fed/command and POST /fed/apply. The browser authenticates every request
8
8
  * with the ?token= it was loaded with (browsers can't set Bearer on navigation).
9
9
  *
10
+ * Three tabs:
11
+ * Fleet — node cards + this-node hardware + live activity feed
12
+ * Park — isometric live map of the mesh (nodes placed on a floor, agent
13
+ * movement + tool-use pulses streamed from /fed/stream; click a robot
14
+ * for its cam / mic / speaker / hardware drawer)
15
+ * Sessions & Telemetry — RoboPark scheduler stats + production_mode + controls
16
+ *
17
+ * The Park cam panel targets a LiveKit video track: it fetches a per-robot stream
18
+ * token from /robopark/api/robots/:id/stream and, when the livekit-client SDK and
19
+ * a published track are present, subscribes; otherwise it shows a styled preview.
20
+ * That token/track is produced by the robot-side RoboPark plugin.
21
+ *
10
22
  * Kept free of backticks and ${...} so the whole page fits inside this TS
11
23
  * template literal without escaping; the client JS uses string concatenation.
12
24
  */
@@ -17,53 +29,66 @@ export const DASHBOARD_HTML = `<!doctype html>
17
29
  <meta name="viewport" content="width=device-width, initial-scale=1">
18
30
  <title>RoboPark Control</title>
19
31
  <style>
32
+ /* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
20
33
  :root{
21
- --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;
22
- --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;
23
- --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;
24
- --info:#1F6F9E; --info-bg:#DCEEF7;
34
+ --bg:#08070F;
35
+ --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),
36
+ radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),
37
+ radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),
38
+ linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);
39
+ --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;
40
+ --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);
41
+ --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);
42
+ --ink:#F2ECDC; --muted:#9E93B4;
43
+ --gold:#E6B23C; --gold-bright:#FFD86B;
44
+ --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);
45
+ --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);
46
+ --shadow: 0 10px 34px rgba(0,0,0,0.42);
25
47
  --mono:ui-monospace,"Cascadia Code",Consolas,monospace;
26
48
  --sans:system-ui,"Segoe UI",Roboto,sans-serif;
27
49
  }
28
- @media (prefers-color-scheme:dark){:root{
29
- --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;
30
- --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;
31
- --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;
32
- --info:#6FB6FF; --info-bg:#0F2231;
33
- }}
34
- :root[data-theme="dark"]{
35
- --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;
36
- --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;
37
- --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;
38
- --info:#6FB6FF; --info-bg:#0F2231;
39
- }
50
+ @media (prefers-color-scheme:dark){:root{}}
51
+ :root[data-theme="dark"]{}
40
52
  :root[data-theme="light"]{
41
- --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;
42
- --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;
43
- --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;
44
- --info:#1F6F9E; --info-bg:#DCEEF7;
53
+ --bg:#EDEAF6;
54
+ --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),
55
+ radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),
56
+ linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);
57
+ --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;
58
+ --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);
59
+ --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);
60
+ --ink:#241A08; --muted:#6E5A3E;
61
+ --gold:#8A5A0C; --gold-bright:#B9781A;
62
+ --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);
63
+ --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);
64
+ --shadow: 0 10px 30px rgba(60,40,10,0.14);
45
65
  }
46
66
  *{box-sizing:border-box;margin:0;padding:0}
47
67
  html,body{height:100%}
48
- body{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
68
+ body{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;
69
+ color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
49
70
  .mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}
71
+ .glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
72
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
50
73
 
51
74
  /* top bar */
52
75
  header.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;
53
- padding:0.7rem 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:0;z-index:5;}
76
+ padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);
77
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
78
+ position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}
54
79
  .brand{display:flex;align-items:center;gap:0.6rem;}
55
- .livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 70%,transparent);animation:pulse 2.4s infinite;flex:none;}
56
- .livedot.stale{background:var(--muted);animation:none;}
57
- @keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}
80
+ .livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}
81
+ .livedot.stale{background:var(--muted);box-shadow:none;animation:none;}
82
+ @keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}
58
83
  @media (prefers-reduced-motion:reduce){.livedot{animation:none}}
59
84
  .brand b{font-size:0.98rem;letter-spacing:-0.01em;}
60
85
  .brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
61
86
  .chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}
62
- .chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--border);
87
+ .chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);
63
88
  background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}
64
89
  .chip b{color:var(--ink);font-weight:600;}
65
90
  .chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}
66
- .themebtn{font-family:var(--mono);font-size:0.68rem;background:transparent;border:1px solid var(--border);color:var(--muted);
91
+ .themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);
67
92
  border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}
68
93
  .themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
69
94
 
@@ -76,10 +101,11 @@ section h2 .count{color:var(--muted);}
76
101
 
77
102
  /* fleet cards */
78
103
  .fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}
79
- .node{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--muted);border-radius:9px;
80
- padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s;}
81
- .node:hover{transform:translateY(-1px);}
82
- .node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.72;}
104
+ .node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;
105
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
106
+ border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
107
+ .node:hover{transform:translateY(-2px);border-color:var(--border-strong);}
108
+ .node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}
83
109
  .node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}
84
110
  .node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
85
111
  .node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}
@@ -88,16 +114,20 @@ section h2 .count{color:var(--muted);}
88
114
  .node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}
89
115
  .node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);
90
116
  border-radius:4px;padding:0 0.3em;}
91
- .bar-track{height:6px;border-radius:4px;background:var(--panel-2);overflow:hidden;margin-bottom:0.3rem;}
92
- .bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;}
93
- .bar-fill.warn{background:var(--warn)} .bar-fill.bad{background:var(--bad)}
117
+ .bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}
118
+ :root[data-theme="light"] .bar-track{background:rgba(90,70,20,0.14);}
119
+ .bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}
120
+ .bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}
121
+ .bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}
94
122
  .node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}
95
123
  .caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}
96
- .cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border-radius:4px;padding:0.05em 0.35em;}
97
- .empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:9px;}
124
+ .cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}
125
+ .empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
98
126
 
99
127
  /* hardware meters */
100
- .hw{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:0.85rem;margin-top:1rem;}
128
+ .hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
129
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
130
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
101
131
  .meter{margin-bottom:0.65rem;}
102
132
  .meter:last-child{margin-bottom:0;}
103
133
  .meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}
@@ -105,8 +135,10 @@ section h2 .count{color:var(--muted);}
105
135
 
106
136
  /* activity feed */
107
137
  aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}
108
- .feed{background:var(--panel);border:1px solid var(--border);border-radius:9px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;}
109
- .line{padding:0.28rem 0.6rem;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent);display:flex;gap:0.5rem;line-height:1.3;}
138
+ .feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;
139
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
140
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
141
+ .line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}
110
142
  .line:last-child{border-bottom:none;}
111
143
  .line .t{color:var(--muted);flex:none;}
112
144
  .line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -114,34 +146,42 @@ aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);di
114
146
  .line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}
115
147
 
116
148
  /* command bar */
117
- footer.cmd{position:fixed;left:0;right:0;bottom:0;background:var(--panel);border-top:1px solid var(--border-strong);
118
- padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;z-index:6;}
149
+ footer.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;
150
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
151
+ border-top:1px solid var(--border-strong);padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;box-shadow:inset 0 1px 0 var(--glass-hi);}
119
152
  footer .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}
120
153
  footer .sel b{color:var(--ink);}
121
- footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:var(--bg);color:var(--ink);
122
- border:1px solid var(--border);border-radius:7px;padding:0.5em 0.7em;}
154
+ footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);
155
+ border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}
156
+ :root[data-theme="light"] footer input{background:rgba(255,255,255,0.6);}
123
157
  footer input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
124
- button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:7px;padding:0.5em 0.85em;
125
- border:1px solid var(--border-strong);background:var(--gold-bright);color:#231704;white-space:nowrap;}
126
- button.act.ghost{background:transparent;color:var(--gold);}
158
+ button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;
159
+ border:1px solid var(--border-strong);background:linear-gradient(180deg,var(--gold-bright),var(--gold));color:#231704;white-space:nowrap;box-shadow:0 4px 14px rgba(255,180,60,0.28);}
160
+ button.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}
127
161
  button.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}
128
162
  button.act:disabled{opacity:0.5;cursor:default;}
129
- #out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;background:var(--panel-2);
130
- border:1px solid var(--border-strong);border-radius:9px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;
131
- white-space:pre-wrap;display:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);z-index:7;}
163
+ #out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;
164
+ background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);
165
+ border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;
166
+ white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}
132
167
  #out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}
133
- .offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;}
168
+ .offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;
169
+ border-bottom:1px solid var(--glass-border);}
134
170
 
135
171
  /* tabs + product panes */
136
- .tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:53px;z-index:4;}
172
+ .tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
173
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
174
+ position:sticky;top:53px;z-index:19;}
137
175
  .tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}
138
- .tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);}
176
+ .tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}
139
177
  .tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
140
178
  .pane{display:none;} .pane.active{display:block;}
141
179
  .rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}
142
180
  .rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}
143
181
  .rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}
144
- .rp-card{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--gold-bright);border-radius:9px;padding:0.75rem 0.85rem;}
182
+ .rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
183
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
184
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
145
185
  .rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
146
186
  .rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
147
187
  .rp-m{display:flex;flex-direction:column;}
@@ -159,12 +199,82 @@ button.act:disabled{opacity:0.5;cursor:default;}
159
199
  .toggle:disabled{opacity:0.45;cursor:default;}
160
200
  .toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
161
201
  .rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}
162
- .rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:6px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}
202
+ .rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}
163
203
  .rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
164
204
  .rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}
165
205
  .rp-btn:disabled{opacity:0.4;cursor:default;}
166
206
  .rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
167
207
  .rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}
208
+
209
+ /* ── Park (isometric live map) ── */
210
+ #pane-park{padding:0;}
211
+ .pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}
212
+ #pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}
213
+ #pk-canvas:active{cursor:grabbing;}
214
+ .pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;
215
+ display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
216
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
217
+ .pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);
218
+ padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}
219
+ .pk-feed .rows{overflow-y:auto;padding:0.25rem;}
220
+ .pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}
221
+ .pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}
222
+ .pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}
223
+ .pk-row .who{font-family:var(--mono);font-size:0.64rem;color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
224
+ .pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}
225
+ .pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}
226
+ .pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}
227
+ .pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}
228
+ .pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);
229
+ background:var(--panel);border:1px solid var(--glass-border);border-radius:9px;padding:0.45rem 0.65rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);}
230
+ .pk-hint b{color:var(--gold-bright);}
231
+ .pk-legend{position:absolute;left:12px;top:12px;z-index:6;display:flex;gap:0.5rem;flex-wrap:wrap;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
232
+ .pk-legend span{background:var(--panel);border:1px solid var(--glass-border);border-radius:100px;padding:0.2rem 0.55rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);display:inline-flex;gap:0.35rem;align-items:center;}
233
+ .pk-legend i{width:8px;height:8px;border-radius:2px;}
234
+
235
+ /* robot drawer */
236
+ .pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;
237
+ background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
238
+ border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);
239
+ transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
240
+ .pk-drawer.open{transform:translateX(0);}
241
+ .pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
242
+ .pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));
243
+ border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}
244
+ .pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
245
+ .pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
246
+ .pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}
247
+ .pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}
248
+ .pk-pill{font-family:var(--mono);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.04em;padding:0.16em 0.5em;border-radius:100px;border:1px solid;}
249
+ .pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}
250
+ .pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}
251
+ .pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}
252
+ .pk-sect{border-radius:12px;padding:0.75rem 0.8rem;background:var(--panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-hi);}
253
+ .pk-sect h4{font-family:var(--mono);font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.55rem;display:flex;align-items:center;gap:0.4rem;}
254
+ .pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}
255
+ .pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}
256
+ .pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}
257
+ .pk-cam .rec{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:0.35rem;color:#fff;text-shadow:0 1px 3px #000;}
258
+ .pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}
259
+ .pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}
260
+ .pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}
261
+ .pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}
262
+ .pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}
263
+ .pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}
264
+ .pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}
265
+ .pk-val{font-family:var(--mono);font-size:0.66rem;color:var(--ink);width:52px;text-align:right;flex:none;font-variant-numeric:tabular-nums;}
266
+ .pk-wave{height:32px;width:100%;}
267
+ .pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}
268
+ .pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
269
+ .pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}
270
+ .pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}
271
+ .pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}
272
+ .pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}
273
+ .pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}
274
+ .pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
275
+ .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
276
+ .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
277
+ .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
168
278
  </style>
169
279
  </head>
170
280
  <body>
@@ -183,6 +293,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
183
293
 
184
294
  <div class="tabs">
185
295
  <button class="tab active" data-tab="fleet" id="tabbtn-fleet">Fleet</button>
296
+ <button class="tab" data-tab="park" id="tabbtn-park">Park</button>
186
297
  <button class="tab" data-tab="robopark" id="tabbtn-robopark">Sessions &amp; Telemetry</button>
187
298
  </div>
188
299
 
@@ -200,6 +311,33 @@ button.act:disabled{opacity:0.5;cursor:default;}
200
311
  </div>
201
312
  </div>
202
313
 
314
+ <div class="pane" id="pane-park">
315
+ <div class="pk-stage" id="pk-stage">
316
+ <canvas id="pk-canvas"></canvas>
317
+ <div class="pk-legend">
318
+ <span><i style="background:var(--gold-bright)"></i>hub</span>
319
+ <span><i style="background:var(--ok);border-radius:50%"></i>robot</span>
320
+ <span><i style="background:var(--info)"></i>peer</span>
321
+ <span><i style="background:var(--info)"></i>dispatch</span>
322
+ <span><i style="background:var(--ok)"></i>telemetry</span>
323
+ </div>
324
+ <aside class="pk-feed">
325
+ <h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
326
+ <div class="rows" id="pk-rows"></div>
327
+ </aside>
328
+ <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
329
+ </div>
330
+ <div class="pk-drawer" id="pk-drawer">
331
+ <div class="pk-dh">
332
+ <div class="pk-av" id="pk-av">🤖</div>
333
+ <div><div class="nm" id="pk-name">robot</div><div class="sub" id="pk-sub">satellite</div></div>
334
+ <span class="pk-pill idle" id="pk-state" style="margin-left:0.4rem">idle</span>
335
+ <button class="x" id="pk-close">✕</button>
336
+ </div>
337
+ <div class="pk-dbody" id="pk-body"></div>
338
+ </div>
339
+ </div>
340
+
203
341
  <div class="pane" id="pane-robopark">
204
342
  <div class="rp-head">
205
343
  <h2 style="margin:0">RoboPark · sessions &amp; telemetry</h2>
@@ -220,6 +358,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
220
358
  <input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
221
359
  <button class="act" id="runBtn">Run</button>
222
360
  <button class="act ghost" id="applyBtn">Apply config to fleet</button>
361
+ <button class="act ghost" id="updateBtn" title="npm-update every node to the latest release and restart (supervised nodes come back on their own)">Update fleet</button>
223
362
  </footer>
224
363
  <div id="out"><button class="x" id="outClose">close</button><span id="outText"></span></div>
225
364
 
@@ -229,6 +368,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
229
368
  var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
230
369
  var selfId = null, selected = null, lastSeq = -1, everOk = false;
231
370
  var rpReadOnly = false, pmBusy = false;
371
+ var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
372
+ var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
232
373
 
233
374
  var $ = function(id){ return document.getElementById(id); };
234
375
  function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }
@@ -236,19 +377,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
236
377
  function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }
237
378
  function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
238
379
 
239
- // theme
380
+ // theme (dark glass is the default; toggle to frosted light)
240
381
  (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}
241
- if(s) document.documentElement.setAttribute('data-theme', s);
382
+ document.documentElement.setAttribute('data-theme', s || 'dark');
242
383
  $('theme').addEventListener('click', function(){
243
- var m=window.matchMedia('(prefers-color-scheme: dark)');
244
- var cur=document.documentElement.getAttribute('data-theme')||(m.matches?'dark':'light');
384
+ var cur=document.documentElement.getAttribute('data-theme')||'dark';
245
385
  var nx=cur==='dark'?'light':'dark';
246
386
  document.documentElement.setAttribute('data-theme', nx);
247
387
  try{localStorage.setItem(k,nx)}catch(e){}
248
388
  });
249
389
  })();
250
390
 
251
- // Pull a short human detail out of an event's data payload.
252
391
  function detailOf(data){
253
392
  var p = data || {};
254
393
  if(typeof p==='string') return p;
@@ -267,11 +406,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
267
406
  if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% · mem '+m+'%'; }
268
407
  if(f.t==='hb'){ return 'heartbeat · load '+(d.load!=null?d.load:'-'); }
269
408
  if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }
270
- // routing decision: compact payload is {k, m, task, w, d}
271
409
  if(f.t==='rt'){ var rp=d.d||{}; return 'route → '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }
272
- // immediate lifecycle event: {k:type, m, task, w, d:payload}
273
410
  if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }
274
- // coalesced chatter: {count, tail:[{k, d}]}
275
411
  if(f.t==='log'){
276
412
  if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);
277
413
  return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' ×'+d.count):''); }
@@ -290,7 +426,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
290
426
  var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
291
427
  var html = '';
292
428
  for(var i=0;i<activity.length;i++){
293
- var e = activity[i]; var f = e.f || e; if(f.s!=null && f.s<=lastSeq && f.t!=='hw') { /* keep */ }
429
+ var e = activity[i]; var f = e.f || e;
294
430
  var name = (f.n||'').slice(0,10);
295
431
  html += '<div class="line k-'+esc(f.t)+'"><span class="t">'+clock(f.ts)+'</span>'
296
432
  + '<span class="n" title="'+esc(f.n)+'">'+esc(name)+'</span>'
@@ -383,24 +519,23 @@ button.act:disabled{opacity:0.5;cursor:default;}
383
519
  if(!r.ok) throw new Error('status '+r.status);
384
520
  return r.json();
385
521
  }).then(function(s){
386
- everOk = true; setLive(true);
522
+ everOk = true; setLive(true); lastStatus = s;
387
523
  if(!selfId && s.self) selfId = s.self.nodeId;
388
524
  if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
389
525
  renderSummary(s);
390
526
  renderFleet(s.nodes||[]);
391
527
  renderHw(s.hardware);
392
528
  renderFeed(s.activity||[]);
529
+ Park.onStatus(s);
393
530
  }).catch(function(){ setLive(false); });
394
531
  }
395
532
 
396
- // command execution
397
533
  function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }
398
534
  $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });
399
535
 
400
536
  function runCommand(input){
401
537
  input = (input||'').trim();
402
538
  if(!input) return;
403
- // Target a peer by prefixing the kernel's node-scoped command form.
404
539
  var payload = selected ? ('/node ' + selected + ' ' + input) : input;
405
540
  $('runBtn').disabled = true;
406
541
  fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })
@@ -420,8 +555,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
420
555
  .catch(function(e){ showOut('apply failed: '+e.message); })
421
556
  .then(function(){ $('applyBtn').disabled=false; });
422
557
  });
558
+ $('updateBtn').addEventListener('click', function(){
559
+ if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;
560
+ $('updateBtn').disabled = true;
561
+ showOut('Updating fleet — installing latest on each node…');
562
+ fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })
563
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
564
+ .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })
565
+ .catch(function(e){ showOut('update failed: '+e.message); })
566
+ .then(function(){ $('updateBtn').disabled=false; });
567
+ });
423
568
 
424
- // ── tabs: Fleet + RoboPark sessions/telemetry ──
569
+ // ── tabs: Fleet + Park + RoboPark sessions/telemetry ──
425
570
  var tabs = document.querySelectorAll('.tab');
426
571
  var activeTab = 'fleet';
427
572
  for (var ti=0; ti<tabs.length; ti++){
@@ -433,19 +578,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
433
578
  activeTab = name;
434
579
  for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);
435
580
  $('pane-fleet').classList.toggle('active', name==='fleet');
581
+ $('pane-park').classList.toggle('active', name==='park');
436
582
  $('pane-robopark').classList.toggle('active', name==='robopark');
437
583
  if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
438
584
  if (name==='robopark') pollRobopark();
585
+ if (name==='park') Park.activate(); else Park.deactivate();
439
586
  }
440
- // deep-link: open the tab named in the URL hash on load
441
- if (location.hash.replace('#','') === 'robopark') selectTab('robopark');
587
+ var initHash = location.hash.replace('#','');
588
+ if (initHash==='robopark'||initHash==='park') selectTab(initHash);
442
589
 
443
590
  function robotsFrom(d){
444
591
  if (Array.isArray(d)) return d;
445
592
  if (d && Array.isArray(d.robots)) return d.robots;
446
593
  return [];
447
594
  }
448
- // Reflect this node's read-only state on the RoboPark controls.
449
595
  function applyReadOnly(){
450
596
  var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;
451
597
  var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;
@@ -457,7 +603,6 @@ button.act:disabled{opacity:0.5;cursor:default;}
457
603
  } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }
458
604
  }
459
605
 
460
- // A write through the scheduler proxy. Handles the read-only 403 gracefully.
461
606
  function rpAction(method, path, body){
462
607
  if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
463
608
  return fetch(path+QS, {
@@ -470,7 +615,6 @@ button.act:disabled{opacity:0.5;cursor:default;}
470
615
  });
471
616
  }
472
617
 
473
- // production_mode master toggle
474
618
  function setPm(on){
475
619
  var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');
476
620
  if(!wrap) return;
@@ -500,10 +644,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
500
644
  function endSession(id){
501
645
  if(!confirm('End the active session for '+id+'?')) return;
502
646
  rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){
503
- showOut('ended session for '+id); pollRobopark();
647
+ showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
504
648
  }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
505
649
  }
506
- // event-delegated action buttons (cards are re-rendered each poll)
507
650
  $('rp-grid').addEventListener('click', function(e){
508
651
  var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;
509
652
  var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
@@ -514,6 +657,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
514
657
  pollSettings();
515
658
  fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
516
659
  var grid = $('rp-grid'), totals = $('rp-totals');
660
+ // stash per-robot rows so the Park drawer can show live session stats
661
+ roboparkByName = {};
662
+ robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; });
517
663
  if (d && (d.disabled || d.ok===false)){
518
664
  totals.innerHTML=''; $('pm-wrap').hidden=true;
519
665
  grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';
@@ -552,6 +698,354 @@ button.act:disabled{opacity:0.5;cursor:default;}
552
698
  });
553
699
  }
554
700
 
701
+ // ══════════════════════════════════════════════════════════════════
702
+ // Park — isometric live map of the mesh
703
+ // Real data: /fed/status nodes (placement + load) + /fed/stream frames
704
+ // (agent movement + tool-use pulses) + /robopark telemetry (session stats).
705
+ // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
706
+ // ══════════════════════════════════════════════════════════════════
707
+ var Park = (function(){
708
+ var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1};
709
+ var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
710
+ var TW=124, TH=62;
711
+ var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
712
+ var RING=[[2,0],[1,2],[-1,2],[-2,0],[-1,-2],[1,-2],[3,1],[3,-1],[0,3],[-3,1],[-3,-1],[0,-3],[2,3],[-2,3],[2,-3],[-2,-3],[4,0],[0,4],[-4,0],[0,-4]];
713
+
714
+ function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
715
+ function kindOf(n){
716
+ var r=(n.role||'').toLowerCase();
717
+ if(r==='hub'||r==='scheduler') return 'hub';
718
+ if(r==='satellite'||r==='robot') return 'robot';
719
+ var caps=(n.capabilities||[]).join(' ');
720
+ if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
721
+ return 'peer';
722
+ }
723
+ function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
724
+ function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
725
+
726
+ function layout(src){
727
+ var list=(src||[]).slice().sort(function(a,b){ return (a.nodeId<b.nodeId?-1:1); });
728
+ var hub=null; for(var i=0;i<list.length;i++){ if(kindOf(list[i])==='hub'){ hub=list[i]; break; } }
729
+ if(!hub && list.length) hub=list[0];
730
+ var out=[], oi=0;
731
+ list.forEach(function(n){
732
+ var cell = (n===hub)? [0,0] : RING[(oi++)%RING.length];
733
+ var prev = byId[n.nodeId];
734
+ out.push({
735
+ id:n.nodeId, name:n.displayName||n.nodeId, role:n.role||'?', kind:kindOf(n),
736
+ connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
737
+ site:n.site||'', caps:n.capabilities||[], gx:cell[0], gy:cell[1], _hit:prev?prev._hit:null
738
+ });
739
+ });
740
+ return out;
741
+ }
742
+
743
+ function onStatus(s){
744
+ if(!s) return;
745
+ nodes = layout(s.nodes||[]);
746
+ byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
747
+ // if not streaming, animate from the polled activity tail
748
+ if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
749
+ }
750
+
751
+ function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
752
+ function findByFrame(f){
753
+ var key=f.n; if(key==null) return null;
754
+ for(var i=0;i<nodes.length;i++){ if(nodes[i].name===key||nodes[i].id===key) return nodes[i]; }
755
+ return null;
756
+ }
757
+ function ingest(f, fromPoll){
758
+ if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }
759
+ var n=findByFrame(f), h=hub();
760
+ if(n && h){
761
+ if(n.kind==='hub'){ pulse(n, glyphFor(f)); }
762
+ else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }
763
+ else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }
764
+ }
765
+ feedRow(f);
766
+ }
767
+ function glyphFor(f){
768
+ if(f.t==='rt') return '⇄';
769
+ if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '⌨'; if(/session|presence/.test(k)) return '🎙'; return '✦'; }
770
+ if(f.t==='hw'||f.t==='hb') return '';
771
+ return '✦';
772
+ }
773
+ function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }
774
+ function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'✦'}); }
775
+
776
+ function feedRow(f){
777
+ var rows=$('pk-rows'); if(!rows) return;
778
+ var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;
779
+ var r=document.createElement('div'); r.className='pk-row';
780
+ r.innerHTML='<span class="k pk-k-'+esc(kind)+'"></span><span class="t">'+clock(f.ts)+'</span>'
781
+ +'<span class="who" title="'+esc(f.n)+'">'+esc((f.n||'').slice(0,10))+'</span>'
782
+ +'<span class="msg">'+esc(summarizeFrame(f))+'</span>';
783
+ rows.insertBefore(r, rows.firstChild);
784
+ while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);
785
+ }
786
+
787
+ // ── canvas + projection ──
788
+ function ensure(){
789
+ if(C) return;
790
+ C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
791
+ DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
792
+ var d=false,px,py;
793
+ C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
794
+ window.addEventListener('mouseup',function(){d=false;});
795
+ window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
796
+ C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});
797
+ C.addEventListener('click',function(e){
798
+ var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
799
+ for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }
800
+ if(hit){ openDrawer(hit.id); selectNode(hit.id, hit.name); }
801
+ });
802
+ window.addEventListener('resize', function(){ if(running) resize(); });
803
+ }
804
+ function resize(){
805
+ var st=$('pk-stage'); if(!st) return;
806
+ W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
807
+ }
808
+ function iso(gx,gy){ return { x: W/2 + cam.x + (gx-gy)*TW/2*cam.z, y: H*0.42 + cam.y + (gx+gy)*TH/2*cam.z }; }
809
+
810
+ function tile(gx,gy,raised){
811
+ var p=iso(gx,gy), z=cam.z, hw=TW/2*z, hh=TH/2*z, dz=raised?13*z:0;
812
+ X.beginPath(); X.moveTo(p.x,p.y-hh-dz); X.lineTo(p.x+hw,p.y-dz); X.lineTo(p.x,p.y+hh-dz); X.lineTo(p.x-hw,p.y-dz); X.closePath();
813
+ var g=X.createLinearGradient(p.x-hw,p.y-hh,p.x+hw,p.y+hh);
814
+ g.addColorStop(0,raised?'rgba(60,52,86,0.55)':'rgba(30,26,44,0.32)'); g.addColorStop(1,raised?'rgba(20,17,32,0.6)':'rgba(16,14,26,0.28)');
815
+ X.fillStyle=g; X.fill();
816
+ X.strokeStyle=raised?'rgba(255,214,112,0.55)':'rgba(255,255,255,0.08)'; X.lineWidth=1; X.stroke();
817
+ if(raised){
818
+ X.beginPath(); X.moveTo(p.x-hw,p.y-dz); X.lineTo(p.x-hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
819
+ X.fillStyle='rgba(10,8,18,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.28)'; X.stroke();
820
+ X.beginPath(); X.moveTo(p.x+hw,p.y-dz); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
821
+ X.fillStyle='rgba(14,11,24,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.2)'; X.stroke();
822
+ }
823
+ return p;
824
+ }
825
+ function ring(cx,cy,r,frac,color){
826
+ X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();
827
+ X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();
828
+ }
829
+ function label(cx,cy,txt,col,sub){
830
+ var z=cam.z; X.textAlign='center';
831
+ X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
832
+ X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
833
+ }
834
+ function drawHub(n){
835
+ var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-14*z-bob;
836
+ X.fillStyle='rgba(40,34,58,0.9)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
837
+ X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
838
+ for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }
839
+ X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
840
+ X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
841
+ label(bx,p.y+18*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
842
+ n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
843
+ }
844
+ function drawRobot(n){
845
+ var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/500+n.gx)*2.4*z, bx=p.x, by=p.y-10*z-bob;
846
+ ring(bx,p.y+2*z,20*z,n.load/100,loadColor(n.load));
847
+ X.fillStyle='rgba(0,0,0,0.35)'; X.beginPath(); X.ellipse(bx,p.y+2*z,15*z,7*z,0,0,7); X.fill();
848
+ var col=n.connected?getVar('--gold-bright'):'rgba(150,140,120,0.5)';
849
+ X.fillStyle='rgba(24,20,38,0.9)'; X.strokeStyle=col; X.lineWidth=1.6;
850
+ X.beginPath(); X.roundRect(bx-12*z,by-24*z,24*z,26*z,5*z); X.fill(); X.stroke();
851
+ X.beginPath(); X.roundRect(bx-9*z,by-38*z,18*z,15*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
852
+ X.fillStyle=n.connected?getVar('--ok'):'#654'; X.beginPath(); X.arc(bx-4*z,by-30*z,2.1*z,0,7); X.arc(bx+4*z,by-30*z,2.1*z,0,7); X.fill();
853
+ X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-38*z); X.lineTo(bx,by-45*z); X.stroke();
854
+ X.fillStyle=n.connected?'#FF5B4A':'#654'; X.beginPath(); X.arc(bx,by-46*z,2.2*z,0,7); X.fill();
855
+ label(bx,p.y+18*z,n.name,col,(n.connected?'load '+Math.round(n.load)+'%':'offline'));
856
+ n._hit={x:bx-16*z,y:by-48*z,w:32*z,h:62*z};
857
+ }
858
+ function drawPeer(n){
859
+ var p=tile(n.gx,n.gy,true), z=cam.z, bx=p.x, by=p.y-8*z;
860
+ var col=n.connected?getVar('--info'):'rgba(150,140,120,0.5)';
861
+ X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
862
+ X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
863
+ X.fillStyle=col; X.globalAlpha=0.5+0.5*Math.abs(Math.sin((performance.now()-t0)/500)); X.fillRect(bx-8*z,by-14*z,16*z,3*z); X.globalAlpha=1;
864
+ label(bx,p.y+16*z,n.name,col,(n.connected?n.role:'offline'));
865
+ n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
866
+ }
867
+
868
+ function frame(){
869
+ if(!running) return;
870
+ X.clearRect(0,0,W,H);
871
+ for(var gx=-4;gx<=4;gx++) for(var gy=-4;gy<=4;gy++){ if(!nodes.some(function(n){return n.gx===gx&&n.gy===gy;})) faint(gx,gy); }
872
+ var sorted=nodes.slice().sort(function(a,b){ return (a.gx+a.gy)-(b.gx+b.gy); });
873
+ sorted.forEach(function(n){ if(n.kind==='hub') drawHub(n); else if(n.kind==='robot') drawRobot(n); else drawPeer(n); });
874
+ for(var i=orbs.length-1;i>=0;i--){ var o=orbs[i]; o.t+=0.024; if(o.t>=1){orbs.splice(i,1);continue;}
875
+ var a=iso(o.a.gx,o.a.gy), b=iso(o.b.gx,o.b.gy), e=o.t<.5?2*o.t*o.t:-1+(4-2*o.t)*o.t;
876
+ var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o.t*Math.PI)*40*cam.z;
877
+ X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o.color; X.shadowColor=o.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
878
+ }
879
+ for(var j=pulses.length-1;j>=0;j--){ var pu=pulses[j]; pu.t+=0.02; if(pu.t>=1){pulses.splice(j,1);continue;}
880
+ var pp=iso(pu.n.gx,pu.n.gy), yy=pp.y-58*cam.z-pu.t*24*cam.z;
881
+ X.globalAlpha=1-pu.t; X.font='700 '+(15*cam.z)+'px '+mono(); X.textAlign='center'; X.fillStyle=getVar('--gold-bright'); X.fillText(pu.g,pp.x,yy);
882
+ X.beginPath(); X.arc(pp.x,pp.y-20*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
883
+ }
884
+ raf=requestAnimationFrame(frame);
885
+ }
886
+ function faint(gx,gy){ var p=iso(gx,gy),z=cam.z,hw=TW/2*z,hh=TH/2*z;
887
+ X.beginPath(); X.moveTo(p.x,p.y-hh); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x-hw,p.y); X.closePath();
888
+ X.strokeStyle='rgba(255,255,255,0.045)'; X.lineWidth=1; X.stroke();
889
+ }
890
+
891
+ function connectStream(){
892
+ if(es) return;
893
+ try{
894
+ es=new EventSource('/fed/stream'+QS);
895
+ es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };
896
+ es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream · live'; };
897
+ es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status · poll'; };
898
+ }catch(e){ es=null; }
899
+ }
900
+
901
+ function activate(){
902
+ ensure();
903
+ if(lastStatus) onStatus(lastStatus);
904
+ resize();
905
+ connectStream();
906
+ if(!running){ running=true; raf=requestAnimationFrame(frame); }
907
+ }
908
+ function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }
909
+
910
+ // ── robot drawer ──
911
+ function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
912
+ function openDrawer(id){
913
+ var n=byId[id]; if(!n) return; drawerId=id;
914
+ $('pk-drawer').classList.add('open');
915
+ $('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
916
+ $('pk-name').textContent=n.name;
917
+ $('pk-sub').textContent=n.role+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'');
918
+ var st=$('pk-state'); st.textContent=n.connected?'live':'idle'; st.className='pk-pill '+(n.connected?'live':'idle');
919
+ buildDrawer(n);
920
+ pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
921
+ if(drawerTimer) clearInterval(drawerTimer);
922
+ drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);
923
+ }
924
+ function closeDrawer(){
925
+ $('pk-drawer').classList.remove('open'); drawerId=null;
926
+ if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
927
+ if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
928
+ if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
929
+ if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
930
+ }
931
+ function pollRoboparkQuiet(){
932
+ fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
933
+ var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
934
+ list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; });
935
+ refreshDrawer();
936
+ }).catch(function(){});
937
+ }
938
+ function buildDrawer(n){
939
+ var b=$('pk-body');
940
+ if(n.kind==='robot'){
941
+ b.innerHTML =
942
+ sect('👁 camera feed',
943
+ '<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
944
+ +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
945
+ +'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>')
946
+ + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
947
+ + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
948
+ + sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
949
+ + sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
950
+ + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');
951
+ startCam(n); startMeters(n);
952
+ var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(n.name); });
953
+ var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
954
+ } else {
955
+ b.innerHTML =
956
+ sect('🖥 node','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-load">—</div><div class="l">load</div></div><div class="pk-kv"><div class="v '+(n.connected?'ok':'')+'">'+(n.connected?'up':'down')+'</div><div class="l">status</div></div></div>')
957
+ + sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
958
+ + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
959
+ var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
960
+ }
961
+ refreshDrawer();
962
+ }
963
+ function refreshDrawer(){
964
+ var n=drawerId?byId[drawerId]:null; if(!n) return;
965
+ setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
966
+ var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
967
+ var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
968
+ var row=roboparkByName[n.name];
969
+ if(row){
970
+ set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
971
+ set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
972
+ set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
973
+ var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
974
+ var active=row.active_session||row.in_session||row.session_active;
975
+ var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }
976
+ }
977
+ }
978
+ function set(id,v){ var e=$(id); if(e) e.textContent=v; }
979
+ function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }
980
+
981
+ // ── cam: target a LiveKit track; fall back to a styled preview ──
982
+ function startCam(n){
983
+ var note=$('pk-camnote'), res=$('pk-camres');
984
+ camAbort = ('AbortController' in window)?new AbortController():null;
985
+ // The robot-side RoboPark plugin exposes this; returns {url, token, room, trackSid}.
986
+ fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
987
+ .then(function(r){ if(!r.ok) throw new Error('no stream'); return r.json(); })
988
+ .then(function(info){
989
+ if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); }
990
+ else if(info && info.url){ if(note) note.textContent='LiveKit track ready — load livekit-client to view'; previewCam(n); }
991
+ else throw new Error('no track');
992
+ })
993
+ .catch(function(){ if(note) note.textContent='no published cam track — showing preview (robot plugin pending)'; if(res) res.textContent='preview'; previewCam(n); });
994
+ }
995
+ function attachLiveKit(info, n){
996
+ var mount=$('pk-cam'), note=$('pk-camnote'), res=$('pk-camres'); if(!mount) return;
997
+ try{
998
+ var LK=window.LivekitClient, room=new LK.Room();
999
+ room.connect(info.url, info.token).then(function(){
1000
+ if(note) note.textContent='LiveKit · connected';
1001
+ room.on(LK.RoomEvent.TrackSubscribed, function(track){
1002
+ if(track.kind==='video'){ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true;
1003
+ var cv=$('pk-camcv'); if(cv&&cv.parentNode){ cv.parentNode.replaceChild(el, cv); } if(res) res.textContent='LiveKit · live'; }
1004
+ });
1005
+ }).catch(function(){ if(note) note.textContent='LiveKit connect failed — preview'; previewCam(n); });
1006
+ }catch(e){ if(note) note.textContent='LiveKit error — preview'; previewCam(n); }
1007
+ }
1008
+ function previewCam(n){
1009
+ var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
1010
+ var res=$('pk-camres'); if(res && res.textContent==='— · —') res.textContent='preview · 24fps';
1011
+ (function loop(){ if(!$('pk-camcv')||drawerId==null) return;
1012
+ var tt=(performance.now()-s0)/1000;
1013
+ var g=cx.createLinearGradient(0,0,0,h); g.addColorStop(0,'#0a1420'); g.addColorStop(1,'#132233'); cx.fillStyle=g; cx.fillRect(0,0,w,h);
1014
+ cx.fillStyle='#0e1a12'; cx.beginPath(); cx.moveTo(0,h*0.62); cx.lineTo(w,h*0.62); cx.lineTo(w,h); cx.lineTo(0,h); cx.fill();
1015
+ var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;
1016
+ cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();
1017
+ cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);
1018
+ cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);
1019
+ cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);
1020
+ cx.globalAlpha=0.06; for(var y=0;y<h;y+=3){ cx.fillStyle=y%2?'#fff':'#000'; cx.fillRect(0,y,w,1); } cx.globalAlpha=1;
1021
+ for(var k=0;k<30;k++){ cx.fillStyle='rgba(255,255,255,'+(Math.random()*0.05)+')'; cx.fillRect(Math.random()*w,Math.random()*h,1,1); }
1022
+ var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());
1023
+ camRAF=requestAnimationFrame(loop);
1024
+ })();
1025
+ }
1026
+ function bars(el,n){ el.innerHTML=''; for(var i=0;i<n;i++){ var b=document.createElement('i'); el.appendChild(b); } return el.querySelectorAll('i'); }
1027
+ function startMeters(n){
1028
+ var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
1029
+ var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw.getContext('2d'), s0=performance.now();
1030
+ // Preview envelopes until the robot audio plugin streams real levels.
1031
+ (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
1032
+ var tt=(performance.now()-s0)/1000;
1033
+ var env=Math.max(0,Math.sin(tt*2.3))*Math.max(0,Math.sin(tt*0.7));
1034
+ for(var i=0;i<mi.length;i++){ var v=env*(0.4+0.6*Math.random())*(i<env*mi.length?1:0.25); mi[i].style.height=(3+v*13)+'px'; mi[i].style.background=i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'; }
1035
+ set('pk-micval', Math.round(env*100)+'%'); set('pk-micstate', env>0.3?'preview · speech':'preview · quiet');
1036
+ var spk=Math.max(0,Math.sin(tt*1.1-1)), speaking=spk>0.35;
1037
+ for(var j=0;j<si.length;j++){ var vv=speaking?(0.3+0.7*Math.random()):0.05; si[j].style.height=(3+vv*13)+'px'; si[j].style.background=speaking?'var(--gold-bright)':'var(--glass-border)'; }
1038
+ set('pk-spkval', speaking?Math.round(spk*100)+'%':'idle'); set('pk-spkstate', speaking?'preview · TTS':'preview · idle');
1039
+ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle=speaking?getVar('--gold-bright'):'rgba(255,255,255,0.12)'; swx.lineWidth=1.5; swx.beginPath();
1040
+ for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.15+tt*8)*(speaking?spk*11:1.4)*Math.sin(x*0.03); if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke();
1041
+ meterRAF=requestAnimationFrame(loop);
1042
+ })();
1043
+ }
1044
+
1045
+ $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
1046
+ return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer };
1047
+ })();
1048
+
555
1049
  poll();
556
1050
  setInterval(poll, 2000);
557
1051
  setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);