infinicode 2.8.33 → 2.8.35
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.
|
@@ -22,4 +22,4 @@
|
|
|
22
22
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
23
23
|
* template literal without escaping; the client JS uses string concatenation.
|
|
24
24
|
*/
|
|
25
|
-
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n/* \u2500\u2500 premium tech glass theme \u2500\u2500 dark is the committed default; light is a frosted toggle \u2500\u2500 */\n:root{\n --bg:#08070F;\n --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),\n radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),\n radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),\n linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);\n --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;\n --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);\n --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);\n --ink:#F2ECDC; --muted:#9E93B4;\n --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);\n --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);\n --shadow: 0 10px 34px rgba(0,0,0,0.42);\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#EDEAF6;\n --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),\n radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),\n linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);\n --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;\n --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);\n --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);\n --ink:#241A08; --muted:#6E5A3E;\n --gold:#8A5A0C; --gold-bright:#B9781A;\n --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);\n --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);\n --shadow: 0 10px 30px rgba(60,40,10,0.14);\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;\n color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.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;}\n.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}\n@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)}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}\n:root[data-theme=\"light\"] .bar-track{background:rgba(90,70,20,0.14);}\n.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);}\n.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}\n.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.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;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}\n\n/* fleet card \u2014 live per-robot hardware */\n.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}\n.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}\n.statdot.off{background:var(--bad);}\n.node .mrow{display:grid;grid-template-columns:2.3rem 1fr 2.6rem;align-items:center;gap:0.45rem;margin-top:0.42rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .mrow .ml{letter-spacing:0.04em;}\n.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}\n.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}\n.node .mrow .bar-track{margin:0;}\n.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}\n.node .stats{display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-top:0.5rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .stats span b{color:var(--ink);font-weight:600;}\n.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}\n.tbadge{font-family:var(--mono);font-size:0.6rem;padding:0.08em 0.45em;border-radius:5px;border:1px solid var(--glass-border);color:var(--muted);}\n.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}\n.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert b{color:var(--bad);}\n\n/* hardware meters */\n.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n 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);}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);\n border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}\n:root[data-theme=\"light\"] footer input{background:rgba(255,255,255,0.6);}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;\n 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);}\nbutton.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;\n background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);\n border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;\n border-bottom:1px solid var(--glass-border);}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:53px;z-index:19;}\n.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;}\n.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);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.7rem;align-items:end;background:var(--panel-2);padding:1rem 1.05rem;border-radius:14px;border:1px solid var(--glass-border);margin:1rem 1.1rem;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-form h3{grid-column:1/-1;font-family:var(--mono);font-size:0.72rem;letter-spacing:0.09em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.35rem;padding-bottom:0.5rem;border-bottom:1px solid var(--glass-border);}\n.rp-field{display:flex;flex-direction:column;gap:0.3rem;}\n.rp-field label{font-size:0.68rem;color:var(--muted);letter-spacing:0.01em;}\n.rp-field input,.rp-field select,.rp-field textarea{font-family:var(--mono);font-size:0.74rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:8px;padding:0.48rem 0.6rem;transition:border-color .15s,box-shadow .15s;}\n.rp-field input:focus,.rp-field select:focus,.rp-field textarea:focus{outline:none;border-color:var(--gold-bright);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 18%,transparent);}\n.rp-quickrow{grid-column:1/-1;display:flex;gap:0.6rem;flex-wrap:wrap;align-items:flex-end;padding-bottom:0.2rem;}\n.rp-quickrow .rp-field{flex:1;min-width:180px;}\n.rp-autonote{grid-column:1/-1;font-family:var(--mono);font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.4rem;min-height:1.2em;}\n.rp-autonote.ok{color:var(--ok);} .rp-autonote.bad{color:var(--bad);} .rp-autonote.busy{color:var(--info);}\n.rp-advanced{grid-column:1/-1;margin-top:0.3rem;border-top:1px dashed var(--glass-border);padding-top:0.7rem;}\n.rp-advanced summary{cursor:pointer;font-family:var(--mono);font-size:0.66rem;letter-spacing:0.05em;text-transform:uppercase;color:var(--muted);list-style:none;display:flex;align-items:center;gap:0.4rem;user-select:none;}\n.rp-advanced summary::-webkit-details-marker{display:none;}\n.rp-advanced summary::before{content:\"\u25B8\";display:inline-block;transition:transform .15s;color:var(--gold);}\n.rp-advanced[open] summary::before{transform:rotate(90deg);}\n.rp-advanced summary:hover{color:var(--gold-bright);}\n.rp-advanced-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.6rem;margin-top:0.7rem;}\n.rp-btn.primary{background:linear-gradient(160deg,color-mix(in srgb,var(--gold-bright) 30%,var(--panel-2)),var(--panel-2));border-color:var(--gold-bright);color:var(--gold-bright);font-weight:600;}\n.rp-btn.primary:hover{box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 16%,transparent);}\n.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.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;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-card .rp-char{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.35rem 0.5rem;width:100%;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n\n/* \u2500\u2500 Park (isometric live map) \u2500\u2500 */\n#pane-park{padding:0;}\n.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}\n#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}\n#pk-canvas:active{cursor:grabbing;}\n.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;\n display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);\n padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}\n.pk-feed .rows{overflow-y:auto;padding:0.25rem;}\n.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}\n.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}\n.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}\n.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;}\n.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}\n.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}\n.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}\n.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}\n.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);\n 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);}\n.pk-hint b{color:var(--gold-bright);}\n.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);}\n.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;}\n.pk-legend i{width:8px;height:8px;border-radius:2px;}\n\n/* robot drawer */\n.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;\n background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);\n transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}\n.pk-drawer.open{transform:translateX(0);}\n.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}\n.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));\n border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}\n.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}\n.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}\n.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;}\n.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.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;}\n.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}\n.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}\n.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}\n.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);}\n.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;}\n.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}\n.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}\n.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}\n.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;}\n.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}\n.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}\n.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}\n.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}\n.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}\n.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;}\n.pk-wave{height:32px;width:100%;}\n.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}\n.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}\n.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}\n.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}\n.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}\n.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}\n.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}\n.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}\n.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"park\" id=\"tabbtn-park\">Park</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions & Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-park\">\n <div class=\"pk-stage\" id=\"pk-stage\">\n <canvas id=\"pk-canvas\"></canvas>\n <div class=\"pk-legend\">\n <span><i style=\"background:var(--gold-bright)\"></i>hub</span>\n <span><i style=\"background:var(--ok);border-radius:50%\"></i>robot</span>\n <span><i style=\"background:var(--info)\"></i>peer</span>\n <span><i style=\"background:var(--info)\"></i>dispatch</span>\n <span><i style=\"background:var(--ok)\"></i>telemetry</span>\n </div>\n <aside class=\"pk-feed\">\n <h3><span>Live activity</span><span style=\"color:var(--muted)\" id=\"pk-src\">/fed/stream</span></h3>\n <div class=\"rows\" id=\"pk-rows\"></div>\n </aside>\n <div class=\"pk-hint\">drag to pan \u00B7 scroll to zoom \u00B7 <b>click a robot</b> for cam / mic / speaker</div>\n </div>\n <div class=\"pk-drawer\" id=\"pk-drawer\">\n <div class=\"pk-dh\">\n <div class=\"pk-av\" id=\"pk-av\">\uD83E\uDD16</div>\n <div><div class=\"nm\" id=\"pk-name\">robot</div><div class=\"sub\" id=\"pk-sub\">satellite</div></div>\n <span class=\"pk-pill idle\" id=\"pk-state\" style=\"margin-left:0.4rem\">idle</span>\n <button class=\"x\" id=\"pk-close\">\u2715</button>\n </div>\n <div class=\"pk-dbody\" id=\"pk-body\"></div>\n </div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions & telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n\n <!-- Add LiveKit server -->\n <div class=\"rp-form\" id=\"rp-addserver\">\n <h3>Add LiveKit server</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\"><label>Name</label><input id=\"srv-name\" placeholder=\"LiveKit Server 1\"></div>\n <button class=\"rp-btn primary\" id=\"srv-detect\" style=\"height:fit-content\" title=\"Probes this hub's own host + common local ports for a running LiveKit instance\">\uD83D\uDD0D Auto-detect & fill</button>\n <button class=\"rp-btn\" id=\"srv-add\" style=\"height:fit-content\">Add server</button>\n </div>\n <div class=\"rp-autonote\" id=\"srv-autonote\"></div>\n <details class=\"rp-advanced\">\n <summary>Advanced \u2014 ID, URL, credentials, capacity</summary>\n <div class=\"rp-advanced-grid\">\n <div class=\"rp-field\"><label>ID</label><input id=\"srv-id\" placeholder=\"auto-filled from name\"></div>\n <div class=\"rp-field\"><label>URL (ws:// or wss://)</label><input id=\"srv-url\" placeholder=\"ws://host:7880 \u2014 auto-detect fills this\"></div>\n <div class=\"rp-field\"><label>API key</label><input id=\"srv-key\" placeholder=\"devkey\" value=\"devkey\"></div>\n <div class=\"rp-field\"><label>API secret</label><input type=\"password\" id=\"srv-secret\" placeholder=\"secretsecretsecretsecretsecretsecret\"></div>\n <div class=\"rp-field\"><label>Max sessions</label><input id=\"srv-max\" type=\"number\" value=\"8\"></div>\n </div>\n </details>\n </div>\n\n <!-- Add robot / device -->\n <div class=\"rp-form\" id=\"rp-addrobot\">\n <h3>Add robot (mint enrollment token)</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rb-name\" placeholder=\"jaguar\"></div>\n <div class=\"rp-field\"><label>Character preset</label><select id=\"rb-char\"><option value=\"\">\u2014 auto (matched from name) \u2014</option></select></div>\n <button class=\"rp-btn primary\" id=\"rb-add\" style=\"height:fit-content\">Add robot</button>\n </div>\n <div class=\"rp-autonote\" id=\"rb-autonote\">the robot self-reports its IP and hardware on first heartbeat \u2014 you only need a name and (optionally) a character preset to get started.</div>\n <div class=\"rp-quickrow\" id=\"rb-clicmd-row\" style=\"display:none;\">\n <div class=\"rp-field\" style=\"flex:1;\"><label>run this ON THE ROBOT (no need to click Add \u2014 this mints its own token)</label><input id=\"rb-clicmd\" readonly style=\"cursor:text;\"></div>\n <button class=\"rp-btn\" id=\"rb-clicmd-copy\" style=\"height:fit-content\">\uD83D\uDCCB Copy</button>\n </div>\n <details class=\"rp-advanced\">\n <summary>Advanced \u2014 pre-set network/voice config before the robot connects</summary>\n <div class=\"rp-advanced-grid\">\n <div class=\"rp-field\"><label>Tailscale IP</label><input id=\"rb-tsip\" placeholder=\"auto-detected once connected\"></div>\n <div class=\"rp-field\"><label>LAN IP</label><input id=\"rb-lanip\" placeholder=\"auto-detected once connected\"></div>\n <div class=\"rp-field\"><label>Motor server URL</label><input id=\"rb-motor\" placeholder=\"http://...:5000\"></div>\n <div class=\"rp-field\"><label>LiveKit URL</label><input id=\"rb-lk\" placeholder=\"\u2014 uses the registered server above \u2014\"></div>\n <div class=\"rp-field\"><label>Voice stack override</label><select id=\"rb-voice\"><option value=\"\">\u2014 from character preset \u2014</option></select></div>\n </div>\n </details>\n </div>\n\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n\n <!-- Config templates -->\n <div class=\"rp-form\" id=\"rp-templates\" style=\"grid-template-columns:1fr 1fr; margin-top:1.2rem;\">\n <h3 style=\"grid-column:1/-1\">Config templates</h3>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Voice stacks</h4>\n <div id=\"rp-voice-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newvoice-name\" placeholder=\"new stack name\" style=\"flex:1;min-width:0;\">\n <button class=\"rp-btn\" id=\"rp-addvoice\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-voice-edit\" style=\"display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"grid-column:1/-1;margin:0 0 0.2rem;\">Edit voice stack: <span id=\"rpve-name-label\"></span></h4>\n <div class=\"rp-field\"><label>STT provider</label><select id=\"rpve-stt-provider\"><option value=\"speaches\">speaches</option><option value=\"groq\">groq</option><option value=\"vibevoice\">vibevoice</option></select></div>\n <div class=\"rp-field\"><label>STT model</label><input id=\"rpve-stt-model\"></div>\n <div class=\"rp-field\"><label>STT language</label><input id=\"rpve-stt-language\" placeholder=\"en / auto\"></div>\n <div class=\"rp-field\"><label>LLM provider</label><select id=\"rpve-llm-provider\"><option value=\"ollama\">ollama</option><option value=\"groq\">groq</option><option value=\"gemini\">gemini</option><option value=\"moltbot\">moltbot</option></select></div>\n <div class=\"rp-field\"><label>LLM model</label><input id=\"rpve-llm-model\"></div>\n <div class=\"rp-field\"><label>LLM API key</label><input type=\"password\" id=\"rpve-llm-key\" placeholder=\"(uses agent's own if blank)\"></div>\n <div class=\"rp-field\"><label>Temperature</label><input id=\"rpve-temperature\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label>Context size (num_ctx)</label><input id=\"rpve-num-ctx\" type=\"number\" value=\"8192\"></div>\n <div class=\"rp-field\"><label>TTS provider</label><select id=\"rpve-tts-provider\"><option value=\"elevenlabs\">elevenlabs</option><option value=\"kokoro\">kokoro</option><option value=\"piper\">piper</option></select></div>\n <div class=\"rp-field\"><label>TTS voice</label><input id=\"rpve-tts-voice\"></div>\n <div class=\"rp-field\"><label>TTS language</label><input id=\"rpve-tts-language\" placeholder=\"en\"></div>\n <div class=\"rp-field\"><label>Max turns</label><input id=\"rpve-max-turns\" type=\"number\" value=\"20\"></div>\n <div class=\"rp-field\"><label>Min endpointing delay (s)</label><input id=\"rpve-endpointing\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-interruptions\" checked style=\"width:auto;margin-right:0.35rem;\">Allow interruptions</label></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-wake-enabled\" style=\"width:auto;margin-right:0.35rem;\">Wake word enabled</label></div>\n <div class=\"rp-field\"><label>Wake word model</label><input id=\"rpve-wake-model\" placeholder=\"models/hey_jarvis.onnx\"></div>\n <div class=\"rp-field\"><label>Wake threshold</label><input id=\"rpve-wake-threshold\" type=\"number\" step=\"0.05\" value=\"0.5\"></div>\n <div class=\"rp-field\"><label>Wake timeout (s)</label><input id=\"rpve-wake-timeout\" type=\"number\" step=\"0.5\" value=\"3.0\"></div>\n <div style=\"grid-column:1/-1;display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpve-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpve-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Character presets</h4>\n <div id=\"rp-char-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newchar-name\" placeholder=\"new preset name\" style=\"flex:1;min-width:0;\">\n <input id=\"rp-newchar-stack\" list=\"rp-newchar-stack-list\" placeholder=\"voice stack id\" style=\"flex:1;min-width:0;\">\n <datalist id=\"rp-newchar-stack-list\"></datalist>\n <button class=\"rp-btn\" id=\"rp-addchar\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-char-edit\" style=\"display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"margin:0 0 0.2rem;\">Edit character: <span id=\"rpce-name-label\"></span></h4>\n <div class=\"rp-field\"><label>Name</label><input id=\"rpce-name\"></div>\n <div class=\"rp-field\"><label>Description</label><input id=\"rpce-description\"></div>\n <div class=\"rp-field\"><label>System prompt</label><textarea id=\"rpce-prompt\" rows=\"4\" style=\"font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;\"></textarea></div>\n <div class=\"rp-field\"><label>Voice stack</label><select id=\"rpce-stack\"></select></div>\n <div class=\"rp-field\"><label>Motors (comma-separated)</label><input id=\"rpce-motors\" placeholder=\"drive, wave\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad X (0-1)</label><input id=\"rpce-nx\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad Y (0-1)</label><input id=\"rpce-ny\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n </div>\n <div class=\"rp-field\"><label>Avatar image path</label><input id=\"rpce-img\" placeholder=\"/static/robopark/jaguar.png\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpce-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpce-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n <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>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastStatus = null; // most recent /fed/status snapshot (shared with Park)\n var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)\n var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)\n var FALLBACK_SLOTS = [\n {label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},\n {label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},\n {label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},\n {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},\n {label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}\n ];\n var parkSlots = FALLBACK_SLOTS.slice();\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n 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()); }\n\n // theme (dark glass is the default; toggle to frosted light)\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n document.documentElement.setAttribute('data-theme', s || 'dark');\n $('theme').addEventListener('click', function(){\n var cur=document.documentElement.getAttribute('data-theme')||'dark';\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n 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+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e;\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n // \u2500\u2500 per-node hardware store + rolling history (drives the Fleet cards + sparklines) \u2500\u2500\n var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)\n var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers\n var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence\n function netTotal(hw){\n var t=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ var f=hw.network.interfaces[i]; t+=(f.rxBytesPerSec||0)+(f.txBytesPerSec||0); } } return t;\n }\n function netRxTx(hw){\n var rx=0,tx=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ rx+=hw.network.interfaces[i].rxBytesPerSec||0; tx+=hw.network.interfaces[i].txBytesPerSec||0; } } return {rx:rx,tx:tx};\n }\n function pushHist(id, snap){\n var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});\n var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;\n var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;\n h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();\n h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();\n h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();\n }\n function updateHw(s){\n if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }\n var act = s.activity||[];\n for(var i=0;i<act.length;i++){ var f=(act[i].f||act[i]); if(f.t==='hw'&&f.n&&f.d){ hwByNode[f.n]=f.d; } }\n var nodes = s.nodes||[];\n for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }\n }\n // Inline sparkline (SVG polyline) from a numeric history buffer.\n function sparkSvg(arr, color, maxv){\n if(!arr || arr.length<2) return '';\n var w=100,h=20,n=arr.length, vals=[];\n for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);\n var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;\n var pts=''; for(var j=0;j<n;j++){ var x=(j/(n-1))*w; var y=h-(vals[j]/mx)*(h-2)-1; pts+=(j?' ':'')+x.toFixed(1)+','+y.toFixed(1); }\n return '<svg class=\"spark\" viewBox=\"0 0 '+w+' '+h+'\" preserveAspectRatio=\"none\" aria-hidden=\"true\">'\n + '<polyline points=\"'+pts+'\" fill=\"none\" stroke=\"'+color+'\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/></svg>';\n }\n function fmtRate(b){ return fmtBytes(b)+'/s'; }\n function fmtUptime(sec){ if(sec==null) return '\u2014'; var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60); if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; return m+'m'; }\n function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'\u00B0C'); }\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]){ var mnt=hw.disk.mounts[0]; html += meter('Disk '+esc(mnt.path), mnt.percent, fmtBytes(mnt.usedBytes)+' / '+fmtBytes(mnt.totalBytes)); }\n var nt=netRxTx(hw); html += '<div class=\"stats\" style=\"margin-top:0.55rem\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span><span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function mrow(label, pct, valTxt, cls){\n var v = Math.max(0, Math.min(100, Math.round(pct||0)));\n return '<div class=\"mrow\"><span class=\"ml\">'+label+'</span>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div>'\n + '<span class=\"mv '+(cls||'')+'\">'+esc(valTxt)+'</span></div>';\n }\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n // connected first, then robots/satellites above hubs so the fleet reads robot-first\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var accent = '#6FE0A6';\n try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var up = !!n.connected;\n var hw = hwByNode[n.nodeId] || null;\n var hist = hwHist[n.nodeId] || {};\n var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);\n var cores = hw&&hw.cpu ? hw.cpu.cores : null;\n var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;\n var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;\n var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;\n var nt = netRxTx(hw);\n var upt = hw&&hw.system ? hw.system.uptimeSec : null;\n var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n\n html += '<div class=\"node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\"><span class=\"statdot '+(up?'on':'off')+'\"></span>'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>';\n\n if(up){\n html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))\n + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')\n + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')\n + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'\u00B0C', tempClass(temp)) : '')\n + '<div class=\"stats\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span>'\n + '<span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')\n + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')\n + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'\n + '</div>';\n } else {\n html += '<div class=\"stats\"><span style=\"color:var(--muted)\">offline'+(n.lastSeenAt?(' \u00B7 last seen '+clock(n.lastSeenAt)):'')+'</span></div>';\n }\n html += (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'') + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n // live aggregates + alerts from per-node hardware\n var hotTemp=null, maxCpu=0, alerts=[];\n for(var k=0;k<nodes.length;k++){\n var nn=nodes[k]; if(!nn.connected) continue;\n var hw=hwByNode[nn.nodeId]; if(!hw) continue;\n if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);\n if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;\n if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'\u00B0C');\n if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');\n if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]&&hw.disk.mounts[0].percent>=90) alerts.push(nn.displayName+' disk '+hw.disk.mounts[0].percent+'%');\n }\n if(down>0) alerts.unshift(down+' offline');\n var html = '<span class=\"chip ok\">up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>'\n + '<span class=\"chip\">peak cpu <b>'+maxCpu+'%</b></span>'\n + (hotTemp!=null?('<span class=\"chip\">hottest <b>'+Math.round(hotTemp)+'\u00B0C</b></span>'):'');\n for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class=\"chip alert\">\u26A0 <b>'+esc(alerts[a2])+'</b></span>'; }\n $('summary').innerHTML = html;\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true); lastStatus = s;\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n updateHw(s);\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n Park.onStatus(s);\n }).catch(function(){ setLive(false); });\n }\n\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n $('updateBtn').addEventListener('click', function(){\n 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;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + Park + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-park').classList.toggle('active', name==='park');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var initHash = location.hash.replace('#','');\n // Defer tab init until Park module is defined (same outer IIFE, later in source).\n function initTab(){\n if (initHash==='robopark'||initHash==='park') selectTab(initHash);\n }\n // initTab() is called at the very bottom of this IIFE, after Park is assigned.\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null;\n if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n if(act==='trigger') triggerSession(id);\n if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }\n return;\n }\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n if(s.dataset.changed) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(!sid || cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n $('rp-grid').addEventListener('change', function(e){\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n\n var characterPresets=[];\n function loadCharacterPresets(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});\n }\n function loadVoiceStacks(){\n fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});\n }\n function loadParkSlots(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=Array.isArray(d)?d:(d&&d.presets)||[];\n var mapped=[];\n for(var i=0;i<list.length;i++){ var p=list[i];\n var id=p.id||p.preset_id||p.character_id||'';\n if(!id) continue;\n mapped.push({\n label:p.name||id.toUpperCase(), id:id,\n keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/s+/)).filter(function(x){return !!x;}),\n nx:(p.nx!=null?p.nx:0.5), ny:(p.ny!=null?p.ny:0.5),\n sub:p.sub||'', img:p.img||''\n });\n }\n parkSlots = mapped.length ? mapped : FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n }).catch(function(){\n parkSlots = FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n });\n }\n function fillCharacterDropdowns(){\n var sels=document.querySelectorAll('.rp-char');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<characterPresets.length;j++){ var p=characterPresets[j];\n var o=document.createElement('option'); o.value=p.id||p.preset_id||p.character_id||''; o.textContent=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):''); if((o.value)===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n function fillVoiceStackDropdowns(){\n var sels=document.querySelectorAll('.rp-voice');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<voiceStacks.length;j++){ var s=voiceStacks[j];\n var o=document.createElement('option'); o.value=s.id||''; o.textContent=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):''); if(o.value===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n\n function slugify(s){ return (s||'').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,40); }\n\n // \u2500\u2500 LiveKit auto-detect: probe this hub's own host + localhost on the\n // standard LiveKit port. A no-cors fetch that *resolves* (even opaquely)\n // means something answered HTTP there; one that *throws* means nothing did.\n // Best-effort only \u2014 the Advanced section stays editable so the user can\n // correct a false positive/negative before submitting. \u2500\u2500\n function detectLocalLiveKit(){\n var note=$('srv-autonote'); note.className='rp-autonote busy'; note.textContent='probing for a local LiveKit instance\u2026';\n var hosts=[]; var h=location.hostname||'localhost';\n hosts.push(h); if(h!=='localhost') hosts.push('localhost');\n var tried=0, found=null;\n function tryNext(i){\n if(i>=hosts.length){\n if(found){\n $('srv-url').value='ws://'+found+':7880';\n if(!$('srv-key').value) $('srv-key').value='devkey';\n note.className='rp-autonote ok'; note.textContent='\u2713 found a LiveKit instance at '+found+':7880 \u2014 filled URL + default dev key (verify the secret in Advanced before adding).';\n } else {\n note.className='rp-autonote bad'; note.textContent='\u2717 no local LiveKit found on '+hosts.join(', ')+':7880 \u2014 fill the URL manually in Advanced.';\n }\n return;\n }\n var host=hosts[i];\n fetch('http://'+host+':7880/', {mode:'no-cors', signal: AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined})\n .then(function(){ found=host; tryNext(hosts.length); })\n .catch(function(){ tryNext(i+1); });\n }\n tryNext(0);\n }\n\n function addServer(){\n var body={\n id:$('srv-id').value.trim()||slugify($('srv-name').value)+'-server', name:$('srv-name').value.trim(), url:$('srv-url').value.trim(),\n api_key:$('srv-key').value.trim()||'devkey', api_secret:$('srv-secret').value,\n max_sessions:parseInt($('srv-max').value,10)||8\n };\n if(!body.name||!body.url){ showOut('Server name and URL are required (use Auto-detect, or fill URL under Advanced)'); return; }\n body.webhook_url=body.url;\n rpAction('POST','/robopark/api/servers',body).then(function(j){ showOut('server added: '+esc((j&&j.id)||body.id)); clearServerForm(); }).catch(function(e){ showOut('add server: '+e.message); });\n }\n function clearServerForm(){ $('srv-id').value=''; $('srv-name').value=''; $('srv-url').value=''; $('srv-key').value=''; $('srv-secret').value=''; $('srv-max').value='8'; $('srv-autonote').textContent=''; $('srv-autonote').className='rp-autonote'; }\n\n // \u2500\u2500 robust CLI one-liner generation, live as you type \u2014 no API call\n // needed to see it; 'robopark add-robot' mints its own token when run \u2500\u2500\n function buildAddRobotCommand(name, characterId){\n if(!name) return '';\n var hubUrl=location.origin;\n var schedulerUrl=(lastStatus && lastStatus.control && lastStatus.control.schedulerUrl) || '';\n var argv=['robopark','add-robot',name];\n if(characterId) argv.push('--character', characterId);\n if(hubUrl) argv.push('--hub-url', hubUrl);\n if(TOKEN) argv.push('--token', TOKEN);\n if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);\n argv.push('--start');\n return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '\"'+a.replace(/\"/g,'\\\"')+'\"' : a; }).join(' ');\n }\n function refreshRobotCliCommand(){\n var name=$('rb-name').value.trim(); var cid=$('rb-char').value||'';\n var row=$('rb-clicmd-row'), inp=$('rb-clicmd');\n if(!name){ row.style.display='none'; return; }\n inp.value=buildAddRobotCommand(name, cid);\n row.style.display='flex';\n }\n var rbNameEl=$('rb-name'); if(rbNameEl) rbNameEl.addEventListener('input', refreshRobotCliCommand);\n var rbCharEl=$('rb-char'); if(rbCharEl) rbCharEl.addEventListener('change', refreshRobotCliCommand);\n var rbCliCopy=$('rb-clicmd-copy'); if(rbCliCopy) rbCliCopy.addEventListener('click', function(){ var i=$('rb-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n\n function addRobot(){\n var body={\n name:$('rb-name').value.trim(), character_id:$('rb-char').value||null,\n tailscale_ip:$('rb-tsip').value.trim()||null, lan_ip:$('rb-lanip').value.trim()||null,\n motor_server_url:$('rb-motor').value.trim()||null, livekit_url:$('rb-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n if(!body.name){ showOut('Robot name is required'); return; }\n rpAction('POST','/robopark/api/devices',body).then(function(j){ showOut('robot added: '+esc((j&&j.name)||body.name)+'\\ntoken: '+esc((j&&j.enrollment_token)||'(none)')); clearRobotForm(); pollRobopark(); }).catch(function(e){ showOut('add robot: '+e.message); });\n }\n function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; refreshRobotCliCommand(); }\n var srvDetectBtn=$('srv-detect'); if(srvDetectBtn) srvDetectBtn.addEventListener('click', detectLocalLiveKit);\n function triggerSession(id){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });\n }\n function updateRobotCharacter(id, cid){\n if(!cid){ showOut('Choose a character preset'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(id)+'/voice-config',{character_id:cid}).then(function(){ showOut('updated '+id+' character to '+cid); pollRobopark(); }).catch(function(e){ showOut('voice-config ('+id+'): '+e.message); });\n }\n $('srv-add').addEventListener('click', addServer);\n $('rb-add').addEventListener('click', addRobot);\n $('rp-addvoice').addEventListener('click', function(){ addVoiceStackFromTemplate(); });\n $('rp-addchar').addEventListener('click', function(){ addCharacterFromTemplate(); });\n\n function addVoiceStackFromTemplate(){\n var name=$('rp-newvoice-name').value.trim(); if(!name){ showOut('Voice stack name required'); return; }\n rpAction('POST','/robopark/api/voice-stacks',{name:name}).then(function(j){ showOut('voice stack added: '+esc(j.id||name)); $('rp-newvoice-name').value=''; loadVoiceStacks(); }).catch(function(e){ showOut('add voice stack: '+e.message); });\n }\n function addCharacterFromTemplate(){\n var name=$('rp-newchar-name').value.trim(); if(!name){ showOut('Preset name required'); return; }\n var voice_stack_id=$('rp-newchar-stack').value.trim()||null;\n var id=name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,32);\n rpAction('POST','/robopark/api/character-presets',{id:id,name:name,voice_stack_id:voice_stack_id,motors:['drive']}).then(function(j){ showOut('character preset added: '+esc(j.id||id)); $('rp-newchar-name').value=''; $('rp-newchar-stack').value=''; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('add character preset: '+e.message); });\n }\n function fillTemplateVoiceList(){\n var el=$('rp-voice-list'); if(!el) return;\n var html='';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class=\"rp-card\" data-vsid=\"'+esc(s.id)+'\" style=\"padding:0.5rem 0.6rem;border-left-color:var(--info);cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(s.name||s.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">'+esc(s.stt_provider)+' \u2192 '+esc(s.llm_provider)+'/'+esc(s.llm_model||'')+' \u2192 '+esc(s.tts_provider)+'</div></div>'; }\n if(!voiceStacks.length) html='<div class=\"pk-note\">no voice stacks loaded</div>';\n el.innerHTML=html;\n var dl=$('rp-newchar-stack-list'); if(dl){ dl.innerHTML=''; for(var j=0;j<voiceStacks.length;j++){ var o=document.createElement('option'); o.value=voiceStacks[j].id; dl.appendChild(o); } }\n }\n function fillTemplateCharacterList(){\n var el=$('rp-char-list'); if(!el) return;\n var html='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class=\"rp-card\" data-cpid=\"'+esc(p.id)+'\" style=\"padding:0.5rem 0.6rem;cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(p.name||p.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">stack: '+esc(p.voice_stack_id||'\u2014')+' \u00B7 motors: '+esc((p.motors||[]).join(', '))+(p.system_prompt?' \u00B7 has prompt':' \u00B7 no prompt set')+'</div></div>'; }\n if(!characterPresets.length) html='<div class=\"pk-note\">no character presets loaded</div>';\n el.innerHTML=html;\n }\n\n // \u2500\u2500 voice-stack edit panel \u2500\u2500\n var editingVoiceStackId=null;\n function openVoiceStackEdit(id){\n var s=null; for(var i=0;i<voiceStacks.length;i++){ if(voiceStacks[i].id===id){ s=voiceStacks[i]; break; } }\n if(!s) return;\n editingVoiceStackId=id;\n $('rpve-name-label').textContent=s.name||s.id;\n $('rpve-stt-provider').value=s.stt_provider||'speaches';\n $('rpve-stt-model').value=s.stt_model||'';\n $('rpve-stt-language').value=s.stt_language||'en';\n $('rpve-llm-provider').value=s.llm_provider||'ollama';\n $('rpve-llm-model').value=s.llm_model||'';\n $('rpve-llm-key').value=s.llm_api_key||'';\n $('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);\n $('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);\n $('rpve-tts-provider').value=s.tts_provider||'elevenlabs';\n $('rpve-tts-voice').value=s.tts_voice||'';\n $('rpve-tts-language').value=s.tts_language||'en';\n $('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);\n $('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);\n $('rpve-interruptions').checked=!!s.allow_interruptions;\n $('rpve-wake-enabled').checked=!!s.wake_word_enabled;\n $('rpve-wake-model').value=s.wake_word_model||'';\n $('rpve-wake-threshold').value=(s.wake_word_threshold!=null?s.wake_word_threshold:0.5);\n $('rpve-wake-timeout').value=(s.wake_word_timeout!=null?s.wake_word_timeout:3.0);\n $('rp-voice-edit').style.display='grid';\n }\n function saveVoiceStackEdit(){\n if(!editingVoiceStackId) return;\n var body={\n name:$('rpve-name-label').textContent,\n stt_provider:$('rpve-stt-provider').value, stt_model:$('rpve-stt-model').value.trim(), stt_language:$('rpve-stt-language').value.trim()||'en',\n llm_provider:$('rpve-llm-provider').value, llm_model:$('rpve-llm-model').value.trim(), llm_api_key:$('rpve-llm-key').value.trim()||null,\n temperature:parseFloat($('rpve-temperature').value)||0.7, num_ctx:parseInt($('rpve-num-ctx').value,10)||8192,\n tts_provider:$('rpve-tts-provider').value, tts_voice:$('rpve-tts-voice').value.trim(), tts_language:$('rpve-tts-language').value.trim()||'en',\n max_turns:parseInt($('rpve-max-turns').value,10)||20, min_endpointing_delay:parseFloat($('rpve-endpointing').value)||0.7,\n allow_interruptions:$('rpve-interruptions').checked, wake_word_enabled:$('rpve-wake-enabled').checked,\n wake_word_model:$('rpve-wake-model').value.trim()||null, wake_word_threshold:parseFloat($('rpve-wake-threshold').value)||0.5,\n wake_word_timeout:parseFloat($('rpve-wake-timeout').value)||3.0\n };\n rpAction('PUT','/robopark/api/voice-stacks/'+encodeURIComponent(editingVoiceStackId),body).then(function(){ showOut('voice stack saved'); $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; loadVoiceStacks(); }).catch(function(e){ showOut('save voice stack: '+e.message); });\n }\n var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });\n var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);\n var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });\n\n // \u2500\u2500 character-preset edit panel \u2500\u2500\n var editingCharPresetId=null;\n function fillCharEditStackOptions(cur){\n var sel=$('rpce-stack'); if(!sel) return;\n sel.innerHTML='<option value=\"\">\u2014 default \u2014</option>';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; var o=document.createElement('option'); o.value=s.id; o.textContent=s.name||s.id; if(s.id===cur) o.selected=true; sel.appendChild(o); }\n }\n function openCharPresetEdit(id){\n var p=null; for(var i=0;i<characterPresets.length;i++){ if(characterPresets[i].id===id){ p=characterPresets[i]; break; } }\n if(!p) return;\n editingCharPresetId=id;\n $('rpce-name-label').textContent=p.name||p.id;\n $('rpce-name').value=p.name||'';\n $('rpce-description').value=p.description||'';\n $('rpce-prompt').value=p.system_prompt||'';\n fillCharEditStackOptions(p.voice_stack_id);\n $('rpce-motors').value=(p.motors||[]).join(', ');\n $('rpce-nx').value=(p.nx!=null?p.nx:0.5);\n $('rpce-ny').value=(p.ny!=null?p.ny:0.5);\n $('rpce-img').value=p.img||'';\n $('rp-char-edit').style.display='grid';\n }\n function saveCharPresetEdit(){\n if(!editingCharPresetId) return;\n var motors=$('rpce-motors').value.split(',').map(function(x){return x.trim();}).filter(function(x){return !!x;});\n var body={\n name:$('rpce-name').value.trim()||editingCharPresetId, description:$('rpce-description').value.trim()||null,\n system_prompt:$('rpce-prompt').value.trim()||null, voice_stack_id:$('rpce-stack').value||null,\n motors:motors, nx:parseFloat($('rpce-nx').value), ny:parseFloat($('rpce-ny').value), img:$('rpce-img').value.trim()||null\n };\n rpAction('PUT','/robopark/api/character-presets/'+encodeURIComponent(editingCharPresetId),body).then(function(){ showOut('character preset saved'); $('rp-char-edit').style.display='none'; editingCharPresetId=null; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('save character preset: '+e.message); });\n }\n var cl=$('rp-char-list'); if(cl) cl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id) openCharPresetEdit(id); });\n var cpSave=$('rpce-save'); if(cpSave) cpSave.addEventListener('click', saveCharPresetEdit);\n var cpCancel=$('rpce-cancel'); if(cpCancel) cpCancel.addEventListener('click', function(){ $('rp-char-edit').style.display='none'; editingCharPresetId=null; });\n\n function renderRobotCardSelect(name, current){\n var sel=$('rp-char-'+name); if(!sel) return;\n sel.dataset.current=current||'';\n fillCharacterDropdowns();\n }\n\n function pollRobopark(){\n pollSettings();\n loadCharacterPresets();\n loadVoiceStacks();\n loadParkSlots();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n // stash per-robot rows so the Park drawer can show live session stats\n roboparkByName = {};\n robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://<scheduler>:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\" data-name=\"'+esc(name)+'\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn\" data-act=\"trigger\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Trigger motion</button>'\n + '<button class=\"rp-btn\" data-act=\"preview\" data-robot=\"'+idAttr+'\">Preview</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div>'\n + '<div class=\"rp-field\" style=\"margin-top:0.45rem\"><label style=\"font-size:0.64rem\">Character preset</label><select class=\"rp-char\" data-act=\"char\" data-robot=\"'+idAttr+'\"><option value=\"\">'+esc(x.character_id||x.character_preset_id||'\u2014 auto \u2014')+'</option></select></div></div>';\n }\n grid.innerHTML = html;\n fillCharacterDropdowns();\n var sels=document.querySelectorAll('.rp-char');\n for(var si=0;si<sels.length;si++){ var ss=sels[si]; var current=ss.value; ss.dataset.current=current; }\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n // Park \u2014 isometric live map of the mesh\n // Real data: /fed/status nodes (placement + load) + /fed/stream frames\n // (agent movement + tool-use pulses) + /robopark telemetry (session stats).\n // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n var Park = (function(){\n var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial photo): named robot pads at their ground\n // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,\n // street and stage. A mesh node binds to a pad when its display name matches\n // the pad keywords (robobmw \u2192 VIXEN/BMW).\n // SLOTS is now rebuilt from scheduler character_presets; keep the same shape.\n var SLOTS = parkSlots.slice();\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};\n // Palm positions tuned to the aerial: clusters along the back edge and\n // scattered down the center of the paved strip.\n var PALMS=[\n [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],\n [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],\n [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],\n [0.62,0.80],[0.76,0.84],[0.35,0.82]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};\n // Street traffic along the top road (yellow taxis + private cars).\n var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\n function kindOf(n){\n var r=(n.role||'').toLowerCase();\n if(r==='hub'||r==='scheduler') return 'hub';\n if(r==='satellite'||r==='robot') return 'robot';\n var caps=(n.capabilities||[]).join(' ');\n if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';\n return 'peer';\n }\n function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }\n function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }\n\n // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\n });\n return out;\n }\n\n function onStatus(s){\n if(!s) return;\n nodes = layout(s.nodes||[]);\n byId={}; nodes.forEach(function(n){ byId[n.id]=n; });\n // if not streaming, animate from the polled activity tail\n if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }\n }\n\n function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }\n function findByFrame(f){\n var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\n return null;\n }\n function ingest(f, fromPoll){\n if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }\n var n=findByFrame(f), h=hub();\n if(n && h){\n if(n.kind==='hub'){ pulse(n, glyphFor(f)); }\n else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }\n else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }\n }\n feedRow(f);\n }\n function glyphFor(f){\n if(f.t==='rt') return '\u21C4';\n if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '\u2328'; if(/session|presence/.test(k)) return '\uD83C\uDF99'; return '\u2726'; }\n if(f.t==='hw'||f.t==='hb') return '';\n return '\u2726';\n }\n function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }\n function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'\u2726'}); }\n\n function feedRow(f){\n var rows=$('pk-rows'); if(!rows) return;\n var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;\n var r=document.createElement('div'); r.className='pk-row';\n r.innerHTML='<span class=\"k pk-k-'+esc(kind)+'\"></span><span class=\"t\">'+clock(f.ts)+'</span>'\n +'<span class=\"who\" title=\"'+esc(f.n)+'\">'+esc((f.n||'').slice(0,10))+'</span>'\n +'<span class=\"msg\">'+esc(summarizeFrame(f))+'</span>';\n rows.insertBefore(r, rows.firstChild);\n while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);\n }\n\n // \u2500\u2500 canvas + projection \u2500\u2500\n function ensure(){\n if(C) return;\n C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();\n DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));\n var d=false,px,py;\n C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});\n window.addEventListener('mouseup',function(){d=false;});\n window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });\n 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});\n C.addEventListener('click',function(e){\n var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;\n 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]; }\n if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }\n });\n window.addEventListener('resize', function(){ if(running) resize(); });\n }\n function resize(){\n var st=$('pk-stage'); if(!st) return;\n W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);\n if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');\n poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);\n // street band along the far edge + dashed centerline + cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // white event tent / stage on the far left of the plaza (matches aerial)\n drawTent(0.85, 0.18);\n // paved plaza \u2014 long rectangular promenade matching the aerial footprint\n var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;\n var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n // plaza grid (tile joints)\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }\n for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }\n // curved pond / water feature at bottom-left\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }\n var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n // winding footpath along the pond\n X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);\n var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];\n X.beginPath();\n for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }\n X.stroke();\n }\n function drawTent(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // canopy shadow\n X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();\n // white peaked tent\n X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n // support poles\n X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;\n X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();\n X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();\n X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n 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();\n 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();\n }\n function label(cx,cy,txt,col,sub){\n var z=cam.z; X.textAlign='center';\n X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);\n X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);\n }\n function padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }\n function drawHub(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();\n X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;\n X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();\n 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; }\n X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();\n X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();\n label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};\n }\n function drawRobot(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;\n if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n var img = n.connected && imgs[n.label];\n if(img && img.complete && img.naturalWidth>0){\n var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();\n X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);\n X.restore();\n X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();\n X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n }\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';\n X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;\n X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();\n 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;\n label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));\n n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};\n }\n\n function frame(){\n if(!running) return;\n X.clearRect(0,0,W,H);\n drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); } });\n for(var i=orbs.length-1;i>=0;i--){ var o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}\n var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;\n var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;\n X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;\n }\n 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;}\n var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;\n 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);\n X.beginPath(); X.arc(pp.x,pp.y-18*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;\n }\n raf=requestAnimationFrame(frame);\n }\n\n function connectStream(){\n if(es) return;\n try{\n es=new EventSource('/fed/stream'+QS);\n es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };\n es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream \u00B7 live'; };\n es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status \u00B7 poll'; };\n }catch(e){ es=null; }\n }\n\n var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; };\n // The SDK is ~500KB \u2014 a camera view or \"Go live\" click can easily fire\n // before it finishes loading. Without this, that first attempt sees\n // window.LivekitClient===undefined, falls into the permanent \"no-sdk\"\n // idle state, and never retries even once the SDK is ready.\n s.onload=function(){\n sdkLoading=false;\n if(!drawerId) return;\n var n=byId[drawerId];\n if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }\n };\n document.head.appendChild(s);\n }\n function loadImages(){\n SLOTS.forEach(function(s){ if(!s.img) return; var i=new Image(); i.src=s.img+QS; i.onload=function(){ imgs[s.label]=i; }; i.onerror=function(){ imgs[s.label]=null; }; });\n }\n function updateSlots(newSlots){\n SLOTS = newSlots.slice();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n }\n function activate(){\n ensure();\n loadSDK();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n resize();\n connectStream();\n if(!running){ running=true; raf=requestAnimationFrame(frame); }\n }\n function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }\n\n // \u2500\u2500 robot drawer \u2500\u2500\n function sect(title, inner){ return '<div class=\"pk-sect\"><h4>'+title+'</h4>'+inner+'</div>'; }\n function openDrawer(id){\n var n=byId[id]; if(!n) return; drawerId=id;\n $('pk-drawer').classList.add('open');\n $('pk-av').textContent = n.kind==='hub'?'\uD83D\uDDA5':(n.kind==='robot'?'\uD83E\uDD16':'\uD83E\uDDE9');\n $('pk-name').textContent=n.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');\n buildDrawer(n);\n pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick\n if(drawerTimer) clearInterval(drawerTimer);\n drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);\n }\n // Resolve a mesh node's *scheduler* robot id \u2014 n.name is the mesh display\n // name, which is not necessarily the scheduler's robots.id (e.g. an\n // enrolled device's id is a generated dev_xxxx, distinct from its name).\n // Falls back to n.name if telemetry hasn't matched a row yet.\n function schedRow(n){\n var row=roboparkByName[n.name];\n if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }\n return row;\n }\n function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }\n function closeDrawer(){\n // tell the scheduler to end any live preview so the robot stops its cam\n if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }\n previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n stopVision();\n $('pk-drawer').classList.remove('open'); drawerId=null;\n if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;\n if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;\n if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }\n if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=null;\n }\n\n // \u2500\u2500 setup flow for unjoined park pads \u2500\u2500\n function submitPadSetup(n){\n var name=$('pk-setup-name').value.trim() || (n.label||n.id||'robot').toString().toLowerCase();\n var body={\n name:name,\n character_id:$('pk-setup-char').value||null,\n tailscale_ip:$('pk-setup-tsip').value.trim()||null,\n lan_ip:$('pk-setup-lanip').value.trim()||null,\n motor_server_url:$('pk-setup-motor').value.trim()||null,\n livekit_url:$('pk-setup-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n rpAction('POST','/robopark/api/devices',body).then(function(j){\n var token=(j&&j.enrollment_token)||'';\n $('pk-setup-token').value=token;\n $('pk-setup-result').style.display='block';\n showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));\n pollRoboparkQuiet();\n }).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });\n }\n\n // \u2500\u2500 effective voice config for connected robots \u2500\u2500\n function loadEffectiveConfig(robotId){\n fetch('/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(cfg){\n var note=$('pk-eff-note'); if(note) note.textContent='Effective config: '+(cfg&&cfg.character_name?esc(cfg.character_name):'\u2014')+' / '+(cfg&&cfg.voice_stack&&cfg.voice_stack.name?esc(cfg.voice_stack.name):'\u2014');\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n if(csel){ csel.value=(cfg&&cfg.character_id)||''; csel.dataset.current=csel.value; }\n if(vsel){ vsel.value=(cfg&&cfg.voice_stack_id)||''; vsel.dataset.current=vsel.value; }\n var saveBtn=$('pk-eff-save');\n if(saveBtn && !saveBtn._bound){ saveBtn._bound=true; saveBtn.addEventListener('click', function(){ applyEffectiveConfig(robotId); }); }\n }).catch(function(){\n var note=$('pk-eff-note'); if(note) note.textContent='effective config unavailable';\n });\n }\n function applyEffectiveConfig(robotId){\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n var body={};\n if(csel && csel.value) body.character_preset_id=csel.value;\n if(vsel && vsel.value) body.voice_stack_id=vsel.value;\n if(!body.character_preset_id && !body.voice_stack_id){ showOut('Choose a character preset or voice stack'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config',body).then(function(cfg){\n showOut('updated robot config: '+(cfg&&cfg.character_name||robotId));\n if(csel) csel.dataset.current=csel.value;\n if(vsel) vsel.dataset.current=vsel.value;\n }).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });\n }\n\n // \u2500\u2500 on-demand operator preview: open the robot's cam without a scene-detection session \u2500\u2500\n function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'\u25A0 Stop live':'\u25B6 Go live'; b.classList.toggle('danger', previewOn); } }\n function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n var note=$('pk-golivenote'); if(note) note.textContent='requesting preview\u2026';\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first\n idleCam('connecting');\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(!previewOn) return; // stopped while awaiting\n if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }\n if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview \u00B7 robot opening camera\u2026'; }\n else { idleCam('no-sdk'); }\n if(previewTimer) clearInterval(previewTimer);\n previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);\n })\n .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });\n }\n function stopPreview(n){\n previewOn=false; updateGoLive();\n if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});\n var note=$('pk-golivenote'); if(note) note.textContent='';\n idleCam('idle');\n }\n // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),\n // so the browser hits the robot's LAN IP straight on, no scheduler proxy.\n // The scheduler learns that IP from the device's own heartbeats.\n function toggleVision(n){\n var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');\n if(visionOn){\n visionOn=false; if(img){ img.src=''; img.style.display='none'; }\n if(btn) btn.textContent='\uD83D\uDD0D Show overlay';\n if(note) note.textContent=\"object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)\";\n return;\n }\n var row=schedRow(n);\n var ip=row&&row.lan_ip;\n if(!ip){ if(note) note.textContent='no known IP for this robot yet \u2014 waiting for a heartbeat with lan_ip'; return; }\n var port=(row&&row.vision_port)||5000;\n visionOn=true;\n if(btn) btn.textContent='\u25A0 Hide overlay';\n if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';\n if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }\n }\n function stopVision(){\n visionOn=false;\n var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }\n }\n function pollRoboparkQuiet(){\n fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};\n list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function buildDrawer(n){\n var b=$('pk-body');\n if(n.kind==='robot' && !n.connected){\n var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/s+/g,'');\n var presetOptions='', voiceOptions='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; presetOptions += '<option value=\"'+esc(p.id||'')+'\"'+((p.id===n.id)?' selected':'')+'>'+esc(p.name||p.id||'')+'</option>'; }\n for(var v=0;v<voiceStacks.length;v++){ var s=voiceStacks[v]; voiceOptions += '<option value=\"'+esc(s.id||'')+'\">'+esc(s.name||s.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label),\n '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted);margin-bottom:0.55rem;\">Set up this pad before a robot joins it.</div>'\n + '<div class=\"pk-grid2\" style=\"margin-bottom:0.55rem;\">'\n + '<div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div>'\n + '<div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div>'\n + '</div>'\n + '<div class=\"rp-form\" style=\"padding:0.7rem;grid-template-columns:1fr;gap:0.55rem;margin:0;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Robot name</label><input id=\"pk-setup-name\" value=\"'+esc(defaultName)+'\"></div>'\n + '<div class=\"rp-field\"><label>Character preset</label><select id=\"pk-setup-char\"><option value=\"\">\u2014 auto \u2014</option>'+presetOptions+'</select></div>'\n + '<div class=\"rp-quickrow\" style=\"padding:0;\"><button class=\"rp-btn primary\" id=\"pk-setup-btn\" data-preset=\"'+esc(n.id)+'\" style=\"width:fit-content;\">Mint enrollment token & add robot</button></div>'\n + '<div class=\"rp-field\"><label>or run this ON THE ROBOT (mints its own token)</label><input id=\"pk-setup-clicmd\" readonly></div>'\n + '<div class=\"rp-quickrow\" style=\"padding:0;\"><button class=\"rp-btn\" id=\"pk-setup-clicmd-copy\" style=\"width:fit-content;\">\uD83D\uDCCB Copy command</button></div>'\n + '<details class=\"rp-advanced\"><summary>Advanced \u2014 pre-set network/voice config</summary>'\n + '<div class=\"rp-advanced-grid\">'\n + '<div class=\"rp-field\"><label>Tailscale IP</label><input id=\"pk-setup-tsip\" placeholder=\"auto-detected once connected\"></div>'\n + '<div class=\"rp-field\"><label>LAN IP</label><input id=\"pk-setup-lanip\" placeholder=\"auto-detected once connected\"></div>'\n + '<div class=\"rp-field\"><label>Motor server URL</label><input id=\"pk-setup-motor\" placeholder=\"http://...:5000\"></div>'\n + '<div class=\"rp-field\"><label>LiveKit URL</label><input id=\"pk-setup-lk\" placeholder=\"\u2014 uses the registered server \u2014\"></div>'\n + '<div class=\"rp-field\"><label>Voice stack override</label><select id=\"pk-setup-voice\"><option value=\"\">\u2014 from character preset \u2014</option>'+voiceOptions+'</select></div>'\n + '</div></details>'\n + '</div>'\n + '<div class=\"pk-sect\" id=\"pk-setup-result\" style=\"display:none;\"><h4>Enrollment token (copy once)</h4><input id=\"pk-setup-token\" class=\"rp-char\" readonly style=\"margin-bottom:0.4rem;\"><button class=\"rp-btn\" id=\"pk-setup-copy\">Copy</button></div>');\n var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });\n var copyBtn=$('pk-setup-copy'); if(copyBtn) copyBtn.addEventListener('click', function(){ var t=$('pk-setup-token'); if(t){ t.select(); try{document.execCommand('copy');}catch(e){} showOut('token copied'); } });\n function refreshPadCliCommand(){\n var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;\n var el=$('pk-setup-clicmd'); if(el) el.value=buildAddRobotCommand(nm, cid);\n }\n var padNameEl=$('pk-setup-name'); if(padNameEl) padNameEl.addEventListener('input', refreshPadCliCommand);\n var padCharEl=$('pk-setup-char'); if(padCharEl) padCharEl.addEventListener('change', refreshPadCliCommand);\n var padCliCopy=$('pk-setup-clicmd-copy'); if(padCliCopy) padCliCopy.addEventListener('click', function(){ var i=$('pk-setup-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n refreshPadCliCommand();\n return;\n }\n if(n.kind==='robot'){\n var effPresetOptions='', effVoiceOptions='';\n for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value=\"'+esc(cp.id||'')+'\"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }\n for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value=\"'+esc(vs.id||'')+'\">'+esc(vs.name||vs.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83D\uDC41 camera feed',\n '<div class=\"pk-cam\" id=\"pk-cam\"><canvas id=\"pk-camcv\" width=\"384\" height=\"216\"></canvas>'\n +'<div class=\"hud\"><div class=\"rec\"><i></i>REC \u00B7 '+esc(n.name)+'</div><div class=\"tr\" id=\"pk-camres\">\u2014 \u00B7 \u2014</div><div class=\"br\" id=\"pk-camts\">--:--:--</div></div></div>'\n +'<div class=\"pk-note\" id=\"pk-camnote\">seeking LiveKit track\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u25B6 Go live</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\"></span></div>')\n + sect('\uD83D\uDD0D vision overlay',\n '<div class=\"pk-cam\" id=\"pk-vision-wrap\"><img id=\"pk-vision-img\" style=\"width:100%;display:none;border-radius:inherit\" alt=\"vision feed\"/></div>'\n +'<div class=\"pk-note\" id=\"pk-vision-note\">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-vision-toggle\">\uD83D\uDD0D Show overlay</button></div>')\n + sect('\uD83C\uDF99 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\">\u2014</span></div><div class=\"pk-note\" id=\"pk-micstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDD0A 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\">\u2014</span></div><div class=\"pk-note\" id=\"pk-spkstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDCCA 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 \u2014 full CPU/mem telemetry shows on the node itself</div>')\n + sect('\uD83D\uDDD3 session telemetry','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-trig\">\u2014</div><div class=\"l\">triggers</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-sess\">\u2014</div><div class=\"l\">sessions</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-lat\">\u2014</div><div class=\"l\">avg latency</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-drop\">\u2014</div><div class=\"l\">drop rate</div></div></div>')\n + sect('\u2699 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>')\n + sect('\uD83C\uDFAD character / voice','<div class=\"pk-note\" id=\"pk-eff-note\" style=\"margin-bottom:0.45rem;\">loading effective config\u2026</div><div class=\"rp-field\"><label>Character preset</label><select class=\"rp-char\" id=\"pk-eff-char\" data-robot=\"'+esc(schedId(n))+'\">'+effPresetOptions+'</select></div><div class=\"rp-field\"><label>Voice stack</label><select class=\"rp-voice\" id=\"pk-eff-voice\" data-robot=\"'+esc(schedId(n))+'\">'+effVoiceOptions+'</select></div><button class=\"rp-btn\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\" style=\"margin-top:0.35rem;\">Set up on this robot</button>');\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n));\n var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n 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); });\n } else {\n b.innerHTML =\n sect('\uD83D\uDDA5 node','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-load\">\u2014</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>')\n + sect('\uD83D\uDD0C 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>'))\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-status\">Run /status</button></div>');\n var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });\n }\n refreshDrawer();\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);\n var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';\n var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';\n var row=schedRow(n);\n if(row){\n set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);\n set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);\n set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'\u2014');\n 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+'%':'\u2014'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }\n var active=row.active_session||row.in_session||row.session_active;\n 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')); }\n }\n }\n function set(id,v){ var e=$(id); if(e) e.textContent=v; }\n 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)'; }\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n var note=$('pk-camnote');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\n }\n function previewCam(n){\n var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();\n var res=$('pk-camres'); if(res && res.textContent==='\u2014 \u00B7 \u2014') res.textContent='preview \u00B7 24fps';\n (function loop(){ if(!$('pk-camcv')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n 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);\n 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();\n var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;\n cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();\n cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);\n cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);\n cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);\n 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;\n 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); }\n var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());\n camRAF=requestAnimationFrame(loop);\n })();\n }\n 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'); }\n function startMeters(n){\n var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;\n var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }\n for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke(); }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n function openDrawerForRobot(name){\n var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i]; if(n.kind==='robot' && ((n.name||'').toLowerCase()===key || n.telemKeys.some(function(t){ return t===key; }))){ found=n; break; } }\n var node=found || { id:id, kind:'robot', name:name, telemKeys:[key], connected:true, label:name };\n if(!byId[id]){ byId[id]=node; nodes.push(node); }\n openDrawer(node.id);\n }\n\n $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);\n return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n initTab();\n})();\n</script>\n</body>\n</html>";
|
|
25
|
+
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n/* \u2500\u2500 premium tech glass theme \u2500\u2500 dark is the committed default; light is a frosted toggle \u2500\u2500 */\n:root{\n --bg:#08070F;\n --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),\n radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),\n radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),\n linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);\n --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;\n --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);\n --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);\n --ink:#F2ECDC; --muted:#9E93B4;\n --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);\n --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);\n --shadow: 0 10px 34px rgba(0,0,0,0.42);\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#EDEAF6;\n --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),\n radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),\n linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);\n --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;\n --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);\n --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);\n --ink:#241A08; --muted:#6E5A3E;\n --gold:#8A5A0C; --gold-bright:#B9781A;\n --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);\n --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);\n --shadow: 0 10px 30px rgba(60,40,10,0.14);\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;\n color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.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;}\n.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}\n@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)}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}\n:root[data-theme=\"light\"] .bar-track{background:rgba(90,70,20,0.14);}\n.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);}\n.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}\n.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.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;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}\n\n/* fleet card \u2014 live per-robot hardware */\n.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}\n.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}\n.statdot.off{background:var(--bad);}\n.node .mrow{display:grid;grid-template-columns:2.3rem 1fr 2.6rem;align-items:center;gap:0.45rem;margin-top:0.42rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .mrow .ml{letter-spacing:0.04em;}\n.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}\n.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}\n.node .mrow .bar-track{margin:0;}\n.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}\n.node .stats{display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-top:0.5rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .stats span b{color:var(--ink);font-weight:600;}\n.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}\n.tbadge{font-family:var(--mono);font-size:0.6rem;padding:0.08em 0.45em;border-radius:5px;border:1px solid var(--glass-border);color:var(--muted);}\n.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}\n.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert b{color:var(--bad);}\n\n/* hardware meters */\n.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n 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);}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);\n border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}\n:root[data-theme=\"light\"] footer input{background:rgba(255,255,255,0.6);}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;\n 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);}\nbutton.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;\n background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);\n border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;\n border-bottom:1px solid var(--glass-border);}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:53px;z-index:19;}\n.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;}\n.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);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.7rem;align-items:end;background:var(--panel-2);padding:1rem 1.05rem;border-radius:14px;border:1px solid var(--glass-border);margin:1rem 1.1rem;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-form h3{grid-column:1/-1;font-family:var(--mono);font-size:0.72rem;letter-spacing:0.09em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.35rem;padding-bottom:0.5rem;border-bottom:1px solid var(--glass-border);}\n.rp-field{display:flex;flex-direction:column;gap:0.3rem;}\n.rp-field label{font-size:0.68rem;color:var(--muted);letter-spacing:0.01em;}\n.rp-field input,.rp-field select,.rp-field textarea{font-family:var(--mono);font-size:0.74rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:8px;padding:0.48rem 0.6rem;transition:border-color .15s,box-shadow .15s;}\n.rp-field input:focus,.rp-field select:focus,.rp-field textarea:focus{outline:none;border-color:var(--gold-bright);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 18%,transparent);}\n.rp-quickrow{grid-column:1/-1;display:flex;gap:0.6rem;flex-wrap:wrap;align-items:flex-end;padding-bottom:0.2rem;}\n.rp-quickrow .rp-field{flex:1;min-width:180px;}\n.rp-autonote{grid-column:1/-1;font-family:var(--mono);font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.4rem;min-height:1.2em;}\n.rp-autonote.ok{color:var(--ok);} .rp-autonote.bad{color:var(--bad);} .rp-autonote.busy{color:var(--info);}\n.rp-advanced{grid-column:1/-1;margin-top:0.3rem;border-top:1px dashed var(--glass-border);padding-top:0.7rem;}\n.rp-advanced summary{cursor:pointer;font-family:var(--mono);font-size:0.66rem;letter-spacing:0.05em;text-transform:uppercase;color:var(--muted);list-style:none;display:flex;align-items:center;gap:0.4rem;user-select:none;}\n.rp-advanced summary::-webkit-details-marker{display:none;}\n.rp-advanced summary::before{content:\"\u25B8\";display:inline-block;transition:transform .15s;color:var(--gold);}\n.rp-advanced[open] summary::before{transform:rotate(90deg);}\n.rp-advanced summary:hover{color:var(--gold-bright);}\n.rp-advanced-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.6rem;margin-top:0.7rem;}\n.rp-btn.primary{background:linear-gradient(160deg,color-mix(in srgb,var(--gold-bright) 30%,var(--panel-2)),var(--panel-2));border-color:var(--gold-bright);color:var(--gold-bright);font-weight:600;}\n.rp-btn.primary:hover{box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 16%,transparent);}\n.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-card-top{display:flex;align-items:flex-start;gap:0.55rem;}\n.rp-card-img{width:42px;height:42px;border-radius:11px;overflow:hidden;flex:none;border:1px solid var(--glass-border);background:var(--panel-2);}\n.rp-card-img img{width:100%;height:100%;object-fit:cover;display:block;}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.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;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-card .rp-char{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.35rem 0.5rem;width:100%;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n\n/* \u2500\u2500 Park (isometric live map) \u2500\u2500 */\n#pane-park{padding:0;}\n.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}\n#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}\n#pk-canvas:active{cursor:grabbing;}\n.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;\n display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);\n padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}\n.pk-feed .rows{overflow-y:auto;padding:0.25rem;}\n.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}\n.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}\n.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}\n.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;}\n.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}\n.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}\n.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}\n.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}\n.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);\n 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);}\n.pk-hint b{color:var(--gold-bright);}\n.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);}\n.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;}\n.pk-legend i{width:8px;height:8px;border-radius:2px;}\n\n/* robot drawer */\n.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;\n background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);\n transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}\n.pk-drawer.open{transform:translateX(0);}\n.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}\n.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));\n border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;overflow:hidden;}\n.pk-av.hasimg{background:var(--panel-2);}\n.pk-av img{width:100%;height:100%;object-fit:cover;display:block;}\n.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}\n.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}\n.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;}\n.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.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;}\n.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}\n.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}\n.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}\n.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);}\n.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;}\n.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}\n.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}\n.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}\n.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;}\n.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}\n.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}\n.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}\n.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}\n.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}\n.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;}\n.pk-wave{height:32px;width:100%;}\n.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}\n.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}\n.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}\n.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}\n.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}\n.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}\n.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}\n.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}\n.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"park\" id=\"tabbtn-park\">Park</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions & Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-park\">\n <div class=\"pk-stage\" id=\"pk-stage\">\n <canvas id=\"pk-canvas\"></canvas>\n <div class=\"pk-legend\">\n <span><i style=\"background:var(--gold-bright)\"></i>hub</span>\n <span><i style=\"background:var(--ok);border-radius:50%\"></i>robot</span>\n <span><i style=\"background:var(--info)\"></i>peer</span>\n <span><i style=\"background:var(--info)\"></i>dispatch</span>\n <span><i style=\"background:var(--ok)\"></i>telemetry</span>\n </div>\n <aside class=\"pk-feed\">\n <h3><span>Live activity</span><span style=\"color:var(--muted)\" id=\"pk-src\">/fed/stream</span></h3>\n <div class=\"rows\" id=\"pk-rows\"></div>\n </aside>\n <div class=\"pk-hint\">drag to pan \u00B7 scroll to zoom \u00B7 <b>click a robot</b> for cam / mic / speaker</div>\n </div>\n <div class=\"pk-drawer\" id=\"pk-drawer\">\n <div class=\"pk-dh\">\n <div class=\"pk-av\" id=\"pk-av\">\uD83E\uDD16</div>\n <div><div class=\"nm\" id=\"pk-name\">robot</div><div class=\"sub\" id=\"pk-sub\">satellite</div></div>\n <span class=\"pk-pill idle\" id=\"pk-state\" style=\"margin-left:0.4rem\">idle</span>\n <button class=\"x\" id=\"pk-close\">\u2715</button>\n </div>\n <div class=\"pk-dbody\" id=\"pk-body\"></div>\n </div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions & telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n\n <!-- Add LiveKit server -->\n <div class=\"rp-form\" id=\"rp-addserver\">\n <h3>Add LiveKit server</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\"><label>Name</label><input id=\"srv-name\" placeholder=\"LiveKit Server 1\"></div>\n <button class=\"rp-btn primary\" id=\"srv-detect\" style=\"height:fit-content\" title=\"Probes this hub's own host + common local ports for a running LiveKit instance\">\uD83D\uDD0D Auto-detect & fill</button>\n <button class=\"rp-btn\" id=\"srv-add\" style=\"height:fit-content\">Add server</button>\n </div>\n <div class=\"rp-autonote\" id=\"srv-autonote\"></div>\n <details class=\"rp-advanced\">\n <summary>Advanced \u2014 ID, URL, credentials, capacity</summary>\n <div class=\"rp-advanced-grid\">\n <div class=\"rp-field\"><label>ID</label><input id=\"srv-id\" placeholder=\"auto-filled from name\"></div>\n <div class=\"rp-field\"><label>URL (ws:// or wss://)</label><input id=\"srv-url\" placeholder=\"ws://host:7880 \u2014 auto-detect fills this\"></div>\n <div class=\"rp-field\"><label>API key</label><input id=\"srv-key\" placeholder=\"devkey\" value=\"devkey\"></div>\n <div class=\"rp-field\"><label>API secret</label><input type=\"password\" id=\"srv-secret\" placeholder=\"secretsecretsecretsecretsecretsecret\"></div>\n <div class=\"rp-field\"><label>Max sessions</label><input id=\"srv-max\" type=\"number\" value=\"8\"></div>\n </div>\n </details>\n </div>\n\n <!-- Add robot / device -->\n <div class=\"rp-form\" id=\"rp-addrobot\">\n <h3>Add robot (mint enrollment token)</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rb-name\" placeholder=\"jaguar\"></div>\n <div class=\"rp-field\"><label>Character preset</label><select id=\"rb-char\"><option value=\"\">\u2014 auto (matched from name) \u2014</option></select></div>\n <button class=\"rp-btn primary\" id=\"rb-add\" style=\"height:fit-content\">Add robot</button>\n </div>\n <div class=\"rp-autonote\" id=\"rb-autonote\">the robot self-reports its IP and hardware on first heartbeat \u2014 you only need a name and (optionally) a character preset to get started.</div>\n <div class=\"rp-quickrow\" id=\"rb-clicmd-row\" style=\"display:none;\">\n <div class=\"rp-field\" style=\"flex:1;\"><label>run this ON THE ROBOT (no need to click Add \u2014 this mints its own token)</label><input id=\"rb-clicmd\" readonly style=\"cursor:text;\"></div>\n <button class=\"rp-btn\" id=\"rb-clicmd-copy\" style=\"height:fit-content\">\uD83D\uDCCB Copy</button>\n </div>\n <details class=\"rp-advanced\">\n <summary>Advanced \u2014 pre-set network/voice config before the robot connects</summary>\n <div class=\"rp-advanced-grid\">\n <div class=\"rp-field\"><label>Tailscale IP</label><input id=\"rb-tsip\" placeholder=\"auto-detected once connected\"></div>\n <div class=\"rp-field\"><label>LAN IP</label><input id=\"rb-lanip\" placeholder=\"auto-detected once connected\"></div>\n <div class=\"rp-field\"><label>Motor server URL</label><input id=\"rb-motor\" placeholder=\"http://...:5000\"></div>\n <div class=\"rp-field\"><label>LiveKit URL</label><input id=\"rb-lk\" placeholder=\"\u2014 uses the registered server above \u2014\"></div>\n <div class=\"rp-field\"><label>Voice stack override</label><select id=\"rb-voice\"><option value=\"\">\u2014 from character preset \u2014</option></select></div>\n </div>\n </details>\n </div>\n\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n\n <!-- Config templates -->\n <div class=\"rp-form\" id=\"rp-templates\" style=\"grid-template-columns:1fr 1fr; margin-top:1.2rem;\">\n <h3 style=\"grid-column:1/-1\">Config templates</h3>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Voice stacks</h4>\n <div id=\"rp-voice-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newvoice-name\" placeholder=\"new stack name\" style=\"flex:1;min-width:0;\">\n <button class=\"rp-btn\" id=\"rp-addvoice\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-voice-edit\" style=\"display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"grid-column:1/-1;margin:0 0 0.2rem;\">Edit voice stack: <span id=\"rpve-name-label\"></span></h4>\n <div class=\"rp-field\"><label>STT provider</label><select id=\"rpve-stt-provider\"><option value=\"speaches\">speaches</option><option value=\"groq\">groq</option><option value=\"vibevoice\">vibevoice</option></select></div>\n <div class=\"rp-field\"><label>STT model</label><input id=\"rpve-stt-model\"></div>\n <div class=\"rp-field\"><label>STT language</label><input id=\"rpve-stt-language\" placeholder=\"en / auto\"></div>\n <div class=\"rp-field\"><label>LLM provider</label><select id=\"rpve-llm-provider\"><option value=\"ollama\">ollama</option><option value=\"groq\">groq</option><option value=\"gemini\">gemini</option><option value=\"moltbot\">moltbot</option></select></div>\n <div class=\"rp-field\"><label>LLM model</label><input id=\"rpve-llm-model\"></div>\n <div class=\"rp-field\"><label>LLM API key</label><input type=\"password\" id=\"rpve-llm-key\" placeholder=\"(uses agent's own if blank)\"></div>\n <div class=\"rp-field\"><label>Temperature</label><input id=\"rpve-temperature\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label>Context size (num_ctx)</label><input id=\"rpve-num-ctx\" type=\"number\" value=\"8192\"></div>\n <div class=\"rp-field\"><label>TTS provider</label><select id=\"rpve-tts-provider\"><option value=\"elevenlabs\">elevenlabs</option><option value=\"kokoro\">kokoro</option><option value=\"piper\">piper</option></select></div>\n <div class=\"rp-field\"><label>TTS voice</label><input id=\"rpve-tts-voice\"></div>\n <div class=\"rp-field\"><label>TTS language</label><input id=\"rpve-tts-language\" placeholder=\"en\"></div>\n <div class=\"rp-field\"><label>Max turns</label><input id=\"rpve-max-turns\" type=\"number\" value=\"20\"></div>\n <div class=\"rp-field\"><label>Min endpointing delay (s)</label><input id=\"rpve-endpointing\" type=\"number\" step=\"0.1\" value=\"0.7\"></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-interruptions\" checked style=\"width:auto;margin-right:0.35rem;\">Allow interruptions</label></div>\n <div class=\"rp-field\"><label><input type=\"checkbox\" id=\"rpve-wake-enabled\" style=\"width:auto;margin-right:0.35rem;\">Wake word enabled</label></div>\n <div class=\"rp-field\"><label>Wake word model</label><input id=\"rpve-wake-model\" placeholder=\"models/hey_jarvis.onnx\"></div>\n <div class=\"rp-field\"><label>Wake threshold</label><input id=\"rpve-wake-threshold\" type=\"number\" step=\"0.05\" value=\"0.5\"></div>\n <div class=\"rp-field\"><label>Wake timeout (s)</label><input id=\"rpve-wake-timeout\" type=\"number\" step=\"0.5\" value=\"3.0\"></div>\n <div style=\"grid-column:1/-1;display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpve-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpve-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n <div>\n <h4 style=\"font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;\">Character presets</h4>\n <div id=\"rp-char-list\" style=\"display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;\"></div>\n <div style=\"display:flex;gap:0.4rem;margin-top:0.55rem;\">\n <input id=\"rp-newchar-name\" placeholder=\"new preset name\" style=\"flex:1;min-width:0;\">\n <input id=\"rp-newchar-stack\" list=\"rp-newchar-stack-list\" placeholder=\"voice stack id\" style=\"flex:1;min-width:0;\">\n <datalist id=\"rp-newchar-stack-list\"></datalist>\n <button class=\"rp-btn\" id=\"rp-addchar\">Add</button>\n </div>\n <div class=\"rp-form\" id=\"rp-char-edit\" style=\"display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);\">\n <h4 style=\"margin:0 0 0.2rem;\">Edit character: <span id=\"rpce-name-label\"></span></h4>\n <div class=\"rp-field\"><label>Name</label><input id=\"rpce-name\"></div>\n <div class=\"rp-field\"><label>Description</label><input id=\"rpce-description\"></div>\n <div class=\"rp-field\"><label>System prompt</label><textarea id=\"rpce-prompt\" rows=\"4\" style=\"font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;\"></textarea></div>\n <div class=\"rp-field\"><label>Voice stack</label><select id=\"rpce-stack\"></select></div>\n <div class=\"rp-field\"><label>Motors (comma-separated)</label><input id=\"rpce-motors\" placeholder=\"drive, wave\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad X (0-1)</label><input id=\"rpce-nx\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n <div class=\"rp-field\" style=\"flex:1\"><label>Pad Y (0-1)</label><input id=\"rpce-ny\" type=\"number\" step=\"0.01\" min=\"0\" max=\"1\"></div>\n </div>\n <div class=\"rp-field\"><label>Avatar image path</label><input id=\"rpce-img\" placeholder=\"/static/robopark/jaguar.png\"></div>\n <div style=\"display:flex;gap:0.4rem;\">\n <button class=\"rp-btn\" id=\"rpce-save\">Save</button>\n <button class=\"rp-btn\" id=\"rpce-cancel\">Cancel</button>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n <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>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastStatus = null; // most recent /fed/status snapshot (shared with Park)\n var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)\n var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)\n var FALLBACK_SLOTS = [\n {label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},\n {label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},\n {label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},\n {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},\n {label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}\n ];\n var parkSlots = FALLBACK_SLOTS.slice();\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n 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()); }\n\n // theme (dark glass is the default; toggle to frosted light)\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n document.documentElement.setAttribute('data-theme', s || 'dark');\n $('theme').addEventListener('click', function(){\n var cur=document.documentElement.getAttribute('data-theme')||'dark';\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n 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+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e;\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n // \u2500\u2500 per-node hardware store + rolling history (drives the Fleet cards + sparklines) \u2500\u2500\n var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)\n var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers\n var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence\n function netTotal(hw){\n var t=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ var f=hw.network.interfaces[i]; t+=(f.rxBytesPerSec||0)+(f.txBytesPerSec||0); } } return t;\n }\n function netRxTx(hw){\n var rx=0,tx=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ rx+=hw.network.interfaces[i].rxBytesPerSec||0; tx+=hw.network.interfaces[i].txBytesPerSec||0; } } return {rx:rx,tx:tx};\n }\n function pushHist(id, snap){\n var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});\n var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;\n var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;\n h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();\n h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();\n h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();\n }\n function updateHw(s){\n if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }\n var act = s.activity||[];\n for(var i=0;i<act.length;i++){ var f=(act[i].f||act[i]); if(f.t==='hw'&&f.n&&f.d){ hwByNode[f.n]=f.d; } }\n var nodes = s.nodes||[];\n for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }\n }\n // Inline sparkline (SVG polyline) from a numeric history buffer.\n function sparkSvg(arr, color, maxv){\n if(!arr || arr.length<2) return '';\n var w=100,h=20,n=arr.length, vals=[];\n for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);\n var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;\n var pts=''; for(var j=0;j<n;j++){ var x=(j/(n-1))*w; var y=h-(vals[j]/mx)*(h-2)-1; pts+=(j?' ':'')+x.toFixed(1)+','+y.toFixed(1); }\n return '<svg class=\"spark\" viewBox=\"0 0 '+w+' '+h+'\" preserveAspectRatio=\"none\" aria-hidden=\"true\">'\n + '<polyline points=\"'+pts+'\" fill=\"none\" stroke=\"'+color+'\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/></svg>';\n }\n function fmtRate(b){ return fmtBytes(b)+'/s'; }\n function fmtUptime(sec){ if(sec==null) return '\u2014'; var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60); if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; return m+'m'; }\n function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'\u00B0C'); }\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]){ var mnt=hw.disk.mounts[0]; html += meter('Disk '+esc(mnt.path), mnt.percent, fmtBytes(mnt.usedBytes)+' / '+fmtBytes(mnt.totalBytes)); }\n var nt=netRxTx(hw); html += '<div class=\"stats\" style=\"margin-top:0.55rem\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span><span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function mrow(label, pct, valTxt, cls){\n var v = Math.max(0, Math.min(100, Math.round(pct||0)));\n return '<div class=\"mrow\"><span class=\"ml\">'+label+'</span>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div>'\n + '<span class=\"mv '+(cls||'')+'\">'+esc(valTxt)+'</span></div>';\n }\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n // connected first, then robots/satellites above hubs so the fleet reads robot-first\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var accent = '#6FE0A6';\n try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var up = !!n.connected;\n var hw = hwByNode[n.nodeId] || null;\n var hist = hwHist[n.nodeId] || {};\n var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);\n var cores = hw&&hw.cpu ? hw.cpu.cores : null;\n var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;\n var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;\n var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;\n var nt = netRxTx(hw);\n var upt = hw&&hw.system ? hw.system.uptimeSec : null;\n var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n\n html += '<div class=\"node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\"><span class=\"statdot '+(up?'on':'off')+'\"></span>'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>';\n\n if(up){\n html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))\n + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')\n + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')\n + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'\u00B0C', tempClass(temp)) : '')\n + '<div class=\"stats\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span>'\n + '<span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')\n + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')\n + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'\n + '</div>';\n } else {\n html += '<div class=\"stats\"><span style=\"color:var(--muted)\">offline'+(n.lastSeenAt?(' \u00B7 last seen '+clock(n.lastSeenAt)):'')+'</span></div>';\n }\n html += (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'') + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n // live aggregates + alerts from per-node hardware\n var hotTemp=null, maxCpu=0, alerts=[];\n for(var k=0;k<nodes.length;k++){\n var nn=nodes[k]; if(!nn.connected) continue;\n var hw=hwByNode[nn.nodeId]; if(!hw) continue;\n if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);\n if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;\n if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'\u00B0C');\n if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');\n if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]&&hw.disk.mounts[0].percent>=90) alerts.push(nn.displayName+' disk '+hw.disk.mounts[0].percent+'%');\n }\n if(down>0) alerts.unshift(down+' offline');\n var html = '<span class=\"chip ok\">up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>'\n + '<span class=\"chip\">peak cpu <b>'+maxCpu+'%</b></span>'\n + (hotTemp!=null?('<span class=\"chip\">hottest <b>'+Math.round(hotTemp)+'\u00B0C</b></span>'):'');\n for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class=\"chip alert\">\u26A0 <b>'+esc(alerts[a2])+'</b></span>'; }\n $('summary').innerHTML = html;\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true); lastStatus = s;\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n updateHw(s);\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n Park.onStatus(s);\n }).catch(function(){ setLive(false); });\n }\n\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n $('updateBtn').addEventListener('click', function(){\n 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;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + Park + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-park').classList.toggle('active', name==='park');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var initHash = location.hash.replace('#','');\n // Defer tab init until Park module is defined (same outer IIFE, later in source).\n function initTab(){\n if (initHash==='robopark'||initHash==='park') selectTab(initHash);\n }\n // initTab() is called at the very bottom of this IIFE, after Park is assigned.\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null;\n if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n if(act==='trigger') triggerSession(id);\n if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }\n return;\n }\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n if(s.dataset.changed) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(!sid || cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n $('rp-grid').addEventListener('change', function(e){\n var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;\n var sid=s.getAttribute('data-robot'), cid=s.value;\n if(cid===s.dataset.current) return;\n updateRobotCharacter(sid, cid);\n });\n\n var characterPresets=[];\n function loadCharacterPresets(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});\n }\n function loadVoiceStacks(){\n fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});\n }\n function loadParkSlots(){\n fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=Array.isArray(d)?d:(d&&d.presets)||[];\n var mapped=[];\n for(var i=0;i<list.length;i++){ var p=list[i];\n var id=p.id||p.preset_id||p.character_id||'';\n if(!id) continue;\n mapped.push({\n label:p.name||id.toUpperCase(), id:id,\n keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/s+/)).filter(function(x){return !!x;}),\n nx:(p.nx!=null?p.nx:0.5), ny:(p.ny!=null?p.ny:0.5),\n sub:p.sub||'', img:p.img||''\n });\n }\n parkSlots = mapped.length ? mapped : FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n }).catch(function(){\n parkSlots = FALLBACK_SLOTS.slice();\n if(Park && Park.updateSlots) Park.updateSlots(parkSlots);\n });\n }\n function fillCharacterDropdowns(){\n var sels=document.querySelectorAll('.rp-char');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<characterPresets.length;j++){ var p=characterPresets[j];\n var o=document.createElement('option'); o.value=p.id||p.preset_id||p.character_id||''; o.textContent=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):''); if((o.value)===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n function fillVoiceStackDropdowns(){\n var sels=document.querySelectorAll('.rp-voice');\n for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';\n var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }\n for(var j=0;j<voiceStacks.length;j++){ var s=voiceStacks[j];\n var o=document.createElement('option'); o.value=s.id||''; o.textContent=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):''); if(o.value===cur) o.selected=true; sel.appendChild(o);\n }\n }\n }\n\n function slugify(s){ return (s||'').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,40); }\n\n // \u2500\u2500 LiveKit auto-detect: probe this hub's own host + localhost on the\n // standard LiveKit port. A no-cors fetch that *resolves* (even opaquely)\n // means something answered HTTP there; one that *throws* means nothing did.\n // Best-effort only \u2014 the Advanced section stays editable so the user can\n // correct a false positive/negative before submitting. \u2500\u2500\n function detectLocalLiveKit(){\n var note=$('srv-autonote'); note.className='rp-autonote busy'; note.textContent='probing for a local LiveKit instance\u2026';\n var hosts=[]; var h=location.hostname||'localhost';\n hosts.push(h); if(h!=='localhost') hosts.push('localhost');\n var tried=0, found=null;\n function tryNext(i){\n if(i>=hosts.length){\n if(found){\n $('srv-url').value='ws://'+found+':7880';\n if(!$('srv-key').value) $('srv-key').value='devkey';\n note.className='rp-autonote ok'; note.textContent='\u2713 found a LiveKit instance at '+found+':7880 \u2014 filled URL + default dev key (verify the secret in Advanced before adding).';\n } else {\n note.className='rp-autonote bad'; note.textContent='\u2717 no local LiveKit found on '+hosts.join(', ')+':7880 \u2014 fill the URL manually in Advanced.';\n }\n return;\n }\n var host=hosts[i];\n fetch('http://'+host+':7880/', {mode:'no-cors', signal: AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined})\n .then(function(){ found=host; tryNext(hosts.length); })\n .catch(function(){ tryNext(i+1); });\n }\n tryNext(0);\n }\n\n function addServer(){\n var body={\n id:$('srv-id').value.trim()||slugify($('srv-name').value)+'-server', name:$('srv-name').value.trim(), url:$('srv-url').value.trim(),\n api_key:$('srv-key').value.trim()||'devkey', api_secret:$('srv-secret').value,\n max_sessions:parseInt($('srv-max').value,10)||8\n };\n if(!body.name||!body.url){ showOut('Server name and URL are required (use Auto-detect, or fill URL under Advanced)'); return; }\n body.webhook_url=body.url;\n rpAction('POST','/robopark/api/servers',body).then(function(j){ showOut('server added: '+esc((j&&j.id)||body.id)); clearServerForm(); }).catch(function(e){ showOut('add server: '+e.message); });\n }\n function clearServerForm(){ $('srv-id').value=''; $('srv-name').value=''; $('srv-url').value=''; $('srv-key').value=''; $('srv-secret').value=''; $('srv-max').value='8'; $('srv-autonote').textContent=''; $('srv-autonote').className='rp-autonote'; }\n\n // \u2500\u2500 robust CLI one-liner generation, live as you type \u2014 no API call\n // needed to see it; 'robopark add-robot' mints its own token when run \u2500\u2500\n function buildAddRobotCommand(name, characterId){\n if(!name) return '';\n var hubUrl=location.origin;\n var schedulerUrl=(lastStatus && lastStatus.control && lastStatus.control.schedulerUrl) || '';\n var argv=['robopark','add-robot',name];\n if(characterId) argv.push('--character', characterId);\n if(hubUrl) argv.push('--hub-url', hubUrl);\n if(TOKEN) argv.push('--token', TOKEN);\n if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);\n argv.push('--start');\n return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '\"'+a.replace(/\"/g,'\\\"')+'\"' : a; }).join(' ');\n }\n function refreshRobotCliCommand(){\n var name=$('rb-name').value.trim(); var cid=$('rb-char').value||'';\n var row=$('rb-clicmd-row'), inp=$('rb-clicmd');\n if(!name){ row.style.display='none'; return; }\n inp.value=buildAddRobotCommand(name, cid);\n row.style.display='flex';\n }\n var rbNameEl=$('rb-name'); if(rbNameEl) rbNameEl.addEventListener('input', refreshRobotCliCommand);\n var rbCharEl=$('rb-char'); if(rbCharEl) rbCharEl.addEventListener('change', refreshRobotCliCommand);\n var rbCliCopy=$('rb-clicmd-copy'); if(rbCliCopy) rbCliCopy.addEventListener('click', function(){ var i=$('rb-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n\n function addRobot(){\n var body={\n name:$('rb-name').value.trim(), character_id:$('rb-char').value||null,\n tailscale_ip:$('rb-tsip').value.trim()||null, lan_ip:$('rb-lanip').value.trim()||null,\n motor_server_url:$('rb-motor').value.trim()||null, livekit_url:$('rb-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n if(!body.name){ showOut('Robot name is required'); return; }\n rpAction('POST','/robopark/api/devices',body).then(function(j){ showOut('robot added: '+esc((j&&j.name)||body.name)+'\\ntoken: '+esc((j&&j.enrollment_token)||'(none)')); clearRobotForm(); pollRobopark(); }).catch(function(e){ showOut('add robot: '+e.message); });\n }\n function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; refreshRobotCliCommand(); }\n var srvDetectBtn=$('srv-detect'); if(srvDetectBtn) srvDetectBtn.addEventListener('click', detectLocalLiveKit);\n function triggerSession(id){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });\n }\n function updateRobotCharacter(id, cid){\n if(!cid){ showOut('Choose a character preset'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(id)+'/voice-config',{character_id:cid}).then(function(){ showOut('updated '+id+' character to '+cid); pollRobopark(); }).catch(function(e){ showOut('voice-config ('+id+'): '+e.message); });\n }\n $('srv-add').addEventListener('click', addServer);\n $('rb-add').addEventListener('click', addRobot);\n $('rp-addvoice').addEventListener('click', function(){ addVoiceStackFromTemplate(); });\n $('rp-addchar').addEventListener('click', function(){ addCharacterFromTemplate(); });\n\n function addVoiceStackFromTemplate(){\n var name=$('rp-newvoice-name').value.trim(); if(!name){ showOut('Voice stack name required'); return; }\n rpAction('POST','/robopark/api/voice-stacks',{name:name}).then(function(j){ showOut('voice stack added: '+esc(j.id||name)); $('rp-newvoice-name').value=''; loadVoiceStacks(); }).catch(function(e){ showOut('add voice stack: '+e.message); });\n }\n function addCharacterFromTemplate(){\n var name=$('rp-newchar-name').value.trim(); if(!name){ showOut('Preset name required'); return; }\n var voice_stack_id=$('rp-newchar-stack').value.trim()||null;\n var id=name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,32);\n rpAction('POST','/robopark/api/character-presets',{id:id,name:name,voice_stack_id:voice_stack_id,motors:['drive']}).then(function(j){ showOut('character preset added: '+esc(j.id||id)); $('rp-newchar-name').value=''; $('rp-newchar-stack').value=''; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('add character preset: '+e.message); });\n }\n function fillTemplateVoiceList(){\n var el=$('rp-voice-list'); if(!el) return;\n var html='';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; html += '<div class=\"rp-card\" data-vsid=\"'+esc(s.id)+'\" style=\"padding:0.5rem 0.6rem;border-left-color:var(--info);cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(s.name||s.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">'+esc(s.stt_provider)+' \u2192 '+esc(s.llm_provider)+'/'+esc(s.llm_model||'')+' \u2192 '+esc(s.tts_provider)+'</div></div>'; }\n if(!voiceStacks.length) html='<div class=\"pk-note\">no voice stacks loaded</div>';\n el.innerHTML=html;\n var dl=$('rp-newchar-stack-list'); if(dl){ dl.innerHTML=''; for(var j=0;j<voiceStacks.length;j++){ var o=document.createElement('option'); o.value=voiceStacks[j].id; dl.appendChild(o); } }\n }\n function fillTemplateCharacterList(){\n var el=$('rp-char-list'); if(!el) return;\n var html='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; html += '<div class=\"rp-card\" data-cpid=\"'+esc(p.id)+'\" style=\"padding:0.5rem 0.6rem;cursor:pointer;\"><div style=\"font-family:var(--mono);font-size:0.74rem;font-weight:600;\">'+esc(p.name||p.id)+'</div><div style=\"font-size:0.6rem;color:var(--muted);margin-top:0.15rem;\">stack: '+esc(p.voice_stack_id||'\u2014')+' \u00B7 motors: '+esc((p.motors||[]).join(', '))+(p.system_prompt?' \u00B7 has prompt':' \u00B7 no prompt set')+'</div></div>'; }\n if(!characterPresets.length) html='<div class=\"pk-note\">no character presets loaded</div>';\n el.innerHTML=html;\n }\n\n // \u2500\u2500 voice-stack edit panel \u2500\u2500\n var editingVoiceStackId=null;\n function openVoiceStackEdit(id){\n var s=null; for(var i=0;i<voiceStacks.length;i++){ if(voiceStacks[i].id===id){ s=voiceStacks[i]; break; } }\n if(!s) return;\n editingVoiceStackId=id;\n $('rpve-name-label').textContent=s.name||s.id;\n $('rpve-stt-provider').value=s.stt_provider||'speaches';\n $('rpve-stt-model').value=s.stt_model||'';\n $('rpve-stt-language').value=s.stt_language||'en';\n $('rpve-llm-provider').value=s.llm_provider||'ollama';\n $('rpve-llm-model').value=s.llm_model||'';\n $('rpve-llm-key').value=s.llm_api_key||'';\n $('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);\n $('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);\n $('rpve-tts-provider').value=s.tts_provider||'elevenlabs';\n $('rpve-tts-voice').value=s.tts_voice||'';\n $('rpve-tts-language').value=s.tts_language||'en';\n $('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);\n $('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);\n $('rpve-interruptions').checked=!!s.allow_interruptions;\n $('rpve-wake-enabled').checked=!!s.wake_word_enabled;\n $('rpve-wake-model').value=s.wake_word_model||'';\n $('rpve-wake-threshold').value=(s.wake_word_threshold!=null?s.wake_word_threshold:0.5);\n $('rpve-wake-timeout').value=(s.wake_word_timeout!=null?s.wake_word_timeout:3.0);\n $('rp-voice-edit').style.display='grid';\n }\n function saveVoiceStackEdit(){\n if(!editingVoiceStackId) return;\n var body={\n name:$('rpve-name-label').textContent,\n stt_provider:$('rpve-stt-provider').value, stt_model:$('rpve-stt-model').value.trim(), stt_language:$('rpve-stt-language').value.trim()||'en',\n llm_provider:$('rpve-llm-provider').value, llm_model:$('rpve-llm-model').value.trim(), llm_api_key:$('rpve-llm-key').value.trim()||null,\n temperature:parseFloat($('rpve-temperature').value)||0.7, num_ctx:parseInt($('rpve-num-ctx').value,10)||8192,\n tts_provider:$('rpve-tts-provider').value, tts_voice:$('rpve-tts-voice').value.trim(), tts_language:$('rpve-tts-language').value.trim()||'en',\n max_turns:parseInt($('rpve-max-turns').value,10)||20, min_endpointing_delay:parseFloat($('rpve-endpointing').value)||0.7,\n allow_interruptions:$('rpve-interruptions').checked, wake_word_enabled:$('rpve-wake-enabled').checked,\n wake_word_model:$('rpve-wake-model').value.trim()||null, wake_word_threshold:parseFloat($('rpve-wake-threshold').value)||0.5,\n wake_word_timeout:parseFloat($('rpve-wake-timeout').value)||3.0\n };\n rpAction('PUT','/robopark/api/voice-stacks/'+encodeURIComponent(editingVoiceStackId),body).then(function(){ showOut('voice stack saved'); $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; loadVoiceStacks(); }).catch(function(e){ showOut('save voice stack: '+e.message); });\n }\n var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });\n var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);\n var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });\n\n // \u2500\u2500 character-preset edit panel \u2500\u2500\n var editingCharPresetId=null;\n function fillCharEditStackOptions(cur){\n var sel=$('rpce-stack'); if(!sel) return;\n sel.innerHTML='<option value=\"\">\u2014 default \u2014</option>';\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i]; var o=document.createElement('option'); o.value=s.id; o.textContent=s.name||s.id; if(s.id===cur) o.selected=true; sel.appendChild(o); }\n }\n function openCharPresetEdit(id){\n var p=null; for(var i=0;i<characterPresets.length;i++){ if(characterPresets[i].id===id){ p=characterPresets[i]; break; } }\n if(!p) return;\n editingCharPresetId=id;\n $('rpce-name-label').textContent=p.name||p.id;\n $('rpce-name').value=p.name||'';\n $('rpce-description').value=p.description||'';\n $('rpce-prompt').value=p.system_prompt||'';\n fillCharEditStackOptions(p.voice_stack_id);\n $('rpce-motors').value=(p.motors||[]).join(', ');\n $('rpce-nx').value=(p.nx!=null?p.nx:0.5);\n $('rpce-ny').value=(p.ny!=null?p.ny:0.5);\n $('rpce-img').value=p.img||'';\n $('rp-char-edit').style.display='grid';\n }\n function saveCharPresetEdit(){\n if(!editingCharPresetId) return;\n var motors=$('rpce-motors').value.split(',').map(function(x){return x.trim();}).filter(function(x){return !!x;});\n var body={\n name:$('rpce-name').value.trim()||editingCharPresetId, description:$('rpce-description').value.trim()||null,\n system_prompt:$('rpce-prompt').value.trim()||null, voice_stack_id:$('rpce-stack').value||null,\n motors:motors, nx:parseFloat($('rpce-nx').value), ny:parseFloat($('rpce-ny').value), img:$('rpce-img').value.trim()||null\n };\n rpAction('PUT','/robopark/api/character-presets/'+encodeURIComponent(editingCharPresetId),body).then(function(){ showOut('character preset saved'); $('rp-char-edit').style.display='none'; editingCharPresetId=null; loadCharacterPresets(); loadParkSlots(); }).catch(function(e){ showOut('save character preset: '+e.message); });\n }\n var cl=$('rp-char-list'); if(cl) cl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id) openCharPresetEdit(id); });\n var cpSave=$('rpce-save'); if(cpSave) cpSave.addEventListener('click', saveCharPresetEdit);\n var cpCancel=$('rpce-cancel'); if(cpCancel) cpCancel.addEventListener('click', function(){ $('rp-char-edit').style.display='none'; editingCharPresetId=null; });\n\n function renderRobotCardSelect(name, current){\n var sel=$('rp-char-'+name); if(!sel) return;\n sel.dataset.current=current||'';\n fillCharacterDropdowns();\n }\n\n function pollRobopark(){\n pollSettings();\n loadCharacterPresets();\n loadVoiceStacks();\n loadParkSlots();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n // stash per-robot rows so the Park drawer can show live session stats\n roboparkByName = {};\n robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://<scheduler>:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||x.character_preset_id||name)});\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\" data-name=\"'+esc(name)+'\"><div class=\"rp-card-top\">'+(avatar?'<div class=\"rp-card-img\"><img alt=\"'+esc(name)+'\" src=\"'+esc(avatar+QS)+'\"></div>':'')+'<div style=\"min-width:0;flex:1;\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4></div></div><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn\" data-act=\"trigger\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Trigger motion</button>'\n + '<button class=\"rp-btn\" data-act=\"preview\" data-robot=\"'+idAttr+'\">Preview</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div>'\n + '<div class=\"rp-field\" style=\"margin-top:0.45rem\"><label style=\"font-size:0.64rem\">Character preset</label><select class=\"rp-char\" data-act=\"char\" data-robot=\"'+idAttr+'\"><option value=\"\">'+esc(x.character_id||x.character_preset_id||'\u2014 auto \u2014')+'</option></select></div></div>';\n }\n grid.innerHTML = html;\n fillCharacterDropdowns();\n var sels=document.querySelectorAll('.rp-char');\n for(var si=0;si<sels.length;si++){ var ss=sels[si]; var current=ss.value; ss.dataset.current=current; }\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n // Park \u2014 isometric live map of the mesh\n // Real data: /fed/status nodes (placement + load) + /fed/stream frames\n // (agent movement + tool-use pulses) + /robopark telemetry (session stats).\n // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n var Park = (function(){\n var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial photo): named robot pads at their ground\n // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,\n // street and stage. A mesh node binds to a pad when its display name matches\n // the pad keywords (robobmw \u2192 VIXEN/BMW).\n // SLOTS is now rebuilt from scheduler character_presets; keep the same shape.\n var SLOTS = parkSlots.slice();\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};\n // Palm positions tuned to the aerial: clusters along the back edge and\n // scattered down the center of the paved strip.\n var PALMS=[\n [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],\n [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],\n [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],\n [0.62,0.80],[0.76,0.84],[0.35,0.82]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};\n // Street traffic along the top road (yellow taxis + private cars).\n var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\n function kindOf(n){\n var r=(n.role||'').toLowerCase();\n if(r==='hub'||r==='scheduler') return 'hub';\n if(r==='satellite'||r==='robot') return 'robot';\n var caps=(n.capabilities||[]).join(' ');\n if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';\n return 'peer';\n }\n function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }\n function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }\n function normalizeAvatarPath(path){\n var p=(path==null?'':String(path)).trim();\n if(!p) return '';\n if(/^https?:///i.test(p) || p.indexOf('data:')===0 || p.indexOf('/')===0) return p;\n return '/' + p.replace(/^.?//, '');\n }\n function slotAliases(slot){\n var out=[], seen={};\n function add(v){\n var s=(v==null?'':String(v)).trim().toLowerCase();\n if(!s || seen[s]) return;\n seen[s]=1;\n out.push(s);\n }\n add(slot && slot.label);\n add(slot && slot.id);\n add(slot && slot.name);\n add(slot && slot.sub);\n (slot && slot.keys ? slot.keys : []).forEach(add);\n return out;\n }\n function slotForNode(n){\n var text=((n && (n.label || n.name || n.id || ''))+'').toLowerCase();\n if(!text) return null;\n var best=null;\n for(var i=0;i<SLOTS.length;i++){\n var slot=SLOTS[i], aliases=slotAliases(slot);\n for(var j=0;j<aliases.length;j++){\n var key=aliases[j];\n if(!key) continue;\n if(text===key || text.indexOf(key)>-1 || key.indexOf(text)>-1) return slot;\n if(!best && key.length>2 && text.indexOf(key.replace(/[^a-z0-9]/g,''))>-1) best=slot;\n }\n }\n return best;\n }\n function avatarSrcForNode(n){\n var slot=slotForNode(n);\n return normalizeAvatarPath(slot && slot.img);\n }\n function avatarImageForNode(n){\n var slot=slotForNode(n);\n if(!slot) return null;\n var aliases=slotAliases(slot);\n var src=normalizeAvatarPath(slot.img);\n if(src) aliases.unshift(src);\n for(var i=0;i<aliases.length;i++){ var img=imgs[aliases[i]]; if(img) return img; }\n return null;\n }\n\n // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\n });\n return out;\n }\n\n function onStatus(s){\n if(!s) return;\n nodes = layout(s.nodes||[]);\n byId={}; nodes.forEach(function(n){ byId[n.id]=n; });\n // if not streaming, animate from the polled activity tail\n if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }\n }\n\n function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }\n function findByFrame(f){\n var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\n return null;\n }\n function ingest(f, fromPoll){\n if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }\n var n=findByFrame(f), h=hub();\n if(n && h){\n if(n.kind==='hub'){ pulse(n, glyphFor(f)); }\n else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }\n else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }\n }\n feedRow(f);\n }\n function glyphFor(f){\n if(f.t==='rt') return '\u21C4';\n if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '\u2328'; if(/session|presence/.test(k)) return '\uD83C\uDF99'; return '\u2726'; }\n if(f.t==='hw'||f.t==='hb') return '';\n return '\u2726';\n }\n function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }\n function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'\u2726'}); }\n\n function feedRow(f){\n var rows=$('pk-rows'); if(!rows) return;\n var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;\n var r=document.createElement('div'); r.className='pk-row';\n r.innerHTML='<span class=\"k pk-k-'+esc(kind)+'\"></span><span class=\"t\">'+clock(f.ts)+'</span>'\n +'<span class=\"who\" title=\"'+esc(f.n)+'\">'+esc((f.n||'').slice(0,10))+'</span>'\n +'<span class=\"msg\">'+esc(summarizeFrame(f))+'</span>';\n rows.insertBefore(r, rows.firstChild);\n while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);\n }\n\n // \u2500\u2500 canvas + projection \u2500\u2500\n function ensure(){\n if(C) return;\n C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();\n DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));\n var d=false,px,py;\n C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});\n window.addEventListener('mouseup',function(){d=false;});\n window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });\n 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});\n C.addEventListener('click',function(e){\n var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;\n 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]; }\n if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }\n });\n window.addEventListener('resize', function(){ if(running) resize(); });\n }\n function resize(){\n var st=$('pk-stage'); if(!st) return;\n W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);\n if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');\n poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);\n // street band along the far edge + dashed centerline + cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // white event tent / stage on the far left of the plaza (matches aerial)\n drawTent(0.85, 0.18);\n // paved plaza \u2014 long rectangular promenade matching the aerial footprint\n var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;\n var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n // plaza grid (tile joints)\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }\n for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }\n // curved pond / water feature at bottom-left\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }\n var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n // winding footpath along the pond\n X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);\n var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];\n X.beginPath();\n for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }\n X.stroke();\n }\n function drawTent(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // canopy shadow\n X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();\n // white peaked tent\n X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();\n // support poles\n X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;\n X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();\n X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();\n X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n 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();\n 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();\n }\n function label(cx,cy,txt,col,sub){\n var z=cam.z; X.textAlign='center';\n X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);\n X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);\n }\n function padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }\n function drawHub(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();\n X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;\n X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();\n 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; }\n X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();\n X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();\n label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};\n }\n function drawRobot(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;\n if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n var img = avatarImageForNode(n);\n if(img && img.complete && img.naturalWidth>0){\n var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();\n X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);\n X.restore();\n X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();\n X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n }\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';\n X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;\n X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();\n 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;\n label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));\n n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};\n }\n\n function frame(){\n if(!running) return;\n X.clearRect(0,0,W,H);\n drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); } });\n for(var i=orbs.length-1;i>=0;i--){ var o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}\n var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;\n var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;\n X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;\n }\n 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;}\n var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;\n 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);\n X.beginPath(); X.arc(pp.x,pp.y-18*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;\n }\n raf=requestAnimationFrame(frame);\n }\n\n function connectStream(){\n if(es) return;\n try{\n es=new EventSource('/fed/stream'+QS);\n es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };\n es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream \u00B7 live'; };\n es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status \u00B7 poll'; };\n }catch(e){ es=null; }\n }\n\n var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; };\n // The SDK is ~500KB \u2014 a camera view or \"Go live\" click can easily fire\n // before it finishes loading. Without this, that first attempt sees\n // window.LivekitClient===undefined, falls into the permanent \"no-sdk\"\n // idle state, and never retries even once the SDK is ready.\n s.onload=function(){\n sdkLoading=false;\n if(!drawerId) return;\n var n=byId[drawerId];\n if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }\n };\n document.head.appendChild(s);\n }\n function loadImages(){\n imgs={};\n var seen={};\n SLOTS.forEach(function(s){\n var src=normalizeAvatarPath(s && s.img);\n if(!src || seen[src]) return;\n seen[src]=1;\n var i=new Image();\n i.decoding='async';\n var aliases=slotAliases(s);\n aliases.push(src);\n i.onload=function(){ aliases.forEach(function(k){ imgs[k]=i; }); };\n i.onerror=function(){ aliases.forEach(function(k){ if(!(k in imgs)) imgs[k]=null; }); };\n i.src=src+QS;\n });\n }\n function updateSlots(newSlots){\n SLOTS = newSlots.slice();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n }\n function activate(){\n ensure();\n loadSDK();\n loadImages();\n if(lastStatus) onStatus(lastStatus);\n resize();\n connectStream();\n if(!running){ running=true; raf=requestAnimationFrame(frame); }\n }\n function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }\n\n // \u2500\u2500 robot drawer \u2500\u2500\n function sect(title, inner){ return '<div class=\"pk-sect\"><h4>'+title+'</h4>'+inner+'</div>'; }\n function openDrawer(id){\n var n=byId[id]; if(!n) return; drawerId=id;\n $('pk-drawer').classList.add('open');\n var av=$('pk-av'), avSrc=avatarSrcForNode(n), avImg=avatarImageForNode(n);\n if(av){\n if(avSrc){\n av.classList.add('hasimg');\n av.innerHTML = '<img alt=\"'+esc(n.label||n.name||'robot')+'\" src=\"'+esc(avImg && avImg.src ? avImg.src : avSrc+QS)+'\">';\n } else {\n av.classList.remove('hasimg');\n av.textContent = n.kind==='hub'?'\uD83D\uDDA5':(n.kind==='robot'?'\uD83E\uDD16':'\uD83E\uDDE9');\n }\n }\n $('pk-name').textContent=n.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');\n buildDrawer(n);\n pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick\n if(drawerTimer) clearInterval(drawerTimer);\n drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);\n }\n // Resolve a mesh node's *scheduler* robot id \u2014 n.name is the mesh display\n // name, which is not necessarily the scheduler's robots.id (e.g. an\n // enrolled device's id is a generated dev_xxxx, distinct from its name).\n // Falls back to n.name if telemetry hasn't matched a row yet.\n function schedRow(n){\n var row=roboparkByName[n.name];\n if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }\n return row;\n }\n function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }\n function closeDrawer(){\n // tell the scheduler to end any live preview so the robot stops its cam\n if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }\n previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n stopVision();\n $('pk-drawer').classList.remove('open'); drawerId=null;\n if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;\n if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;\n if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }\n if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=null;\n }\n\n // \u2500\u2500 setup flow for unjoined park pads \u2500\u2500\n function submitPadSetup(n){\n var name=$('pk-setup-name').value.trim() || (n.label||n.id||'robot').toString().toLowerCase();\n var body={\n name:name,\n character_id:$('pk-setup-char').value||null,\n tailscale_ip:$('pk-setup-tsip').value.trim()||null,\n lan_ip:$('pk-setup-lanip').value.trim()||null,\n motor_server_url:$('pk-setup-motor').value.trim()||null,\n livekit_url:$('pk-setup-lk').value.trim()||null,\n video_device:'auto', audio_device:'default'\n };\n rpAction('POST','/robopark/api/devices',body).then(function(j){\n var token=(j&&j.enrollment_token)||'';\n $('pk-setup-token').value=token;\n $('pk-setup-result').style.display='block';\n showOut('robot added: '+esc((j&&j.name)||name)+'\\ntoken: '+esc(token));\n pollRoboparkQuiet();\n }).catch(function(e){ showOut('pad setup ('+esc(n.label)+'): '+e.message); });\n }\n\n // \u2500\u2500 effective voice config for connected robots \u2500\u2500\n function loadEffectiveConfig(robotId){\n fetch('/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(cfg){\n var note=$('pk-eff-note'); if(note) note.textContent='Effective config: '+(cfg&&cfg.character_name?esc(cfg.character_name):'\u2014')+' / '+(cfg&&cfg.voice_stack&&cfg.voice_stack.name?esc(cfg.voice_stack.name):'\u2014');\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n if(csel){ csel.value=(cfg&&cfg.character_id)||''; csel.dataset.current=csel.value; }\n if(vsel){ vsel.value=(cfg&&cfg.voice_stack_id)||''; vsel.dataset.current=vsel.value; }\n var saveBtn=$('pk-eff-save');\n if(saveBtn && !saveBtn._bound){ saveBtn._bound=true; saveBtn.addEventListener('click', function(){ applyEffectiveConfig(robotId); }); }\n }).catch(function(){\n var note=$('pk-eff-note'); if(note) note.textContent='effective config unavailable';\n });\n }\n function applyEffectiveConfig(robotId){\n var csel=$('pk-eff-char'), vsel=$('pk-eff-voice');\n var body={};\n if(csel && csel.value) body.character_preset_id=csel.value;\n if(vsel && vsel.value) body.voice_stack_id=vsel.value;\n if(!body.character_preset_id && !body.voice_stack_id){ showOut('Choose a character preset or voice stack'); return; }\n rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/voice-config',body).then(function(cfg){\n showOut('updated robot config: '+(cfg&&cfg.character_name||robotId));\n if(csel) csel.dataset.current=csel.value;\n if(vsel) vsel.dataset.current=vsel.value;\n }).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });\n }\n\n // \u2500\u2500 on-demand operator preview: open the robot's cam without a scene-detection session \u2500\u2500\n function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'\u25A0 Stop live':'\u25B6 Go live'; b.classList.toggle('danger', previewOn); } }\n function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n var note=$('pk-golivenote'); if(note) note.textContent='requesting preview\u2026';\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first\n idleCam('connecting');\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(!previewOn) return; // stopped while awaiting\n if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }\n if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview \u00B7 robot opening camera\u2026'; }\n else { idleCam('no-sdk'); }\n if(previewTimer) clearInterval(previewTimer);\n previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);\n })\n .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });\n }\n function stopPreview(n){\n previewOn=false; updateGoLive();\n if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});\n var note=$('pk-golivenote'); if(note) note.textContent='';\n idleCam('idle');\n }\n // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),\n // so the browser hits the robot's LAN IP straight on, no scheduler proxy.\n // The scheduler learns that IP from the device's own heartbeats.\n function toggleVision(n){\n var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');\n if(visionOn){\n visionOn=false; if(img){ img.src=''; img.style.display='none'; }\n if(btn) btn.textContent='\uD83D\uDD0D Show overlay';\n if(note) note.textContent=\"object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)\";\n return;\n }\n var row=schedRow(n);\n var ip=row&&row.lan_ip;\n if(!ip){ if(note) note.textContent='no known IP for this robot yet \u2014 waiting for a heartbeat with lan_ip'; return; }\n var port=(row&&row.vision_port)||5000;\n visionOn=true;\n if(btn) btn.textContent='\u25A0 Hide overlay';\n if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';\n if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }\n }\n function stopVision(){\n visionOn=false;\n var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }\n }\n function pollRoboparkQuiet(){\n fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};\n list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function buildDrawer(n){\n var b=$('pk-body');\n if(n.kind==='robot' && !n.connected){\n var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/s+/g,'');\n var presetOptions='', voiceOptions='';\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i]; presetOptions += '<option value=\"'+esc(p.id||'')+'\"'+((p.id===n.id)?' selected':'')+'>'+esc(p.name||p.id||'')+'</option>'; }\n for(var v=0;v<voiceStacks.length;v++){ var s=voiceStacks[v]; voiceOptions += '<option value=\"'+esc(s.id||'')+'\">'+esc(s.name||s.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label),\n '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted);margin-bottom:0.55rem;\">Set up this pad before a robot joins it.</div>'\n + '<div class=\"pk-grid2\" style=\"margin-bottom:0.55rem;\">'\n + '<div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div>'\n + '<div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div>'\n + '</div>'\n + '<div class=\"rp-form\" style=\"padding:0.7rem;grid-template-columns:1fr;gap:0.55rem;margin:0;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Robot name</label><input id=\"pk-setup-name\" value=\"'+esc(defaultName)+'\"></div>'\n + '<div class=\"rp-field\"><label>Character preset</label><select id=\"pk-setup-char\"><option value=\"\">\u2014 auto \u2014</option>'+presetOptions+'</select></div>'\n + '<div class=\"rp-quickrow\" style=\"padding:0;\"><button class=\"rp-btn primary\" id=\"pk-setup-btn\" data-preset=\"'+esc(n.id)+'\" style=\"width:fit-content;\">Mint enrollment token & add robot</button></div>'\n + '<div class=\"rp-field\"><label>or run this ON THE ROBOT (mints its own token)</label><input id=\"pk-setup-clicmd\" readonly></div>'\n + '<div class=\"rp-quickrow\" style=\"padding:0;\"><button class=\"rp-btn\" id=\"pk-setup-clicmd-copy\" style=\"width:fit-content;\">\uD83D\uDCCB Copy command</button></div>'\n + '<details class=\"rp-advanced\"><summary>Advanced \u2014 pre-set network/voice config</summary>'\n + '<div class=\"rp-advanced-grid\">'\n + '<div class=\"rp-field\"><label>Tailscale IP</label><input id=\"pk-setup-tsip\" placeholder=\"auto-detected once connected\"></div>'\n + '<div class=\"rp-field\"><label>LAN IP</label><input id=\"pk-setup-lanip\" placeholder=\"auto-detected once connected\"></div>'\n + '<div class=\"rp-field\"><label>Motor server URL</label><input id=\"pk-setup-motor\" placeholder=\"http://...:5000\"></div>'\n + '<div class=\"rp-field\"><label>LiveKit URL</label><input id=\"pk-setup-lk\" placeholder=\"\u2014 uses the registered server \u2014\"></div>'\n + '<div class=\"rp-field\"><label>Voice stack override</label><select id=\"pk-setup-voice\"><option value=\"\">\u2014 from character preset \u2014</option>'+voiceOptions+'</select></div>'\n + '</div></details>'\n + '</div>'\n + '<div class=\"pk-sect\" id=\"pk-setup-result\" style=\"display:none;\"><h4>Enrollment token (copy once)</h4><input id=\"pk-setup-token\" class=\"rp-char\" readonly style=\"margin-bottom:0.4rem;\"><button class=\"rp-btn\" id=\"pk-setup-copy\">Copy</button></div>');\n var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });\n var copyBtn=$('pk-setup-copy'); if(copyBtn) copyBtn.addEventListener('click', function(){ var t=$('pk-setup-token'); if(t){ t.select(); try{document.execCommand('copy');}catch(e){} showOut('token copied'); } });\n function refreshPadCliCommand(){\n var nm=$('pk-setup-name').value.trim()||defaultName; var cid=$('pk-setup-char').value||n.id;\n var el=$('pk-setup-clicmd'); if(el) el.value=buildAddRobotCommand(nm, cid);\n }\n var padNameEl=$('pk-setup-name'); if(padNameEl) padNameEl.addEventListener('input', refreshPadCliCommand);\n var padCharEl=$('pk-setup-char'); if(padCharEl) padCharEl.addEventListener('change', refreshPadCliCommand);\n var padCliCopy=$('pk-setup-clicmd-copy'); if(padCliCopy) padCliCopy.addEventListener('click', function(){ var i=$('pk-setup-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n refreshPadCliCommand();\n return;\n }\n if(n.kind==='robot'){\n var effPresetOptions='', effVoiceOptions='';\n for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value=\"'+esc(cp.id||'')+'\"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }\n for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value=\"'+esc(vs.id||'')+'\">'+esc(vs.name||vs.id||'')+'</option>'; }\n b.innerHTML =\n sect('\uD83D\uDC41 camera feed',\n '<div class=\"pk-cam\" id=\"pk-cam\"><canvas id=\"pk-camcv\" width=\"384\" height=\"216\"></canvas>'\n +'<div class=\"hud\"><div class=\"rec\"><i></i>REC \u00B7 '+esc(n.name)+'</div><div class=\"tr\" id=\"pk-camres\">\u2014 \u00B7 \u2014</div><div class=\"br\" id=\"pk-camts\">--:--:--</div></div></div>'\n +'<div class=\"pk-note\" id=\"pk-camnote\">seeking LiveKit track\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u25B6 Go live</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\"></span></div>')\n + sect('\uD83D\uDD0D vision overlay',\n '<div class=\"pk-cam\" id=\"pk-vision-wrap\"><img id=\"pk-vision-img\" style=\"width:100%;display:none;border-radius:inherit\" alt=\"vision feed\"/></div>'\n +'<div class=\"pk-note\" id=\"pk-vision-note\">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-vision-toggle\">\uD83D\uDD0D Show overlay</button></div>')\n + sect('\uD83C\uDF99 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\">\u2014</span></div><div class=\"pk-note\" id=\"pk-micstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDD0A 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\">\u2014</span></div><div class=\"pk-note\" id=\"pk-spkstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDCCA 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 \u2014 full CPU/mem telemetry shows on the node itself</div>')\n + sect('\uD83D\uDDD3 session telemetry','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-trig\">\u2014</div><div class=\"l\">triggers</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-sess\">\u2014</div><div class=\"l\">sessions</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-lat\">\u2014</div><div class=\"l\">avg latency</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-drop\">\u2014</div><div class=\"l\">drop rate</div></div></div>')\n + sect('\u2699 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>')\n + sect('\uD83C\uDFAD character / voice','<div class=\"pk-note\" id=\"pk-eff-note\" style=\"margin-bottom:0.45rem;\">loading effective config\u2026</div><div class=\"rp-field\"><label>Character preset</label><select class=\"rp-char\" id=\"pk-eff-char\" data-robot=\"'+esc(schedId(n))+'\">'+effPresetOptions+'</select></div><div class=\"rp-field\"><label>Voice stack</label><select class=\"rp-voice\" id=\"pk-eff-voice\" data-robot=\"'+esc(schedId(n))+'\">'+effVoiceOptions+'</select></div><button class=\"rp-btn\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\" style=\"margin-top:0.35rem;\">Set up on this robot</button>');\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n));\n var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n 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); });\n } else {\n b.innerHTML =\n sect('\uD83D\uDDA5 node','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-load\">\u2014</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>')\n + sect('\uD83D\uDD0C 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>'))\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-status\">Run /status</button></div>');\n var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });\n }\n refreshDrawer();\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);\n var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';\n var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';\n var row=schedRow(n);\n if(row){\n set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);\n set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);\n set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'\u2014');\n 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+'%':'\u2014'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }\n var active=row.active_session||row.in_session||row.session_active;\n 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')); }\n }\n }\n function set(id,v){ var e=$(id); if(e) e.textContent=v; }\n 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)'; }\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n var note=$('pk-camnote');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\n }\n function previewCam(n){\n var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();\n var res=$('pk-camres'); if(res && res.textContent==='\u2014 \u00B7 \u2014') res.textContent='preview \u00B7 24fps';\n (function loop(){ if(!$('pk-camcv')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n 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);\n 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();\n var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;\n cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();\n cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);\n cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);\n cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);\n 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;\n 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); }\n var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());\n camRAF=requestAnimationFrame(loop);\n })();\n }\n 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'); }\n function startMeters(n){\n var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;\n var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }\n for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke(); }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n function openDrawerForRobot(name){\n var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i]; if(n.kind==='robot' && ((n.name||'').toLowerCase()===key || n.telemKeys.some(function(t){ return t===key; }))){ found=n; break; } }\n var node=found || { id:id, kind:'robot', name:name, telemKeys:[key], connected:true, label:name };\n if(!byId[id]){ byId[id]=node; nodes.push(node); }\n openDrawer(node.id);\n }\n\n $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);\n return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n initTab();\n})();\n</script>\n</body>\n</html>";
|
|
@@ -217,10 +217,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
217
217
|
.rp-advanced-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.6rem;margin-top:0.7rem;}
|
|
218
218
|
.rp-btn.primary{background:linear-gradient(160deg,color-mix(in srgb,var(--gold-bright) 30%,var(--panel-2)),var(--panel-2));border-color:var(--gold-bright);color:var(--gold-bright);font-weight:600;}
|
|
219
219
|
.rp-btn.primary:hover{box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 16%,transparent);}
|
|
220
|
-
.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
|
|
221
|
-
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
222
|
-
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
223
|
-
.rp-card
|
|
220
|
+
.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
|
|
221
|
+
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
222
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
223
|
+
.rp-card-top{display:flex;align-items:flex-start;gap:0.55rem;}
|
|
224
|
+
.rp-card-img{width:42px;height:42px;border-radius:11px;overflow:hidden;flex:none;border:1px solid var(--glass-border);background:var(--panel-2);}
|
|
225
|
+
.rp-card-img img{width:100%;height:100%;object-fit:cover;display:block;}
|
|
226
|
+
.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
|
|
224
227
|
.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
|
|
225
228
|
.rp-m{display:flex;flex-direction:column;}
|
|
226
229
|
.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}
|
|
@@ -278,8 +281,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
278
281
|
transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
|
|
279
282
|
.pk-drawer.open{transform:translateX(0);}
|
|
280
283
|
.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
|
|
281
|
-
.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));
|
|
282
|
-
border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}
|
|
284
|
+
.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));
|
|
285
|
+
border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;overflow:hidden;}
|
|
286
|
+
.pk-av.hasimg{background:var(--panel-2);}
|
|
287
|
+
.pk-av img{width:100%;height:100%;object-fit:cover;display:block;}
|
|
283
288
|
.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
|
|
284
289
|
.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
|
|
285
290
|
.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;}
|
|
@@ -1205,13 +1210,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1205
1210
|
var html='';
|
|
1206
1211
|
for (var i=0;i<robots.length;i++){
|
|
1207
1212
|
var x = robots[i];
|
|
1208
|
-
var name = robotName(x);
|
|
1209
|
-
var
|
|
1213
|
+
var name = robotName(x);
|
|
1214
|
+
var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||x.character_preset_id||name)});
|
|
1215
|
+
var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
|
|
1210
1216
|
var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
|
|
1211
1217
|
var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
|
|
1212
1218
|
var active = x.active_session||x.in_session||x.session_active;
|
|
1213
1219
|
var idAttr = esc(name);
|
|
1214
|
-
html += '<div class="rp-card" data-name="'+esc(name)+'"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4><div class="rp-metrics">'
|
|
1220
|
+
html += '<div class="rp-card" data-name="'+esc(name)+'"><div class="rp-card-top">'+(avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')+'<div style="min-width:0;flex:1;"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4></div></div><div class="rp-metrics">'
|
|
1215
1221
|
+ '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
|
|
1216
1222
|
+ '<div class="rp-m"><span class="v">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class="l">sessions</span></div>'
|
|
1217
1223
|
+ '<div class="rp-m"><span class="v">'+lat+'</span><span class="l">avg latency</span></div>'
|
|
@@ -1277,10 +1283,59 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1277
1283
|
if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
|
|
1278
1284
|
return 'peer';
|
|
1279
1285
|
}
|
|
1280
|
-
function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
|
|
1281
|
-
function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
|
|
1282
|
-
|
|
1283
|
-
|
|
1286
|
+
function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
|
|
1287
|
+
function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
|
|
1288
|
+
function normalizeAvatarPath(path){
|
|
1289
|
+
var p=(path==null?'':String(path)).trim();
|
|
1290
|
+
if(!p) return '';
|
|
1291
|
+
if(/^https?:\/\//i.test(p) || p.indexOf('data:')===0 || p.indexOf('/')===0) return p;
|
|
1292
|
+
return '/' + p.replace(/^\.?\//, '');
|
|
1293
|
+
}
|
|
1294
|
+
function slotAliases(slot){
|
|
1295
|
+
var out=[], seen={};
|
|
1296
|
+
function add(v){
|
|
1297
|
+
var s=(v==null?'':String(v)).trim().toLowerCase();
|
|
1298
|
+
if(!s || seen[s]) return;
|
|
1299
|
+
seen[s]=1;
|
|
1300
|
+
out.push(s);
|
|
1301
|
+
}
|
|
1302
|
+
add(slot && slot.label);
|
|
1303
|
+
add(slot && slot.id);
|
|
1304
|
+
add(slot && slot.name);
|
|
1305
|
+
add(slot && slot.sub);
|
|
1306
|
+
(slot && slot.keys ? slot.keys : []).forEach(add);
|
|
1307
|
+
return out;
|
|
1308
|
+
}
|
|
1309
|
+
function slotForNode(n){
|
|
1310
|
+
var text=((n && (n.label || n.name || n.id || ''))+'').toLowerCase();
|
|
1311
|
+
if(!text) return null;
|
|
1312
|
+
var best=null;
|
|
1313
|
+
for(var i=0;i<SLOTS.length;i++){
|
|
1314
|
+
var slot=SLOTS[i], aliases=slotAliases(slot);
|
|
1315
|
+
for(var j=0;j<aliases.length;j++){
|
|
1316
|
+
var key=aliases[j];
|
|
1317
|
+
if(!key) continue;
|
|
1318
|
+
if(text===key || text.indexOf(key)>-1 || key.indexOf(text)>-1) return slot;
|
|
1319
|
+
if(!best && key.length>2 && text.indexOf(key.replace(/[^a-z0-9]/g,''))>-1) best=slot;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return best;
|
|
1323
|
+
}
|
|
1324
|
+
function avatarSrcForNode(n){
|
|
1325
|
+
var slot=slotForNode(n);
|
|
1326
|
+
return normalizeAvatarPath(slot && slot.img);
|
|
1327
|
+
}
|
|
1328
|
+
function avatarImageForNode(n){
|
|
1329
|
+
var slot=slotForNode(n);
|
|
1330
|
+
if(!slot) return null;
|
|
1331
|
+
var aliases=slotAliases(slot);
|
|
1332
|
+
var src=normalizeAvatarPath(slot.img);
|
|
1333
|
+
if(src) aliases.unshift(src);
|
|
1334
|
+
for(var i=0;i<aliases.length;i++){ var img=imgs[aliases[i]]; if(img) return img; }
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Bind mesh nodes onto the fixed park pads; unmatched pads render as
|
|
1284
1339
|
// offline placeholders so the whole park is always visible.
|
|
1285
1340
|
function layout(src){
|
|
1286
1341
|
var list=(src||[]).slice(), used={}, out=[];
|
|
@@ -1488,12 +1543,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1488
1543
|
var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;
|
|
1489
1544
|
if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);
|
|
1490
1545
|
X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();
|
|
1491
|
-
var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
|
|
1492
|
-
X.globalAlpha=n.connected?1:0.7;
|
|
1493
|
-
var img = n
|
|
1494
|
-
if(img && img.complete && img.naturalWidth>0){
|
|
1495
|
-
var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;
|
|
1496
|
-
X.save();
|
|
1546
|
+
var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
|
|
1547
|
+
X.globalAlpha=n.connected?1:0.7;
|
|
1548
|
+
var img = avatarImageForNode(n);
|
|
1549
|
+
if(img && img.complete && img.naturalWidth>0){
|
|
1550
|
+
var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;
|
|
1551
|
+
X.save();
|
|
1497
1552
|
X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();
|
|
1498
1553
|
X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);
|
|
1499
1554
|
X.restore();
|
|
@@ -1572,9 +1627,22 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1572
1627
|
};
|
|
1573
1628
|
document.head.appendChild(s);
|
|
1574
1629
|
}
|
|
1575
|
-
function loadImages(){
|
|
1576
|
-
|
|
1577
|
-
|
|
1630
|
+
function loadImages(){
|
|
1631
|
+
imgs={};
|
|
1632
|
+
var seen={};
|
|
1633
|
+
SLOTS.forEach(function(s){
|
|
1634
|
+
var src=normalizeAvatarPath(s && s.img);
|
|
1635
|
+
if(!src || seen[src]) return;
|
|
1636
|
+
seen[src]=1;
|
|
1637
|
+
var i=new Image();
|
|
1638
|
+
i.decoding='async';
|
|
1639
|
+
var aliases=slotAliases(s);
|
|
1640
|
+
aliases.push(src);
|
|
1641
|
+
i.onload=function(){ aliases.forEach(function(k){ imgs[k]=i; }); };
|
|
1642
|
+
i.onerror=function(){ aliases.forEach(function(k){ if(!(k in imgs)) imgs[k]=null; }); };
|
|
1643
|
+
i.src=src+QS;
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1578
1646
|
function updateSlots(newSlots){
|
|
1579
1647
|
SLOTS = newSlots.slice();
|
|
1580
1648
|
loadImages();
|
|
@@ -1593,11 +1661,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1593
1661
|
|
|
1594
1662
|
// ── robot drawer ──
|
|
1595
1663
|
function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
|
|
1596
|
-
function openDrawer(id){
|
|
1597
|
-
var n=byId[id]; if(!n) return; drawerId=id;
|
|
1598
|
-
$('pk-drawer').classList.add('open');
|
|
1599
|
-
|
|
1600
|
-
|
|
1664
|
+
function openDrawer(id){
|
|
1665
|
+
var n=byId[id]; if(!n) return; drawerId=id;
|
|
1666
|
+
$('pk-drawer').classList.add('open');
|
|
1667
|
+
var av=$('pk-av'), avSrc=avatarSrcForNode(n), avImg=avatarImageForNode(n);
|
|
1668
|
+
if(av){
|
|
1669
|
+
if(avSrc){
|
|
1670
|
+
av.classList.add('hasimg');
|
|
1671
|
+
av.innerHTML = '<img alt="'+esc(n.label||n.name||'robot')+'" src="'+esc(avImg && avImg.src ? avImg.src : avSrc+QS)+'">';
|
|
1672
|
+
} else {
|
|
1673
|
+
av.classList.remove('hasimg');
|
|
1674
|
+
av.textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
$('pk-name').textContent=n.label||n.name;
|
|
1601
1678
|
$('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
|
|
1602
1679
|
var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
|
|
1603
1680
|
buildDrawer(n);
|
package/package.json
CHANGED